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();
|
||||
+12
@@ -15,6 +15,7 @@ import { ArcamFmjIntegration } from './integrations/arcam_fmj/index.js';
|
||||
import { AsuswrtIntegration } from './integrations/asuswrt/index.js';
|
||||
import { BleboxIntegration } from './integrations/blebox/index.js';
|
||||
import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js';
|
||||
import { BluesoundIntegration } from './integrations/bluesound/index.js';
|
||||
import { BoschShcIntegration } from './integrations/bosch_shc/index.js';
|
||||
import { BraviatvIntegration } from './integrations/braviatv/index.js';
|
||||
import { BroadlinkIntegration } from './integrations/broadlink/index.js';
|
||||
@@ -24,6 +25,7 @@ import { DenonavrIntegration } from './integrations/denonavr/index.js';
|
||||
import { DevoloHomeNetworkIntegration } from './integrations/devolo_home_network/index.js';
|
||||
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
|
||||
import { DsmrIntegration } from './integrations/dsmr/index.js';
|
||||
import { DunehdIntegration } from './integrations/dunehd/index.js';
|
||||
import { EsphomeIntegration } from './integrations/esphome/index.js';
|
||||
import { FritzIntegration } from './integrations/fritz/index.js';
|
||||
import { GlancesIntegration } from './integrations/glances/index.js';
|
||||
@@ -34,6 +36,8 @@ import { IppIntegration } from './integrations/ipp/index.js';
|
||||
import { JellyfinIntegration } from './integrations/jellyfin/index.js';
|
||||
import { KnxIntegration } from './integrations/knx/index.js';
|
||||
import { KodiIntegration } from './integrations/kodi/index.js';
|
||||
import { LinkplayIntegration } from './integrations/linkplay/index.js';
|
||||
import { MadvrIntegration } from './integrations/madvr/index.js';
|
||||
import { MatterIntegration } from './integrations/matter/index.js';
|
||||
import { MikrotikIntegration } from './integrations/mikrotik/index.js';
|
||||
import { ModbusIntegration } from './integrations/modbus/index.js';
|
||||
@@ -44,6 +48,7 @@ import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
|
||||
import { OpenthermGwIntegration } from './integrations/opentherm_gw/index.js';
|
||||
import { OpnsenseIntegration } from './integrations/opnsense/index.js';
|
||||
import { OnvifIntegration } from './integrations/onvif/index.js';
|
||||
import { OpenRGBIntegration } from './integrations/openrgb/index.js';
|
||||
import { PiHoleIntegration } from './integrations/pi_hole/index.js';
|
||||
import { PlexIntegration } from './integrations/plex/index.js';
|
||||
import { RainbirdIntegration } from './integrations/rainbird/index.js';
|
||||
@@ -53,6 +58,7 @@ import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
|
||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||
import { SnapcastIntegration } from './integrations/snapcast/index.js';
|
||||
import { SonosIntegration } from './integrations/sonos/index.js';
|
||||
import { SoundtouchIntegration } from './integrations/soundtouch/index.js';
|
||||
import { SqueezeboxIntegration } from './integrations/squeezebox/index.js';
|
||||
import { SynologyDsmIntegration } from './integrations/synology_dsm/index.js';
|
||||
import { TplinkIntegration } from './integrations/tplink/index.js';
|
||||
@@ -83,6 +89,7 @@ export const integrations = [
|
||||
new AxisIntegration(),
|
||||
new BleboxIntegration(),
|
||||
new BluetoothLeTrackerIntegration(),
|
||||
new BluesoundIntegration(),
|
||||
new BoschShcIntegration(),
|
||||
new BraviatvIntegration(),
|
||||
new BroadlinkIntegration(),
|
||||
@@ -92,6 +99,7 @@ export const integrations = [
|
||||
new DevoloHomeNetworkIntegration(),
|
||||
new DlnaDmrIntegration(),
|
||||
new DsmrIntegration(),
|
||||
new DunehdIntegration(),
|
||||
new EsphomeIntegration(),
|
||||
new FritzIntegration(),
|
||||
new GlancesIntegration(),
|
||||
@@ -103,6 +111,8 @@ export const integrations = [
|
||||
new JellyfinIntegration(),
|
||||
new KnxIntegration(),
|
||||
new KodiIntegration(),
|
||||
new LinkplayIntegration(),
|
||||
new MadvrIntegration(),
|
||||
new MatterIntegration(),
|
||||
new MikrotikIntegration(),
|
||||
new ModbusIntegration(),
|
||||
@@ -113,6 +123,7 @@ export const integrations = [
|
||||
new OpenthermGwIntegration(),
|
||||
new OpnsenseIntegration(),
|
||||
new OnvifIntegration(),
|
||||
new OpenRGBIntegration(),
|
||||
new PiHoleIntegration(),
|
||||
new PlexIntegration(),
|
||||
new RainbirdIntegration(),
|
||||
@@ -122,6 +133,7 @@ export const integrations = [
|
||||
new ShellyIntegration(),
|
||||
new SnapcastIntegration(),
|
||||
new SonosIntegration(),
|
||||
new SoundtouchIntegration(),
|
||||
new SqueezeboxIntegration(),
|
||||
new SynologyDsmIntegration(),
|
||||
new TplinkIntegration(),
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,654 @@
|
||||
import type {
|
||||
IBluesoundCommandRequest,
|
||||
IBluesoundConfig,
|
||||
IBluesoundInput,
|
||||
IBluesoundPairedPlayer,
|
||||
IBluesoundPlayerSnapshot,
|
||||
IBluesoundPreset,
|
||||
IBluesoundRawCommandRequest,
|
||||
IBluesoundRawCommandResponse,
|
||||
IBluesoundSnapshot,
|
||||
IBluesoundStatus,
|
||||
IBluesoundSyncStatus,
|
||||
} from './bluesound.types.js';
|
||||
import { bluesoundDefaultPort, bluesoundDefaultTimeoutMs } from './bluesound.types.js';
|
||||
|
||||
export class BluesoundHttpError extends Error {
|
||||
constructor(public readonly status: number, messageArg: string) {
|
||||
super(messageArg);
|
||||
this.name = 'BluesoundHttpError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BluesoundClient {
|
||||
private currentSnapshot?: IBluesoundSnapshot;
|
||||
|
||||
constructor(private readonly config: IBluesoundConfig) {
|
||||
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneSnapshot(config.snapshot), 'snapshot') : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(): Promise<IBluesoundSnapshot> {
|
||||
if (this.config.snapshot) {
|
||||
this.currentSnapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
if (!this.config.host && !this.config.commandExecutor) {
|
||||
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(false, 'Bluesound refresh requires config.host, config.snapshot, or commandExecutor.'), 'runtime');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
try {
|
||||
this.currentSnapshot = this.normalizeSnapshot(await this.fetchSnapshot(), 'http');
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<IBluesoundSnapshot> {
|
||||
const syncStatus = await this.getSyncStatus();
|
||||
if (!syncStatus.mac) {
|
||||
throw new Error('Bluesound player did not provide a MAC address.');
|
||||
}
|
||||
const snapshot = await this.fetchSnapshot(syncStatus);
|
||||
return this.normalizeSnapshot(snapshot, 'http');
|
||||
}
|
||||
|
||||
public async getStatus(): Promise<IBluesoundStatus> {
|
||||
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
|
||||
return this.cloneValue(this.currentSnapshot.players[0]?.status || { state: 'unknown' });
|
||||
}
|
||||
const response = await this.requestText('/Status');
|
||||
return parseStatus(response);
|
||||
}
|
||||
|
||||
public async getSyncStatus(): Promise<IBluesoundSyncStatus> {
|
||||
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
|
||||
const syncStatus = this.currentSnapshot.players[0]?.syncStatus;
|
||||
if (!syncStatus) {
|
||||
throw new Error('Bluesound snapshot does not contain a player.');
|
||||
}
|
||||
return this.cloneValue(syncStatus);
|
||||
}
|
||||
const response = await this.requestText('/SyncStatus');
|
||||
return parseSyncStatus(response);
|
||||
}
|
||||
|
||||
public async getPresets(): Promise<IBluesoundPreset[]> {
|
||||
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
|
||||
return this.cloneValue(this.currentSnapshot.players[0]?.presets || []);
|
||||
}
|
||||
const response = await this.requestText('/Presets');
|
||||
return parsePresets(response);
|
||||
}
|
||||
|
||||
public async getInputs(): Promise<IBluesoundInput[]> {
|
||||
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
|
||||
return this.cloneValue(this.currentSnapshot.players[0]?.inputs || []);
|
||||
}
|
||||
const response = await this.requestText('/RadioBrowse', { service: 'Capture' });
|
||||
return parseInputs(response);
|
||||
}
|
||||
|
||||
public async execute(requestArg: IBluesoundCommandRequest): Promise<unknown> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const player = this.resolvePlayer(snapshot, requestArg.playerId);
|
||||
if (this.isLeaderOnlyCommand(requestArg.command) && player && isGroupedFollower(player)) {
|
||||
return { skipped: true, reason: 'Grouped Bluesound followers are controlled by their leader.' };
|
||||
}
|
||||
|
||||
if (requestArg.command === 'play') {
|
||||
return this.requestRaw('/Play', {}, player);
|
||||
}
|
||||
if (requestArg.command === 'pause') {
|
||||
return this.requestRaw('/Pause', {}, player);
|
||||
}
|
||||
if (requestArg.command === 'stop') {
|
||||
return this.requestRaw('/Stop', {}, player);
|
||||
}
|
||||
if (requestArg.command === 'next_track') {
|
||||
return this.requestRaw('/Skip', {}, player);
|
||||
}
|
||||
if (requestArg.command === 'previous_track') {
|
||||
return this.requestRaw('/Back', {}, player);
|
||||
}
|
||||
if (requestArg.command === 'seek') {
|
||||
if (typeof requestArg.position !== 'number' || !Number.isFinite(requestArg.position)) {
|
||||
throw new Error('Bluesound seek requires position.');
|
||||
}
|
||||
return this.requestRaw('/Play', { seek: Math.max(0, Math.round(requestArg.position)) }, player);
|
||||
}
|
||||
if (requestArg.command === 'set_volume') {
|
||||
return this.requestRaw('/Volume', { level: this.volumePercent(requestArg) }, player);
|
||||
}
|
||||
if (requestArg.command === 'mute') {
|
||||
if (typeof requestArg.muted !== 'boolean') {
|
||||
throw new Error('Bluesound mute requires muted.');
|
||||
}
|
||||
return this.requestRaw('/Volume', { mute: requestArg.muted ? 1 : 0 }, player);
|
||||
}
|
||||
if (requestArg.command === 'play_media') {
|
||||
if (!requestArg.mediaId) {
|
||||
throw new Error('Bluesound play_media requires mediaId.');
|
||||
}
|
||||
return this.requestRaw('/Play', { url: requestArg.mediaId }, player);
|
||||
}
|
||||
if (requestArg.command === 'select_source') {
|
||||
if (!requestArg.source) {
|
||||
throw new Error('Bluesound select_source requires source.');
|
||||
}
|
||||
return this.selectSource(player, requestArg.source);
|
||||
}
|
||||
if (requestArg.command === 'play_preset') {
|
||||
return this.playPreset(player, requestArg);
|
||||
}
|
||||
if (requestArg.command === 'clear_playlist') {
|
||||
return this.requestRaw('/Clear', {}, player);
|
||||
}
|
||||
if (requestArg.command === 'shuffle') {
|
||||
if (typeof requestArg.shuffle !== 'boolean') {
|
||||
throw new Error('Bluesound shuffle requires shuffle.');
|
||||
}
|
||||
return this.requestRaw('/Shuffle', { state: requestArg.shuffle ? 1 : 0 }, player);
|
||||
}
|
||||
if (requestArg.command === 'join') {
|
||||
return this.joinPlayers(snapshot, player, requestArg);
|
||||
}
|
||||
if (requestArg.command === 'unjoin') {
|
||||
return this.unjoinPlayer(snapshot, player, requestArg);
|
||||
}
|
||||
if (requestArg.command === 'add_follower') {
|
||||
if (!requestArg.follower) {
|
||||
throw new Error('Bluesound add_follower requires follower.');
|
||||
}
|
||||
return this.requestRaw('/AddSlave', { slave: requestArg.follower.ip, port: requestArg.follower.port }, player);
|
||||
}
|
||||
if (requestArg.command === 'remove_follower') {
|
||||
if (!requestArg.follower) {
|
||||
throw new Error('Bluesound remove_follower requires follower.');
|
||||
}
|
||||
return this.requestRaw('/RemoveSlave', { slave: requestArg.follower.ip, port: requestArg.follower.port }, player);
|
||||
}
|
||||
throw new Error(`Unsupported Bluesound command: ${requestArg.command}`);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async fetchSnapshot(syncStatusArg?: IBluesoundSyncStatus): Promise<IBluesoundSnapshot> {
|
||||
const syncStatus = syncStatusArg || await this.getSyncStatus();
|
||||
const [status, presets, inputs] = await Promise.all([
|
||||
this.getStatus(),
|
||||
this.getPresets().catch(() => []),
|
||||
this.getInputs().catch(() => []),
|
||||
]);
|
||||
const updatedAt = new Date().toISOString();
|
||||
return {
|
||||
players: [{
|
||||
host: this.config.host,
|
||||
port: this.config.port || bluesoundDefaultPort,
|
||||
syncStatus,
|
||||
status,
|
||||
presets,
|
||||
inputs,
|
||||
available: true,
|
||||
updatedAt,
|
||||
}],
|
||||
online: true,
|
||||
updatedAt,
|
||||
source: 'http',
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IBluesoundSnapshot {
|
||||
const host = this.config.host;
|
||||
const port = this.config.port || bluesoundDefaultPort;
|
||||
const hasPlayerMetadata = Boolean(host || this.config.name || this.config.macAddress || this.config.model || this.config.modelName);
|
||||
return {
|
||||
players: hasPlayerMetadata ? [{
|
||||
host,
|
||||
port,
|
||||
syncStatus: {
|
||||
id: host ? `${host}:${port}` : this.config.name || 'bluesound',
|
||||
mac: this.config.macAddress || host || 'bluesound',
|
||||
name: this.config.name || host || 'Bluesound',
|
||||
brand: this.config.manufacturer || 'Bluesound',
|
||||
model: this.config.model,
|
||||
modelName: this.config.modelName || this.config.model,
|
||||
initialized: undefined,
|
||||
},
|
||||
status: { state: onlineArg ? 'stop' : 'unknown' },
|
||||
presets: [],
|
||||
inputs: [],
|
||||
available: onlineArg,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}] : [],
|
||||
online: onlineArg,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'runtime',
|
||||
error: errorArg,
|
||||
};
|
||||
}
|
||||
|
||||
private async selectSource(playerArg: IBluesoundPlayerSnapshot | undefined, sourceArg: string): Promise<unknown> {
|
||||
const player = this.requirePlayer(playerArg);
|
||||
const input = (player.inputs || []).find((inputArg) => sourceArg === inputArg.text || sourceArg === inputArg.id);
|
||||
if (input) {
|
||||
return this.requestRaw('/Play', { url: input.url }, player);
|
||||
}
|
||||
const preset = (player.presets || []).find((presetArg) => presetArg.name === sourceArg);
|
||||
if (preset) {
|
||||
return this.requestRaw('/Preset', { id: preset.id }, player);
|
||||
}
|
||||
throw new Error(`Unknown Bluesound source: ${sourceArg}`);
|
||||
}
|
||||
|
||||
private async playPreset(playerArg: IBluesoundPlayerSnapshot | undefined, requestArg: IBluesoundCommandRequest): Promise<unknown> {
|
||||
const player = this.requirePlayer(playerArg);
|
||||
const presetId = requestArg.presetId ?? (requestArg.presetName ? (player.presets || []).find((presetArg) => presetArg.name === requestArg.presetName)?.id : undefined);
|
||||
if (typeof presetId !== 'number' || !Number.isFinite(presetId)) {
|
||||
throw new Error('Bluesound play_preset requires presetId or a matching presetName.');
|
||||
}
|
||||
return this.requestRaw('/Preset', { id: Math.round(presetId) }, player);
|
||||
}
|
||||
|
||||
private async joinPlayers(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot | undefined, requestArg: IBluesoundCommandRequest): Promise<unknown> {
|
||||
const leader = this.requirePlayer(playerArg);
|
||||
const followers = this.joinFollowers(snapshotArg, leader, requestArg);
|
||||
if (!followers.length) {
|
||||
throw new Error('Bluesound join requires followers or playerIds.');
|
||||
}
|
||||
if (followers.length === 1) {
|
||||
return this.requestRaw('/AddSlave', { slave: followers[0].ip, port: followers[0].port }, leader);
|
||||
}
|
||||
return this.requestRaw('/AddSlave', {
|
||||
slaves: followers.map((followerArg) => followerArg.ip).join(','),
|
||||
ports: followers.map((followerArg) => followerArg.port).join(','),
|
||||
}, leader);
|
||||
}
|
||||
|
||||
private async unjoinPlayer(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot | undefined, requestArg: IBluesoundCommandRequest): Promise<unknown> {
|
||||
const player = this.requirePlayer(playerArg);
|
||||
const explicitFollower = requestArg.follower || requestArg.followers?.[0];
|
||||
if (explicitFollower) {
|
||||
return this.requestRaw('/RemoveSlave', { slave: explicitFollower.ip, port: explicitFollower.port }, player);
|
||||
}
|
||||
|
||||
const playerEndpoint = endpointFromPlayer(player);
|
||||
if (player.syncStatus.leader && playerEndpoint) {
|
||||
const leader = snapshotArg.players.find((candidateArg) => candidateArg.syncStatus.id === pairedPlayerId(player.syncStatus.leader as IBluesoundPairedPlayer));
|
||||
return this.requestRaw('/RemoveSlave', { slave: playerEndpoint.ip, port: playerEndpoint.port }, leader || endpointPlayer(player.syncStatus.leader, snapshotArg.updatedAt));
|
||||
}
|
||||
|
||||
if (player.syncStatus.followers?.length) {
|
||||
const follower = player.syncStatus.followers[0];
|
||||
return this.requestRaw('/RemoveSlave', { slave: follower.ip, port: follower.port }, player);
|
||||
}
|
||||
|
||||
return { skipped: true, reason: 'Bluesound player is not grouped.' };
|
||||
}
|
||||
|
||||
private joinFollowers(snapshotArg: IBluesoundSnapshot, leaderArg: IBluesoundPlayerSnapshot, requestArg: IBluesoundCommandRequest): IBluesoundPairedPlayer[] {
|
||||
const fromDirect = requestArg.followers || [];
|
||||
const ids = (requestArg.playerIds || []).filter((playerIdArg) => playerIdArg !== leaderArg.syncStatus.id);
|
||||
const fromIds = ids.map((playerIdArg) => {
|
||||
const player = snapshotArg.players.find((candidateArg) => candidateArg.syncStatus.id === playerIdArg);
|
||||
return player ? endpointFromPlayer(player) : pairedPlayerFromId(playerIdArg);
|
||||
}).filter((valueArg): valueArg is IBluesoundPairedPlayer => Boolean(valueArg));
|
||||
const unique = new Map<string, IBluesoundPairedPlayer>();
|
||||
[...fromDirect, ...fromIds].forEach((followerArg) => unique.set(pairedPlayerId(followerArg), followerArg));
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
private resolvePlayer(snapshotArg: IBluesoundSnapshot, playerIdArg: string | undefined): IBluesoundPlayerSnapshot | undefined {
|
||||
if (playerIdArg) {
|
||||
return snapshotArg.players.find((playerArg) => playerArg.syncStatus.id === playerIdArg);
|
||||
}
|
||||
return snapshotArg.players.length === 1 ? snapshotArg.players[0] : undefined;
|
||||
}
|
||||
|
||||
private requirePlayer(playerArg: IBluesoundPlayerSnapshot | undefined): IBluesoundPlayerSnapshot {
|
||||
if (playerArg) {
|
||||
return playerArg;
|
||||
}
|
||||
throw new Error('Bluesound command requires a target player.');
|
||||
}
|
||||
|
||||
private isLeaderOnlyCommand(commandArg: IBluesoundCommandRequest['command']): boolean {
|
||||
return commandArg === 'play'
|
||||
|| commandArg === 'pause'
|
||||
|| commandArg === 'stop'
|
||||
|| commandArg === 'next_track'
|
||||
|| commandArg === 'previous_track'
|
||||
|| commandArg === 'seek'
|
||||
|| commandArg === 'select_source'
|
||||
|| commandArg === 'play_media'
|
||||
|| commandArg === 'play_preset'
|
||||
|| commandArg === 'clear_playlist'
|
||||
|| commandArg === 'shuffle';
|
||||
}
|
||||
|
||||
private volumePercent(requestArg: IBluesoundCommandRequest): number {
|
||||
const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel * 100 : undefined);
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error('Bluesound set_volume requires volume or volumeLevel.');
|
||||
}
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
private async requestText(pathArg: string, parametersArg: Record<string, string | number | boolean> = {}): Promise<string> {
|
||||
const response = await this.requestRaw(pathArg, parametersArg);
|
||||
if (typeof response === 'string') {
|
||||
return response;
|
||||
}
|
||||
if (typeof (response as IBluesoundRawCommandResponse).body === 'string') {
|
||||
return (response as IBluesoundRawCommandResponse).body as string;
|
||||
}
|
||||
return typeof (response as IBluesoundRawCommandResponse).data === 'string' ? (response as IBluesoundRawCommandResponse).data as string : '';
|
||||
}
|
||||
|
||||
private async requestRaw(pathArg: string, parametersArg: Record<string, string | number | boolean>, playerArg?: IBluesoundPlayerSnapshot): Promise<unknown> {
|
||||
const endpoint = this.endpoint(playerArg);
|
||||
const url = endpoint.host ? this.url(pathArg, parametersArg, endpoint.host, endpoint.port) : undefined;
|
||||
const request: IBluesoundRawCommandRequest = {
|
||||
method: 'GET',
|
||||
command: pathArg.replace(/^\//, ''),
|
||||
path: pathArg,
|
||||
parameters: parametersArg,
|
||||
url,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
};
|
||||
|
||||
if (this.config.commandExecutor) {
|
||||
return this.executorResult(await this.config.commandExecutor.execute(request), pathArg);
|
||||
}
|
||||
if (!this.config.host || !endpoint.host || !url) {
|
||||
throw new Error('Bluesound command transport requires config.host or commandExecutor. Static snapshots are read-only.');
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || bluesoundDefaultTimeoutMs);
|
||||
try {
|
||||
const response = await globalThis.fetch(url, { method: 'GET', signal: abortController.signal });
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new BluesoundHttpError(response.status, `Bluesound request ${pathArg} failed with HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
return { status: response.status, body } satisfies IBluesoundRawCommandResponse;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private executorResult(resultArg: unknown, pathArg: string): unknown {
|
||||
if (isRawCommandResponse(resultArg) && typeof resultArg.status === 'number' && resultArg.status >= 400) {
|
||||
throw new BluesoundHttpError(resultArg.status, `Bluesound request ${pathArg} failed with HTTP ${resultArg.status}: ${resultArg.body || ''}`);
|
||||
}
|
||||
return resultArg;
|
||||
}
|
||||
|
||||
private endpoint(playerArg?: IBluesoundPlayerSnapshot): { host?: string; port: number } {
|
||||
const paired = playerArg ? endpointFromPlayer(playerArg) : undefined;
|
||||
return {
|
||||
host: playerArg?.host || paired?.ip || this.config.host,
|
||||
port: playerArg?.port || paired?.port || this.config.port || bluesoundDefaultPort,
|
||||
};
|
||||
}
|
||||
|
||||
private url(pathArg: string, parametersArg: Record<string, string | number | boolean>, hostArg: string, portArg: number): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(parametersArg)) {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const query = params.toString();
|
||||
return `http://${formatHost(hostArg)}:${portArg}${pathArg}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IBluesoundSnapshot, sourceArg: IBluesoundSnapshot['source']): IBluesoundSnapshot {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const players = (snapshotArg.players || []).map((playerArg) => ({
|
||||
...playerArg,
|
||||
host: playerArg.host || this.config.host,
|
||||
port: playerArg.port || this.config.port || bluesoundDefaultPort,
|
||||
status: { ...playerArg.status, state: playerArg.status.state || 'unknown' },
|
||||
syncStatus: {
|
||||
...playerArg.syncStatus,
|
||||
name: playerArg.syncStatus.name || this.config.name || playerArg.host || 'Bluesound',
|
||||
brand: playerArg.syncStatus.brand || this.config.manufacturer || 'Bluesound',
|
||||
model: playerArg.syncStatus.model || this.config.model,
|
||||
modelName: playerArg.syncStatus.modelName || this.config.modelName || this.config.model,
|
||||
},
|
||||
presets: playerArg.presets || [],
|
||||
inputs: playerArg.inputs || [],
|
||||
available: playerArg.available ?? snapshotArg.online,
|
||||
updatedAt: playerArg.updatedAt || updatedAt,
|
||||
}));
|
||||
return {
|
||||
...snapshotArg,
|
||||
players,
|
||||
online: snapshotArg.online,
|
||||
updatedAt,
|
||||
source: snapshotArg.source || sourceArg,
|
||||
};
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IBluesoundSnapshot): IBluesoundSnapshot {
|
||||
return this.cloneValue(snapshotArg);
|
||||
}
|
||||
|
||||
private cloneValue<TValue>(valueArg: TValue): TValue {
|
||||
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
}
|
||||
|
||||
const parseStatus = (xmlArg: string): IBluesoundStatus => {
|
||||
const status = firstElement(xmlArg, 'status');
|
||||
if (!status) {
|
||||
throw new Error('Bluesound status response missing status element.');
|
||||
}
|
||||
const child = (tagArg: string): string | undefined => childText(status.body, tagArg);
|
||||
return {
|
||||
etag: status.attributes.etag,
|
||||
inputId: child('inputId'),
|
||||
service: child('service'),
|
||||
state: child('state') || 'unknown',
|
||||
shuffle: booleanOne(child('shuffle')),
|
||||
album: child('album') || child('title3'),
|
||||
artist: child('artist') || child('title2'),
|
||||
name: child('name') || child('title1'),
|
||||
image: child('image'),
|
||||
volume: numberValue(child('volume')),
|
||||
volumeDb: numberValue(child('db')),
|
||||
mute: booleanOne(child('mute')),
|
||||
muteVolume: numberValue(child('muteVolume')),
|
||||
muteVolumeDb: numberValue(child('muteDb')),
|
||||
seconds: numberValue(child('secs')),
|
||||
totalSeconds: numberValue(child('totlen')),
|
||||
canSeek: booleanOne(child('canSeek')),
|
||||
sleep: numberValue(child('sleep')) || 0,
|
||||
groupName: child('groupName'),
|
||||
groupVolume: numberValue(child('groupVolume')),
|
||||
indexing: booleanOne(child('indexing')),
|
||||
streamUrl: child('streamUrl'),
|
||||
};
|
||||
};
|
||||
|
||||
const parseSyncStatus = (xmlArg: string): IBluesoundSyncStatus => {
|
||||
const syncStatus = firstElement(xmlArg, 'SyncStatus');
|
||||
if (!syncStatus) {
|
||||
throw new Error('Bluesound sync response missing SyncStatus element.');
|
||||
}
|
||||
const leaderElement = firstElement(syncStatus.body, 'master');
|
||||
const leader = leaderElement?.body.trim() ? {
|
||||
ip: unescapeXml(leaderElement.body.trim()),
|
||||
port: numberValue(leaderElement.attributes.port) || bluesoundDefaultPort,
|
||||
} : undefined;
|
||||
const followers = allElements(syncStatus.body, 'slave')
|
||||
.map((elementArg) => ({
|
||||
ip: elementArg.attributes.id,
|
||||
port: numberValue(elementArg.attributes.port) || bluesoundDefaultPort,
|
||||
}))
|
||||
.filter((playerArg): playerArg is IBluesoundPairedPlayer => Boolean(playerArg.ip));
|
||||
const attrs = syncStatus.attributes;
|
||||
return {
|
||||
etag: attrs.etag,
|
||||
id: attrs.id || attrs.name || 'bluesound',
|
||||
mac: attrs.mac || attrs.id || attrs.name || 'bluesound',
|
||||
name: attrs.name || attrs.id || 'Bluesound',
|
||||
image: attrs.icon,
|
||||
initialized: attrs.initialized === undefined ? undefined : attrs.initialized === 'true',
|
||||
group: attrs.group,
|
||||
leader,
|
||||
followers: followers.length ? followers : undefined,
|
||||
zone: attrs.zone,
|
||||
zoneLeader: attrs.zoneMaster === undefined ? undefined : attrs.zoneMaster === 'true',
|
||||
zoneFollower: attrs.zoneMaster === undefined ? undefined : attrs.zoneMaster !== 'true',
|
||||
brand: attrs.brand || 'Bluesound',
|
||||
model: attrs.model,
|
||||
modelName: attrs.modelName || attrs.model,
|
||||
muteVolumeDb: numberValue(attrs.muteDb),
|
||||
muteVolume: numberValue(attrs.muteVolume),
|
||||
volumeDb: numberValue(attrs.db),
|
||||
volume: numberValue(attrs.volume),
|
||||
};
|
||||
};
|
||||
|
||||
const parsePresets = (xmlArg: string): IBluesoundPreset[] => {
|
||||
return allElements(xmlArg, 'preset').map((elementArg) => ({
|
||||
name: elementArg.attributes.name || `Preset ${elementArg.attributes.id || ''}`.trim(),
|
||||
id: numberValue(elementArg.attributes.id) || 0,
|
||||
url: elementArg.attributes.url || '',
|
||||
image: elementArg.attributes.image,
|
||||
volume: numberValue(elementArg.attributes.volume),
|
||||
})).filter((presetArg) => Boolean(presetArg.name && presetArg.url));
|
||||
};
|
||||
|
||||
const parseInputs = (xmlArg: string): IBluesoundInput[] => {
|
||||
return allElements(xmlArg, 'item').map((elementArg) => {
|
||||
const url = elementArg.attributes.URL || elementArg.attributes.url;
|
||||
return {
|
||||
id: elementArg.attributes.id,
|
||||
text: elementArg.attributes.text,
|
||||
image: elementArg.attributes.image,
|
||||
url: decodeURIComponentSafe(url || ''),
|
||||
};
|
||||
}).filter((inputArg) => Boolean(inputArg.url));
|
||||
};
|
||||
|
||||
export const parseBluesoundStatusXml = parseStatus;
|
||||
export const parseBluesoundSyncStatusXml = parseSyncStatus;
|
||||
export const parseBluesoundPresetsXml = parsePresets;
|
||||
export const parseBluesoundInputsXml = parseInputs;
|
||||
|
||||
const allElements = (xmlArg: string, tagArg: string): Array<{ attributes: Record<string, string>; body: string }> => {
|
||||
const escapedTag = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`<${escapedTag}\\b([^>]*)>([\\s\\S]*?)<\\/${escapedTag}>|<${escapedTag}\\b([^>]*)\\/>`, 'gi');
|
||||
const result: Array<{ attributes: Record<string, string>; body: string }> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(xmlArg))) {
|
||||
result.push({ attributes: parseAttributes(match[1] || match[3] || ''), body: match[2] || '' });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const firstElement = (xmlArg: string, tagArg: string): { attributes: Record<string, string>; body: string } | undefined => allElements(xmlArg, tagArg)[0];
|
||||
|
||||
const childText = (xmlArg: string, tagArg: string): string | undefined => {
|
||||
const child = firstElement(xmlArg, tagArg);
|
||||
const value = child?.body.trim();
|
||||
return value ? unescapeXml(value) : undefined;
|
||||
};
|
||||
|
||||
const parseAttributes = (valueArg: string): Record<string, string> => {
|
||||
const result: Record<string, string> = {};
|
||||
const regex = /([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(valueArg))) {
|
||||
result[match[1]] = unescapeXml(match[2] ?? match[3] ?? '');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const booleanOne = (valueArg: string | undefined): boolean | undefined => {
|
||||
if (valueArg === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return valueArg === '1' || valueArg.toLowerCase() === 'true';
|
||||
};
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const endpointFromPlayer = (playerArg: IBluesoundPlayerSnapshot): IBluesoundPairedPlayer | undefined => {
|
||||
if (playerArg.host) {
|
||||
return { ip: playerArg.host, port: playerArg.port || bluesoundDefaultPort };
|
||||
}
|
||||
return pairedPlayerFromId(playerArg.syncStatus.id);
|
||||
};
|
||||
|
||||
const endpointPlayer = (playerArg: IBluesoundPairedPlayer, updatedAtArg: string | undefined): IBluesoundPlayerSnapshot => ({
|
||||
host: playerArg.ip,
|
||||
port: playerArg.port,
|
||||
syncStatus: {
|
||||
id: pairedPlayerId(playerArg),
|
||||
mac: pairedPlayerId(playerArg),
|
||||
name: pairedPlayerId(playerArg),
|
||||
brand: 'Bluesound',
|
||||
},
|
||||
status: { state: 'unknown' },
|
||||
presets: [],
|
||||
inputs: [],
|
||||
available: true,
|
||||
updatedAt: updatedAtArg,
|
||||
});
|
||||
|
||||
const pairedPlayerFromId = (idArg: string | undefined): IBluesoundPairedPlayer | undefined => {
|
||||
if (!idArg) {
|
||||
return undefined;
|
||||
}
|
||||
const separator = idArg.lastIndexOf(':');
|
||||
if (separator <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const ip = idArg.slice(0, separator);
|
||||
const port = Number(idArg.slice(separator + 1));
|
||||
return Number.isFinite(port) ? { ip, port } : undefined;
|
||||
};
|
||||
|
||||
const pairedPlayerId = (playerArg: IBluesoundPairedPlayer): string => `${playerArg.ip}:${playerArg.port}`;
|
||||
|
||||
const isGroupedFollower = (playerArg: IBluesoundPlayerSnapshot): boolean => Boolean(playerArg.syncStatus.leader);
|
||||
|
||||
const isRawCommandResponse = (valueArg: unknown): valueArg is IBluesoundRawCommandResponse => {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && ('status' in valueArg || 'body' in valueArg || 'data' in valueArg));
|
||||
};
|
||||
|
||||
const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
|
||||
const unescapeXml = (valueArg: string): string => {
|
||||
return valueArg
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
};
|
||||
|
||||
const decodeURIComponentSafe = (valueArg: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(valueArg);
|
||||
} catch {
|
||||
return valueArg;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IBluesoundConfig } from './bluesound.types.js';
|
||||
import { bluesoundDefaultPort, bluesoundDefaultTimeoutMs } from './bluesound.types.js';
|
||||
|
||||
export class BluesoundConfigFlow implements IConfigFlow<IBluesoundConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBluesoundConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Bluesound',
|
||||
description: candidateArg.source === 'manual'
|
||||
? 'Configure a local Bluesound/BluOS player HTTP endpoint.'
|
||||
: 'Confirm or adjust the discovered local Bluesound/BluOS player.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host || '';
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'Bluesound setup failed', error: 'Bluesound setup requires a host.' };
|
||||
}
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || bluesoundDefaultPort;
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Bluesound configured',
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name,
|
||||
macAddress: candidateArg.macAddress,
|
||||
manufacturer: candidateArg.manufacturer,
|
||||
model: candidateArg.model,
|
||||
timeoutMs: bluesoundDefaultTimeoutMs,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,237 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { BluesoundClient } from './bluesound.classes.client.js';
|
||||
import { BluesoundConfigFlow } from './bluesound.classes.configflow.js';
|
||||
import { createBluesoundDiscoveryDescriptor } from './bluesound.discovery.js';
|
||||
import { BluesoundMapper } from './bluesound.mapper.js';
|
||||
import type { IBluesoundCommandRequest, IBluesoundConfig, IBluesoundPairedPlayer } from './bluesound.types.js';
|
||||
|
||||
export class HomeAssistantBluesoundIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "bluesound",
|
||||
displayName: "Bluesound",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/bluesound",
|
||||
"upstreamDomain": "bluesound",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"pyblu==2.0.6"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [
|
||||
"zeroconf"
|
||||
],
|
||||
"codeowners": [
|
||||
"@thrawnarn",
|
||||
"@LouisChrist"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class BluesoundIntegration extends BaseIntegration<IBluesoundConfig> {
|
||||
public readonly domain = 'bluesound';
|
||||
public readonly displayName = 'Bluesound';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createBluesoundDiscoveryDescriptor();
|
||||
public readonly configFlow = new BluesoundConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/bluesound',
|
||||
upstreamDomain: 'bluesound',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['pyblu==2.0.6'],
|
||||
dependencies: [],
|
||||
afterDependencies: ['zeroconf'],
|
||||
codeowners: ['@thrawnarn', '@LouisChrist'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/bluesound',
|
||||
zeroconf: ['_musc._tcp.local.'],
|
||||
};
|
||||
|
||||
public async setup(configArg: IBluesoundConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new BluesoundRuntime(new BluesoundClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantBluesoundIntegration extends BluesoundIntegration {}
|
||||
|
||||
class BluesoundRuntime implements IIntegrationRuntime {
|
||||
public domain = 'bluesound';
|
||||
|
||||
constructor(private readonly client: BluesoundClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return BluesoundMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return BluesoundMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'media_player') {
|
||||
return await this.callMediaPlayerService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'bluesound') {
|
||||
return await this.callBluesoundService(requestArg);
|
||||
}
|
||||
return { success: false, error: `Unsupported Bluesound service domain: ${requestArg.domain}` };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'media_play' || requestArg.service === 'play') {
|
||||
return this.execute({ command: 'play', playerId: await this.playerIdFromRequest(requestArg) });
|
||||
}
|
||||
if (requestArg.service === 'media_pause' || requestArg.service === 'pause') {
|
||||
return this.execute({ command: 'pause', playerId: await this.playerIdFromRequest(requestArg) });
|
||||
}
|
||||
if (requestArg.service === 'media_stop' || requestArg.service === 'stop') {
|
||||
return this.execute({ command: 'stop', playerId: await this.playerIdFromRequest(requestArg) });
|
||||
}
|
||||
if (requestArg.service === 'media_next_track' || requestArg.service === 'next_track' || requestArg.service === 'next') {
|
||||
return this.execute({ command: 'next_track', playerId: await this.playerIdFromRequest(requestArg) });
|
||||
}
|
||||
if (requestArg.service === 'media_previous_track' || requestArg.service === 'previous_track' || requestArg.service === 'previous') {
|
||||
return this.execute({ command: 'previous_track', playerId: await this.playerIdFromRequest(requestArg) });
|
||||
}
|
||||
if (requestArg.service === 'media_seek' || requestArg.service === 'seek') {
|
||||
return this.execute({ command: 'seek', playerId: await this.playerIdFromRequest(requestArg), position: this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position') });
|
||||
}
|
||||
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') {
|
||||
return this.execute({ command: 'set_volume', playerId: await this.playerIdFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level'), volume: this.numberData(requestArg, 'volume') });
|
||||
}
|
||||
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
|
||||
return this.execute({ command: 'mute', playerId: await this.playerIdFromRequest(requestArg), muted: this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute') });
|
||||
}
|
||||
if (requestArg.service === 'select_source' || requestArg.service === 'source') {
|
||||
return this.execute({ command: 'select_source', playerId: await this.playerIdFromRequest(requestArg), source: this.stringData(requestArg, 'source') });
|
||||
}
|
||||
if (requestArg.service === 'play_media') {
|
||||
return this.execute({ command: 'play_media', playerId: await this.playerIdFromRequest(requestArg), mediaId: this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri'), mediaType: this.stringData(requestArg, 'media_content_type') });
|
||||
}
|
||||
if (requestArg.service === 'clear_playlist') {
|
||||
return this.execute({ command: 'clear_playlist', playerId: await this.playerIdFromRequest(requestArg) });
|
||||
}
|
||||
if (requestArg.service === 'shuffle_set') {
|
||||
return this.execute({ command: 'shuffle', playerId: await this.playerIdFromRequest(requestArg), shuffle: this.booleanData(requestArg, 'shuffle') });
|
||||
}
|
||||
if (requestArg.service === 'join' || requestArg.service === 'join_players') {
|
||||
return this.execute(await this.joinCommandRequest(requestArg));
|
||||
}
|
||||
if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
|
||||
return this.execute({ command: 'unjoin', playerId: await this.playerIdFromRequest(requestArg) });
|
||||
}
|
||||
return { success: false, error: `Unsupported Bluesound media_player service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callBluesoundService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
|
||||
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
||||
}
|
||||
if (requestArg.service === 'play_preset' || requestArg.service === 'preset') {
|
||||
return this.execute({
|
||||
command: 'play_preset',
|
||||
playerId: await this.playerIdFromRequest(requestArg),
|
||||
presetId: this.numberData(requestArg, 'preset_id') ?? this.numberData(requestArg, 'presetId') ?? this.numberData(requestArg, 'id'),
|
||||
presetName: this.stringData(requestArg, 'preset') || this.stringData(requestArg, 'preset_name') || this.stringData(requestArg, 'name'),
|
||||
});
|
||||
}
|
||||
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'stop' || requestArg.service === 'media_play' || requestArg.service === 'media_pause' || requestArg.service === 'media_stop' || requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume_mute' || requestArg.service === 'mute' || requestArg.service === 'select_source' || requestArg.service === 'source' || requestArg.service === 'clear_playlist' || requestArg.service === 'shuffle_set') {
|
||||
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
||||
}
|
||||
return { success: false, error: `Unsupported Bluesound service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async execute(requestArg: IBluesoundCommandRequest): Promise<IServiceCallResult> {
|
||||
return { success: true, data: await this.client.execute(requestArg) };
|
||||
}
|
||||
|
||||
private async joinCommandRequest(requestArg: IServiceCallRequest): Promise<IBluesoundCommandRequest> {
|
||||
const leaderId = await this.playerIdFromRequest(requestArg);
|
||||
const playerIds = await this.joinPlayerIdsFromRequest(requestArg);
|
||||
const followers = this.followersFromRequest(requestArg);
|
||||
return {
|
||||
command: 'join',
|
||||
playerId: leaderId,
|
||||
playerIds: [leaderId, ...playerIds.filter((playerIdArg) => playerIdArg !== leaderId)],
|
||||
followers,
|
||||
};
|
||||
}
|
||||
|
||||
private async playerIdFromRequest(requestArg: IServiceCallRequest): Promise<string> {
|
||||
const direct = this.stringData(requestArg, 'player_id') || this.stringData(requestArg, 'playerId') || this.stringData(requestArg, 'id');
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
if (requestArg.target.entityId) {
|
||||
const entityPlayerId = BluesoundMapper.entityPlayerId(snapshot, requestArg.target.entityId);
|
||||
if (entityPlayerId) {
|
||||
return entityPlayerId;
|
||||
}
|
||||
}
|
||||
if (requestArg.target.deviceId) {
|
||||
const player = snapshot.players.find((playerArg) => BluesoundMapper.playerDeviceId(playerArg) === requestArg.target.deviceId);
|
||||
if (player) {
|
||||
return player.syncStatus.id;
|
||||
}
|
||||
}
|
||||
if (snapshot.players.length === 1) {
|
||||
return snapshot.players[0].syncStatus.id;
|
||||
}
|
||||
throw new Error('Bluesound service call requires data.player_id or a target Bluesound media_player entity.');
|
||||
}
|
||||
|
||||
private async joinPlayerIdsFromRequest(requestArg: IServiceCallRequest): Promise<string[]> {
|
||||
const direct = this.stringArrayData(requestArg, 'player_ids') || this.stringArrayData(requestArg, 'playerIds');
|
||||
if (direct?.length) {
|
||||
return direct;
|
||||
}
|
||||
const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers');
|
||||
if (!members?.length) {
|
||||
return [];
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
return members.map((memberArg) => BluesoundMapper.entityPlayerId(snapshot, memberArg) || memberArg);
|
||||
}
|
||||
|
||||
private followersFromRequest(requestArg: IServiceCallRequest): IBluesoundPairedPlayer[] | undefined {
|
||||
const host = this.stringData(requestArg, 'host') || this.stringData(requestArg, 'follower_host') || this.stringData(requestArg, 'followerHost');
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
return [{ ip: host, port: this.numberData(requestArg, 'port') || this.numberData(requestArg, 'follower_port') || 11000 }];
|
||||
}
|
||||
|
||||
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private booleanData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (value.toLowerCase() === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value.toLowerCase() === 'false') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
}
|
||||
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string') ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IBluesoundManualEntry, IBluesoundMdnsRecord } from './bluesound.types.js';
|
||||
import { bluesoundDefaultPort } from './bluesound.types.js';
|
||||
|
||||
const bluesoundDomain = 'bluesound';
|
||||
const bluesoundMdnsType = '_musc._tcp';
|
||||
|
||||
export class BluesoundMdnsMatcher implements IDiscoveryMatcher<IBluesoundMdnsRecord> {
|
||||
public id = 'bluesound-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Bluesound/BluOS mDNS advertisements.';
|
||||
|
||||
public async matches(recordArg: IBluesoundMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
|
||||
const txt = { ...recordArg.txt, ...recordArg.properties };
|
||||
const name = cleanName(recordArg.name || recordArg.hostname || valueForKey(txt, 'name'));
|
||||
const manufacturer = valueForKey(txt, 'brand') || valueForKey(txt, 'manufacturer');
|
||||
const model = valueForKey(txt, 'modelName') || valueForKey(txt, 'model');
|
||||
const haystack = `${name || ''} ${type} ${manufacturer || ''} ${model || ''}`.toLowerCase();
|
||||
const matched = type === bluesoundMdnsType || haystack.includes('bluesound') || haystack.includes('bluos');
|
||||
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Bluesound/BluOS advertisement.' };
|
||||
}
|
||||
|
||||
const host = recordArg.host || recordArg.addresses?.[0];
|
||||
const port = recordArg.port || numberString(valueForKey(txt, 'port')) || bluesoundDefaultPort;
|
||||
const macAddress = valueForKey(txt, 'mac') || valueForKey(txt, 'macAddress');
|
||||
const id = macAddress || valueForKey(txt, 'id') || valueForKey(txt, 'uuid') || (host ? `${host}:${port}` : name);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: type === bluesoundMdnsType && host ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: type === bluesoundMdnsType ? 'mDNS service type matches Home Assistant Bluesound zeroconf.' : 'mDNS metadata contains Bluesound/BluOS hints.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: bluesoundDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
manufacturer: manufacturer || 'Bluesound',
|
||||
model,
|
||||
macAddress,
|
||||
metadata: {
|
||||
mdnsType: type,
|
||||
txt,
|
||||
},
|
||||
},
|
||||
metadata: { mdnsType: type },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BluesoundManualMatcher implements IDiscoveryMatcher<IBluesoundManualEntry> {
|
||||
public id = 'bluesound-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Bluesound/BluOS setup entries.';
|
||||
|
||||
public async matches(inputArg: IBluesoundManualEntry): Promise<IDiscoveryMatch> {
|
||||
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''} ${inputArg.modelName || ''}`.toLowerCase();
|
||||
const matched = Boolean(inputArg.host || inputArg.metadata?.bluesound || inputArg.metadata?.bluos || haystack.includes('bluesound') || haystack.includes('bluos'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Bluesound/BluOS setup hints.' };
|
||||
}
|
||||
|
||||
const port = inputArg.port || bluesoundDefaultPort;
|
||||
const id = inputArg.macAddress || inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Bluesound setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: bluesoundDomain,
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port,
|
||||
name: inputArg.name,
|
||||
manufacturer: inputArg.manufacturer || 'Bluesound',
|
||||
model: inputArg.modelName || inputArg.model,
|
||||
macAddress: inputArg.macAddress,
|
||||
metadata: inputArg.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BluesoundCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'bluesound-candidate-validator';
|
||||
public description = 'Validate Bluesound/BluOS discovery candidates.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const mdnsType = typeof metadata.mdnsType === 'string' ? normalizeMdnsType(metadata.mdnsType) : '';
|
||||
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === bluesoundDomain
|
||||
|| mdnsType === bluesoundMdnsType
|
||||
|| Boolean(metadata.bluesound || metadata.bluos)
|
||||
|| haystack.includes('bluesound')
|
||||
|| haystack.includes('bluos');
|
||||
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has Bluesound/BluOS metadata.' : 'Candidate is not Bluesound/BluOS.',
|
||||
normalizedDeviceId: candidateArg.macAddress || candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || bluesoundDefaultPort}` : undefined),
|
||||
candidate: matched ? { ...candidateArg, port: candidateArg.port || bluesoundDefaultPort } : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createBluesoundDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: bluesoundDomain, displayName: 'Bluesound' })
|
||||
.addMatcher(new BluesoundMdnsMatcher())
|
||||
.addMatcher(new BluesoundManualMatcher())
|
||||
.addValidator(new BluesoundCandidateValidator());
|
||||
};
|
||||
|
||||
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
|
||||
|
||||
const cleanName = (valueArg: string | undefined): string | undefined => {
|
||||
return valueArg
|
||||
?.replace(/\._musc\._tcp\.local\.?$/i, '')
|
||||
.replace(/\.local\.?$/i, '')
|
||||
.trim() || undefined;
|
||||
};
|
||||
|
||||
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerKey = keyArg.toLowerCase();
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (key.toLowerCase() === lowerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const numberString = (valueArg: string | undefined): number | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number(valueArg);
|
||||
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { IBluesoundPlayerSnapshot, IBluesoundSnapshot, IBluesoundStatus } from './bluesound.types.js';
|
||||
import { bluesoundDefaultPort } from './bluesound.types.js';
|
||||
|
||||
export class BluesoundMapper {
|
||||
public static toDevices(snapshotArg: IBluesoundSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
return snapshotArg.players.map((playerArg) => {
|
||||
const status = playerArg.status;
|
||||
return {
|
||||
id: this.playerDeviceId(playerArg),
|
||||
integrationDomain: 'bluesound',
|
||||
name: playerArg.syncStatus.name,
|
||||
protocol: 'http',
|
||||
manufacturer: playerArg.syncStatus.brand || 'Bluesound',
|
||||
model: playerArg.syncStatus.modelName || playerArg.syncStatus.model,
|
||||
online: this.playerAvailable(playerArg),
|
||||
features: [
|
||||
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
|
||||
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
|
||||
{ id: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
|
||||
{ id: 'presets', capability: 'media', name: 'Presets', readable: true, writable: true },
|
||||
{ id: 'group', capability: 'media', name: 'Group', readable: true, writable: true },
|
||||
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||
{ id: 'media_position', capability: 'media', name: 'Media position', readable: true, writable: true, unit: 's' },
|
||||
{ id: 'media_duration', capability: 'media', name: 'Media duration', readable: true, writable: false, unit: 's' },
|
||||
{ id: 'shuffle', capability: 'media', name: 'Shuffle', readable: true, writable: true },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'playback', value: this.mediaState(playerArg), updatedAt },
|
||||
{ featureId: 'volume', value: this.volumePercent(playerArg) ?? null, updatedAt },
|
||||
{ featureId: 'muted', value: this.muted(playerArg) ?? null, updatedAt },
|
||||
{ featureId: 'source', value: this.currentSource(playerArg) || null, updatedAt },
|
||||
{ featureId: 'presets', value: playerArg.presets?.length || 0, updatedAt },
|
||||
{ featureId: 'group', value: playerArg.syncStatus.group || status.groupName || null, updatedAt },
|
||||
{ featureId: 'current_title', value: this.mediaTitle(playerArg) || null, updatedAt },
|
||||
{ featureId: 'media_position', value: this.mediaPosition(status) ?? null, updatedAt },
|
||||
{ featureId: 'media_duration', value: this.seconds(status.totalSeconds) ?? null, updatedAt },
|
||||
{ featureId: 'shuffle', value: status.shuffle ?? null, updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
playerId: playerArg.syncStatus.id,
|
||||
macAddress: formatMac(playerArg.syncStatus.mac),
|
||||
host: playerArg.host,
|
||||
port: playerArg.port || bluesoundDefaultPort,
|
||||
modelId: playerArg.syncStatus.model,
|
||||
groupRole: this.groupRole(playerArg),
|
||||
source: snapshotArg.source,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IBluesoundSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
for (const player of snapshotArg.players) {
|
||||
const base = this.playerEntityBase(player);
|
||||
const sourceList = this.sourceList(player);
|
||||
const presets = player.presets || [];
|
||||
const inputs = player.inputs || [];
|
||||
entities.push({
|
||||
id: this.playerEntityId(player),
|
||||
uniqueId: `bluesound_${this.slug(this.formatUniqueId(player))}`,
|
||||
integrationDomain: 'bluesound',
|
||||
deviceId: this.playerDeviceId(player),
|
||||
platform: 'media_player',
|
||||
name: player.syncStatus.name,
|
||||
state: this.mediaState(player),
|
||||
attributes: {
|
||||
deviceClass: 'speaker',
|
||||
playerId: player.syncStatus.id,
|
||||
bluesoundDeviceName: player.syncStatus.name,
|
||||
macAddress: formatMac(player.syncStatus.mac),
|
||||
host: player.host,
|
||||
port: player.port || bluesoundDefaultPort,
|
||||
brand: player.syncStatus.brand,
|
||||
model: player.syncStatus.model,
|
||||
modelName: player.syncStatus.modelName,
|
||||
initialized: player.syncStatus.initialized,
|
||||
volumeLevel: this.volumeLevel(player),
|
||||
volume: this.volumePercent(player),
|
||||
volumeDb: this.isGrouped(player) ? player.syncStatus.volumeDb : player.status.volumeDb,
|
||||
isVolumeMuted: this.muted(player),
|
||||
source: this.currentSource(player),
|
||||
sourceList,
|
||||
presets,
|
||||
inputs,
|
||||
mediaContentType: 'music',
|
||||
mediaContentId: player.status.streamUrl,
|
||||
mediaTitle: this.mediaTitle(player),
|
||||
mediaArtist: this.mediaArtist(player),
|
||||
mediaAlbumName: this.isGroupedFollower(player) ? undefined : player.status.album,
|
||||
mediaDuration: this.isGroupedFollower(player) ? undefined : this.seconds(player.status.totalSeconds),
|
||||
mediaPosition: this.isGroupedFollower(player) ? undefined : this.mediaPosition(player.status),
|
||||
mediaImageUrl: this.mediaImageUrl(player),
|
||||
canSeek: player.status.canSeek,
|
||||
shuffle: player.status.shuffle,
|
||||
sleep: player.status.sleep,
|
||||
indexing: player.status.indexing,
|
||||
groupName: player.syncStatus.group || player.status.groupName,
|
||||
groupRole: this.groupRole(player),
|
||||
groupMembers: this.groupMembers(snapshotArg, player),
|
||||
bluesoundGroup: this.bluesoundGroupNames(snapshotArg, player),
|
||||
master: this.isLeader(player),
|
||||
leader: player.syncStatus.leader,
|
||||
followers: player.syncStatus.followers,
|
||||
zone: player.syncStatus.zone,
|
||||
zoneLeader: player.syncStatus.zoneLeader,
|
||||
zoneFollower: player.syncStatus.zoneFollower,
|
||||
},
|
||||
available: this.playerAvailable(player),
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_bluesound_presets`,
|
||||
uniqueId: `bluesound_${this.slug(this.formatUniqueId(player))}_presets`,
|
||||
integrationDomain: 'bluesound',
|
||||
deviceId: this.playerDeviceId(player),
|
||||
platform: 'sensor',
|
||||
name: `${player.syncStatus.name} Bluesound Presets`,
|
||||
state: presets.length,
|
||||
attributes: { playerId: player.syncStatus.id, presets },
|
||||
available: this.playerAvailable(player),
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_bluesound_sources`,
|
||||
uniqueId: `bluesound_${this.slug(this.formatUniqueId(player))}_sources`,
|
||||
integrationDomain: 'bluesound',
|
||||
deviceId: this.playerDeviceId(player),
|
||||
platform: 'sensor',
|
||||
name: `${player.syncStatus.name} Bluesound Sources`,
|
||||
state: sourceList.length,
|
||||
attributes: { playerId: player.syncStatus.id, sourceList, inputs, presets },
|
||||
available: this.playerAvailable(player),
|
||||
});
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static entityPlayerId(snapshotArg: IBluesoundSnapshot, entityIdArg: string): string | undefined {
|
||||
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
|
||||
const playerId = entity?.attributes?.playerId;
|
||||
return typeof playerId === 'string' ? playerId : undefined;
|
||||
}
|
||||
|
||||
public static playerEntityId(playerArg: IBluesoundPlayerSnapshot): string {
|
||||
return `media_player.${this.playerEntityBase(playerArg)}`;
|
||||
}
|
||||
|
||||
public static playerDeviceId(playerArg: IBluesoundPlayerSnapshot): string {
|
||||
return `bluesound.player.${this.slug(this.deviceUniqueBase(playerArg))}`;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string | undefined): string {
|
||||
return (valueArg || 'bluesound').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'bluesound';
|
||||
}
|
||||
|
||||
public static sourceList(playerArg: IBluesoundPlayerSnapshot): string[] {
|
||||
const sources = [
|
||||
...(playerArg.presets || []).map((presetArg) => presetArg.name),
|
||||
...(playerArg.inputs || []).map((inputArg) => inputArg.text || inputArg.id),
|
||||
].filter((valueArg): valueArg is string => Boolean(valueArg));
|
||||
return [...new Set(sources)];
|
||||
}
|
||||
|
||||
private static mediaState(playerArg: IBluesoundPlayerSnapshot): string {
|
||||
if (!this.playerAvailable(playerArg)) {
|
||||
return 'off';
|
||||
}
|
||||
if (this.isGroupedFollower(playerArg)) {
|
||||
return 'idle';
|
||||
}
|
||||
if (playerArg.status.state === 'pause') {
|
||||
return 'paused';
|
||||
}
|
||||
if (playerArg.status.state === 'play' || playerArg.status.state === 'stream') {
|
||||
return 'playing';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
private static currentSource(playerArg: IBluesoundPlayerSnapshot): string | undefined {
|
||||
if (this.isGroupedFollower(playerArg)) {
|
||||
return undefined;
|
||||
}
|
||||
const status = playerArg.status;
|
||||
if (status.inputId) {
|
||||
const input = (playerArg.inputs || []).find((inputArg) => inputArg.id === status.inputId || inputArg.url === status.streamUrl);
|
||||
if (input) {
|
||||
return input.text || input.id;
|
||||
}
|
||||
}
|
||||
const preset = (playerArg.presets || []).find((presetArg) => presetArg.url === status.streamUrl);
|
||||
if (preset) {
|
||||
return preset.name;
|
||||
}
|
||||
return status.service;
|
||||
}
|
||||
|
||||
private static groupMembers(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot): string[] | undefined {
|
||||
const leader = this.leaderPlayer(snapshotArg, playerArg);
|
||||
if (!leader?.syncStatus.followers?.length) {
|
||||
return undefined;
|
||||
}
|
||||
const ids = [leader.syncStatus.id, ...leader.syncStatus.followers.map((followerArg) => pairedPlayerId(followerArg))];
|
||||
return ids
|
||||
.map((idArg) => snapshotArg.players.find((candidateArg) => candidateArg.syncStatus.id === idArg))
|
||||
.filter((candidateArg): candidateArg is IBluesoundPlayerSnapshot => Boolean(candidateArg))
|
||||
.map((candidateArg) => this.playerEntityId(candidateArg));
|
||||
}
|
||||
|
||||
private static bluesoundGroupNames(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot): string[] | undefined {
|
||||
const members = this.groupMembers(snapshotArg, playerArg);
|
||||
if (!members?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return members
|
||||
.map((entityIdArg) => snapshotArg.players.find((candidateArg) => this.playerEntityId(candidateArg) === entityIdArg)?.syncStatus.name)
|
||||
.filter((valueArg): valueArg is string => Boolean(valueArg));
|
||||
}
|
||||
|
||||
private static leaderPlayer(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot): IBluesoundPlayerSnapshot | undefined {
|
||||
if (this.isLeader(playerArg)) {
|
||||
return playerArg;
|
||||
}
|
||||
const leaderId = playerArg.syncStatus.leader ? pairedPlayerId(playerArg.syncStatus.leader) : undefined;
|
||||
return leaderId ? snapshotArg.players.find((candidateArg) => candidateArg.syncStatus.id === leaderId) : undefined;
|
||||
}
|
||||
|
||||
private static mediaTitle(playerArg: IBluesoundPlayerSnapshot): string | undefined {
|
||||
return this.isGroupedFollower(playerArg) ? undefined : playerArg.status.name;
|
||||
}
|
||||
|
||||
private static mediaArtist(playerArg: IBluesoundPlayerSnapshot): string | undefined {
|
||||
if (this.isGroupedFollower(playerArg)) {
|
||||
return playerArg.syncStatus.group || playerArg.status.groupName;
|
||||
}
|
||||
return playerArg.status.artist;
|
||||
}
|
||||
|
||||
private static mediaImageUrl(playerArg: IBluesoundPlayerSnapshot): string | undefined {
|
||||
if (this.isGroupedFollower(playerArg)) {
|
||||
return undefined;
|
||||
}
|
||||
const image = playerArg.status.image;
|
||||
if (!image) {
|
||||
return undefined;
|
||||
}
|
||||
if (!image.startsWith('/')) {
|
||||
return image;
|
||||
}
|
||||
const host = playerArg.host;
|
||||
if (!host) {
|
||||
return image;
|
||||
}
|
||||
return `http://${formatHost(host)}:${playerArg.port || bluesoundDefaultPort}${image}`;
|
||||
}
|
||||
|
||||
private static volumeLevel(playerArg: IBluesoundPlayerSnapshot): number | undefined {
|
||||
const volume = this.volumePercent(playerArg);
|
||||
return typeof volume === 'number' && volume >= 0 ? volume / 100 : undefined;
|
||||
}
|
||||
|
||||
private static volumePercent(playerArg: IBluesoundPlayerSnapshot): number | undefined {
|
||||
const volume = this.isGrouped(playerArg) ? playerArg.syncStatus.volume : playerArg.status.volume;
|
||||
return typeof volume === 'number' && volume >= 0 ? Math.max(0, Math.min(100, Math.round(volume))) : undefined;
|
||||
}
|
||||
|
||||
private static muted(playerArg: IBluesoundPlayerSnapshot): boolean | undefined {
|
||||
if (this.isGrouped(playerArg)) {
|
||||
return playerArg.syncStatus.muteVolume !== undefined;
|
||||
}
|
||||
return playerArg.status.mute;
|
||||
}
|
||||
|
||||
private static mediaPosition(statusArg: IBluesoundStatus): number | undefined {
|
||||
return this.seconds(statusArg.seconds);
|
||||
}
|
||||
|
||||
private static seconds(valueArg: number | undefined): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? Math.floor(valueArg) : undefined;
|
||||
}
|
||||
|
||||
private static groupRole(playerArg: IBluesoundPlayerSnapshot): string | undefined {
|
||||
if (this.isLeader(playerArg)) {
|
||||
return 'leader';
|
||||
}
|
||||
if (this.isGroupedFollower(playerArg)) {
|
||||
return 'member';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static isGrouped(playerArg: IBluesoundPlayerSnapshot): boolean {
|
||||
return this.isLeader(playerArg) || this.isGroupedFollower(playerArg);
|
||||
}
|
||||
|
||||
private static isLeader(playerArg: IBluesoundPlayerSnapshot): boolean {
|
||||
return Boolean(playerArg.syncStatus.followers?.length);
|
||||
}
|
||||
|
||||
private static isGroupedFollower(playerArg: IBluesoundPlayerSnapshot): boolean {
|
||||
return Boolean(playerArg.syncStatus.leader);
|
||||
}
|
||||
|
||||
private static playerAvailable(playerArg: IBluesoundPlayerSnapshot): boolean {
|
||||
return playerArg.available !== false;
|
||||
}
|
||||
|
||||
private static playerEntityBase(playerArg: IBluesoundPlayerSnapshot): string {
|
||||
return this.slug(playerArg.syncStatus.name || playerArg.syncStatus.id);
|
||||
}
|
||||
|
||||
private static deviceUniqueBase(playerArg: IBluesoundPlayerSnapshot): string {
|
||||
const mac = formatMac(playerArg.syncStatus.mac) || playerArg.syncStatus.id;
|
||||
const port = playerArg.port || bluesoundDefaultPort;
|
||||
return port === bluesoundDefaultPort ? mac : `${mac}-${port}`;
|
||||
}
|
||||
|
||||
private static formatUniqueId(playerArg: IBluesoundPlayerSnapshot): string {
|
||||
return `${formatMac(playerArg.syncStatus.mac) || playerArg.syncStatus.id}-${playerArg.port || bluesoundDefaultPort}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const formatBluesoundUniqueId = (macArg: string, portArg: number): string => `${formatMac(macArg) || macArg}-${portArg}`;
|
||||
|
||||
const pairedPlayerId = (playerArg: { ip: string; port: number }): string => `${playerArg.ip}:${playerArg.port}`;
|
||||
|
||||
const formatMac = (valueArg: string | undefined): string => {
|
||||
const clean = (valueArg || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
if (clean.length !== 12) {
|
||||
return valueArg?.toLowerCase() || '';
|
||||
}
|
||||
return clean.match(/.{1,2}/g)?.join(':') || clean;
|
||||
};
|
||||
|
||||
const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
@@ -1,4 +1,188 @@
|
||||
export interface IHomeAssistantBluesoundConfig {
|
||||
// TODO: replace with the TypeScript-native config for bluesound.
|
||||
[key: string]: unknown;
|
||||
export const bluesoundDefaultPort = 11000;
|
||||
export const bluesoundDefaultTimeoutMs = 5000;
|
||||
|
||||
export type TBluesoundSnapshotSource = 'snapshot' | 'http' | 'executor' | 'manual' | 'runtime';
|
||||
export type TBluesoundPlaybackState = 'play' | 'stream' | 'pause' | 'stop' | 'unknown' | (string & {});
|
||||
export type TBluesoundCommand =
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'next_track'
|
||||
| 'previous_track'
|
||||
| 'seek'
|
||||
| 'set_volume'
|
||||
| 'mute'
|
||||
| 'select_source'
|
||||
| 'play_media'
|
||||
| 'play_preset'
|
||||
| 'clear_playlist'
|
||||
| 'shuffle'
|
||||
| 'join'
|
||||
| 'unjoin'
|
||||
| 'add_follower'
|
||||
| 'remove_follower';
|
||||
|
||||
export interface IBluesoundConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
macAddress?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
modelName?: string;
|
||||
timeoutMs?: number;
|
||||
snapshot?: IBluesoundSnapshot;
|
||||
commandExecutor?: IBluesoundCommandExecutor;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantBluesoundConfig extends IBluesoundConfig {}
|
||||
|
||||
export interface IBluesoundCommandExecutor {
|
||||
execute(requestArg: IBluesoundRawCommandRequest): Promise<IBluesoundRawCommandResponse | Record<string, unknown> | unknown>;
|
||||
}
|
||||
|
||||
export interface IBluesoundRawCommandRequest {
|
||||
method: 'GET';
|
||||
command: string;
|
||||
path: string;
|
||||
parameters: Record<string, string | number | boolean>;
|
||||
url?: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface IBluesoundRawCommandResponse {
|
||||
status?: number;
|
||||
body?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface IBluesoundCommandRequest {
|
||||
command: TBluesoundCommand;
|
||||
playerId?: string;
|
||||
playerIds?: string[];
|
||||
followers?: IBluesoundPairedPlayer[];
|
||||
follower?: IBluesoundPairedPlayer;
|
||||
source?: string;
|
||||
presetId?: number;
|
||||
presetName?: string;
|
||||
mediaId?: string;
|
||||
mediaType?: string;
|
||||
volumeLevel?: number;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
shuffle?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface IBluesoundPairedPlayer {
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface IBluesoundStatus {
|
||||
etag?: string;
|
||||
inputId?: string;
|
||||
service?: string;
|
||||
state: TBluesoundPlaybackState;
|
||||
shuffle?: boolean;
|
||||
album?: string;
|
||||
artist?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
volume?: number;
|
||||
volumeDb?: number;
|
||||
mute?: boolean;
|
||||
muteVolume?: number;
|
||||
muteVolumeDb?: number;
|
||||
seconds?: number;
|
||||
totalSeconds?: number;
|
||||
canSeek?: boolean;
|
||||
sleep?: number;
|
||||
groupName?: string;
|
||||
groupVolume?: number;
|
||||
indexing?: boolean;
|
||||
streamUrl?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IBluesoundSyncStatus {
|
||||
etag?: string;
|
||||
id: string;
|
||||
mac: string;
|
||||
name: string;
|
||||
image?: string;
|
||||
initialized?: boolean;
|
||||
group?: string;
|
||||
leader?: IBluesoundPairedPlayer;
|
||||
followers?: IBluesoundPairedPlayer[];
|
||||
zone?: string;
|
||||
zoneLeader?: boolean;
|
||||
zoneFollower?: boolean;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
modelName?: string;
|
||||
muteVolumeDb?: number;
|
||||
muteVolume?: number;
|
||||
volumeDb?: number;
|
||||
volume?: number;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IBluesoundPreset {
|
||||
name: string;
|
||||
id: number;
|
||||
url: string;
|
||||
image?: string;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface IBluesoundInput {
|
||||
id?: string;
|
||||
text?: string;
|
||||
image?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface IBluesoundPlayerSnapshot {
|
||||
host?: string;
|
||||
port?: number;
|
||||
syncStatus: IBluesoundSyncStatus;
|
||||
status: IBluesoundStatus;
|
||||
presets?: IBluesoundPreset[];
|
||||
inputs?: IBluesoundInput[];
|
||||
available?: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IBluesoundSnapshot {
|
||||
players: IBluesoundPlayerSnapshot[];
|
||||
online: boolean;
|
||||
updatedAt?: string;
|
||||
source?: TBluesoundSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IBluesoundMdnsRecord {
|
||||
name?: string;
|
||||
type?: string;
|
||||
serviceType?: string;
|
||||
host?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
addresses?: string[];
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface IBluesoundManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
modelName?: string;
|
||||
macAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './bluesound.classes.client.js';
|
||||
export * from './bluesound.classes.configflow.js';
|
||||
export * from './bluesound.classes.integration.js';
|
||||
export * from './bluesound.discovery.js';
|
||||
export * from './bluesound.mapper.js';
|
||||
export * from './bluesound.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,440 @@
|
||||
import type {
|
||||
IDunehdCommandRequest,
|
||||
IDunehdConfig,
|
||||
IDunehdDeviceInfo,
|
||||
IDunehdRawCommandRequest,
|
||||
IDunehdSnapshot,
|
||||
IDunehdState,
|
||||
} from './dunehd.types.js';
|
||||
import { dunehdDefaultName, dunehdDefaultPort } from './dunehd.types.js';
|
||||
|
||||
const playbackSpeedPlay = 256;
|
||||
const playbackSpeedPause = 0;
|
||||
const playbackSpeedFastForward = 512;
|
||||
const playbackSpeedRewind = -512;
|
||||
const defaultTimeoutMs = 5000;
|
||||
|
||||
const irCodes = {
|
||||
turnOn: 'A05FBF00',
|
||||
turnOff: 'A15EBF00',
|
||||
previousTrack: 'B649BF00',
|
||||
nextTrack: 'E21DBF00',
|
||||
} as const;
|
||||
|
||||
export class DunehdTransportError extends Error {
|
||||
constructor(messageArg: string) {
|
||||
super(messageArg);
|
||||
this.name = 'DunehdTransportError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DunehdClient {
|
||||
private currentSnapshot?: IDunehdSnapshot;
|
||||
|
||||
constructor(private readonly config: IDunehdConfig) {
|
||||
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot)) : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(): Promise<IDunehdSnapshot> {
|
||||
if (this.currentSnapshot) {
|
||||
return this.normalizeSnapshot(this.cloneValue(this.currentSnapshot));
|
||||
}
|
||||
|
||||
if (!this.config.host && !this.config.commandExecutor) {
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig());
|
||||
}
|
||||
|
||||
const state = await this.updateState().catch(() => ({}));
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo: this.deviceInfoFromConfig(),
|
||||
state,
|
||||
online: Object.keys(state).length > 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<IDunehdSnapshot> {
|
||||
const state = await this.updateState();
|
||||
if (!Object.keys(state).length) {
|
||||
throw new DunehdTransportError('Dune HD did not return status data.');
|
||||
}
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo: this.deviceInfoFromConfig(),
|
||||
state,
|
||||
online: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
public async updateState(): Promise<IDunehdState> {
|
||||
return this.sendCommand('status');
|
||||
}
|
||||
|
||||
public async execute(requestArg: IDunehdCommandRequest): Promise<IDunehdState> {
|
||||
if (requestArg.command === 'turn_on') {
|
||||
return this.turnOn();
|
||||
}
|
||||
if (requestArg.command === 'turn_off') {
|
||||
return this.turnOff();
|
||||
}
|
||||
if (requestArg.command === 'play') {
|
||||
return this.play();
|
||||
}
|
||||
if (requestArg.command === 'pause') {
|
||||
return this.pause();
|
||||
}
|
||||
if (requestArg.command === 'stop') {
|
||||
return this.stop();
|
||||
}
|
||||
if (requestArg.command === 'previous_track') {
|
||||
return this.previousTrack();
|
||||
}
|
||||
if (requestArg.command === 'next_track') {
|
||||
return this.nextTrack();
|
||||
}
|
||||
if (requestArg.command === 'volume_up') {
|
||||
return this.volumeUp();
|
||||
}
|
||||
if (requestArg.command === 'volume_down') {
|
||||
return this.volumeDown();
|
||||
}
|
||||
if (requestArg.command === 'mute') {
|
||||
if (typeof requestArg.muted !== 'boolean') {
|
||||
throw new Error('Dune HD mute command requires muted.');
|
||||
}
|
||||
return this.mute(requestArg.muted);
|
||||
}
|
||||
if (requestArg.command === 'play_media') {
|
||||
if (!requestArg.mediaUrl) {
|
||||
throw new Error('Dune HD play_media requires mediaUrl.');
|
||||
}
|
||||
return this.launchMediaUrl(requestArg.mediaUrl);
|
||||
}
|
||||
if (requestArg.command === 'remote') {
|
||||
if (!requestArg.remoteCommand) {
|
||||
throw new Error('Dune HD remote command requires remoteCommand.');
|
||||
}
|
||||
return this.sendRemoteCommand(requestArg.remoteCommand);
|
||||
}
|
||||
throw new Error(`Unsupported Dune HD command: ${requestArg.command}`);
|
||||
}
|
||||
|
||||
public async launchMediaUrl(mediaUrlArg: string): Promise<IDunehdState> {
|
||||
return this.sendCommand('launch_media_url', { media_url: mediaUrlArg });
|
||||
}
|
||||
|
||||
public async play(): Promise<IDunehdState> {
|
||||
return this.changePlaybackSpeed(playbackSpeedPlay);
|
||||
}
|
||||
|
||||
public async pause(): Promise<IDunehdState> {
|
||||
return this.changePlaybackSpeed(playbackSpeedPause);
|
||||
}
|
||||
|
||||
public async fastForward(): Promise<IDunehdState> {
|
||||
return this.changePlaybackSpeed(playbackSpeedFastForward);
|
||||
}
|
||||
|
||||
public async rewind(): Promise<IDunehdState> {
|
||||
return this.changePlaybackSpeed(playbackSpeedRewind);
|
||||
}
|
||||
|
||||
public async stop(): Promise<IDunehdState> {
|
||||
return this.sendCommand('standby');
|
||||
}
|
||||
|
||||
public async turnOn(): Promise<IDunehdState> {
|
||||
return this.sendIrCode(irCodes.turnOn);
|
||||
}
|
||||
|
||||
public async turnOff(): Promise<IDunehdState> {
|
||||
return this.sendIrCode(irCodes.turnOff);
|
||||
}
|
||||
|
||||
public async previousTrack(): Promise<IDunehdState> {
|
||||
return this.sendIrCode(irCodes.previousTrack);
|
||||
}
|
||||
|
||||
public async nextTrack(): Promise<IDunehdState> {
|
||||
return this.sendIrCode(irCodes.nextTrack);
|
||||
}
|
||||
|
||||
public async volumeUp(): Promise<IDunehdState> {
|
||||
const state = await this.updateState();
|
||||
return this.setVolume(Math.min(100, this.numberValue(state.playback_volume, 0) + 10));
|
||||
}
|
||||
|
||||
public async volumeDown(): Promise<IDunehdState> {
|
||||
const state = await this.updateState();
|
||||
return this.setVolume(Math.max(0, this.numberValue(state.playback_volume, 0) - 10));
|
||||
}
|
||||
|
||||
public async setVolume(volumePercentArg: number): Promise<IDunehdState> {
|
||||
return this.sendCommand('set_playback_state', { volume: Math.max(0, Math.min(100, Math.round(volumePercentArg))) });
|
||||
}
|
||||
|
||||
public async mute(mutedArg: boolean): Promise<IDunehdState> {
|
||||
return this.sendCommand('set_playback_state', { mute: mutedArg ? 1 : 0 });
|
||||
}
|
||||
|
||||
public async sendRemoteCommand(commandArg: string): Promise<IDunehdState> {
|
||||
const command = commandArg.trim();
|
||||
if (!command) {
|
||||
throw new Error('Dune HD remote command cannot be empty.');
|
||||
}
|
||||
|
||||
const normalized = command.replace(/[_\s-]+/g, '').toLowerCase();
|
||||
const aliases: Record<string, () => Promise<IDunehdState>> = {
|
||||
on: () => this.turnOn(),
|
||||
poweron: () => this.turnOn(),
|
||||
turnon: () => this.turnOn(),
|
||||
off: () => this.turnOff(),
|
||||
poweroff: () => this.turnOff(),
|
||||
turnoff: () => this.turnOff(),
|
||||
play: () => this.play(),
|
||||
pause: () => this.pause(),
|
||||
stop: () => this.stop(),
|
||||
previous: () => this.previousTrack(),
|
||||
previoustrack: () => this.previousTrack(),
|
||||
prev: () => this.previousTrack(),
|
||||
next: () => this.nextTrack(),
|
||||
nexttrack: () => this.nextTrack(),
|
||||
volumeup: () => this.volumeUp(),
|
||||
volup: () => this.volumeUp(),
|
||||
volumedown: () => this.volumeDown(),
|
||||
voldown: () => this.volumeDown(),
|
||||
mute: () => this.mute(true),
|
||||
unmute: () => this.mute(false),
|
||||
};
|
||||
const alias = aliases[normalized];
|
||||
if (alias) {
|
||||
return alias();
|
||||
}
|
||||
|
||||
if (/^[a-f0-9]{8}$/i.test(command)) {
|
||||
return this.sendIrCode(command.toUpperCase());
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Dune HD remote command: ${command}`);
|
||||
}
|
||||
|
||||
public parseStatus(statusArg: string): IDunehdState {
|
||||
const state: IDunehdState = {};
|
||||
this.readStatusAttributes(statusArg).forEach((attrsArg) => {
|
||||
const name = attrsArg.name;
|
||||
if (name) {
|
||||
state[name] = attrsArg.value || '';
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async sendIrCode(codeArg: string): Promise<IDunehdState> {
|
||||
return this.sendCommand('ir_code', { ir_code: codeArg });
|
||||
}
|
||||
|
||||
private async changePlaybackSpeed(speedArg: number): Promise<IDunehdState> {
|
||||
return this.sendCommand('set_playback_state', { speed: speedArg });
|
||||
}
|
||||
|
||||
private async sendCommand(cmdArg: string, paramsArg: Record<string, string | number | boolean> = {}): Promise<IDunehdState> {
|
||||
const request = this.rawRequest(cmdArg, paramsArg);
|
||||
if (this.config.commandExecutor) {
|
||||
const result = await this.config.commandExecutor.execute(request);
|
||||
const state = this.resultToState(result);
|
||||
this.updateCurrentSnapshot(state);
|
||||
return state;
|
||||
}
|
||||
const state = await this.requestHttp(request);
|
||||
this.updateCurrentSnapshot(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private async requestHttp(requestArg: IDunehdRawCommandRequest): Promise<IDunehdState> {
|
||||
if (!this.config.host) {
|
||||
throw new DunehdTransportError('Dune HD command transport requires config.host or commandExecutor.');
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
|
||||
try {
|
||||
const response = await globalThis.fetch(requestArg.uri, { method: requestArg.method, signal: abortController.signal });
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new DunehdTransportError(`Dune HD request ${requestArg.cmd} failed with HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
return this.parseStatus(text);
|
||||
} catch (errorArg) {
|
||||
if (errorArg instanceof DunehdTransportError) {
|
||||
throw errorArg;
|
||||
}
|
||||
if (errorArg instanceof Error && errorArg.name === 'AbortError') {
|
||||
throw new DunehdTransportError(`Dune HD request ${requestArg.cmd} timed out after ${this.config.timeoutMs || defaultTimeoutMs}ms.`);
|
||||
}
|
||||
throw new DunehdTransportError(`Dune HD request ${requestArg.cmd} failed: ${errorArg instanceof Error ? errorArg.message : String(errorArg)}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private rawRequest(cmdArg: string, paramsArg: Record<string, string | number | boolean>): IDunehdRawCommandRequest {
|
||||
return {
|
||||
cmd: cmdArg,
|
||||
params: { ...paramsArg },
|
||||
uri: this.commandUri(cmdArg, paramsArg),
|
||||
host: this.config.host,
|
||||
port: this.config.port || dunehdDefaultPort,
|
||||
method: 'GET',
|
||||
};
|
||||
}
|
||||
|
||||
private commandUri(cmdArg: string, paramsArg: Record<string, string | number | boolean>): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('cmd', cmdArg);
|
||||
for (const [key, value] of Object.entries(paramsArg)) {
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
if (!this.config.host) {
|
||||
return `/cgi-bin/do?${searchParams.toString()}`;
|
||||
}
|
||||
const port = this.config.port && this.config.port !== dunehdDefaultPort ? `:${this.config.port}` : '';
|
||||
return `http://${this.config.host}${port}/cgi-bin/do?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
private resultToState(resultArg: unknown): IDunehdState {
|
||||
if (typeof resultArg === 'string') {
|
||||
return this.parseStatus(resultArg);
|
||||
}
|
||||
if (this.isRecord(resultArg)) {
|
||||
if (this.isRecord(resultArg.state)) {
|
||||
return this.recordToState(resultArg.state);
|
||||
}
|
||||
return this.recordToState(resultArg);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private recordToState(recordArg: Record<string, unknown>): IDunehdState {
|
||||
const state: IDunehdState = {};
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === undefined) {
|
||||
state[key] = value;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private updateCurrentSnapshot(stateArg: IDunehdState): void {
|
||||
if (!this.currentSnapshot) {
|
||||
if (this.config.snapshot) {
|
||||
this.currentSnapshot = this.normalizeSnapshot(this.config.snapshot);
|
||||
} else if (this.config.state) {
|
||||
this.currentSnapshot = this.snapshotFromConfig();
|
||||
}
|
||||
}
|
||||
if (!this.currentSnapshot) {
|
||||
return;
|
||||
}
|
||||
this.currentSnapshot.state = { ...this.currentSnapshot.state, ...stateArg };
|
||||
this.currentSnapshot.online = Object.keys(this.currentSnapshot.state).length > 0;
|
||||
this.currentSnapshot.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
private snapshotFromConfig(): IDunehdSnapshot {
|
||||
const state = { ...(this.config.state || {}) };
|
||||
return {
|
||||
deviceInfo: this.deviceInfoFromConfig(),
|
||||
state,
|
||||
online: Object.keys(state).length > 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private deviceInfoFromConfig(): IDunehdDeviceInfo {
|
||||
return {
|
||||
...this.config.deviceInfo,
|
||||
id: this.config.deviceInfo?.id || this.config.uniqueId || this.config.serialNumber || this.config.macAddress || this.config.host,
|
||||
host: this.config.deviceInfo?.host || this.config.host,
|
||||
port: this.config.deviceInfo?.port || this.config.port || dunehdDefaultPort,
|
||||
name: this.config.deviceInfo?.name || this.config.name || this.config.host || dunehdDefaultName,
|
||||
manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'Dune',
|
||||
model: this.config.deviceInfo?.model || this.config.model,
|
||||
serialNumber: this.config.deviceInfo?.serialNumber || this.config.serialNumber,
|
||||
macAddress: this.config.deviceInfo?.macAddress || this.config.macAddress,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IDunehdSnapshot): IDunehdSnapshot {
|
||||
const state = { ...(snapshotArg.state || {}) };
|
||||
const deviceInfo = {
|
||||
...this.deviceInfoFromConfig(),
|
||||
...snapshotArg.deviceInfo,
|
||||
};
|
||||
return {
|
||||
...snapshotArg,
|
||||
deviceInfo,
|
||||
state,
|
||||
online: snapshotArg.online ?? Object.keys(state).length > 0,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private readStatusAttributes(statusArg: string): Array<Record<string, string>> {
|
||||
const result: Array<Record<string, string>> = [];
|
||||
const tagRegex = /<[^>]+>/g;
|
||||
let tagMatch: RegExpExecArray | null;
|
||||
while ((tagMatch = tagRegex.exec(statusArg))) {
|
||||
const attrs = this.readAttributes(tagMatch[0]);
|
||||
if (attrs.name !== undefined && attrs.value !== undefined) {
|
||||
result.push(attrs);
|
||||
}
|
||||
}
|
||||
const lineRegex = /name=(['"])(.*?)\1\s+value=(['"])(.*?)\3/g;
|
||||
let lineMatch: RegExpExecArray | null;
|
||||
while ((lineMatch = lineRegex.exec(statusArg))) {
|
||||
result.push({ name: this.decodeHtml(lineMatch[2]), value: this.decodeHtml(lineMatch[4]) });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private readAttributes(valueArg: string): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
const attrRegex = /([a-zA-Z0-9_-]+)=(['"])(.*?)\2/g;
|
||||
let attrMatch: RegExpExecArray | null;
|
||||
while ((attrMatch = attrRegex.exec(valueArg))) {
|
||||
attrs[attrMatch[1]] = this.decodeHtml(attrMatch[3]);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
private decodeHtml(valueArg: string): string {
|
||||
return valueArg
|
||||
.replace(/&#(\d+);/g, (_matchArg, codeArg: string) => String.fromCharCode(Number(codeArg)))
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown, fallbackArg: number): number {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : fallbackArg;
|
||||
}
|
||||
return fallbackArg;
|
||||
}
|
||||
|
||||
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||
}
|
||||
|
||||
private cloneValue<TValue>(valueArg: TValue): TValue {
|
||||
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IDunehdConfig } from './dunehd.types.js';
|
||||
import { dunehdDefaultName, dunehdDefaultPort } from './dunehd.types.js';
|
||||
|
||||
export class DunehdConfigFlow implements IConfigFlow<IDunehdConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDunehdConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Dune HD',
|
||||
description: 'Configure the local Dune HD HTTP CGI endpoint. Ensure the player is powered on before validating live control.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'HTTP port', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'model', label: 'Model', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
|
||||
};
|
||||
}
|
||||
|
||||
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IDunehdConfig>> {
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', error: 'Dune HD host is required.' };
|
||||
}
|
||||
if (!this.isHostValid(host)) {
|
||||
return { kind: 'error', error: 'Dune HD host is invalid.' };
|
||||
}
|
||||
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || dunehdDefaultPort;
|
||||
const name = this.stringValue(valuesArg.name) || candidateArg.name || dunehdDefaultName;
|
||||
const model = this.stringValue(valuesArg.model) || candidateArg.model;
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Dune HD configured',
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
model,
|
||||
manufacturer: candidateArg.manufacturer || 'Dune',
|
||||
uniqueId: candidateArg.id || candidateArg.serialNumber || candidateArg.macAddress || host,
|
||||
serialNumber: candidateArg.serialNumber,
|
||||
macAddress: candidateArg.macAddress,
|
||||
deviceInfo: {
|
||||
id: candidateArg.id || candidateArg.serialNumber || candidateArg.macAddress || host,
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
manufacturer: candidateArg.manufacturer || 'Dune',
|
||||
model,
|
||||
serialNumber: candidateArg.serialNumber,
|
||||
macAddress: candidateArg.macAddress,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const value = Number(valueArg);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isHostValid(hostArg: string): boolean {
|
||||
return hostArg.length <= 253 && !/[\s/]/.test(hostArg) && !hostArg.includes('://');
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,181 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { DunehdClient } from './dunehd.classes.client.js';
|
||||
import { DunehdConfigFlow } from './dunehd.classes.configflow.js';
|
||||
import { createDunehdDiscoveryDescriptor } from './dunehd.discovery.js';
|
||||
import { DunehdMapper } from './dunehd.mapper.js';
|
||||
import type { IDunehdCommandRequest, IDunehdConfig, TDunehdPlaybackCommand } from './dunehd.types.js';
|
||||
|
||||
export class HomeAssistantDunehdIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "dunehd",
|
||||
displayName: "Dune HD",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/dunehd",
|
||||
"upstreamDomain": "dunehd",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"pdunehd==1.3.2"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": []
|
||||
},
|
||||
});
|
||||
export class DunehdIntegration extends BaseIntegration<IDunehdConfig> {
|
||||
public readonly domain = 'dunehd';
|
||||
public readonly displayName = 'Dune HD';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createDunehdDiscoveryDescriptor();
|
||||
public readonly configFlow = new DunehdConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/dunehd',
|
||||
upstreamDomain: 'dunehd',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['pdunehd==1.3.2'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: [],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/dunehd',
|
||||
};
|
||||
|
||||
public async setup(configArg: IDunehdConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new DunehdRuntime(new DunehdClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantDunehdIntegration extends DunehdIntegration {}
|
||||
|
||||
class DunehdRuntime implements IIntegrationRuntime {
|
||||
public domain = 'dunehd';
|
||||
|
||||
constructor(private readonly client: DunehdClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return DunehdMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return DunehdMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'remote') {
|
||||
return await this.callRemoteService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'dunehd') {
|
||||
return await this.callDunehdService(requestArg);
|
||||
}
|
||||
if (requestArg.domain !== 'media_player') {
|
||||
return { success: false, error: `Unsupported Dune HD service domain: ${requestArg.domain}` };
|
||||
}
|
||||
return await this.callMediaPlayerService(requestArg);
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const command = this.commandFromMediaService(requestArg.service);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Dune HD media_player service: ${requestArg.service}` };
|
||||
}
|
||||
const result = await this.client.execute(this.commandRequest(command, requestArg));
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service !== 'send_command') {
|
||||
return { success: false, error: `Unsupported Dune HD remote service: ${requestArg.service}` };
|
||||
}
|
||||
const commands = this.stringArrayData(requestArg, 'command');
|
||||
if (!commands?.length) {
|
||||
return { success: false, error: 'Dune HD remote.send_command requires data.command.' };
|
||||
}
|
||||
const repeatsValue = requestArg.data?.num_repeats ?? requestArg.data?.numRepeats ?? 1;
|
||||
const repeats = typeof repeatsValue === 'number' && Number.isFinite(repeatsValue) ? Math.max(1, Math.floor(repeatsValue)) : 1;
|
||||
let result: unknown;
|
||||
for (let index = 0; index < repeats; index += 1) {
|
||||
for (const command of commands) {
|
||||
result = await this.client.execute({ command: 'remote', remoteCommand: command });
|
||||
}
|
||||
}
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
private async callDunehdService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'send_ir_code') {
|
||||
const code = this.stringData(requestArg, 'ir_code') || this.stringData(requestArg, 'code');
|
||||
if (!code) {
|
||||
return { success: false, error: 'Dune HD send_ir_code requires data.ir_code.' };
|
||||
}
|
||||
return { success: true, data: await this.client.execute({ command: 'remote', remoteCommand: code }) };
|
||||
}
|
||||
if (requestArg.service === 'status') {
|
||||
return { success: true, data: await this.client.updateState() };
|
||||
}
|
||||
return { success: false, error: `Unsupported Dune HD service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private commandFromMediaService(serviceArg: string): TDunehdPlaybackCommand | undefined {
|
||||
if (serviceArg === 'turn_on') {
|
||||
return 'turn_on';
|
||||
}
|
||||
if (serviceArg === 'turn_off') {
|
||||
return 'turn_off';
|
||||
}
|
||||
if (serviceArg === 'play' || serviceArg === 'media_play') {
|
||||
return 'play';
|
||||
}
|
||||
if (serviceArg === 'pause' || serviceArg === 'media_pause') {
|
||||
return 'pause';
|
||||
}
|
||||
if (serviceArg === 'stop' || serviceArg === 'media_stop') {
|
||||
return 'stop';
|
||||
}
|
||||
if (serviceArg === 'previous_track' || serviceArg === 'media_previous_track') {
|
||||
return 'previous_track';
|
||||
}
|
||||
if (serviceArg === 'next_track' || serviceArg === 'media_next_track') {
|
||||
return 'next_track';
|
||||
}
|
||||
if (serviceArg === 'volume_up') {
|
||||
return 'volume_up';
|
||||
}
|
||||
if (serviceArg === 'volume_down') {
|
||||
return 'volume_down';
|
||||
}
|
||||
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
|
||||
return 'mute';
|
||||
}
|
||||
if (serviceArg === 'play_media') {
|
||||
return 'play_media';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private commandRequest(commandArg: TDunehdPlaybackCommand, requestArg: IServiceCallRequest): IDunehdCommandRequest {
|
||||
const result: IDunehdCommandRequest = { command: commandArg };
|
||||
if (commandArg === 'mute') {
|
||||
result.muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'muted') ?? this.boolData(requestArg, 'mute');
|
||||
}
|
||||
if (commandArg === 'play_media') {
|
||||
result.mediaUrl = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'uri') || this.stringData(requestArg, 'mediaUrl');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'string' && value) {
|
||||
return [value];
|
||||
}
|
||||
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string' && Boolean(itemArg)) ? value : undefined;
|
||||
}
|
||||
|
||||
private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IDunehdManualEntry, IDunehdMdnsRecord, IDunehdSsdpRecord } from './dunehd.types.js';
|
||||
import { dunehdDefaultPort } from './dunehd.types.js';
|
||||
|
||||
export class DunehdSsdpMatcher implements IDiscoveryMatcher<IDunehdSsdpRecord> {
|
||||
public id = 'dunehd-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize local Dune HD UPnP/SSDP advertisements.';
|
||||
|
||||
public async matches(recordArg: IDunehdSsdpRecord): Promise<IDiscoveryMatch> {
|
||||
const st = stringValue(recordArg, 'st', 'ST', 'ssdp_st') || recordArg.ssdp_st;
|
||||
const usn = stringValue(recordArg, 'usn', 'USN', 'udn', 'UDN', 'ssdp_usn') || recordArg.ssdp_usn;
|
||||
const location = stringValue(recordArg, 'location', 'LOCATION', 'ssdp_location') || recordArg.ssdp_location;
|
||||
const manufacturer = stringValue(recordArg, 'manufacturer', 'MANUFACTURER', 'upnp:manufacturer');
|
||||
const model = stringValue(recordArg, 'modelName', 'model_name', 'model', 'upnp:modelName');
|
||||
const name = stringValue(recordArg, 'friendlyName', 'friendly_name', 'upnp:friendlyName', 'name');
|
||||
const matched = isDuneHint(st, usn, manufacturer, model, name) || Boolean(recordArg.upnp?.dunehd);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'SSDP record does not contain Dune HD hints.' };
|
||||
}
|
||||
|
||||
const url = safeUrl(location);
|
||||
const id = stripUuid(usn || stringValue(recordArg, 'udn', 'UDN')) || stringValue(recordArg, 'serialNumber', 'serial_number', 'macAddress');
|
||||
return {
|
||||
matched: true,
|
||||
confidence: id ? 'certain' : url?.hostname ? 'high' : 'medium',
|
||||
reason: 'SSDP record matches Dune HD metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: 'dunehd',
|
||||
id,
|
||||
host: url?.hostname,
|
||||
port: portFromUrl(url) || dunehdDefaultPort,
|
||||
name,
|
||||
manufacturer: manufacturer || 'Dune',
|
||||
model,
|
||||
metadata: { st, usn, location },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DunehdMdnsMatcher implements IDiscoveryMatcher<IDunehdMdnsRecord> {
|
||||
public id = 'dunehd-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize local Dune HD mDNS setup hints.';
|
||||
|
||||
public async matches(recordArg: IDunehdMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const properties = { ...(recordArg.txt || {}), ...(recordArg.properties || {}) };
|
||||
const type = normalizeType(recordArg.type);
|
||||
const manufacturer = valueForKey(properties, 'manufacturer') || valueForKey(properties, 'mf');
|
||||
const model = valueForKey(properties, 'model') || valueForKey(properties, 'md') || valueForKey(properties, 'modelName');
|
||||
const name = cleanName(valueForKey(properties, 'name') || valueForKey(properties, 'fn') || recordArg.name);
|
||||
const matched = type.includes('dune') || isDuneHint(manufacturer, model, name) || Boolean(valueForKey(properties, 'dunehd'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record does not contain Dune HD hints.' };
|
||||
}
|
||||
|
||||
const id = valueForKey(properties, 'id') || valueForKey(properties, 'serial') || valueForKey(properties, 'mac') || name;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: id ? 'certain' : recordArg.host ? 'high' : 'medium',
|
||||
reason: 'mDNS record matches Dune HD metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: 'dunehd',
|
||||
id,
|
||||
host: recordArg.host,
|
||||
port: recordArg.port || dunehdDefaultPort,
|
||||
name,
|
||||
manufacturer: manufacturer || 'Dune',
|
||||
model,
|
||||
macAddress: valueForKey(properties, 'mac') || valueForKey(properties, 'macaddress'),
|
||||
metadata: { mdnsType: recordArg.type, mdnsName: recordArg.name, txt: properties },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DunehdManualMatcher implements IDiscoveryMatcher<IDunehdManualEntry> {
|
||||
public id = 'dunehd-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Dune HD setup entries.';
|
||||
|
||||
public async matches(inputArg: IDunehdManualEntry): Promise<IDiscoveryMatch> {
|
||||
const matched = Boolean(inputArg.host || inputArg.metadata?.dunehd || isDuneHint(inputArg.manufacturer, inputArg.model, inputArg.name));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Dune HD setup hints.' };
|
||||
}
|
||||
const id = inputArg.id || inputArg.serialNumber || inputArg.macAddress;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Dune HD setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'dunehd',
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port: inputArg.port || dunehdDefaultPort,
|
||||
name: inputArg.name,
|
||||
manufacturer: inputArg.manufacturer || 'Dune',
|
||||
model: inputArg.model,
|
||||
serialNumber: inputArg.serialNumber,
|
||||
macAddress: inputArg.macAddress,
|
||||
metadata: inputArg.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DunehdCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'dunehd-candidate-validator';
|
||||
public description = 'Validate Dune HD discovery candidates.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const matched = candidateArg.integrationDomain === 'dunehd'
|
||||
|| Boolean(candidateArg.metadata?.dunehd)
|
||||
|| isDuneHint(candidateArg.manufacturer, candidateArg.model, candidateArg.name);
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has Dune HD metadata.' : 'Candidate is not Dune HD.',
|
||||
candidate: matched ? candidateArg : undefined,
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || candidateArg.host,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createDunehdDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'dunehd', displayName: 'Dune HD' })
|
||||
.addMatcher(new DunehdSsdpMatcher())
|
||||
.addMatcher(new DunehdMdnsMatcher())
|
||||
.addMatcher(new DunehdManualMatcher())
|
||||
.addValidator(new DunehdCandidateValidator());
|
||||
};
|
||||
|
||||
const isDuneHint = (...valuesArg: Array<string | undefined>): boolean => {
|
||||
const haystack = valuesArg.filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes('dune hd') || haystack.includes('dunehd') || haystack.includes('dune');
|
||||
};
|
||||
|
||||
const normalizeType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||
|
||||
const cleanName = (valueArg?: string): string | undefined => valueArg?.replace(/\._[^.]+\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
|
||||
|
||||
const stripUuid = (valueArg?: string): string | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
return valueArg.replace(/^uuid:/i, '').split('::')[0];
|
||||
};
|
||||
|
||||
const safeUrl = (valueArg?: string): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const portFromUrl = (urlArg: URL | undefined): number | undefined => {
|
||||
if (!urlArg?.port) {
|
||||
return undefined;
|
||||
}
|
||||
const port = Number(urlArg.port);
|
||||
return Number.isFinite(port) ? port : undefined;
|
||||
};
|
||||
|
||||
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerKey = keyArg.toLowerCase();
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (key.toLowerCase() === lowerKey && value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const stringValue = (recordArg: IDunehdSsdpRecord, ...keysArg: string[]): string | undefined => {
|
||||
const maps = [recordArg.headers, recordArg.upnp, recordArg as Record<string, unknown>].filter(Boolean) as Array<Record<string, unknown>>;
|
||||
for (const key of keysArg) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
for (const map of maps) {
|
||||
for (const [candidateKey, value] of Object.entries(map)) {
|
||||
if (candidateKey.toLowerCase() === lowerKey && typeof value === 'string' && value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { IDunehdSnapshot, IDunehdState } from './dunehd.types.js';
|
||||
import { dunehdDefaultName } from './dunehd.types.js';
|
||||
|
||||
export class DunehdMapper {
|
||||
public static toDevices(snapshotArg: IDunehdSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const state = snapshotArg.state || {};
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: 'dunehd',
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'http',
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Dune',
|
||||
model: snapshotArg.deviceInfo.model,
|
||||
online: this.available(snapshotArg),
|
||||
features: [
|
||||
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
|
||||
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
|
||||
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
|
||||
{ id: 'media_url', capability: 'media', name: 'Media URL', readable: true, writable: true },
|
||||
{ id: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'power', value: this.mediaState(state) === 'off' ? 'off' : 'on', updatedAt },
|
||||
{ featureId: 'playback', value: this.mediaState(state), updatedAt },
|
||||
{ featureId: 'current_title', value: this.mediaTitle(state) || null, updatedAt },
|
||||
{ featureId: 'volume', value: this.volumePercent(state), updatedAt },
|
||||
{ featureId: 'muted', value: this.isMuted(state), updatedAt },
|
||||
{ featureId: 'media_url', value: state.playback_url || null, updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
port: snapshotArg.deviceInfo.port,
|
||||
serialNumber: snapshotArg.deviceInfo.serialNumber,
|
||||
macAddress: snapshotArg.deviceInfo.macAddress,
|
||||
firmwareVersion: snapshotArg.deviceInfo.firmwareVersion,
|
||||
playerState: state.player_state,
|
||||
playbackSpeed: state.playback_speed,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IDunehdSnapshot): IIntegrationEntity[] {
|
||||
const state = snapshotArg.state || {};
|
||||
return [{
|
||||
id: `media_player.${this.slug(this.deviceName(snapshotArg))}`,
|
||||
uniqueId: `dunehd_${this.slug(this.identity(snapshotArg))}`,
|
||||
integrationDomain: 'dunehd',
|
||||
deviceId: this.deviceId(snapshotArg),
|
||||
platform: 'media_player',
|
||||
name: this.deviceName(snapshotArg),
|
||||
state: this.mediaState(state),
|
||||
attributes: {
|
||||
volumeLevel: this.volumeLevel(state),
|
||||
isVolumeMuted: this.isMuted(state),
|
||||
mediaTitle: this.mediaTitle(state),
|
||||
mediaContentId: state.playback_url,
|
||||
mediaContentType: this.mediaContentType(state),
|
||||
mediaDuration: this.numberValue(state.playback_duration),
|
||||
mediaPosition: this.numberValue(state.playback_position),
|
||||
playbackSpeed: this.numberValue(state.playback_speed),
|
||||
playerState: state.player_state,
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
supportedFeatures: [
|
||||
'pause',
|
||||
'turn_on',
|
||||
'turn_off',
|
||||
'previous_track',
|
||||
'next_track',
|
||||
'play',
|
||||
'play_media',
|
||||
'browse_media',
|
||||
'volume_step',
|
||||
'volume_mute',
|
||||
],
|
||||
},
|
||||
available: this.available(snapshotArg),
|
||||
}];
|
||||
}
|
||||
|
||||
public static mediaState(stateArg: IDunehdState): string {
|
||||
let state = 'off';
|
||||
if ('playback_position' in stateArg) {
|
||||
state = 'playing';
|
||||
}
|
||||
if (stateArg.player_state === 'playing' || stateArg.player_state === 'buffering' || stateArg.player_state === 'photo_viewer') {
|
||||
state = 'playing';
|
||||
}
|
||||
if (this.numberValue(stateArg.playback_speed, 1234) === 0) {
|
||||
state = 'paused';
|
||||
}
|
||||
if (stateArg.player_state === 'navigator') {
|
||||
state = 'on';
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
public static mediaTitle(stateArg: IDunehdState): string | undefined {
|
||||
if (stateArg.player_state === 'bluray_playback') {
|
||||
return 'Blu-Ray';
|
||||
}
|
||||
if (stateArg.player_state === 'photo_viewer') {
|
||||
return 'Photo Viewer';
|
||||
}
|
||||
if (typeof stateArg.playback_url === 'string' && stateArg.playback_url) {
|
||||
return stateArg.playback_url.split('/').pop() || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IDunehdSnapshot): string {
|
||||
return `dunehd.device.${this.slug(this.identity(snapshotArg))}`;
|
||||
}
|
||||
|
||||
private static available(snapshotArg: IDunehdSnapshot): boolean {
|
||||
return snapshotArg.online && Object.keys(snapshotArg.state || {}).length > 0;
|
||||
}
|
||||
|
||||
private static mediaContentType(stateArg: IDunehdState): string | undefined {
|
||||
if (stateArg.player_state === 'photo_viewer') {
|
||||
return 'image';
|
||||
}
|
||||
if (stateArg.player_state === 'bluray_playback') {
|
||||
return 'video';
|
||||
}
|
||||
const url = typeof stateArg.playback_url === 'string' ? stateArg.playback_url.toLowerCase() : '';
|
||||
if (/\.(mp3|flac|aac|ogg|wav)(\?|$)/.test(url)) {
|
||||
return 'music';
|
||||
}
|
||||
if (/\.(jpg|jpeg|png|gif|webp)(\?|$)/.test(url)) {
|
||||
return 'image';
|
||||
}
|
||||
if (url) {
|
||||
return 'video';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static volumeLevel(stateArg: IDunehdState): number {
|
||||
return this.volumePercent(stateArg) / 100;
|
||||
}
|
||||
|
||||
private static volumePercent(stateArg: IDunehdState): number {
|
||||
return Math.max(0, Math.min(100, Math.round(this.numberValue(stateArg.playback_volume, 0))));
|
||||
}
|
||||
|
||||
private static isMuted(stateArg: IDunehdState): boolean {
|
||||
if (typeof stateArg.playback_mute === 'boolean') {
|
||||
return stateArg.playback_mute;
|
||||
}
|
||||
return this.numberValue(stateArg.playback_mute, 0) === 1;
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IDunehdSnapshot): string {
|
||||
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || dunehdDefaultName;
|
||||
}
|
||||
|
||||
private static identity(snapshotArg: IDunehdSnapshot): string {
|
||||
return snapshotArg.deviceInfo.id
|
||||
|| snapshotArg.deviceInfo.serialNumber
|
||||
|| snapshotArg.deviceInfo.macAddress
|
||||
|| snapshotArg.deviceInfo.host
|
||||
|| this.deviceName(snapshotArg);
|
||||
}
|
||||
|
||||
private static numberValue(valueArg: unknown, fallbackArg?: number): number {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : fallbackArg ?? 0;
|
||||
}
|
||||
return fallbackArg ?? 0;
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'dunehd';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,119 @@
|
||||
export interface IHomeAssistantDunehdConfig {
|
||||
// TODO: replace with the TypeScript-native config for dunehd.
|
||||
export const dunehdDefaultPort = 80;
|
||||
export const dunehdDefaultName = 'Dune HD';
|
||||
|
||||
export type TDunehdStateValue = string | number | boolean | undefined;
|
||||
|
||||
export type TDunehdPlaybackCommand =
|
||||
| 'turn_on'
|
||||
| 'turn_off'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'previous_track'
|
||||
| 'next_track'
|
||||
| 'volume_up'
|
||||
| 'volume_down'
|
||||
| 'mute'
|
||||
| 'play_media'
|
||||
| 'remote';
|
||||
|
||||
export interface IDunehdConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
uniqueId?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
timeoutMs?: number;
|
||||
deviceInfo?: IDunehdDeviceInfo;
|
||||
state?: IDunehdState;
|
||||
snapshot?: IDunehdSnapshot;
|
||||
commandExecutor?: IDunehdCommandExecutor;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantDunehdConfig extends IDunehdConfig {}
|
||||
|
||||
export interface IDunehdDeviceInfo {
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
firmwareVersion?: string;
|
||||
}
|
||||
|
||||
export interface IDunehdState {
|
||||
[key: string]: TDunehdStateValue;
|
||||
player_state?: string;
|
||||
playback_position?: string | number;
|
||||
playback_speed?: string | number;
|
||||
playback_volume?: string | number;
|
||||
playback_mute?: string | number | boolean;
|
||||
playback_url?: string;
|
||||
playback_duration?: string | number;
|
||||
}
|
||||
|
||||
export interface IDunehdSnapshot {
|
||||
deviceInfo: IDunehdDeviceInfo;
|
||||
state: IDunehdState;
|
||||
online: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IDunehdRawCommandRequest {
|
||||
cmd: string;
|
||||
params: Record<string, string | number | boolean>;
|
||||
uri: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
method: 'GET';
|
||||
}
|
||||
|
||||
export interface IDunehdCommandExecutor {
|
||||
execute(requestArg: IDunehdRawCommandRequest): Promise<IDunehdState | string | unknown>;
|
||||
}
|
||||
|
||||
export interface IDunehdCommandRequest {
|
||||
command: TDunehdPlaybackCommand;
|
||||
mediaUrl?: string;
|
||||
muted?: boolean;
|
||||
remoteCommand?: string;
|
||||
}
|
||||
|
||||
export interface IDunehdSsdpRecord {
|
||||
st?: string;
|
||||
usn?: string;
|
||||
location?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
upnp?: Record<string, unknown>;
|
||||
ssdp_st?: string;
|
||||
ssdp_usn?: string;
|
||||
ssdp_location?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IDunehdMdnsRecord {
|
||||
type?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface IDunehdManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './dunehd.classes.integration.js';
|
||||
export * from './dunehd.classes.client.js';
|
||||
export * from './dunehd.classes.configflow.js';
|
||||
export * from './dunehd.discovery.js';
|
||||
export * from './dunehd.mapper.js';
|
||||
export * from './dunehd.types.js';
|
||||
|
||||
@@ -128,7 +128,6 @@ import { HomeAssistantBlockchainIntegration } from '../blockchain/index.js';
|
||||
import { HomeAssistantBlueCurrentIntegration } from '../blue_current/index.js';
|
||||
import { HomeAssistantBluemaestroIntegration } from '../bluemaestro/index.js';
|
||||
import { HomeAssistantBlueprintIntegration } from '../blueprint/index.js';
|
||||
import { HomeAssistantBluesoundIntegration } from '../bluesound/index.js';
|
||||
import { HomeAssistantBluetoothIntegration } from '../bluetooth/index.js';
|
||||
import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters/index.js';
|
||||
import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js';
|
||||
@@ -261,7 +260,6 @@ import { HomeAssistantDsmrReaderIntegration } from '../dsmr_reader/index.js';
|
||||
import { HomeAssistantDublinBusTransportIntegration } from '../dublin_bus_transport/index.js';
|
||||
import { HomeAssistantDuckdnsIntegration } from '../duckdns/index.js';
|
||||
import { HomeAssistantDucoIntegration } from '../duco/index.js';
|
||||
import { HomeAssistantDunehdIntegration } from '../dunehd/index.js';
|
||||
import { HomeAssistantDuotecnoIntegration } from '../duotecno/index.js';
|
||||
import { HomeAssistantDuquesneLightIntegration } from '../duquesne_light/index.js';
|
||||
import { HomeAssistantDwdWeatherWarningsIntegration } from '../dwd_weather_warnings/index.js';
|
||||
@@ -651,7 +649,6 @@ import { HomeAssistantLightwaveIntegration } from '../lightwave/index.js';
|
||||
import { HomeAssistantLimitlessledIntegration } from '../limitlessled/index.js';
|
||||
import { HomeAssistantLinakIntegration } from '../linak/index.js';
|
||||
import { HomeAssistantLinkedgoIntegration } from '../linkedgo/index.js';
|
||||
import { HomeAssistantLinkplayIntegration } from '../linkplay/index.js';
|
||||
import { HomeAssistantLinksysSmartIntegration } from '../linksys_smart/index.js';
|
||||
import { HomeAssistantLinodeIntegration } from '../linode/index.js';
|
||||
import { HomeAssistantLinuxBatteryIntegration } from '../linux_battery/index.js';
|
||||
@@ -685,7 +682,6 @@ import { HomeAssistantLuxaflexIntegration } from '../luxaflex/index.js';
|
||||
import { HomeAssistantLw12wifiIntegration } from '../lw12wifi/index.js';
|
||||
import { HomeAssistantLyricIntegration } from '../lyric/index.js';
|
||||
import { HomeAssistantMadecoIntegration } from '../madeco/index.js';
|
||||
import { HomeAssistantMadvrIntegration } from '../madvr/index.js';
|
||||
import { HomeAssistantMailgunIntegration } from '../mailgun/index.js';
|
||||
import { HomeAssistantManualIntegration } from '../manual/index.js';
|
||||
import { HomeAssistantManualMqttIntegration } from '../manual_mqtt/index.js';
|
||||
@@ -855,7 +851,6 @@ import { HomeAssistantOpenexchangeratesIntegration } from '../openexchangerates/
|
||||
import { HomeAssistantOpengarageIntegration } from '../opengarage/index.js';
|
||||
import { HomeAssistantOpenhardwaremonitorIntegration } from '../openhardwaremonitor/index.js';
|
||||
import { HomeAssistantOpenhomeIntegration } from '../openhome/index.js';
|
||||
import { HomeAssistantOpenrgbIntegration } from '../openrgb/index.js';
|
||||
import { HomeAssistantOpensensemapIntegration } from '../opensensemap/index.js';
|
||||
import { HomeAssistantOpenskyIntegration } from '../opensky/index.js';
|
||||
import { HomeAssistantOpenuvIntegration } from '../openuv/index.js';
|
||||
@@ -1115,7 +1110,6 @@ import { HomeAssistantSomfyMylinkIntegration } from '../somfy_mylink/index.js';
|
||||
import { HomeAssistantSonarrIntegration } from '../sonarr/index.js';
|
||||
import { HomeAssistantSongpalIntegration } from '../songpal/index.js';
|
||||
import { HomeAssistantSonyProjectorIntegration } from '../sony_projector/index.js';
|
||||
import { HomeAssistantSoundtouchIntegration } from '../soundtouch/index.js';
|
||||
import { HomeAssistantSpaceapiIntegration } from '../spaceapi/index.js';
|
||||
import { HomeAssistantSpcIntegration } from '../spc/index.js';
|
||||
import { HomeAssistantSpeedtestdotnetIntegration } from '../speedtestdotnet/index.js';
|
||||
@@ -1524,7 +1518,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlockchainIntegrati
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueCurrentIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluemaestroIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueprintIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluesoundIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration());
|
||||
@@ -1657,7 +1650,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrReaderIntegrati
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDublinBusTransportIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuckdnsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDucoIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDunehdIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuotecnoIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuquesneLightIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDwdWeatherWarningsIntegration());
|
||||
@@ -2047,7 +2039,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantLightwaveIntegratio
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLimitlessledIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinakIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinkedgoIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinkplayIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinksysSmartIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinodeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinuxBatteryIntegration());
|
||||
@@ -2081,7 +2072,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantLuxaflexIntegration
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLw12wifiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLyricIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMadecoIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMadvrIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMailgunIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantManualIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantManualMqttIntegration());
|
||||
@@ -2251,7 +2241,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenexchangeratesIn
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpengarageIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenhardwaremonitorIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenhomeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenrgbIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpensensemapIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenskyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenuvIntegration());
|
||||
@@ -2511,7 +2500,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSomfyMylinkIntegrat
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonarrIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSongpalIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonyProjectorIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSoundtouchIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpaceapiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpcIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpeedtestdotnetIntegration());
|
||||
@@ -2792,7 +2780,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1394;
|
||||
export const generatedHomeAssistantPortCount = 1388;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"adguard",
|
||||
"airgradient",
|
||||
@@ -2805,6 +2793,7 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"asuswrt",
|
||||
"axis",
|
||||
"blebox",
|
||||
"bluesound",
|
||||
"bluetooth_le_tracker",
|
||||
"bosch_shc",
|
||||
"braviatv",
|
||||
@@ -2815,6 +2804,7 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"devolo_home_network",
|
||||
"dlna_dmr",
|
||||
"dsmr",
|
||||
"dunehd",
|
||||
"esphome",
|
||||
"fritz",
|
||||
"glances",
|
||||
@@ -2826,6 +2816,8 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"jellyfin",
|
||||
"knx",
|
||||
"kodi",
|
||||
"linkplay",
|
||||
"madvr",
|
||||
"matter",
|
||||
"mikrotik",
|
||||
"modbus",
|
||||
@@ -2834,6 +2826,7 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"mqtt",
|
||||
"nanoleaf",
|
||||
"onvif",
|
||||
"openrgb",
|
||||
"opentherm_gw",
|
||||
"opnsense",
|
||||
"pi_hole",
|
||||
@@ -2845,6 +2838,7 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"shelly",
|
||||
"snapcast",
|
||||
"sonos",
|
||||
"soundtouch",
|
||||
"squeezebox",
|
||||
"synology_dsm",
|
||||
"tplink",
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from './arcam_fmj/index.js';
|
||||
export * from './asuswrt/index.js';
|
||||
export * from './axis/index.js';
|
||||
export * from './blebox/index.js';
|
||||
export * from './bluesound/index.js';
|
||||
export * from './bluetooth_le_tracker/index.js';
|
||||
export * from './bosch_shc/index.js';
|
||||
export * from './braviatv/index.js';
|
||||
@@ -21,6 +22,7 @@ export * from './denonavr/index.js';
|
||||
export * from './devolo_home_network/index.js';
|
||||
export * from './dlna_dmr/index.js';
|
||||
export * from './dsmr/index.js';
|
||||
export * from './dunehd/index.js';
|
||||
export * from './esphome/index.js';
|
||||
export * from './fritz/index.js';
|
||||
export * from './glances/index.js';
|
||||
@@ -32,6 +34,8 @@ export * from './ipp/index.js';
|
||||
export * from './jellyfin/index.js';
|
||||
export * from './knx/index.js';
|
||||
export * from './kodi/index.js';
|
||||
export * from './linkplay/index.js';
|
||||
export * from './madvr/index.js';
|
||||
export * from './matter/index.js';
|
||||
export * from './mikrotik/index.js';
|
||||
export * from './modbus/index.js';
|
||||
@@ -40,6 +44,7 @@ export * from './mpd/index.js';
|
||||
export * from './mqtt/index.js';
|
||||
export * from './nanoleaf/index.js';
|
||||
export * from './onvif/index.js';
|
||||
export * from './openrgb/index.js';
|
||||
export * from './opentherm_gw/index.js';
|
||||
export * from './opnsense/index.js';
|
||||
export * from './pi_hole/index.js';
|
||||
@@ -51,6 +56,7 @@ export * from './samsungtv/index.js';
|
||||
export * from './shelly/index.js';
|
||||
export * from './snapcast/index.js';
|
||||
export * from './sonos/index.js';
|
||||
export * from './soundtouch/index.js';
|
||||
export * from './squeezebox/index.js';
|
||||
export * from './synology_dsm/index.js';
|
||||
export * from './tplink/index.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './linkplay.classes.client.js';
|
||||
export * from './linkplay.classes.configflow.js';
|
||||
export * from './linkplay.classes.integration.js';
|
||||
export * from './linkplay.discovery.js';
|
||||
export * from './linkplay.mapper.js';
|
||||
export * from './linkplay.types.js';
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
import type {
|
||||
ILinkplayCommandExecutorResult,
|
||||
ILinkplayCommandRequest,
|
||||
ILinkplayConfig,
|
||||
ILinkplayDeviceInfo,
|
||||
ILinkplayDeviceProperties,
|
||||
ILinkplayMetaInfo,
|
||||
ILinkplayMultiroomGroup,
|
||||
ILinkplayPlayerProperties,
|
||||
ILinkplayPlayerStatus,
|
||||
ILinkplayPreset,
|
||||
ILinkplayRawCommandRequest,
|
||||
ILinkplaySnapshot,
|
||||
ILinkplaySource,
|
||||
ILinkplaySpeaker,
|
||||
TLinkplayAudioOutputHardwareMode,
|
||||
TLinkplayLoopMode,
|
||||
TLinkplayPlayingMode,
|
||||
TLinkplayProtocol,
|
||||
TLinkplayRepeatMode,
|
||||
} from './linkplay.types.js';
|
||||
import {
|
||||
linkplayAudioOutputHardwareCodeToMode,
|
||||
linkplayAudioOutputHardwareModeToCode,
|
||||
linkplayDefaultPort,
|
||||
linkplayDefaultTimeoutMs,
|
||||
linkplayLoopModeToRepeat,
|
||||
linkplayNormalEqualizerModeToNumber,
|
||||
linkplayNormalEqualizerNumberToMode,
|
||||
linkplayPlayModeSendMap,
|
||||
linkplayProjectLookup,
|
||||
linkplayRepeatToLoopMode,
|
||||
linkplaySourceMap,
|
||||
} from './linkplay.types.js';
|
||||
|
||||
const linkplayCommands = {
|
||||
deviceStatus: 'getStatusEx',
|
||||
playerStatus: 'getPlayerStatusEx',
|
||||
metaInfo: 'getMetaInfo',
|
||||
next: 'setPlayerCmd:next',
|
||||
previous: 'setPlayerCmd:prev',
|
||||
unmute: 'setPlayerCmd:mute:0',
|
||||
mute: 'setPlayerCmd:mute:1',
|
||||
resume: 'setPlayerCmd:resume',
|
||||
pause: 'setPlayerCmd:pause',
|
||||
stop: 'setPlayerCmd:stop',
|
||||
play: (valueArg: string) => `setPlayerCmd:play:${valueArg}`,
|
||||
seek: (valueArg: number) => `setPlayerCmd:seek:${valueArg}`,
|
||||
volume: (valueArg: number) => `setPlayerCmd:vol:${valueArg}`,
|
||||
equalizerMode: (valueArg: string) => `setPlayerCmd:equalizer:${valueArg}`,
|
||||
loopMode: (valueArg: TLinkplayLoopMode) => `setPlayerCmd:loopmode:${valueArg}`,
|
||||
switchMode: (valueArg: string) => `setPlayerCmd:switchmode:${valueArg}`,
|
||||
playPreset: (valueArg: number) => `MCUKeyShortClick:${valueArg}`,
|
||||
timesync: (valueArg: string) => `timeSync:${valueArg}`,
|
||||
restart: 'reboot',
|
||||
multiroomList: 'multiroom:getSlaveList',
|
||||
multiroomUngroup: 'multiroom:ungroup',
|
||||
multiroomKick: (ethArg: string) => `multiroom:SlaveKickout:${ethArg}`,
|
||||
multiroomVolume: (valueArg: number) => `setPlayerCmd:slave_vol:${valueArg}`,
|
||||
multiroomMute: 'setPlayerCmd:slave_mute:mute',
|
||||
multiroomUnmute: 'setPlayerCmd:slave_mute:unmute',
|
||||
multiroomJoin: (leaderEthArg: string) => `ConnectMasterAp:JoinGroupMaster:eth${leaderEthArg}:wifi0.0.0.0`,
|
||||
audioOutputHardwareMode: 'getNewAudioOutputHardwareMode',
|
||||
setAudioOutputHardwareMode: (valueArg: string) => `setAudioOutputHardwareMode:${valueArg}`,
|
||||
wiimEqualizerOff: 'EQOff',
|
||||
wiimEqualizerLoad: (valueArg: string) => `EQLoad:${valueArg}`,
|
||||
} as const;
|
||||
|
||||
const inputModeMap: Array<{ flag: number; mode: TLinkplayPlayingMode }> = [
|
||||
{ flag: 2, mode: '40' },
|
||||
{ flag: 4, mode: '41' },
|
||||
{ flag: 8, mode: '21' },
|
||||
{ flag: 16, mode: '43' },
|
||||
{ flag: 32, mode: '44' },
|
||||
{ flag: 64, mode: '45' },
|
||||
{ flag: 128, mode: '46' },
|
||||
{ flag: 256, mode: '47' },
|
||||
{ flag: 512, mode: '48' },
|
||||
{ flag: 1024, mode: '49' },
|
||||
{ flag: 2048, mode: '50' },
|
||||
{ flag: 8192, mode: '16' },
|
||||
{ flag: 16384, mode: '53' },
|
||||
{ flag: 32768, mode: '51' },
|
||||
{ flag: 65536, mode: '54' },
|
||||
{ flag: 262144, mode: '56' },
|
||||
{ flag: 524288, mode: '57' },
|
||||
{ flag: 2097152, mode: '99' },
|
||||
{ flag: 4194304, mode: '58' },
|
||||
];
|
||||
|
||||
export class LinkplayHttpError extends Error {
|
||||
constructor(public readonly status: number, messageArg: string) {
|
||||
super(messageArg);
|
||||
this.name = 'LinkplayHttpError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkplayCommandError extends Error {
|
||||
constructor(public readonly command: string, messageArg: string) {
|
||||
super(`LinkPlay command ${command} failed: ${messageArg}`);
|
||||
this.name = 'LinkplayCommandError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkplayClient {
|
||||
private currentSnapshot?: ILinkplaySnapshot;
|
||||
|
||||
constructor(private readonly config: ILinkplayConfig) {
|
||||
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot), 'snapshot') : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(): Promise<ILinkplaySnapshot> {
|
||||
if (this.currentSnapshot && (!this.config.host || this.config.snapshot)) {
|
||||
return this.normalizeSnapshot(this.cloneValue(this.currentSnapshot), this.currentSnapshot.source || 'snapshot');
|
||||
}
|
||||
if (!this.config.host && !this.config.commandExecutor) {
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'LinkPlay refresh requires config.host, config.snapshot, or commandExecutor.'), 'runtime');
|
||||
}
|
||||
if (!this.config.host && this.config.commandExecutor) {
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'LinkPlay executor configs need a snapshot for state mapping.'), 'runtime');
|
||||
}
|
||||
|
||||
try {
|
||||
this.currentSnapshot = await this.fetchSnapshot();
|
||||
return this.cloneValue(this.currentSnapshot);
|
||||
} catch (errorArg) {
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
|
||||
}
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<ILinkplaySnapshot> {
|
||||
const snapshot = await this.fetchSnapshot();
|
||||
if (!snapshot.speakers[0]?.uuid) {
|
||||
throw new Error('LinkPlay device did not provide a unique identifier.');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async execute(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
if (requestArg.command === 'play') {
|
||||
return this.sendCommand(linkplayCommands.resume, await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'pause') {
|
||||
return this.sendCommand(linkplayCommands.pause, await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'stop') {
|
||||
return this.sendCommand(linkplayCommands.stop, await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'next_track') {
|
||||
return this.sendCommand(linkplayCommands.next, await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'previous_track') {
|
||||
return this.sendCommand(linkplayCommands.previous, await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'play_media') {
|
||||
const mediaId = requestArg.url || requestArg.mediaId;
|
||||
if (!mediaId) {
|
||||
throw new Error('LinkPlay play_media requires mediaId or url.');
|
||||
}
|
||||
return this.sendCommand(linkplayCommands.play(mediaId), await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'seek') {
|
||||
return this.sendCommand(linkplayCommands.seek(Math.max(0, Math.round(this.requiredNumber(requestArg.position, 'LinkPlay seek requires position.')))), await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'set_volume') {
|
||||
return this.sendCommand(linkplayCommands.volume(this.volumePercent(requestArg)), await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'mute') {
|
||||
if (typeof requestArg.muted !== 'boolean') {
|
||||
throw new Error('LinkPlay mute requires muted.');
|
||||
}
|
||||
return this.sendCommand(requestArg.muted ? linkplayCommands.mute : linkplayCommands.unmute, await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'select_source') {
|
||||
return this.selectSource(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'set_repeat') {
|
||||
return this.setRepeat(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'set_sound_mode') {
|
||||
return this.setSoundMode(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'play_preset') {
|
||||
return this.playPreset(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'timesync') {
|
||||
return this.sendCommand(linkplayCommands.timesync(this.timestamp()), await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'restart') {
|
||||
return this.sendCommand(linkplayCommands.restart, await this.speakerForRequest(requestArg));
|
||||
}
|
||||
if (requestArg.command === 'set_audio_output_hardware_mode') {
|
||||
return this.setAudioOutputHardwareMode(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'join') {
|
||||
return this.joinPlayers(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'unjoin') {
|
||||
return this.unjoinPlayer(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'group_volume_set') {
|
||||
return this.setGroupVolume(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'group_mute') {
|
||||
return this.setGroupMute(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'raw_command') {
|
||||
if (!requestArg.rawCommand) {
|
||||
throw new Error('LinkPlay raw_command requires rawCommand.');
|
||||
}
|
||||
return this.sendCommand(requestArg.rawCommand, await this.speakerForRequest(requestArg));
|
||||
}
|
||||
throw new Error(`Unsupported LinkPlay command: ${requestArg.command}`);
|
||||
}
|
||||
|
||||
public async command(rawCommandArg: string, speakerUuidArg?: string): Promise<unknown> {
|
||||
return this.execute({ command: 'raw_command', rawCommand: rawCommandArg, speakerUuid: speakerUuidArg });
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async fetchSnapshot(): Promise<ILinkplaySnapshot> {
|
||||
const deviceProperties = await this.sendJsonCommand<ILinkplayDeviceProperties>(linkplayCommands.deviceStatus);
|
||||
const playerProperties = await this.sendJsonCommand<ILinkplayPlayerProperties>(linkplayCommands.playerStatus);
|
||||
const device = this.deviceInfoFromProperties(deviceProperties);
|
||||
const metaInfo = await this.sendJsonCommand<ILinkplayMetaInfo>(linkplayCommands.metaInfo).catch(() => undefined);
|
||||
const player = this.playerStatusFromProperties(playerProperties, metaInfo);
|
||||
const speaker: ILinkplaySpeaker = {
|
||||
uuid: device.uuid,
|
||||
name: device.name,
|
||||
device,
|
||||
player,
|
||||
available: true,
|
||||
sources: this.sourcesFromDevice(device),
|
||||
presets: this.presetsFromDevice(device),
|
||||
};
|
||||
const audioMode = await this.sendJsonCommand<Record<string, unknown>>(linkplayCommands.audioOutputHardwareMode).catch(() => undefined);
|
||||
if (audioMode) {
|
||||
speaker.audioOutputHardwareMode = linkplayAudioOutputHardwareCodeToMode[String(audioMode.hardware)] || String(audioMode.hardware || '');
|
||||
}
|
||||
const multirooms = await this.multiroomsFromDevice(speaker).catch(() => []);
|
||||
return this.normalizeSnapshot({
|
||||
speakers: [speaker],
|
||||
multirooms,
|
||||
online: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'http',
|
||||
raw: { deviceProperties, playerProperties, metaInfo },
|
||||
}, 'http');
|
||||
}
|
||||
|
||||
private async selectSource(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
const source = this.requiredString(requestArg.source, 'LinkPlay select_source requires source.');
|
||||
const mode = this.sourceMode(source);
|
||||
const commandValue = linkplayPlayModeSendMap[mode];
|
||||
if (!commandValue) {
|
||||
throw new Error(`Unsupported LinkPlay source: ${source}`);
|
||||
}
|
||||
return this.sendCommand(linkplayCommands.switchMode(commandValue), await this.speakerForRequest(requestArg));
|
||||
}
|
||||
|
||||
private async setRepeat(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
const repeat = this.requiredString(requestArg.repeat, 'LinkPlay set_repeat requires repeat.') as TLinkplayRepeatMode;
|
||||
const loopMode = linkplayRepeatToLoopMode[repeat];
|
||||
if (!loopMode) {
|
||||
throw new Error(`Unsupported LinkPlay repeat mode: ${repeat}`);
|
||||
}
|
||||
return this.sendCommand(linkplayCommands.loopMode(loopMode), await this.speakerForRequest(requestArg));
|
||||
}
|
||||
|
||||
private async setSoundMode(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
const soundMode = this.requiredString(requestArg.soundMode, 'LinkPlay set_sound_mode requires soundMode.');
|
||||
const speaker = await this.speakerForRequest(requestArg);
|
||||
if (speaker?.device.manufacturer === 'WiiM') {
|
||||
return this.sendCommand(soundMode === 'None' ? linkplayCommands.wiimEqualizerOff : linkplayCommands.wiimEqualizerLoad(soundMode), speaker);
|
||||
}
|
||||
const modeNumber = linkplayNormalEqualizerModeToNumber[soundMode];
|
||||
if (!modeNumber) {
|
||||
throw new Error(`Unsupported LinkPlay sound mode: ${soundMode}`);
|
||||
}
|
||||
return this.sendCommand(linkplayCommands.equalizerMode(modeNumber), speaker);
|
||||
}
|
||||
|
||||
private async playPreset(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
const preset = this.requiredNumber(requestArg.preset, 'LinkPlay play_preset requires preset.');
|
||||
const speaker = await this.speakerForRequest(requestArg);
|
||||
const maxPreset = speaker?.device.maxPresets || 10;
|
||||
if (!Number.isInteger(preset) || preset < 1 || preset > maxPreset) {
|
||||
throw new Error(`LinkPlay preset must be between 1 and ${maxPreset}.`);
|
||||
}
|
||||
return this.sendCommand(linkplayCommands.playPreset(preset), speaker);
|
||||
}
|
||||
|
||||
private async setAudioOutputHardwareMode(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
const mode = this.requiredString(requestArg.hardwareMode, 'LinkPlay set_audio_output_hardware_mode requires hardwareMode.');
|
||||
const code = linkplayAudioOutputHardwareModeToCode[mode] || mode;
|
||||
if (!['1', '2', '3', '4'].includes(code)) {
|
||||
throw new Error(`Unsupported LinkPlay audio output hardware mode: ${mode}`);
|
||||
}
|
||||
return this.sendCommand(linkplayCommands.setAudioOutputHardwareMode(code), await this.speakerForRequest(requestArg));
|
||||
}
|
||||
|
||||
private async joinPlayers(requestArg: ILinkplayCommandRequest): Promise<unknown[]> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const leader = this.requiredSpeaker(snapshot, requestArg.speakerUuid);
|
||||
if (!requestArg.groupMemberUuids?.length) {
|
||||
throw new Error('LinkPlay join requires groupMemberUuids.');
|
||||
}
|
||||
const leaderEth = this.requiredString(leader.device.ethernetAddress, 'LinkPlay join requires leader ethernet address.');
|
||||
const results: unknown[] = [];
|
||||
for (const memberUuid of requestArg.groupMemberUuids.filter((uuidArg) => uuidArg !== leader.uuid)) {
|
||||
const follower = this.requiredSpeaker(snapshot, memberUuid);
|
||||
results.push(await this.sendCommand(linkplayCommands.multiroomJoin(leaderEth), follower));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async unjoinPlayer(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const speaker = this.requiredSpeaker(snapshot, requestArg.speakerUuid);
|
||||
const group = this.groupForSpeaker(snapshot, speaker.uuid);
|
||||
if (!group) {
|
||||
return undefined;
|
||||
}
|
||||
const leader = this.requiredSpeaker(snapshot, group.leaderUuid);
|
||||
if (speaker.uuid === leader.uuid) {
|
||||
return this.sendCommand(linkplayCommands.multiroomUngroup, leader);
|
||||
}
|
||||
const followerEth = this.requiredString(speaker.device.ethernetAddress, 'LinkPlay unjoin requires follower ethernet address.');
|
||||
return this.sendCommand(linkplayCommands.multiroomKick(followerEth), leader);
|
||||
}
|
||||
|
||||
private async setGroupVolume(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const speaker = this.requiredSpeaker(snapshot, requestArg.speakerUuid);
|
||||
const group = this.groupForSpeaker(snapshot, speaker.uuid);
|
||||
const leader = this.requiredSpeaker(snapshot, group?.leaderUuid || speaker.uuid);
|
||||
return this.sendCommand(linkplayCommands.multiroomVolume(this.volumePercent(requestArg)), leader);
|
||||
}
|
||||
|
||||
private async setGroupMute(requestArg: ILinkplayCommandRequest): Promise<unknown> {
|
||||
if (typeof requestArg.muted !== 'boolean') {
|
||||
throw new Error('LinkPlay group_mute requires muted.');
|
||||
}
|
||||
const snapshot = await this.getSnapshot();
|
||||
const speaker = this.requiredSpeaker(snapshot, requestArg.speakerUuid);
|
||||
const group = this.groupForSpeaker(snapshot, speaker.uuid);
|
||||
const leader = this.requiredSpeaker(snapshot, group?.leaderUuid || speaker.uuid);
|
||||
return this.sendCommand(requestArg.muted ? linkplayCommands.multiroomMute : linkplayCommands.multiroomUnmute, leader);
|
||||
}
|
||||
|
||||
private async sendCommand(commandArg: string, speakerArg?: ILinkplaySpeaker): Promise<unknown> {
|
||||
if (this.config.commandExecutor) {
|
||||
return this.executorResultToOk(commandArg, await this.config.commandExecutor.execute(this.rawRequest(commandArg, false, speakerArg)));
|
||||
}
|
||||
const response = await this.requestText(commandArg, speakerArg);
|
||||
if (response.trim() !== 'OK') {
|
||||
throw new LinkplayCommandError(commandArg, `expected OK, received ${response}`);
|
||||
}
|
||||
return { response };
|
||||
}
|
||||
|
||||
private async sendJsonCommand<TValue = Record<string, unknown>>(commandArg: string, speakerArg?: ILinkplaySpeaker): Promise<TValue> {
|
||||
if (this.config.commandExecutor) {
|
||||
const result = await this.config.commandExecutor.execute(this.rawRequest(commandArg, true, speakerArg));
|
||||
return this.executorResultToJson<TValue>(commandArg, result);
|
||||
}
|
||||
const text = await this.requestText(commandArg, speakerArg);
|
||||
try {
|
||||
return JSON.parse(text) as TValue;
|
||||
} catch (errorArg) {
|
||||
throw new LinkplayCommandError(commandArg, `invalid JSON response: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestText(commandArg: string, speakerArg?: ILinkplaySpeaker): Promise<string> {
|
||||
const request = this.rawRequest(commandArg, false, speakerArg);
|
||||
if (!request.host) {
|
||||
throw new Error('LinkPlay HTTP command requires config.host or commandExecutor. Static snapshots are read-only.');
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || linkplayDefaultTimeoutMs);
|
||||
try {
|
||||
const response = await globalThis.fetch(request.url || this.commandUrl(request.protocol, request.host, request.port, commandArg), {
|
||||
method: 'GET',
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new LinkplayHttpError(response.status, `LinkPlay request ${commandArg} failed with HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
return text;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private rawRequest(commandArg: string, expectJsonArg: boolean, speakerArg?: ILinkplaySpeaker): ILinkplayRawCommandRequest {
|
||||
const protocol = speakerArg?.device.protocol || this.config.protocol || 'http';
|
||||
const host = speakerArg?.device.host || this.config.host;
|
||||
const port = speakerArg?.device.port || this.config.port || defaultPort(protocol);
|
||||
return {
|
||||
command: commandArg,
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
expectJson: expectJsonArg,
|
||||
speakerUuid: speakerArg?.uuid,
|
||||
speakerName: speakerArg?.name || speakerArg?.device.name,
|
||||
url: host ? this.commandUrl(protocol, host, port, commandArg) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private commandUrl(protocolArg: TLinkplayProtocol, hostArg: string, portArg: number, commandArg: string): string {
|
||||
const includePort = !(protocolArg === 'http' && portArg === 80) && !(protocolArg === 'https' && portArg === 443);
|
||||
const encodedHost = hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
const url = new URL(`${protocolArg}://${encodedHost}${includePort ? `:${portArg}` : ''}/httpapi.asp`);
|
||||
url.searchParams.set('command', commandArg);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private executorResultToOk(commandArg: string, resultArg: unknown): unknown {
|
||||
if (resultArg === undefined || resultArg === true) {
|
||||
return resultArg;
|
||||
}
|
||||
if (typeof resultArg === 'string') {
|
||||
if (resultArg.trim() === 'OK') {
|
||||
return { response: resultArg };
|
||||
}
|
||||
throw new LinkplayCommandError(commandArg, `executor returned ${resultArg}`);
|
||||
}
|
||||
if (this.isRecord(resultArg)) {
|
||||
const result = resultArg as ILinkplayCommandExecutorResult;
|
||||
if (result.ok === false || result.success === false || result.status && result.status >= 400) {
|
||||
throw new LinkplayCommandError(commandArg, JSON.stringify(resultArg));
|
||||
}
|
||||
if (typeof result.response === 'string' && result.response.trim() !== 'OK') {
|
||||
throw new LinkplayCommandError(commandArg, `executor returned ${result.response}`);
|
||||
}
|
||||
}
|
||||
return resultArg;
|
||||
}
|
||||
|
||||
private executorResultToJson<TValue>(commandArg: string, resultArg: unknown): TValue {
|
||||
if (typeof resultArg === 'string') {
|
||||
try {
|
||||
return JSON.parse(resultArg) as TValue;
|
||||
} catch {
|
||||
throw new LinkplayCommandError(commandArg, `executor returned invalid JSON: ${resultArg}`);
|
||||
}
|
||||
}
|
||||
if (this.isRecord(resultArg) && 'data' in resultArg) {
|
||||
return resultArg.data as TValue;
|
||||
}
|
||||
if (this.isRecord(resultArg)) {
|
||||
return resultArg as TValue;
|
||||
}
|
||||
throw new LinkplayCommandError(commandArg, 'executor returned no JSON data.');
|
||||
}
|
||||
|
||||
private deviceInfoFromProperties(propertiesArg: ILinkplayDeviceProperties): ILinkplayDeviceInfo {
|
||||
const project = stringValue(propertiesArg.project);
|
||||
const projectInfo = project ? linkplayProjectLookup[project] : undefined;
|
||||
const protocol = this.config.protocol || 'http';
|
||||
const host = this.config.host;
|
||||
const port = this.config.port || defaultPort(protocol);
|
||||
const uuid = stringValue(propertiesArg.uuid) || this.config.uniqueId || (host ? `${host}:${port}` : 'linkplay');
|
||||
const name = stringValue(propertiesArg.DeviceName) || this.config.name || host || 'LinkPlay';
|
||||
return {
|
||||
uuid,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
endpoint: host ? `${protocol}://${host}${port === defaultPort(protocol) ? '' : `:${port}`}` : undefined,
|
||||
manufacturer: this.config.manufacturer || projectInfo?.manufacturer || 'Generic',
|
||||
model: this.config.model || projectInfo?.model || 'Generic',
|
||||
modelId: this.config.modelId || (projectInfo?.model === 'Generic' ? undefined : project),
|
||||
project,
|
||||
macAddress: this.macAddress(propertiesArg),
|
||||
hardwareVersion: stringValue(propertiesArg.hardware),
|
||||
firmwareVersion: stringValue(propertiesArg.firmware),
|
||||
ethernetAddress: this.ethernetAddress(propertiesArg),
|
||||
playModeSupport: this.playModeSupport(propertiesArg),
|
||||
maxPresets: numberValue(propertiesArg.preset_key) || 10,
|
||||
properties: propertiesArg,
|
||||
};
|
||||
}
|
||||
|
||||
private playerStatusFromProperties(propertiesArg: ILinkplayPlayerProperties, metaInfoArg?: ILinkplayMetaInfo): ILinkplayPlayerStatus {
|
||||
const playMode = stringValue(propertiesArg.mode) as TLinkplayPlayingMode | undefined;
|
||||
const loopMode = stringValue(propertiesArg.loop) as TLinkplayLoopMode | undefined;
|
||||
const eq = stringValue(propertiesArg.eq);
|
||||
const status = validStatus(stringValue(propertiesArg.status)) || 'stop';
|
||||
return {
|
||||
status,
|
||||
playMode,
|
||||
loopMode,
|
||||
equalizerMode: eq ? linkplayNormalEqualizerNumberToMode[eq] || eq : undefined,
|
||||
volume: numberValue(propertiesArg.vol),
|
||||
muted: stringValue(propertiesArg.mute) === '1',
|
||||
title: decodeHexString(stringValue(propertiesArg.Title)),
|
||||
artist: decodeHexString(stringValue(propertiesArg.Artist)),
|
||||
album: decodeHexString(stringValue(propertiesArg.Album)),
|
||||
albumArtUrl: metaInfoArg?.metaData?.albumArtURI,
|
||||
currentPositionMs: numberValue(propertiesArg.curpos),
|
||||
totalLengthMs: numberValue(propertiesArg.totlen),
|
||||
source: playMode ? linkplaySourceMap[playMode] || 'other' : undefined,
|
||||
speakerType: stringValue(propertiesArg.type),
|
||||
channelType: stringValue(propertiesArg.ch),
|
||||
properties: propertiesArg,
|
||||
metaInfo: metaInfoArg,
|
||||
};
|
||||
}
|
||||
|
||||
private sourcesFromDevice(deviceArg: ILinkplayDeviceInfo): ILinkplaySource[] {
|
||||
return (deviceArg.playModeSupport || ['10']).map((modeArg) => ({
|
||||
mode: modeArg,
|
||||
name: linkplaySourceMap[modeArg] || modeArg,
|
||||
commandValue: linkplayPlayModeSendMap[modeArg],
|
||||
available: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private presetsFromDevice(deviceArg: ILinkplayDeviceInfo): ILinkplayPreset[] {
|
||||
const maxPresets = deviceArg.maxPresets || 10;
|
||||
return Array.from({ length: maxPresets }, (_itemArg, indexArg) => ({ number: indexArg + 1, name: `Preset ${indexArg + 1}` }));
|
||||
}
|
||||
|
||||
private async multiroomsFromDevice(speakerArg: ILinkplaySpeaker): Promise<ILinkplayMultiroomGroup[]> {
|
||||
const response = await this.sendJsonCommand<Record<string, unknown>>(linkplayCommands.multiroomList, speakerArg);
|
||||
const followerRecords = Array.isArray(response.slave_list) ? response.slave_list.filter((itemArg): itemArg is Record<string, unknown> => this.isRecord(itemArg)) : [];
|
||||
const followerUuids = followerRecords.map((itemArg) => stringValue(itemArg.uuid)).filter((valueArg): valueArg is string => Boolean(valueArg));
|
||||
if (!followerUuids.length) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
id: `multiroom_${speakerArg.uuid}`,
|
||||
name: `${speakerArg.name || speakerArg.device.name} Multiroom`,
|
||||
leaderUuid: speakerArg.uuid,
|
||||
followerUuids,
|
||||
}];
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: ILinkplaySnapshot, sourceArg: ILinkplaySnapshot['source']): ILinkplaySnapshot {
|
||||
const speakers = (snapshotArg.speakers || []).map((speakerArg) => this.normalizeSpeaker(speakerArg));
|
||||
const sources = snapshotArg.sources || uniqueSources(speakers.flatMap((speakerArg) => speakerArg.sources || []));
|
||||
const presets = snapshotArg.presets || uniquePresets(speakers.flatMap((speakerArg) => speakerArg.presets || []));
|
||||
return {
|
||||
...snapshotArg,
|
||||
speakers,
|
||||
multirooms: snapshotArg.multirooms || [],
|
||||
sources,
|
||||
presets,
|
||||
online: Boolean(snapshotArg.online),
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
source: snapshotArg.source || sourceArg,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSpeaker(speakerArg: ILinkplaySpeaker): ILinkplaySpeaker {
|
||||
const uuid = speakerArg.uuid || speakerArg.device.uuid;
|
||||
const device = {
|
||||
...speakerArg.device,
|
||||
uuid,
|
||||
name: speakerArg.device.name || speakerArg.name || this.config.name || this.config.host || 'LinkPlay',
|
||||
host: speakerArg.device.host || this.config.host,
|
||||
port: speakerArg.device.port || this.config.port || (speakerArg.device.host || this.config.host ? defaultPort(speakerArg.device.protocol || this.config.protocol || 'http') : undefined),
|
||||
protocol: speakerArg.device.protocol || this.config.protocol || 'http',
|
||||
playModeSupport: speakerArg.device.playModeSupport || ['10'],
|
||||
maxPresets: speakerArg.device.maxPresets || 10,
|
||||
};
|
||||
return {
|
||||
...speakerArg,
|
||||
uuid,
|
||||
name: speakerArg.name || device.name,
|
||||
device,
|
||||
player: {
|
||||
...speakerArg.player,
|
||||
status: speakerArg.player.status || 'stop',
|
||||
},
|
||||
available: speakerArg.available ?? true,
|
||||
sources: speakerArg.sources || this.sourcesFromDevice(device),
|
||||
presets: speakerArg.presets || this.presetsFromDevice(device),
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): ILinkplaySnapshot {
|
||||
const protocol = this.config.protocol || 'http';
|
||||
const host = this.config.host;
|
||||
const port = this.config.port || (host ? defaultPort(protocol) : undefined);
|
||||
const uuid = this.config.uniqueId || (host && port ? `${host}:${port}` : 'linkplay');
|
||||
const device: ILinkplayDeviceInfo = {
|
||||
uuid,
|
||||
name: this.config.name || host || 'LinkPlay',
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
manufacturer: this.config.manufacturer || 'Generic',
|
||||
model: this.config.model || 'Generic',
|
||||
modelId: this.config.modelId,
|
||||
macAddress: this.config.macAddress,
|
||||
playModeSupport: ['10'],
|
||||
maxPresets: 10,
|
||||
};
|
||||
return {
|
||||
speakers: [{ uuid, name: device.name, device, player: { status: onlineArg ? 'stop' : 'stop' }, available: onlineArg }],
|
||||
multirooms: [],
|
||||
online: onlineArg,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'runtime',
|
||||
error: errorArg,
|
||||
};
|
||||
}
|
||||
|
||||
private async speakerForRequest(requestArg: ILinkplayCommandRequest): Promise<ILinkplaySpeaker | undefined> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
if (requestArg.speakerUuid) {
|
||||
return this.requiredSpeaker(snapshot, requestArg.speakerUuid);
|
||||
}
|
||||
return snapshot.speakers.length === 1 ? snapshot.speakers[0] : undefined;
|
||||
}
|
||||
|
||||
private requiredSpeaker(snapshotArg: ILinkplaySnapshot, speakerUuidArg: string | undefined): ILinkplaySpeaker {
|
||||
if (speakerUuidArg) {
|
||||
const speaker = snapshotArg.speakers.find((speakerArg) => speakerArg.uuid === speakerUuidArg || speakerArg.device.uuid === speakerUuidArg);
|
||||
if (speaker) {
|
||||
return speaker;
|
||||
}
|
||||
throw new Error(`LinkPlay speaker not found: ${speakerUuidArg}`);
|
||||
}
|
||||
if (snapshotArg.speakers.length === 1) {
|
||||
return snapshotArg.speakers[0];
|
||||
}
|
||||
throw new Error('LinkPlay service call requires data.speaker_uuid or a target LinkPlay media_player entity.');
|
||||
}
|
||||
|
||||
private groupForSpeaker(snapshotArg: ILinkplaySnapshot, speakerUuidArg: string): ILinkplayMultiroomGroup | undefined {
|
||||
return (snapshotArg.multirooms || []).find((groupArg) => groupArg.leaderUuid === speakerUuidArg || groupArg.followerUuids.includes(speakerUuidArg));
|
||||
}
|
||||
|
||||
private sourceMode(sourceArg: string): TLinkplayPlayingMode {
|
||||
const byName = Object.entries(linkplaySourceMap).find(([_modeArg, nameArg]) => nameArg.toLowerCase() === sourceArg.toLowerCase());
|
||||
if (byName) {
|
||||
return byName[0] as TLinkplayPlayingMode;
|
||||
}
|
||||
if (linkplayPlayModeSendMap[sourceArg]) {
|
||||
return sourceArg as TLinkplayPlayingMode;
|
||||
}
|
||||
throw new Error(`Unknown LinkPlay source: ${sourceArg}`);
|
||||
}
|
||||
|
||||
private playModeSupport(propertiesArg: ILinkplayDeviceProperties): TLinkplayPlayingMode[] {
|
||||
const support = stringValue(propertiesArg.plm_support);
|
||||
const modes: TLinkplayPlayingMode[] = ['10'];
|
||||
if (!support) {
|
||||
return modes;
|
||||
}
|
||||
const flags = Number.parseInt(support, 16);
|
||||
if (!Number.isFinite(flags)) {
|
||||
return modes;
|
||||
}
|
||||
for (const item of inputModeMap) {
|
||||
if ((flags & item.flag) === item.flag) {
|
||||
modes.push(item.mode);
|
||||
}
|
||||
}
|
||||
return [...new Set(modes)];
|
||||
}
|
||||
|
||||
private macAddress(propertiesArg: ILinkplayDeviceProperties): string | undefined {
|
||||
return firstUsableMac(stringValue(propertiesArg.ETH_MAC), stringValue(propertiesArg.STA_MAC), stringValue(propertiesArg.MAC));
|
||||
}
|
||||
|
||||
private ethernetAddress(propertiesArg: ILinkplayDeviceProperties): string | undefined {
|
||||
return firstUsableIp(stringValue(propertiesArg.eth2), stringValue(propertiesArg.eth0), stringValue(propertiesArg.apcli0));
|
||||
}
|
||||
|
||||
private volumePercent(requestArg: ILinkplayCommandRequest): number {
|
||||
const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel * 100 : undefined);
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error('LinkPlay volume command requires volumeLevel or volume.');
|
||||
}
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
private timestamp(): string {
|
||||
const now = new Date();
|
||||
const parts = [
|
||||
now.getFullYear(),
|
||||
now.getMonth() + 1,
|
||||
now.getDate(),
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
];
|
||||
return `${parts[0]}${parts.slice(1).map((partArg) => String(partArg).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
private requiredString(valueArg: unknown, errorArg: string): string {
|
||||
if (typeof valueArg !== 'string' || !valueArg) {
|
||||
throw new Error(errorArg);
|
||||
}
|
||||
return valueArg;
|
||||
}
|
||||
|
||||
private requiredNumber(valueArg: unknown, errorArg: string): number {
|
||||
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
|
||||
throw new Error(errorArg);
|
||||
}
|
||||
return valueArg;
|
||||
}
|
||||
|
||||
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
|
||||
}
|
||||
|
||||
private cloneValue<TValue>(valueArg: TValue): TValue {
|
||||
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPort = (protocolArg: TLinkplayProtocol): number => protocolArg === 'https' ? 443 : linkplayDefaultPort;
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
return valueArg.trim();
|
||||
}
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return String(valueArg);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validStatus = (valueArg: string | undefined): 'play' | 'pause' | 'stop' | 'load' | undefined => {
|
||||
return valueArg === 'play' || valueArg === 'pause' || valueArg === 'stop' || valueArg === 'load' ? valueArg : undefined;
|
||||
};
|
||||
|
||||
const decodeHexString = (valueArg: string | undefined): string | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
if (!/^(?:[0-9a-f]{2})+$/i.test(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
try {
|
||||
return Buffer.from(valueArg, 'hex').toString('utf8');
|
||||
} catch {
|
||||
return valueArg;
|
||||
}
|
||||
};
|
||||
|
||||
const firstUsableMac = (...valuesArg: Array<string | undefined>): string | undefined => {
|
||||
return valuesArg.find((valueArg) => valueArg && valueArg !== '00:00:00:00:00:00');
|
||||
};
|
||||
|
||||
const firstUsableIp = (...valuesArg: Array<string | undefined>): string | undefined => {
|
||||
return valuesArg.find((valueArg) => valueArg && valueArg !== '0.0.0.0');
|
||||
};
|
||||
|
||||
const uniqueSources = (sourcesArg: ILinkplaySource[]): ILinkplaySource[] => {
|
||||
const seen = new Set<string>();
|
||||
return sourcesArg.filter((sourceArg) => {
|
||||
const key = sourceArg.mode || sourceArg.name;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const uniquePresets = (presetsArg: ILinkplayPreset[]): ILinkplayPreset[] => {
|
||||
const seen = new Set<number>();
|
||||
return presetsArg.filter((presetArg) => {
|
||||
if (seen.has(presetArg.number)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(presetArg.number);
|
||||
return true;
|
||||
}).sort((leftArg, rightArg) => leftArg.number - rightArg.number);
|
||||
};
|
||||
|
||||
export const linkplayRepeatFromLoopMode = (loopModeArg: TLinkplayLoopMode | undefined): TLinkplayRepeatMode | undefined => {
|
||||
return loopModeArg ? linkplayLoopModeToRepeat[loopModeArg] : undefined;
|
||||
};
|
||||
|
||||
export const linkplaySourceName = (modeArg: TLinkplayPlayingMode | undefined): string | undefined => {
|
||||
return modeArg ? linkplaySourceMap[modeArg] || 'other' : undefined;
|
||||
};
|
||||
|
||||
export const linkplayAudioOutputHardwareMode = (modeArg: TLinkplayAudioOutputHardwareMode | string | undefined): string | undefined => modeArg;
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { ILinkplayConfig, TLinkplayProtocol } from './linkplay.types.js';
|
||||
import { linkplayDefaultPort } from './linkplay.types.js';
|
||||
|
||||
export class LinkplayConfigFlow implements IConfigFlow<ILinkplayConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ILinkplayConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect LinkPlay',
|
||||
description: candidateArg.source === 'manual'
|
||||
? 'Configure a local LinkPlay HTTP API endpoint.'
|
||||
: 'Confirm or adjust the discovered local LinkPlay endpoint.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'HTTP API port', type: 'number' },
|
||||
{ name: 'protocol', label: 'Protocol', type: 'select', options: [{ label: 'HTTP', value: 'http' }, { label: 'HTTPS', value: 'https' }] },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
if (isWiimCandidate(candidateArg)) {
|
||||
return { kind: 'error', title: 'LinkPlay setup failed', error: 'This device should be set up with the WiiM integration.' };
|
||||
}
|
||||
const host = stringValue(valuesArg.host) || candidateArg.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'LinkPlay setup failed', error: 'LinkPlay host is required.' };
|
||||
}
|
||||
const protocol = protocolValue(valuesArg.protocol) || protocolMetadata(candidateArg) || protocolFromPort(candidateArg.port);
|
||||
const port = numberValue(valuesArg.port) || candidateArg.port || defaultPort(protocol);
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'LinkPlay configured',
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
name: stringValue(valuesArg.name) || candidateArg.name,
|
||||
uniqueId: candidateArg.id,
|
||||
manufacturer: candidateArg.manufacturer,
|
||||
model: candidateArg.model,
|
||||
modelId: stringMetadata(candidateArg.metadata?.modelId),
|
||||
macAddress: candidateArg.macAddress,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
};
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const protocolValue = (valueArg: unknown): TLinkplayProtocol | undefined => {
|
||||
return valueArg === 'http' || valueArg === 'https' ? valueArg : undefined;
|
||||
};
|
||||
|
||||
const protocolMetadata = (candidateArg: IDiscoveryCandidate): TLinkplayProtocol | undefined => protocolValue(candidateArg.metadata?.protocol);
|
||||
|
||||
const protocolFromPort = (portArg: number | undefined): TLinkplayProtocol => portArg === 443 || portArg === 4443 ? 'https' : 'http';
|
||||
|
||||
const defaultPort = (protocolArg: TLinkplayProtocol): number => protocolArg === 'https' ? 443 : linkplayDefaultPort;
|
||||
|
||||
const stringMetadata = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg : undefined;
|
||||
|
||||
const isWiimCandidate = (candidateArg: IDiscoveryCandidate): boolean => {
|
||||
const haystack = `${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${stringMetadata(candidateArg.metadata?.project) || ''}`.toLowerCase();
|
||||
return haystack.includes('wiim') || haystack.includes('muzo_mini');
|
||||
};
|
||||
@@ -1,26 +1,283 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { LinkplayClient } from './linkplay.classes.client.js';
|
||||
import { LinkplayConfigFlow } from './linkplay.classes.configflow.js';
|
||||
import { createLinkplayDiscoveryDescriptor } from './linkplay.discovery.js';
|
||||
import { LinkplayMapper } from './linkplay.mapper.js';
|
||||
import type { ILinkplayCommandRequest, ILinkplayConfig } from './linkplay.types.js';
|
||||
|
||||
export class HomeAssistantLinkplayIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "linkplay",
|
||||
displayName: "LinkPlay",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/linkplay",
|
||||
"upstreamDomain": "linkplay",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"python-linkplay==0.2.12"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@Velleman"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class LinkplayIntegration extends BaseIntegration<ILinkplayConfig> {
|
||||
public readonly domain = 'linkplay';
|
||||
public readonly displayName = 'LinkPlay';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createLinkplayDiscoveryDescriptor();
|
||||
public readonly configFlow = new LinkplayConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/linkplay',
|
||||
upstreamDomain: 'linkplay',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['python-linkplay==0.2.12'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@Velleman'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/linkplay',
|
||||
zeroconf: ['_linkplay._tcp.local.'],
|
||||
};
|
||||
|
||||
public async setup(configArg: ILinkplayConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new LinkplayRuntime(new LinkplayClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantLinkplayIntegration extends LinkplayIntegration {}
|
||||
|
||||
class LinkplayRuntime implements IIntegrationRuntime {
|
||||
public domain = 'linkplay';
|
||||
|
||||
constructor(private readonly client: LinkplayClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return LinkplayMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return LinkplayMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'media_player') {
|
||||
return await this.callMediaPlayerService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'linkplay') {
|
||||
return await this.callLinkplayService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'select') {
|
||||
return await this.callSelectService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'button') {
|
||||
return await this.callButtonService(requestArg);
|
||||
}
|
||||
return { success: false, error: `Unsupported LinkPlay service domain: ${requestArg.domain}` };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'join' || requestArg.service === 'join_players') {
|
||||
return { success: true, data: await this.client.execute({ command: 'join', speakerUuid: await this.speakerUuidFromRequest(requestArg), groupMemberUuids: await this.memberUuidsFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
|
||||
return { success: true, data: await this.client.execute({ command: 'unjoin', speakerUuid: await this.speakerUuidFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'group_volume_set') {
|
||||
return { success: true, data: await this.client.execute({ command: 'group_volume_set', speakerUuid: await this.speakerUuidFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level'), volume: this.numberData(requestArg, 'volume') }) };
|
||||
}
|
||||
if (requestArg.service === 'group_mute') {
|
||||
return { success: true, data: await this.client.execute({ command: 'group_mute', speakerUuid: await this.speakerUuidFromRequest(requestArg), muted: this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute') }) };
|
||||
}
|
||||
|
||||
const command = this.commandFromMediaService(requestArg.service);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported LinkPlay media_player service: ${requestArg.service}` };
|
||||
}
|
||||
return { success: true, data: await this.client.execute(await this.commandRequest(command, requestArg)) };
|
||||
}
|
||||
|
||||
private async callLinkplayService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'play_preset') {
|
||||
return { success: true, data: await this.client.execute({ command: 'play_preset', speakerUuid: await this.speakerUuidFromRequest(requestArg), preset: this.numberData(requestArg, 'preset_number') ?? this.numberData(requestArg, 'preset') }) };
|
||||
}
|
||||
if (requestArg.service === 'timesync' || requestArg.service === 'sync_time') {
|
||||
return { success: true, data: await this.client.execute({ command: 'timesync', speakerUuid: await this.speakerUuidFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'restart' || requestArg.service === 'reboot') {
|
||||
return { success: true, data: await this.client.execute({ command: 'restart', speakerUuid: await this.speakerUuidFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'set_audio_output_hardware_mode') {
|
||||
return { success: true, data: await this.client.execute({ command: 'set_audio_output_hardware_mode', speakerUuid: await this.speakerUuidFromRequest(requestArg), hardwareMode: this.stringData(requestArg, 'hardware_mode') || this.stringData(requestArg, 'option') }) };
|
||||
}
|
||||
if (requestArg.service === 'command' || requestArg.service === 'raw_command') {
|
||||
const rawCommand = this.stringData(requestArg, 'command') || this.stringData(requestArg, 'rawCommand');
|
||||
if (!rawCommand) {
|
||||
throw new Error('LinkPlay raw command service requires data.command.');
|
||||
}
|
||||
return { success: true, data: await this.client.execute({ command: 'raw_command', speakerUuid: await this.optionalSpeakerUuidFromRequest(requestArg), rawCommand }) };
|
||||
}
|
||||
if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player' || requestArg.service === 'group_volume_set' || requestArg.service === 'group_mute') {
|
||||
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
||||
}
|
||||
if (this.commandFromMediaService(requestArg.service)) {
|
||||
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
||||
}
|
||||
return { success: false, error: `Unsupported LinkPlay service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callSelectService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service !== 'select_option') {
|
||||
return { success: false, error: `Unsupported LinkPlay select service: ${requestArg.service}` };
|
||||
}
|
||||
return { success: true, data: await this.client.execute({ command: 'set_audio_output_hardware_mode', speakerUuid: await this.speakerUuidFromRequest(requestArg), hardwareMode: this.stringData(requestArg, 'option') }) };
|
||||
}
|
||||
|
||||
private async callButtonService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'press') {
|
||||
const button = this.stringData(requestArg, 'button') || requestArg.target.entityId || '';
|
||||
const command = button.includes('restart') ? 'restart' : 'timesync';
|
||||
return { success: true, data: await this.client.execute({ command, speakerUuid: await this.speakerUuidFromRequest(requestArg) }) };
|
||||
}
|
||||
return { success: false, error: `Unsupported LinkPlay button service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private commandFromMediaService(serviceArg: string): ILinkplayCommandRequest['command'] | undefined {
|
||||
if (serviceArg === 'media_play' || serviceArg === 'play') {
|
||||
return 'play';
|
||||
}
|
||||
if (serviceArg === 'media_pause' || serviceArg === 'pause') {
|
||||
return 'pause';
|
||||
}
|
||||
if (serviceArg === 'media_stop' || serviceArg === 'stop') {
|
||||
return 'stop';
|
||||
}
|
||||
if (serviceArg === 'media_next_track' || serviceArg === 'next_track' || serviceArg === 'next') {
|
||||
return 'next_track';
|
||||
}
|
||||
if (serviceArg === 'media_previous_track' || serviceArg === 'previous_track' || serviceArg === 'previous') {
|
||||
return 'previous_track';
|
||||
}
|
||||
if (serviceArg === 'volume_set' || serviceArg === 'set_volume') {
|
||||
return 'set_volume';
|
||||
}
|
||||
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
|
||||
return 'mute';
|
||||
}
|
||||
if (serviceArg === 'select_source' || serviceArg === 'source') {
|
||||
return 'select_source';
|
||||
}
|
||||
if (serviceArg === 'select_sound_mode' || serviceArg === 'set_sound_mode') {
|
||||
return 'set_sound_mode';
|
||||
}
|
||||
if (serviceArg === 'repeat_set' || serviceArg === 'set_repeat') {
|
||||
return 'set_repeat';
|
||||
}
|
||||
if (serviceArg === 'media_seek' || serviceArg === 'seek') {
|
||||
return 'seek';
|
||||
}
|
||||
if (serviceArg === 'play_media') {
|
||||
return 'play_media';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async commandRequest(commandArg: ILinkplayCommandRequest['command'], requestArg: IServiceCallRequest): Promise<ILinkplayCommandRequest> {
|
||||
const base: ILinkplayCommandRequest = {
|
||||
command: commandArg,
|
||||
speakerUuid: await this.speakerUuidFromRequest(requestArg),
|
||||
};
|
||||
if (commandArg === 'set_volume') {
|
||||
base.volumeLevel = this.numberData(requestArg, 'volume_level');
|
||||
base.volume = this.numberData(requestArg, 'volume');
|
||||
}
|
||||
if (commandArg === 'mute') {
|
||||
base.muted = this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute');
|
||||
}
|
||||
if (commandArg === 'select_source') {
|
||||
base.source = this.stringData(requestArg, 'source');
|
||||
}
|
||||
if (commandArg === 'set_sound_mode') {
|
||||
base.soundMode = this.stringData(requestArg, 'sound_mode') || this.stringData(requestArg, 'soundMode');
|
||||
}
|
||||
if (commandArg === 'set_repeat') {
|
||||
const repeat = this.stringData(requestArg, 'repeat') || this.stringData(requestArg, 'repeat_mode');
|
||||
if (repeat === 'off' || repeat === 'one' || repeat === 'all') {
|
||||
base.repeat = repeat;
|
||||
}
|
||||
}
|
||||
if (commandArg === 'seek') {
|
||||
base.position = this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position');
|
||||
}
|
||||
if (commandArg === 'play_media') {
|
||||
base.mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri');
|
||||
base.url = this.stringData(requestArg, 'url');
|
||||
base.mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType');
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
private async speakerUuidFromRequest(requestArg: IServiceCallRequest): Promise<string> {
|
||||
const speakerUuid = await this.optionalSpeakerUuidFromRequest(requestArg);
|
||||
if (speakerUuid) {
|
||||
return speakerUuid;
|
||||
}
|
||||
throw new Error('LinkPlay service call requires data.speaker_uuid or a target LinkPlay media_player entity.');
|
||||
}
|
||||
|
||||
private async optionalSpeakerUuidFromRequest(requestArg: IServiceCallRequest): Promise<string | undefined> {
|
||||
const direct = this.stringData(requestArg, 'speaker_uuid') || this.stringData(requestArg, 'speakerUuid') || this.stringData(requestArg, 'uuid');
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
if (requestArg.target.entityId) {
|
||||
const entitySpeakerUuid = LinkplayMapper.entitySpeakerUuid(snapshot, requestArg.target.entityId);
|
||||
if (entitySpeakerUuid) {
|
||||
return entitySpeakerUuid;
|
||||
}
|
||||
}
|
||||
if (requestArg.target.deviceId) {
|
||||
const speaker = snapshot.speakers.find((speakerArg) => LinkplayMapper.speakerDeviceId(speakerArg) === requestArg.target.deviceId);
|
||||
if (speaker) {
|
||||
return speaker.uuid;
|
||||
}
|
||||
}
|
||||
return snapshot.speakers.length === 1 ? snapshot.speakers[0].uuid : undefined;
|
||||
}
|
||||
|
||||
private async memberUuidsFromRequest(requestArg: IServiceCallRequest): Promise<string[]> {
|
||||
const direct = this.stringArrayData(requestArg, 'speaker_uuids') || this.stringArrayData(requestArg, 'speakerUuids') || this.stringArrayData(requestArg, 'member_uuids') || this.stringArrayData(requestArg, 'memberUuids');
|
||||
if (direct?.length) {
|
||||
return direct;
|
||||
}
|
||||
const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers');
|
||||
if (!members?.length) {
|
||||
throw new Error('LinkPlay join service requires data.group_members or data.speaker_uuids.');
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
return members.map((memberArg) => LinkplayMapper.entitySpeakerUuid(snapshot, memberArg) || memberArg);
|
||||
}
|
||||
|
||||
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
private booleanData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
}
|
||||
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string') ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { ILinkplayManualEntry, ILinkplayMdnsRecord, ILinkplaySsdpRecord, TLinkplayProtocol } from './linkplay.types.js';
|
||||
import { linkplayDefaultPort, linkplayProjectLookup } from './linkplay.types.js';
|
||||
|
||||
const linkplayDomain = 'linkplay';
|
||||
const linkplayMdnsType = '_linkplay._tcp';
|
||||
const mediaRendererUrn = 'urn:schemas-upnp-org:device:MediaRenderer:1';
|
||||
const linkplayNames = ['linkplay', 'arylic', 'up2stream', 'wiim', 'muzo', 'audiocast', 'ieast', 'ggmm', 'medion'];
|
||||
|
||||
export class LinkplayMdnsMatcher implements IDiscoveryMatcher<ILinkplayMdnsRecord> {
|
||||
public id = 'linkplay-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize LinkPlay zeroconf advertisements.';
|
||||
|
||||
public async matches(recordArg: ILinkplayMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
|
||||
const properties = { ...recordArg.txt, ...recordArg.properties };
|
||||
const project = valueForKey(properties, 'project') || valueForKey(properties, 'project_build_name');
|
||||
const uuid = valueForKey(properties, 'uuid') || valueForKey(properties, 'id') || valueForKey(properties, 'UDN');
|
||||
const name = cleanName(valueForKey(properties, 'name') || valueForKey(properties, 'DeviceName') || recordArg.name || recordArg.hostname);
|
||||
const projectInfo = project ? linkplayProjectLookup[project] : undefined;
|
||||
const haystack = `${name || ''} ${type} ${project || ''} ${projectInfo?.manufacturer || ''} ${projectInfo?.model || ''}`.toLowerCase();
|
||||
const serviceMatch = type === linkplayMdnsType;
|
||||
const hintMatch = linkplayNames.some((needleArg) => haystack.includes(needleArg));
|
||||
if (!serviceMatch && !hintMatch) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not a LinkPlay advertisement.' };
|
||||
}
|
||||
|
||||
const host = recordArg.host || recordArg.addresses?.[0];
|
||||
const port = recordArg.port || linkplayDefaultPort;
|
||||
const id = uuid || (host ? `${host}:${port}` : name);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: serviceMatch && uuid ? 'certain' : serviceMatch && host ? 'high' : 'medium',
|
||||
reason: serviceMatch ? 'mDNS service is a LinkPlay zeroconf service.' : 'mDNS metadata contains LinkPlay hints.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: linkplayDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
manufacturer: projectInfo?.manufacturer || valueForKey(properties, 'manufacturer') || 'LinkPlay',
|
||||
model: projectInfo?.model || valueForKey(properties, 'model'),
|
||||
metadata: {
|
||||
mdnsName: recordArg.name,
|
||||
mdnsType: type,
|
||||
txt: properties,
|
||||
project,
|
||||
},
|
||||
},
|
||||
metadata: { mdnsType: type, project },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkplaySsdpMatcher implements IDiscoveryMatcher<ILinkplaySsdpRecord> {
|
||||
public id = 'linkplay-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize LinkPlay UPnP MediaRenderer advertisements with LinkPlay metadata.';
|
||||
|
||||
public async matches(recordArg: ILinkplaySsdpRecord): Promise<IDiscoveryMatch> {
|
||||
const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType');
|
||||
const usn = header(recordArg, 'usn');
|
||||
const location = header(recordArg, 'location');
|
||||
const manufacturer = upnp(recordArg, 'manufacturer');
|
||||
const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model');
|
||||
const friendlyName = upnp(recordArg, 'friendlyName');
|
||||
const serialNumber = upnp(recordArg, 'serialNumber') || upnp(recordArg, 'serial');
|
||||
const project = upnp(recordArg, 'project') || upnp(recordArg, 'modelNumber');
|
||||
const projectInfo = project ? linkplayProjectLookup[project] : undefined;
|
||||
const haystack = `${st || ''} ${usn || ''} ${manufacturer || ''} ${model || ''} ${friendlyName || ''} ${project || ''}`.toLowerCase();
|
||||
const renderer = normalizeUrn(st) === normalizeUrn(mediaRendererUrn);
|
||||
const hintMatch = linkplayNames.some((needleArg) => haystack.includes(needleArg)) || Boolean(projectInfo);
|
||||
if (!renderer || !hintMatch) {
|
||||
return { matched: false, confidence: renderer ? 'medium' : 'low', reason: renderer ? 'MediaRenderer record lacks LinkPlay metadata.' : 'SSDP record is not a LinkPlay MediaRenderer.' };
|
||||
}
|
||||
|
||||
const url = parseUrl(location);
|
||||
const id = serialNumber || stripUuid(usn) || (url?.hostname ? `${url.hostname}:${linkplayDefaultPort}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: url?.hostname && id ? 'high' : 'medium',
|
||||
reason: 'SSDP MediaRenderer has LinkPlay-specific metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: linkplayDomain,
|
||||
id,
|
||||
host: url?.hostname,
|
||||
port: linkplayDefaultPort,
|
||||
name: friendlyName,
|
||||
manufacturer: projectInfo?.manufacturer || manufacturer || 'LinkPlay',
|
||||
model: projectInfo?.model || model,
|
||||
serialNumber,
|
||||
metadata: { st, usn, location, project, deviceType: upnp(recordArg, 'deviceType') },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkplayManualMatcher implements IDiscoveryMatcher<ILinkplayManualEntry> {
|
||||
public id = 'linkplay-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual LinkPlay setup entries.';
|
||||
|
||||
public async matches(inputArg: ILinkplayManualEntry): Promise<IDiscoveryMatch> {
|
||||
const projectInfo = inputArg.project ? linkplayProjectLookup[inputArg.project] : undefined;
|
||||
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''} ${inputArg.project || ''}`.toLowerCase();
|
||||
const matched = Boolean(inputArg.host || inputArg.metadata?.linkplay || projectInfo || linkplayNames.some((needleArg) => haystack.includes(needleArg)));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain LinkPlay setup hints.' };
|
||||
}
|
||||
|
||||
const protocol = inputArg.protocol || protocolFromPort(inputArg.port);
|
||||
const port = inputArg.port || linkplayDefaultPort;
|
||||
const id = inputArg.uuid || inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start LinkPlay setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: linkplayDomain,
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port,
|
||||
name: inputArg.name,
|
||||
manufacturer: inputArg.manufacturer || projectInfo?.manufacturer || 'LinkPlay',
|
||||
model: inputArg.model || projectInfo?.model,
|
||||
macAddress: inputArg.macAddress,
|
||||
metadata: {
|
||||
...inputArg.metadata,
|
||||
protocol,
|
||||
project: inputArg.project,
|
||||
modelId: inputArg.modelId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkplayCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'linkplay-candidate-validator';
|
||||
public description = 'Validate LinkPlay candidates have local endpoint metadata.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const project = stringMetadata(metadata.project);
|
||||
const mdnsType = stringMetadata(metadata.mdnsType);
|
||||
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${project || ''}`.toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === linkplayDomain
|
||||
|| Boolean(metadata.linkplay)
|
||||
|| normalizeMdnsType(mdnsType || '') === linkplayMdnsType
|
||||
|| Boolean(project && linkplayProjectLookup[project])
|
||||
|| linkplayNames.some((needleArg) => haystack.includes(needleArg));
|
||||
|
||||
if (!matched || !candidateArg.host) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'LinkPlay candidate lacks host information.' : 'Candidate is not LinkPlay.',
|
||||
};
|
||||
}
|
||||
|
||||
const protocol = stringMetadata(metadata.protocol) === 'https' ? 'https' : protocolFromPort(candidateArg.port);
|
||||
const port = candidateArg.port || linkplayDefaultPort;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: candidateArg.id ? 'certain' : 'high',
|
||||
reason: 'Candidate has LinkPlay metadata and host information.',
|
||||
normalizedDeviceId: candidateArg.id || `${candidateArg.host}:${port}`,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
port,
|
||||
metadata: {
|
||||
...metadata,
|
||||
protocol,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createLinkplayDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: linkplayDomain, displayName: 'LinkPlay' })
|
||||
.addMatcher(new LinkplayMdnsMatcher())
|
||||
.addMatcher(new LinkplaySsdpMatcher())
|
||||
.addMatcher(new LinkplayManualMatcher())
|
||||
.addValidator(new LinkplayCandidateValidator());
|
||||
};
|
||||
|
||||
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
|
||||
|
||||
const cleanName = (valueArg: string | undefined): string | undefined => {
|
||||
return valueArg?.replace(/\._linkplay\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
|
||||
};
|
||||
|
||||
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerKey = keyArg.toLowerCase();
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (key.toLowerCase() === lowerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const header = (recordArg: ILinkplaySsdpRecord, keyArg: string): string | undefined => {
|
||||
return recordArg[keyArg as keyof ILinkplaySsdpRecord] as string | undefined || valueForKey(recordArg.headers, keyArg);
|
||||
};
|
||||
|
||||
const upnp = (recordArg: ILinkplaySsdpRecord, keyArg: string): string | undefined => {
|
||||
return valueForKey(recordArg.upnp, keyArg) || valueForKey(recordArg.headers, keyArg);
|
||||
};
|
||||
|
||||
const parseUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const stripUuid = (valueArg: string | undefined): string | undefined => valueArg?.replace(/^uuid:/i, '').split('::')[0];
|
||||
|
||||
const normalizeUrn = (valueArg: string | undefined): string => (valueArg || '').toLowerCase();
|
||||
|
||||
const protocolFromPort = (portArg: number | undefined): TLinkplayProtocol => portArg === 443 || portArg === 4443 ? 'https' : 'http';
|
||||
|
||||
const stringMetadata = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg : undefined;
|
||||
@@ -0,0 +1,323 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import { linkplayRepeatFromLoopMode, linkplaySourceName } from './linkplay.classes.client.js';
|
||||
import type { ILinkplayMultiroomGroup, ILinkplaySnapshot, ILinkplaySpeaker } from './linkplay.types.js';
|
||||
|
||||
export class LinkplayMapper {
|
||||
public static toDevices(snapshotArg: ILinkplaySnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [];
|
||||
for (const speaker of snapshotArg.speakers) {
|
||||
devices.push({
|
||||
id: this.speakerDeviceId(speaker),
|
||||
integrationDomain: 'linkplay',
|
||||
name: this.speakerName(speaker),
|
||||
protocol: speaker.device.protocol === 'http' ? 'http' : 'unknown',
|
||||
manufacturer: speaker.device.manufacturer || 'Generic',
|
||||
model: speaker.device.model || speaker.device.modelId || 'LinkPlay Speaker',
|
||||
online: snapshotArg.online && speaker.available !== false,
|
||||
features: [
|
||||
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
|
||||
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
|
||||
{ id: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
|
||||
{ id: 'preset_count', capability: 'media', name: 'Presets', readable: true, writable: true },
|
||||
{ id: 'multiroom_group', capability: 'media', name: 'Multiroom group', readable: true, writable: true },
|
||||
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'playback', value: this.mediaState(snapshotArg, speaker), updatedAt },
|
||||
{ featureId: 'volume', value: speaker.player.volume ?? null, updatedAt },
|
||||
{ featureId: 'muted', value: speaker.player.muted ?? null, updatedAt },
|
||||
{ featureId: 'source', value: this.currentSource(speaker) || null, updatedAt },
|
||||
{ featureId: 'preset_count', value: (speaker.presets || snapshotArg.presets || []).length, updatedAt },
|
||||
{ featureId: 'multiroom_group', value: this.groupForSpeaker(snapshotArg, speaker.uuid)?.id || null, updatedAt },
|
||||
{ featureId: 'current_title', value: this.mediaTitle(speaker) || null, updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
uuid: speaker.uuid,
|
||||
host: speaker.device.host,
|
||||
port: speaker.device.port,
|
||||
project: speaker.device.project,
|
||||
modelId: speaker.device.modelId,
|
||||
macAddress: speaker.device.macAddress,
|
||||
hardwareVersion: speaker.device.hardwareVersion,
|
||||
firmwareVersion: speaker.device.firmwareVersion,
|
||||
viaIntegration: snapshotArg.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of snapshotArg.multirooms || []) {
|
||||
devices.push({
|
||||
id: this.multiroomDeviceId(group),
|
||||
integrationDomain: 'linkplay',
|
||||
name: group.name || 'LinkPlay Multiroom',
|
||||
protocol: 'http',
|
||||
manufacturer: 'LinkPlay',
|
||||
model: 'Multiroom Group',
|
||||
online: snapshotArg.online,
|
||||
features: [
|
||||
{ id: 'members', capability: 'media', name: 'Members', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Group volume', readable: true, writable: true, unit: '%' },
|
||||
{ id: 'muted', capability: 'media', name: 'Group muted', readable: true, writable: true },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'members', value: this.groupEntityIds(snapshotArg, group).length, updatedAt },
|
||||
{ featureId: 'volume', value: group.volume ?? null, updatedAt },
|
||||
{ featureId: 'muted', value: group.muted ?? null, updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
leaderUuid: group.leaderUuid,
|
||||
followerUuids: group.followerUuids,
|
||||
members: this.groupEntityIds(snapshotArg, group),
|
||||
},
|
||||
});
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: ILinkplaySnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
for (const speaker of snapshotArg.speakers) {
|
||||
const base = this.speakerEntityBase(speaker);
|
||||
const available = snapshotArg.online && speaker.available !== false;
|
||||
const group = this.groupForSpeaker(snapshotArg, speaker.uuid);
|
||||
entities.push({
|
||||
id: this.speakerEntityId(speaker),
|
||||
uniqueId: `linkplay_${this.slug(speaker.uuid)}`,
|
||||
integrationDomain: 'linkplay',
|
||||
deviceId: this.speakerDeviceId(speaker),
|
||||
platform: 'media_player',
|
||||
name: this.speakerName(speaker),
|
||||
state: this.mediaState(snapshotArg, speaker),
|
||||
attributes: {
|
||||
deviceClass: 'receiver',
|
||||
speakerUuid: speaker.uuid,
|
||||
model: speaker.device.model,
|
||||
modelId: speaker.device.modelId,
|
||||
project: speaker.device.project,
|
||||
host: speaker.device.host,
|
||||
volumeLevel: this.volumeLevel(speaker),
|
||||
volume: speaker.player.volume,
|
||||
isVolumeMuted: speaker.player.muted,
|
||||
repeat: linkplayRepeatFromLoopMode(speaker.player.loopMode),
|
||||
shuffle: speaker.player.loopMode === '2',
|
||||
soundMode: speaker.player.equalizerMode,
|
||||
soundModeList: this.soundModeList(speaker),
|
||||
source: this.currentSource(speaker),
|
||||
sourceList: this.sourceList(snapshotArg, speaker),
|
||||
presets: speaker.presets || snapshotArg.presets || [],
|
||||
audioOutputHardwareMode: speaker.audioOutputHardwareMode,
|
||||
mediaContentType: 'music',
|
||||
mediaDuration: this.seconds(speaker.player.totalLengthMs),
|
||||
mediaPosition: this.seconds(speaker.player.currentPositionMs),
|
||||
mediaTitle: speaker.player.title,
|
||||
mediaArtist: speaker.player.artist,
|
||||
mediaAlbumName: speaker.player.album,
|
||||
mediaImageUrl: speaker.player.status === 'play' || speaker.player.status === 'pause' ? speaker.player.albumArtUrl : undefined,
|
||||
playMode: speaker.player.playMode,
|
||||
loopMode: speaker.player.loopMode,
|
||||
groupId: group?.id,
|
||||
groupRole: group ? group.leaderUuid === speaker.uuid ? 'leader' : 'follower' : undefined,
|
||||
groupMembers: group ? this.groupEntityIds(snapshotArg, group) : undefined,
|
||||
},
|
||||
available,
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_linkplay_media`,
|
||||
uniqueId: `linkplay_${this.slug(speaker.uuid)}_media`,
|
||||
integrationDomain: 'linkplay',
|
||||
deviceId: this.speakerDeviceId(speaker),
|
||||
platform: 'sensor',
|
||||
name: `${this.speakerName(speaker)} LinkPlay Media`,
|
||||
state: this.mediaTitle(speaker) || 'None',
|
||||
attributes: {
|
||||
speakerUuid: speaker.uuid,
|
||||
player: speaker.player,
|
||||
},
|
||||
available,
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_linkplay_sources`,
|
||||
uniqueId: `linkplay_${this.slug(speaker.uuid)}_sources`,
|
||||
integrationDomain: 'linkplay',
|
||||
deviceId: this.speakerDeviceId(speaker),
|
||||
platform: 'sensor',
|
||||
name: `${this.speakerName(speaker)} LinkPlay Sources`,
|
||||
state: this.sourceList(snapshotArg, speaker).length,
|
||||
attributes: {
|
||||
speakerUuid: speaker.uuid,
|
||||
sources: speaker.sources || snapshotArg.sources || [],
|
||||
sourceList: this.sourceList(snapshotArg, speaker),
|
||||
},
|
||||
available,
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_linkplay_presets`,
|
||||
uniqueId: `linkplay_${this.slug(speaker.uuid)}_presets`,
|
||||
integrationDomain: 'linkplay',
|
||||
deviceId: this.speakerDeviceId(speaker),
|
||||
platform: 'sensor',
|
||||
name: `${this.speakerName(speaker)} LinkPlay Presets`,
|
||||
state: (speaker.presets || snapshotArg.presets || []).length,
|
||||
attributes: {
|
||||
speakerUuid: speaker.uuid,
|
||||
presets: speaker.presets || snapshotArg.presets || [],
|
||||
},
|
||||
available,
|
||||
});
|
||||
|
||||
if (group) {
|
||||
entities.push({
|
||||
id: `sensor.${base}_linkplay_multiroom`,
|
||||
uniqueId: `linkplay_${this.slug(speaker.uuid)}_multiroom`,
|
||||
integrationDomain: 'linkplay',
|
||||
deviceId: this.speakerDeviceId(speaker),
|
||||
platform: 'sensor',
|
||||
name: `${this.speakerName(speaker)} LinkPlay Multiroom`,
|
||||
state: group.leaderUuid === speaker.uuid ? 'leader' : 'follower',
|
||||
attributes: {
|
||||
speakerUuid: speaker.uuid,
|
||||
group,
|
||||
members: this.groupEntityIds(snapshotArg, group),
|
||||
},
|
||||
available,
|
||||
});
|
||||
}
|
||||
|
||||
if (speaker.audioOutputHardwareMode) {
|
||||
entities.push({
|
||||
id: `select.${base}_audio_output_hardware_mode`,
|
||||
uniqueId: `linkplay_${this.slug(speaker.uuid)}_audio_output_hardware_mode`,
|
||||
integrationDomain: 'linkplay',
|
||||
deviceId: this.speakerDeviceId(speaker),
|
||||
platform: 'select',
|
||||
name: `${this.speakerName(speaker)} Audio output hardware mode`,
|
||||
state: speaker.audioOutputHardwareMode,
|
||||
attributes: {
|
||||
speakerUuid: speaker.uuid,
|
||||
options: ['optical', 'line_out', 'coaxial', 'headphones'],
|
||||
},
|
||||
available,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((snapshotArg.multirooms || []).length) {
|
||||
entities.push({
|
||||
id: `sensor.${this.slug(snapshotArg.speakers[0]?.name || 'linkplay')}_multiroom_groups`,
|
||||
uniqueId: `linkplay_${this.snapshotUniqueBase(snapshotArg)}_multiroom_groups`,
|
||||
integrationDomain: 'linkplay',
|
||||
deviceId: this.speakerDeviceId(snapshotArg.speakers[0]),
|
||||
platform: 'sensor',
|
||||
name: 'LinkPlay Multiroom Groups',
|
||||
state: (snapshotArg.multirooms || []).length,
|
||||
attributes: {
|
||||
groups: (snapshotArg.multirooms || []).map((groupArg) => ({
|
||||
...groupArg,
|
||||
members: this.groupEntityIds(snapshotArg, groupArg),
|
||||
})),
|
||||
},
|
||||
available: snapshotArg.online,
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static entitySpeakerUuid(snapshotArg: ILinkplaySnapshot, entityIdArg: string): string | undefined {
|
||||
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
|
||||
const speakerUuid = entity?.attributes?.speakerUuid;
|
||||
return typeof speakerUuid === 'string' ? speakerUuid : undefined;
|
||||
}
|
||||
|
||||
public static speakerEntityId(speakerArg: ILinkplaySpeaker): string {
|
||||
return `media_player.${this.speakerEntityBase(speakerArg)}`;
|
||||
}
|
||||
|
||||
public static speakerDeviceId(speakerArg: ILinkplaySpeaker | undefined): string {
|
||||
return `linkplay.speaker.${this.slug(speakerArg?.uuid || speakerArg?.device.uuid || speakerArg?.device.host || speakerArg?.name || 'linkplay')}`;
|
||||
}
|
||||
|
||||
public static multiroomDeviceId(groupArg: ILinkplayMultiroomGroup): string {
|
||||
return `linkplay.multiroom.${this.slug(groupArg.id || groupArg.leaderUuid)}`;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string | undefined): string {
|
||||
return (valueArg || 'linkplay').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'linkplay';
|
||||
}
|
||||
|
||||
private static mediaState(snapshotArg: ILinkplaySnapshot, speakerArg: ILinkplaySpeaker): string {
|
||||
if (!snapshotArg.online || speakerArg.available === false) {
|
||||
return 'off';
|
||||
}
|
||||
if (speakerArg.player.status === 'play') {
|
||||
return 'playing';
|
||||
}
|
||||
if (speakerArg.player.status === 'pause') {
|
||||
return 'paused';
|
||||
}
|
||||
if (speakerArg.player.status === 'load') {
|
||||
return 'buffering';
|
||||
}
|
||||
if (speakerArg.player.status === 'stop') {
|
||||
return 'idle';
|
||||
}
|
||||
return speakerArg.player.status || 'unknown';
|
||||
}
|
||||
|
||||
private static currentSource(speakerArg: ILinkplaySpeaker): string | undefined {
|
||||
return speakerArg.player.source || linkplaySourceName(speakerArg.player.playMode);
|
||||
}
|
||||
|
||||
private static sourceList(snapshotArg: ILinkplaySnapshot, speakerArg: ILinkplaySpeaker): string[] {
|
||||
const sources = speakerArg.sources || snapshotArg.sources || [];
|
||||
return [...new Set(sources.map((sourceArg) => sourceArg.name).filter(Boolean))];
|
||||
}
|
||||
|
||||
private static soundModeList(speakerArg: ILinkplaySpeaker): string[] {
|
||||
if (speakerArg.device.manufacturer === 'WiiM') {
|
||||
return ['None', 'Flat', 'Acoustic', 'Bass Booster', 'Bass Reducer', 'Classical', 'Dance', 'Deep', 'Electronic', 'Hip-Hop', 'Jazz', 'Latin', 'Loudness', 'Lounge', 'Piano', 'Pop', 'R&B', 'Rock', 'Small Speakers', 'Spoken Word', 'Treble Booster', 'Treble Reducer', 'Vocal Booster'];
|
||||
}
|
||||
return ['None', 'Classic', 'Pop', 'Jazz', 'Vocal'];
|
||||
}
|
||||
|
||||
private static volumeLevel(speakerArg: ILinkplaySpeaker): number | undefined {
|
||||
return typeof speakerArg.player.volume === 'number' ? Math.max(0, Math.min(1, speakerArg.player.volume / 100)) : undefined;
|
||||
}
|
||||
|
||||
private static mediaTitle(speakerArg: ILinkplaySpeaker): string | undefined {
|
||||
return speakerArg.player.title || speakerArg.player.metaInfo?.metaData?.title || speakerArg.player.metaInfo?.metaData?.subtitle;
|
||||
}
|
||||
|
||||
private static seconds(valueArg: number | undefined): number | undefined {
|
||||
return typeof valueArg === 'number' ? Math.floor(valueArg / 1000) : undefined;
|
||||
}
|
||||
|
||||
private static groupForSpeaker(snapshotArg: ILinkplaySnapshot, speakerUuidArg: string): ILinkplayMultiroomGroup | undefined {
|
||||
return (snapshotArg.multirooms || []).find((groupArg) => groupArg.leaderUuid === speakerUuidArg || groupArg.followerUuids.includes(speakerUuidArg));
|
||||
}
|
||||
|
||||
private static groupEntityIds(snapshotArg: ILinkplaySnapshot, groupArg: ILinkplayMultiroomGroup): string[] {
|
||||
return [groupArg.leaderUuid, ...groupArg.followerUuids]
|
||||
.map((uuidArg) => snapshotArg.speakers.find((speakerArg) => speakerArg.uuid === uuidArg))
|
||||
.filter((speakerArg): speakerArg is ILinkplaySpeaker => Boolean(speakerArg))
|
||||
.map((speakerArg) => this.speakerEntityId(speakerArg));
|
||||
}
|
||||
|
||||
private static speakerEntityBase(speakerArg: ILinkplaySpeaker): string {
|
||||
return this.slug(this.speakerName(speakerArg));
|
||||
}
|
||||
|
||||
private static speakerName(speakerArg: ILinkplaySpeaker): string {
|
||||
return speakerArg.name || speakerArg.device.name || speakerArg.uuid || 'LinkPlay';
|
||||
}
|
||||
|
||||
private static snapshotUniqueBase(snapshotArg: ILinkplaySnapshot): string {
|
||||
return this.slug(snapshotArg.speakers[0]?.uuid || snapshotArg.speakers[0]?.device.host || 'linkplay');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,442 @@
|
||||
export interface IHomeAssistantLinkplayConfig {
|
||||
// TODO: replace with the TypeScript-native config for linkplay.
|
||||
export const linkplayDefaultPort = 80;
|
||||
export const linkplayDefaultTimeoutMs = 5000;
|
||||
|
||||
export type TLinkplayProtocol = 'http' | 'https';
|
||||
export type TLinkplaySnapshotSource = 'snapshot' | 'http' | 'executor' | 'manual' | 'runtime';
|
||||
export type TLinkplayPlayingStatus = 'play' | 'pause' | 'stop' | 'load' | (string & {});
|
||||
export type TLinkplayRepeatMode = 'off' | 'one' | 'all';
|
||||
export type TLinkplayCommand =
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'next_track'
|
||||
| 'previous_track'
|
||||
| 'play_media'
|
||||
| 'seek'
|
||||
| 'set_volume'
|
||||
| 'mute'
|
||||
| 'select_source'
|
||||
| 'set_repeat'
|
||||
| 'set_sound_mode'
|
||||
| 'play_preset'
|
||||
| 'timesync'
|
||||
| 'restart'
|
||||
| 'set_audio_output_hardware_mode'
|
||||
| 'join'
|
||||
| 'unjoin'
|
||||
| 'group_volume_set'
|
||||
| 'group_mute'
|
||||
| 'raw_command';
|
||||
|
||||
export type TLinkplayPlayingMode =
|
||||
| '-96'
|
||||
| '-1'
|
||||
| '0'
|
||||
| '1'
|
||||
| '2'
|
||||
| '3'
|
||||
| '10'
|
||||
| '11'
|
||||
| '12'
|
||||
| '13'
|
||||
| '14'
|
||||
| '16'
|
||||
| '19'
|
||||
| '20'
|
||||
| '21'
|
||||
| '29'
|
||||
| '30'
|
||||
| '31'
|
||||
| '32'
|
||||
| '36'
|
||||
| '40'
|
||||
| '41'
|
||||
| '43'
|
||||
| '44'
|
||||
| '45'
|
||||
| '46'
|
||||
| '47'
|
||||
| '48'
|
||||
| '49'
|
||||
| '50'
|
||||
| '51'
|
||||
| '52'
|
||||
| '53'
|
||||
| '54'
|
||||
| '56'
|
||||
| '57'
|
||||
| '58'
|
||||
| '60'
|
||||
| '99'
|
||||
| (string & {});
|
||||
|
||||
export type TLinkplayLoopMode = '-1' | '0' | '1' | '2' | '3' | '4' | '5' | (string & {});
|
||||
export type TLinkplayAudioOutputHardwareMode = 'optical' | 'line_out' | 'coaxial' | 'headphones' | (string & {});
|
||||
|
||||
export interface ILinkplayConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TLinkplayProtocol;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
modelId?: string;
|
||||
macAddress?: string;
|
||||
snapshot?: ILinkplaySnapshot;
|
||||
commandExecutor?: ILinkplayCommandExecutor;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantLinkplayConfig extends ILinkplayConfig {}
|
||||
|
||||
export interface ILinkplayCommandExecutor {
|
||||
execute(requestArg: ILinkplayRawCommandRequest): Promise<ILinkplayCommandExecutorResult | unknown>;
|
||||
}
|
||||
|
||||
export interface ILinkplayRawCommandRequest {
|
||||
command: string;
|
||||
url?: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
protocol: TLinkplayProtocol;
|
||||
expectJson?: boolean;
|
||||
speakerUuid?: string;
|
||||
speakerName?: string;
|
||||
}
|
||||
|
||||
export interface ILinkplayCommandExecutorResult {
|
||||
ok?: boolean;
|
||||
success?: boolean;
|
||||
status?: number;
|
||||
response?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface ILinkplayCommandRequest {
|
||||
command: TLinkplayCommand;
|
||||
speakerUuid?: string;
|
||||
source?: string;
|
||||
mediaId?: string;
|
||||
mediaType?: string;
|
||||
url?: string;
|
||||
volumeLevel?: number;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
repeat?: TLinkplayRepeatMode;
|
||||
soundMode?: string;
|
||||
preset?: number;
|
||||
position?: number;
|
||||
groupMemberUuids?: string[];
|
||||
hardwareMode?: TLinkplayAudioOutputHardwareMode | string;
|
||||
rawCommand?: string;
|
||||
}
|
||||
|
||||
export interface ILinkplayDeviceProperties {
|
||||
uuid?: string;
|
||||
DeviceName?: string;
|
||||
GroupName?: string;
|
||||
firmware?: string;
|
||||
hardware?: string;
|
||||
project?: string;
|
||||
MAC?: string;
|
||||
STA_MAC?: string;
|
||||
ETH_MAC?: string;
|
||||
eth2?: string;
|
||||
eth0?: string;
|
||||
apcli0?: string;
|
||||
plm_support?: string;
|
||||
preset_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ILinkplayPlayerProperties {
|
||||
type?: string;
|
||||
ch?: string;
|
||||
mode?: TLinkplayPlayingMode;
|
||||
loop?: TLinkplayLoopMode;
|
||||
eq?: string;
|
||||
status?: TLinkplayPlayingStatus;
|
||||
curpos?: string | number;
|
||||
offset_pts?: string | number;
|
||||
totlen?: string | number;
|
||||
Title?: string;
|
||||
Artist?: string;
|
||||
Album?: string;
|
||||
plicount?: string | number;
|
||||
plicurr?: string | number;
|
||||
vol?: string | number;
|
||||
mute?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ILinkplayMetaInfo {
|
||||
metaData?: {
|
||||
album?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
albumArtURI?: string;
|
||||
sampleRate?: string | number;
|
||||
bitDepth?: string | number;
|
||||
bitRate?: string | number;
|
||||
trackId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ILinkplayDeviceInfo {
|
||||
uuid: string;
|
||||
name: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TLinkplayProtocol;
|
||||
endpoint?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
modelId?: string;
|
||||
project?: string;
|
||||
macAddress?: string;
|
||||
hardwareVersion?: string;
|
||||
firmwareVersion?: string;
|
||||
ethernetAddress?: string;
|
||||
playModeSupport?: TLinkplayPlayingMode[];
|
||||
maxPresets?: number;
|
||||
properties?: ILinkplayDeviceProperties;
|
||||
}
|
||||
|
||||
export interface ILinkplayPlayerStatus {
|
||||
status: TLinkplayPlayingStatus;
|
||||
playMode?: TLinkplayPlayingMode;
|
||||
loopMode?: TLinkplayLoopMode;
|
||||
equalizerMode?: string;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
albumArtUrl?: string;
|
||||
currentPositionMs?: number;
|
||||
totalLengthMs?: number;
|
||||
source?: string;
|
||||
speakerType?: string;
|
||||
channelType?: string;
|
||||
properties?: ILinkplayPlayerProperties;
|
||||
metaInfo?: ILinkplayMetaInfo;
|
||||
}
|
||||
|
||||
export interface ILinkplaySource {
|
||||
mode: TLinkplayPlayingMode;
|
||||
name: string;
|
||||
commandValue?: string;
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
export interface ILinkplayPreset {
|
||||
number: number;
|
||||
name?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface ILinkplaySpeaker {
|
||||
uuid: string;
|
||||
name?: string;
|
||||
device: ILinkplayDeviceInfo;
|
||||
player: ILinkplayPlayerStatus;
|
||||
available?: boolean;
|
||||
audioOutputHardwareMode?: TLinkplayAudioOutputHardwareMode | string;
|
||||
sources?: ILinkplaySource[];
|
||||
presets?: ILinkplayPreset[];
|
||||
}
|
||||
|
||||
export interface ILinkplayMultiroomGroup {
|
||||
id: string;
|
||||
name?: string;
|
||||
leaderUuid: string;
|
||||
followerUuids: string[];
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface ILinkplaySnapshot {
|
||||
speakers: ILinkplaySpeaker[];
|
||||
multirooms?: ILinkplayMultiroomGroup[];
|
||||
sources?: ILinkplaySource[];
|
||||
presets?: ILinkplayPreset[];
|
||||
online: boolean;
|
||||
updatedAt?: string;
|
||||
source?: TLinkplaySnapshotSource;
|
||||
error?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ILinkplayMdnsRecord {
|
||||
type?: string;
|
||||
serviceType?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
addresses?: string[];
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ILinkplaySsdpRecord {
|
||||
st?: string;
|
||||
usn?: string;
|
||||
location?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
upnp?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ILinkplayManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TLinkplayProtocol;
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
modelId?: string;
|
||||
project?: string;
|
||||
macAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const linkplaySourceMap: Record<string, string> = {
|
||||
'10': 'Wifi',
|
||||
'40': 'Line In',
|
||||
'41': 'Bluetooth',
|
||||
'43': 'Optical',
|
||||
'47': 'Line In 2',
|
||||
'51': 'USB DAC',
|
||||
'45': 'Coaxial',
|
||||
'48': 'XLR',
|
||||
'49': 'HDMI',
|
||||
'56': 'Optical 2',
|
||||
'53': 'External Bluetooth',
|
||||
'54': 'Phono',
|
||||
'58': 'ARC',
|
||||
'57': 'Coaxial 2',
|
||||
'16': 'SD Card 1',
|
||||
'52': 'SD Card 2',
|
||||
'50': 'CD',
|
||||
'-96': 'DAB Radio',
|
||||
'46': 'FM Radio',
|
||||
'44': 'RCA',
|
||||
'21': 'USB',
|
||||
'31': 'Spotify',
|
||||
'32': 'Tidal',
|
||||
'99': 'Follower',
|
||||
};
|
||||
|
||||
export const linkplayPlayModeSendMap: Record<string, string> = {
|
||||
'0': 'Idle',
|
||||
'1': 'Airplay',
|
||||
'2': 'DLNA',
|
||||
'3': 'QPlay',
|
||||
'10': 'wifi',
|
||||
'11': 'udisk',
|
||||
'16': 'TFcard',
|
||||
'20': 'API',
|
||||
'21': 'udisk',
|
||||
'30': 'Alarm',
|
||||
'31': 'Spotify',
|
||||
'32': 'Tidal',
|
||||
'36': 'Qobuz',
|
||||
'40': 'line-in',
|
||||
'41': 'bluetooth',
|
||||
'43': 'optical',
|
||||
'44': 'RCA',
|
||||
'45': 'co-axial',
|
||||
'46': 'FM',
|
||||
'47': 'line-in2',
|
||||
'48': 'XLR',
|
||||
'49': 'HDMI',
|
||||
'50': 'cd',
|
||||
'51': 'PCUSB',
|
||||
'52': 'TFcard',
|
||||
'60': 'Talk',
|
||||
'99': 'Idle',
|
||||
'56': 'optical2',
|
||||
'54': 'phono',
|
||||
'57': 'co-axial2',
|
||||
'58': 'ARC',
|
||||
'-96': 'DAB',
|
||||
'53': 'extern_bluetooth',
|
||||
};
|
||||
|
||||
export const linkplayRepeatToLoopMode: Record<TLinkplayRepeatMode, TLinkplayLoopMode> = {
|
||||
one: '-1',
|
||||
off: '4',
|
||||
all: '5',
|
||||
};
|
||||
|
||||
export const linkplayLoopModeToRepeat: Record<string, TLinkplayRepeatMode> = {
|
||||
'-1': 'one',
|
||||
'0': 'off',
|
||||
'1': 'all',
|
||||
'2': 'all',
|
||||
'3': 'all',
|
||||
'4': 'off',
|
||||
'5': 'all',
|
||||
};
|
||||
|
||||
export const linkplayNormalEqualizerModeToNumber: Record<string, string> = {
|
||||
None: '0',
|
||||
Classic: '1',
|
||||
Pop: '2',
|
||||
Jazz: '3',
|
||||
Vocal: '4',
|
||||
};
|
||||
|
||||
export const linkplayNormalEqualizerNumberToMode: Record<string, string> = {
|
||||
'0': 'None',
|
||||
'1': 'Classic',
|
||||
'2': 'Pop',
|
||||
'3': 'Jazz',
|
||||
'4': 'Vocal',
|
||||
};
|
||||
|
||||
export const linkplayAudioOutputHardwareModeToCode: Record<string, string> = {
|
||||
optical: '1',
|
||||
line_out: '2',
|
||||
coaxial: '3',
|
||||
headphones: '4',
|
||||
};
|
||||
|
||||
export const linkplayAudioOutputHardwareCodeToMode: Record<string, TLinkplayAudioOutputHardwareMode> = {
|
||||
'1': 'optical',
|
||||
'2': 'line_out',
|
||||
'3': 'coaxial',
|
||||
'4': 'headphones',
|
||||
};
|
||||
|
||||
export const linkplayProjectLookup: Record<string, { manufacturer: string; model: string }> = {
|
||||
SMART_ZONE4_AMP: { manufacturer: 'ArtSound', model: 'Smart Zone 4 AMP' },
|
||||
SMART_HYDE: { manufacturer: 'ArtSound', model: 'Smart Hyde' },
|
||||
ARYLIC_S50: { manufacturer: 'Arylic', model: 'S50+' },
|
||||
RP0016_S50PRO_S: { manufacturer: 'Arylic', model: 'S50 Pro' },
|
||||
RP0011_WB60_S: { manufacturer: 'Arylic', model: 'A30' },
|
||||
'X-50': { manufacturer: 'Arylic', model: 'A50' },
|
||||
ARYLIC_A50S: { manufacturer: 'Arylic', model: 'A50+' },
|
||||
RP0011_WB60: { manufacturer: 'Arylic', model: 'Up2Stream Amp 2.0' },
|
||||
UP2STREAM_AMP_V3: { manufacturer: 'Arylic', model: 'Up2Stream Amp v3' },
|
||||
UP2STREAM_AMP_V4: { manufacturer: 'Arylic', model: 'Up2Stream Amp v4' },
|
||||
UP2STREAM_PRO_V3: { manufacturer: 'Arylic', model: 'Up2Stream Pro v3' },
|
||||
UP2STREAM_PRO_V4: { manufacturer: 'Arylic', model: 'Up2Stream Pro v4' },
|
||||
S10P_WIFI: { manufacturer: 'Arylic', model: 'Arylic S10+' },
|
||||
ARYLIC_V20: { manufacturer: 'Arylic', model: 'Up2Stream Plate Amp' },
|
||||
UP2STREAM_MINI_V3: { manufacturer: 'Arylic', model: 'Up2Stream Mini' },
|
||||
UP2STREAM_AMP_2P1: { manufacturer: 'Arylic', model: 'Up2Stream Amp 2.1' },
|
||||
'iEAST-02': { manufacturer: 'iEAST', model: 'AudioCast M5' },
|
||||
WiiM_Amp_4layer: { manufacturer: 'WiiM', model: 'WiiM Amp' },
|
||||
WiiM_Pro_with_gc4a: { manufacturer: 'WiiM', model: 'WiiM Pro' },
|
||||
WiiM_Pro_Plus: { manufacturer: 'WiiM', model: 'WiiM Pro Plus' },
|
||||
WiiM_AMP: { manufacturer: 'WiiM', model: 'WiiM Amp' },
|
||||
Muzo_Mini: { manufacturer: 'WiiM', model: 'WiiM Mini' },
|
||||
GGMM_E2A: { manufacturer: 'GGMM', model: 'GGMM E2' },
|
||||
A16: { manufacturer: 'Medion', model: 'Life P66970 (MD 43970)' },
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './madvr.classes.client.js';
|
||||
export * from './madvr.classes.configflow.js';
|
||||
export * from './madvr.classes.integration.js';
|
||||
export * from './madvr.discovery.js';
|
||||
export * from './madvr.mapper.js';
|
||||
export * from './madvr.types.js';
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
IMadvrCommandModel,
|
||||
IMadvrCommandRequest,
|
||||
IMadvrCommandResult,
|
||||
IMadvrConfig,
|
||||
IMadvrDisplayState,
|
||||
IMadvrProcessorState,
|
||||
IMadvrRawState,
|
||||
IMadvrSnapshot,
|
||||
TMadvrCommandInput,
|
||||
TMadvrCommandName,
|
||||
TMadvrMenu,
|
||||
TMadvrProfileGroup,
|
||||
TMadvrRemoteKey,
|
||||
TMadvrToggle,
|
||||
} from './madvr.types.js';
|
||||
import { madvrDefaultPort } from './madvr.types.js';
|
||||
|
||||
type TMadvrCommandKind = 'single' | 'info' | 'key' | 'menu' | 'profile' | 'toggle';
|
||||
|
||||
interface IMadvrCommandDefinition {
|
||||
wire: string;
|
||||
kind: TMadvrCommandKind;
|
||||
informational: boolean;
|
||||
}
|
||||
|
||||
const commandTimeoutMs = 3000;
|
||||
const connectTimeoutMs = 5000;
|
||||
const snapshotCollectMs = 1000;
|
||||
const commandResponseCollectMs = 150;
|
||||
const heartbeatCommand = 'Heartbeat\r\n';
|
||||
|
||||
const commandDefinitions: Record<Exclude<TMadvrCommandName, 'PowerOn'>, IMadvrCommandDefinition> = {
|
||||
PowerOff: { wire: 'PowerOff', kind: 'single', informational: false },
|
||||
Standby: { wire: 'Standby', kind: 'single', informational: false },
|
||||
Restart: { wire: 'Restart', kind: 'single', informational: false },
|
||||
ReloadSoftware: { wire: 'ReloadSoftware', kind: 'single', informational: false },
|
||||
Bye: { wire: 'Bye', kind: 'single', informational: false },
|
||||
ResetTemporary: { wire: 'ResetTemporary', kind: 'single', informational: false },
|
||||
ActivateProfile: { wire: 'ActivateProfile', kind: 'profile', informational: false },
|
||||
OpenMenu: { wire: 'OpenMenu', kind: 'menu', informational: false },
|
||||
CloseMenu: { wire: 'CloseMenu', kind: 'single', informational: false },
|
||||
KeyPress: { wire: 'KeyPress', kind: 'key', informational: false },
|
||||
KeyHold: { wire: 'KeyHold', kind: 'key', informational: false },
|
||||
GetIncomingSignalInfo: { wire: 'GetIncomingSignalInfo', kind: 'info', informational: true },
|
||||
GetOutgoingSignalInfo: { wire: 'GetOutgoingSignalInfo', kind: 'info', informational: true },
|
||||
GetAspectRatio: { wire: 'GetAspectRatio', kind: 'info', informational: true },
|
||||
GetMaskingRatio: { wire: 'GetMaskingRatio', kind: 'info', informational: true },
|
||||
GetTemperatures: { wire: 'GetTemperatures', kind: 'info', informational: true },
|
||||
GetMacAddress: { wire: 'GetMacAddress', kind: 'info', informational: true },
|
||||
Toggle: { wire: 'Toggle', kind: 'toggle', informational: false },
|
||||
ToneMapOn: { wire: 'ToneMapOn', kind: 'single', informational: false },
|
||||
ToneMapOff: { wire: 'ToneMapOff', kind: 'single', informational: false },
|
||||
Hotplug: { wire: 'Hotplug', kind: 'single', informational: false },
|
||||
RefreshLicenseInfo: { wire: 'RefreshLicenseInfo', kind: 'single', informational: false },
|
||||
Force1080p60Output: { wire: 'Force1080p60Output', kind: 'single', informational: false },
|
||||
};
|
||||
|
||||
const remoteKeys: TMadvrRemoteKey[] = ['MENU', 'UP', 'DOWN', 'LEFT', 'RIGHT', 'OK', 'INPUT', 'SETTINGS', 'RED', 'GREEN', 'BLUE', 'YELLOW', 'POWER'];
|
||||
const menus: TMadvrMenu[] = ['Info', 'Settings', 'Configuration', 'Profiles', 'TestPatterns'];
|
||||
const profileGroups: TMadvrProfileGroup[] = ['SOURCE', 'DISPLAY', 'CUSTOM'];
|
||||
const toggles: TMadvrToggle[] = ['ToneMap', 'HighlightRecovery', 'ContrastRecovery', 'ShadowRecovery', '3DLUT', 'ScreenBoundaries', 'Histogram', 'DebugOSD'];
|
||||
|
||||
export class MadvrCommandModeler {
|
||||
public static requestsFromInput(inputArg: unknown): IMadvrCommandRequest[] {
|
||||
if (typeof inputArg === 'string') {
|
||||
return [this.requestFromString(inputArg)];
|
||||
}
|
||||
if (Array.isArray(inputArg)) {
|
||||
const strings = inputArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg.trim()));
|
||||
if (!strings.length) {
|
||||
return [];
|
||||
}
|
||||
const firstCommand = this.normalizeCommandName(strings[0]);
|
||||
const firstDefinition = firstCommand === 'PowerOn' ? undefined : commandDefinitions[firstCommand as Exclude<TMadvrCommandName, 'PowerOn'>];
|
||||
if (firstDefinition && firstDefinition.kind !== 'single' && firstDefinition.kind !== 'info' && strings.length <= 3) {
|
||||
return [{ command: firstCommand, args: strings.slice(1).map((itemArg) => itemArg.trim()).filter(Boolean) }];
|
||||
}
|
||||
return strings.flatMap((commandArg) => this.requestsFromInput(commandArg));
|
||||
}
|
||||
if (this.isCommandRequest(inputArg)) {
|
||||
return [{ ...inputArg, command: this.normalizeCommandName(inputArg.command), args: inputArg.args?.map((itemArg) => String(itemArg).trim()).filter(Boolean) }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public static model(inputArg: TMadvrCommandInput): IMadvrCommandModel {
|
||||
const requests = this.requestsFromInput(inputArg);
|
||||
if (requests.length !== 1) {
|
||||
throw new Error('madVR command modeling requires exactly one command request.');
|
||||
}
|
||||
return this.modelRequest(requests[0]);
|
||||
}
|
||||
|
||||
public static modelRequest(requestArg: IMadvrCommandRequest): IMadvrCommandModel {
|
||||
const command = this.normalizeCommandName(requestArg.command);
|
||||
const request: IMadvrCommandRequest = { ...requestArg, command };
|
||||
if (command === 'PowerOn') {
|
||||
return {
|
||||
request,
|
||||
transport: 'wake_on_lan',
|
||||
informational: false,
|
||||
requiresConnection: false,
|
||||
description: 'Wake madVR Envy with Wake-on-LAN. Requires a MAC address or custom executor.',
|
||||
};
|
||||
}
|
||||
|
||||
const definition = commandDefinitions[command as Exclude<TMadvrCommandName, 'PowerOn'>];
|
||||
if (!definition) {
|
||||
throw new Error(`Unsupported madVR command: ${requestArg.command}`);
|
||||
}
|
||||
|
||||
const args = this.protocolArgs(definition, request.args || []);
|
||||
const wireCommand = `${definition.wire}${args.length ? ` ${args.join(' ')}` : ''}\r\n`;
|
||||
return {
|
||||
request: { ...request, args },
|
||||
transport: 'tcp',
|
||||
wireCommand,
|
||||
informational: definition.informational,
|
||||
requiresConnection: true,
|
||||
};
|
||||
}
|
||||
|
||||
private static requestFromString(valueArg: string): IMadvrCommandRequest {
|
||||
const parts = valueArg.split(',').map((partArg) => partArg.trim()).filter(Boolean);
|
||||
if (!parts.length) {
|
||||
throw new Error('madVR command cannot be empty.');
|
||||
}
|
||||
return { command: this.normalizeCommandName(parts[0]), args: parts.slice(1) };
|
||||
}
|
||||
|
||||
private static protocolArgs(definitionArg: IMadvrCommandDefinition, argsArg: string[]): string[] {
|
||||
if (definitionArg.kind === 'single' || definitionArg.kind === 'info') {
|
||||
if (argsArg.length) {
|
||||
throw new Error(`${definitionArg.wire} does not accept parameters.`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (definitionArg.kind === 'key') {
|
||||
return [this.normalizeKey(this.requiredArg(definitionArg.wire, argsArg, 0))];
|
||||
}
|
||||
if (definitionArg.kind === 'menu') {
|
||||
return [this.normalizeMenu(this.requiredArg(definitionArg.wire, argsArg, 0))];
|
||||
}
|
||||
if (definitionArg.kind === 'toggle') {
|
||||
return [this.normalizeToggle(this.requiredArg(definitionArg.wire, argsArg, 0))];
|
||||
}
|
||||
const group = this.normalizeProfileGroup(this.requiredArg(definitionArg.wire, argsArg, 0));
|
||||
const profileNumber = argsArg[1] ? this.normalizeInteger(argsArg[1], 'profile number') : undefined;
|
||||
if (argsArg.length > 2) {
|
||||
throw new Error('ActivateProfile accepts a profile group and optional numeric profile number.');
|
||||
}
|
||||
return profileNumber ? [group, profileNumber] : [group];
|
||||
}
|
||||
|
||||
private static requiredArg(commandArg: string, argsArg: string[], indexArg: number): string {
|
||||
const value = argsArg[indexArg];
|
||||
if (!value) {
|
||||
throw new Error(`${commandArg} requires parameter ${indexArg + 1}.`);
|
||||
}
|
||||
if (/\r|\n/.test(value)) {
|
||||
throw new Error('madVR command parameters cannot contain line breaks.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static normalizeCommandName(valueArg: string): TMadvrCommandName {
|
||||
const normalized = valueArg.replace(/[_\s-]+/g, '').toLowerCase();
|
||||
const aliases: Record<string, TMadvrCommandName> = {
|
||||
on: 'PowerOn',
|
||||
turnon: 'PowerOn',
|
||||
poweron: 'PowerOn',
|
||||
off: 'PowerOff',
|
||||
turnoff: 'PowerOff',
|
||||
poweroff: 'PowerOff',
|
||||
standby: 'Standby',
|
||||
key: 'KeyPress',
|
||||
keypress: 'KeyPress',
|
||||
keyhold: 'KeyHold',
|
||||
openmenu: 'OpenMenu',
|
||||
closemenu: 'CloseMenu',
|
||||
activateprofile: 'ActivateProfile',
|
||||
profile: 'ActivateProfile',
|
||||
toggle: 'Toggle',
|
||||
getincomingsignalinfo: 'GetIncomingSignalInfo',
|
||||
getoutgoingsignalinfo: 'GetOutgoingSignalInfo',
|
||||
getaspectratio: 'GetAspectRatio',
|
||||
getmaskingratio: 'GetMaskingRatio',
|
||||
gettemperatures: 'GetTemperatures',
|
||||
getmacaddress: 'GetMacAddress',
|
||||
};
|
||||
if (aliases[normalized]) {
|
||||
return aliases[normalized];
|
||||
}
|
||||
const direct = (['PowerOn', ...Object.keys(commandDefinitions)] as TMadvrCommandName[]).find((commandArg) => commandArg.toLowerCase() === valueArg.toLowerCase());
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
return valueArg as TMadvrCommandName;
|
||||
}
|
||||
|
||||
private static normalizeKey(valueArg: string): TMadvrRemoteKey {
|
||||
const key = valueArg.toUpperCase();
|
||||
if (remoteKeys.includes(key as TMadvrRemoteKey)) {
|
||||
return key as TMadvrRemoteKey;
|
||||
}
|
||||
throw new Error(`Unsupported madVR remote key: ${valueArg}`);
|
||||
}
|
||||
|
||||
private static normalizeMenu(valueArg: string): TMadvrMenu {
|
||||
const menu = menus.find((itemArg) => itemArg.toLowerCase() === valueArg.toLowerCase());
|
||||
if (menu) {
|
||||
return menu;
|
||||
}
|
||||
throw new Error(`Unsupported madVR menu: ${valueArg}`);
|
||||
}
|
||||
|
||||
private static normalizeProfileGroup(valueArg: string): TMadvrProfileGroup {
|
||||
const group = valueArg.toUpperCase();
|
||||
if (profileGroups.includes(group as TMadvrProfileGroup)) {
|
||||
return group as TMadvrProfileGroup;
|
||||
}
|
||||
throw new Error(`Unsupported madVR profile group: ${valueArg}`);
|
||||
}
|
||||
|
||||
private static normalizeToggle(valueArg: string): TMadvrToggle {
|
||||
const normalized = valueArg.replace(/^_/, '').toLowerCase();
|
||||
const toggle = toggles.find((itemArg) => itemArg.toLowerCase() === normalized);
|
||||
if (toggle) {
|
||||
return toggle;
|
||||
}
|
||||
throw new Error(`Unsupported madVR toggle: ${valueArg}`);
|
||||
}
|
||||
|
||||
private static normalizeInteger(valueArg: string, labelArg: string): string {
|
||||
if (!/^\d+$/.test(valueArg)) {
|
||||
throw new Error(`madVR ${labelArg} must be a non-negative integer.`);
|
||||
}
|
||||
return String(Number(valueArg));
|
||||
}
|
||||
|
||||
private static isCommandRequest(valueArg: unknown): valueArg is IMadvrCommandRequest {
|
||||
return typeof valueArg === 'object' && valueArg !== null && typeof (valueArg as IMadvrCommandRequest).command === 'string';
|
||||
}
|
||||
}
|
||||
|
||||
export class MadvrClient {
|
||||
constructor(private readonly config: IMadvrConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IMadvrSnapshot> {
|
||||
if (this.config.commandExecutor?.getSnapshot) {
|
||||
return this.normalizeSnapshot(await this.config.commandExecutor.getSnapshot());
|
||||
}
|
||||
if (this.config.snapshot) {
|
||||
return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
|
||||
}
|
||||
if (this.config.host) {
|
||||
try {
|
||||
return await this.getLiveSnapshot();
|
||||
} catch (errorArg) {
|
||||
return this.snapshotFromConfig(this.errorMessage(errorArg));
|
||||
}
|
||||
}
|
||||
return this.snapshotFromConfig();
|
||||
}
|
||||
|
||||
public async execute(commandArg: IMadvrCommandRequest): Promise<IMadvrCommandResult> {
|
||||
const model = MadvrCommandModeler.modelRequest(commandArg);
|
||||
if (this.config.commandExecutor) {
|
||||
const result = await this.config.commandExecutor.execute(model.request, model);
|
||||
if (typeof result === 'string') {
|
||||
return { executed: true, command: model, transport: 'executor', response: result };
|
||||
}
|
||||
if (result) {
|
||||
return { ...result, command: result.command || model, transport: result.transport || 'executor' };
|
||||
}
|
||||
return { executed: true, command: model, transport: 'executor' };
|
||||
}
|
||||
|
||||
if (model.transport === 'wake_on_lan') {
|
||||
const macAddress = this.config.macAddress || this.config.snapshot?.deviceInfo.macAddress || this.config.state?.mac_address || this.config.state?.macAddress;
|
||||
if (!macAddress) {
|
||||
throw new Error('madVR Envy turn_on requires a MAC address or commandExecutor for Wake-on-LAN.');
|
||||
}
|
||||
await this.sendWakeOnLan(String(macAddress));
|
||||
return { executed: true, command: model, transport: 'wake_on_lan' };
|
||||
}
|
||||
|
||||
if (!model.wireCommand) {
|
||||
throw new Error(`madVR command ${model.request.command} does not have a TCP wire command.`);
|
||||
}
|
||||
const response = await this.sendTcpCommands([model.wireCommand], commandResponseCollectMs);
|
||||
this.applyLocalState(model.request);
|
||||
return { executed: true, command: model, transport: 'tcp', response };
|
||||
}
|
||||
|
||||
public async executeInput(inputArg: unknown): Promise<IMadvrCommandResult[]> {
|
||||
const requests = MadvrCommandModeler.requestsFromInput(inputArg);
|
||||
if (!requests.length) {
|
||||
throw new Error('madVR command input did not contain any supported commands.');
|
||||
}
|
||||
const results: IMadvrCommandResult[] = [];
|
||||
for (const request of requests) {
|
||||
results.push(await this.execute(request));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.config.commandExecutor?.destroy?.();
|
||||
}
|
||||
|
||||
public static parseNotificationText(textArg: string): IMadvrRawState {
|
||||
const state: IMadvrRawState = {};
|
||||
const notifications = textArg.split(/\r?\n/).map((lineArg) => lineArg.trim()).filter(Boolean);
|
||||
for (const notification of notifications) {
|
||||
if (notification === 'OK' || notification === 'WELCOME') {
|
||||
continue;
|
||||
}
|
||||
const [title, ...info] = notification.split(/\s+/);
|
||||
if (title === 'PowerOff' || title === 'Standby') {
|
||||
state.is_on = false;
|
||||
continue;
|
||||
}
|
||||
if (title === 'NoSignal') {
|
||||
state.is_signal = false;
|
||||
continue;
|
||||
}
|
||||
if (title === 'MacAddress' && info[0]) {
|
||||
state.mac_address = info[0];
|
||||
continue;
|
||||
}
|
||||
if (title === 'Temperatures') {
|
||||
state.temp_gpu = info[0];
|
||||
state.temp_hdmi = info[1];
|
||||
state.temp_cpu = info[2];
|
||||
state.temp_mainboard = info[3];
|
||||
continue;
|
||||
}
|
||||
if (title === 'IncomingSignalInfo') {
|
||||
state.is_signal = true;
|
||||
state.incoming_res = info[0];
|
||||
state.incoming_frame_rate = info[1];
|
||||
state.incoming_signal_type = info[2];
|
||||
state.incoming_color_space = info[3];
|
||||
state.incoming_bit_depth = info[4];
|
||||
state.hdr_flag = info[5]?.includes('HDR') || false;
|
||||
state.incoming_colorimetry = info[6];
|
||||
state.incoming_black_levels = info[7];
|
||||
state.incoming_aspect_ratio = info[8];
|
||||
continue;
|
||||
}
|
||||
if (title === 'OutgoingSignalInfo' || title === 'OngoingSignalInfo') {
|
||||
state.outgoing_res = info[0];
|
||||
state.outgoing_frame_rate = info[1];
|
||||
state.outgoing_signal_type = info[2];
|
||||
state.outgoing_color_space = info[3];
|
||||
state.outgoing_bit_depth = info[4];
|
||||
state.outgoing_hdr_flag = info[5]?.includes('HDR') || false;
|
||||
state.outgoing_colorimetry = info[6];
|
||||
state.outgoing_black_levels = info[7];
|
||||
continue;
|
||||
}
|
||||
if (title === 'AspectRatio') {
|
||||
state.aspect_res = info[0];
|
||||
state.aspect_dec = info[1];
|
||||
state.aspect_int = info[2];
|
||||
state.aspect_name = info.slice(3).join(' ') || undefined;
|
||||
continue;
|
||||
}
|
||||
if (title === 'MaskingRatio') {
|
||||
state.masking_res = info[0];
|
||||
state.masking_dec = info[1];
|
||||
state.masking_int = info[2];
|
||||
continue;
|
||||
}
|
||||
if ((title === 'ActivateProfile' || title === 'ActiveProfile') && info[0]) {
|
||||
state.profile_name = info[0];
|
||||
state.profile_num = info[1];
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private async getLiveSnapshot(): Promise<IMadvrSnapshot> {
|
||||
const commands = [
|
||||
'GetMacAddress\r\n',
|
||||
'GetTemperatures\r\n',
|
||||
'GetIncomingSignalInfo\r\n',
|
||||
'GetOutgoingSignalInfo\r\n',
|
||||
'GetAspectRatio\r\n',
|
||||
'GetMaskingRatio\r\n',
|
||||
];
|
||||
const response = await this.sendTcpCommands(commands, this.config.snapshotCollectMs ?? snapshotCollectMs);
|
||||
const raw = { is_on: true, ...MadvrClient.parseNotificationText(response) };
|
||||
return this.snapshotFromRawState(raw, true);
|
||||
}
|
||||
|
||||
private async sendTcpCommands(commandsArg: string[], collectMsArg: number): Promise<string> {
|
||||
if (!this.config.host) {
|
||||
throw new Error('madVR Envy host is required for live TCP commands.');
|
||||
}
|
||||
const host = this.config.host;
|
||||
const port = this.port();
|
||||
const timeoutMs = this.config.commandTimeoutMs ?? commandTimeoutMs;
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const socket = plugins.net.createConnection({ host, port });
|
||||
const chunks: Buffer[] = [];
|
||||
let settled = false;
|
||||
let wroteCommands = false;
|
||||
let finishTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutTimer = setTimeout(() => finish(new Error(`madVR Envy TCP command timed out after ${timeoutMs}ms.`)), timeoutMs);
|
||||
|
||||
const finish = (errorArg?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutTimer);
|
||||
if (finishTimer) {
|
||||
clearTimeout(finishTimer);
|
||||
}
|
||||
socket.destroy();
|
||||
if (errorArg) {
|
||||
reject(errorArg);
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
};
|
||||
|
||||
const writeNext = (payloadsArg: string[], indexArg = 0) => {
|
||||
if (indexArg >= payloadsArg.length) {
|
||||
wroteCommands = true;
|
||||
finishTimer = setTimeout(() => finish(), collectMsArg);
|
||||
return;
|
||||
}
|
||||
socket.write(payloadsArg[indexArg], (errorArg?: Error | null) => {
|
||||
if (errorArg) {
|
||||
finish(errorArg);
|
||||
return;
|
||||
}
|
||||
writeNext(payloadsArg, indexArg + 1);
|
||||
});
|
||||
};
|
||||
|
||||
socket.setTimeout(this.config.connectTimeoutMs ?? connectTimeoutMs, () => finish(new Error(`madVR Envy TCP connection to ${host}:${port} timed out.`)));
|
||||
socket.on('connect', () => writeNext([heartbeatCommand, ...commandsArg]));
|
||||
socket.on('data', (chunkArg) => chunks.push(Buffer.from(chunkArg)));
|
||||
socket.on('error', (errorArg) => finish(errorArg));
|
||||
socket.on('close', () => {
|
||||
if (!settled && wroteCommands) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async sendWakeOnLan(macAddressArg: string): Promise<void> {
|
||||
const packet = this.createMagicPacket(macAddressArg);
|
||||
const dgram = await import('node:dgram');
|
||||
const socket = dgram.createSocket('udp4');
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('error', reject);
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
socket.send(packet, this.config.wolPort ?? 9, this.config.wolBroadcastAddress || '255.255.255.255', (errorArg) => {
|
||||
if (errorArg) {
|
||||
reject(errorArg);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
private createMagicPacket(macAddressArg: string): Buffer {
|
||||
let mac = macAddressArg.trim();
|
||||
if (mac.length === 17) {
|
||||
mac = mac.replaceAll(mac[2], '');
|
||||
} else if (mac.length === 14) {
|
||||
mac = mac.replaceAll(mac[4], '');
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{12}$/.test(mac)) {
|
||||
throw new Error('madVR Envy Wake-on-LAN requires a valid MAC address.');
|
||||
}
|
||||
return Buffer.from(`${'F'.repeat(12)}${mac.repeat(16)}`, 'hex');
|
||||
}
|
||||
|
||||
private snapshotFromConfig(errorMessageArg?: string): IMadvrSnapshot {
|
||||
return this.snapshotFromRawState(this.config.state || {}, errorMessageArg ? false : undefined, errorMessageArg);
|
||||
}
|
||||
|
||||
private snapshotFromRawState(rawArg: IMadvrRawState, availableArg?: boolean, errorMessageArg?: string): IMadvrSnapshot {
|
||||
const parts = this.statePartsFromRaw(rawArg);
|
||||
const processor = this.mergeProcessor(parts.processor, this.config.processor);
|
||||
const display = this.mergeDisplay(parts.display, this.config.display);
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo: this.deviceInfoFromConfig(rawArg),
|
||||
processor: {
|
||||
...processor,
|
||||
lastError: errorMessageArg || processor.lastError,
|
||||
},
|
||||
projector: {
|
||||
profileName: processor.profileName,
|
||||
profileNumber: processor.profileNumber,
|
||||
...this.config.projector,
|
||||
},
|
||||
display,
|
||||
buttons: this.config.buttons,
|
||||
selects: this.config.selects,
|
||||
raw: rawArg,
|
||||
available: availableArg ?? this.config.processor?.isOn ?? this.config.state?.is_on ?? this.config.state?.isOn ?? Boolean(this.config.host || this.config.snapshot),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IMadvrSnapshot): IMadvrSnapshot {
|
||||
const rawParts = snapshotArg.raw ? this.statePartsFromRaw(snapshotArg.raw) : undefined;
|
||||
const processor = this.mergeProcessor(rawParts?.processor, snapshotArg.processor);
|
||||
const display = this.mergeDisplay(rawParts?.display, snapshotArg.display);
|
||||
return {
|
||||
...snapshotArg,
|
||||
deviceInfo: {
|
||||
...this.deviceInfoFromConfig(snapshotArg.raw),
|
||||
...snapshotArg.deviceInfo,
|
||||
port: snapshotArg.deviceInfo.port || this.port(),
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || this.config.manufacturer || 'madVR',
|
||||
model: snapshotArg.deviceInfo.model || this.config.model || 'Envy',
|
||||
name: snapshotArg.deviceInfo.name || this.config.name || 'madVR Envy',
|
||||
},
|
||||
processor,
|
||||
projector: snapshotArg.projector || (processor.profileName || processor.profileNumber ? { profileName: processor.profileName, profileNumber: processor.profileNumber } : undefined),
|
||||
display,
|
||||
buttons: snapshotArg.buttons || [],
|
||||
selects: snapshotArg.selects || [],
|
||||
available: snapshotArg.available ?? true,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private statePartsFromRaw(rawArg: IMadvrRawState): { processor: IMadvrProcessorState; display: IMadvrDisplayState } {
|
||||
const isOn = this.boolRaw(rawArg, 'is_on', 'isOn');
|
||||
const isSignal = this.boolRaw(rawArg, 'is_signal', 'isSignal');
|
||||
const processor: IMadvrProcessorState = {
|
||||
isOn,
|
||||
isSignal,
|
||||
power: isOn === true ? 'on' : isOn === false ? 'off' : undefined,
|
||||
incoming: {
|
||||
resolution: this.stringRaw(rawArg, 'incoming_res', 'incomingRes'),
|
||||
frameRate: this.stringRaw(rawArg, 'incoming_frame_rate', 'incomingFrameRate'),
|
||||
signalType: this.stringRaw(rawArg, 'incoming_signal_type', 'incomingSignalType'),
|
||||
colorSpace: this.stringRaw(rawArg, 'incoming_color_space', 'incomingColorSpace'),
|
||||
bitDepth: this.stringRaw(rawArg, 'incoming_bit_depth', 'incomingBitDepth'),
|
||||
hdr: this.boolRaw(rawArg, 'hdr_flag', 'hdrFlag'),
|
||||
colorimetry: this.stringRaw(rawArg, 'incoming_colorimetry', 'incomingColorimetry'),
|
||||
blackLevels: this.stringRaw(rawArg, 'incoming_black_levels', 'incomingBlackLevels'),
|
||||
aspectRatio: this.stringRaw(rawArg, 'incoming_aspect_ratio', 'incomingAspectRatio'),
|
||||
},
|
||||
outgoing: {
|
||||
resolution: this.stringRaw(rawArg, 'outgoing_res', 'outgoingRes'),
|
||||
frameRate: this.stringRaw(rawArg, 'outgoing_frame_rate', 'outgoingFrameRate'),
|
||||
signalType: this.stringRaw(rawArg, 'outgoing_signal_type', 'outgoingSignalType'),
|
||||
colorSpace: this.stringRaw(rawArg, 'outgoing_color_space', 'outgoingColorSpace'),
|
||||
bitDepth: this.stringRaw(rawArg, 'outgoing_bit_depth', 'outgoingBitDepth'),
|
||||
hdr: this.boolRaw(rawArg, 'outgoing_hdr_flag', 'outgoingHdrFlag'),
|
||||
colorimetry: this.stringRaw(rawArg, 'outgoing_colorimetry', 'outgoingColorimetry'),
|
||||
blackLevels: this.stringRaw(rawArg, 'outgoing_black_levels', 'outgoingBlackLevels'),
|
||||
},
|
||||
temperatures: {
|
||||
gpu: this.numberRaw(rawArg, 'temp_gpu', 'tempGpu'),
|
||||
hdmi: this.numberRaw(rawArg, 'temp_hdmi', 'tempHdmi'),
|
||||
cpu: this.numberRaw(rawArg, 'temp_cpu', 'tempCpu'),
|
||||
mainboard: this.numberRaw(rawArg, 'temp_mainboard', 'tempMainboard'),
|
||||
},
|
||||
profileName: this.stringRaw(rawArg, 'profile_name', 'profileName'),
|
||||
profileNumber: this.stringRaw(rawArg, 'profile_num', 'profileNumber'),
|
||||
raw: rawArg,
|
||||
};
|
||||
const display: IMadvrDisplayState = {
|
||||
hdrFlag: this.boolRaw(rawArg, 'hdr_flag', 'hdrFlag'),
|
||||
outgoingHdrFlag: this.boolRaw(rawArg, 'outgoing_hdr_flag', 'outgoingHdrFlag'),
|
||||
aspect: {
|
||||
resolution: this.stringRaw(rawArg, 'aspect_res', 'aspectRes'),
|
||||
decimal: this.numberRaw(rawArg, 'aspect_dec', 'aspectDec'),
|
||||
integer: this.stringRaw(rawArg, 'aspect_int', 'aspectInt'),
|
||||
name: this.stringRaw(rawArg, 'aspect_name', 'aspectName'),
|
||||
},
|
||||
masking: {
|
||||
resolution: this.stringRaw(rawArg, 'masking_res', 'maskingRes'),
|
||||
decimal: this.numberRaw(rawArg, 'masking_dec', 'maskingDec'),
|
||||
integer: this.stringRaw(rawArg, 'masking_int', 'maskingInt'),
|
||||
},
|
||||
};
|
||||
return { processor: this.cleanProcessor(processor), display: this.cleanDisplay(display) };
|
||||
}
|
||||
|
||||
private mergeProcessor(baseArg: IMadvrProcessorState | undefined, overrideArg: IMadvrProcessorState | undefined): IMadvrProcessorState {
|
||||
const base = baseArg || {};
|
||||
const override = overrideArg || {};
|
||||
return this.cleanProcessor({
|
||||
...base,
|
||||
...override,
|
||||
incoming: { ...base.incoming, ...override.incoming },
|
||||
outgoing: { ...base.outgoing, ...override.outgoing },
|
||||
temperatures: { ...base.temperatures, ...override.temperatures },
|
||||
raw: { ...base.raw, ...override.raw },
|
||||
});
|
||||
}
|
||||
|
||||
private mergeDisplay(baseArg: IMadvrDisplayState | undefined, overrideArg: IMadvrDisplayState | undefined): IMadvrDisplayState {
|
||||
const base = baseArg || {};
|
||||
const override = overrideArg || {};
|
||||
return this.cleanDisplay({
|
||||
...base,
|
||||
...override,
|
||||
aspect: { ...base.aspect, ...override.aspect },
|
||||
masking: { ...base.masking, ...override.masking },
|
||||
});
|
||||
}
|
||||
|
||||
private cleanProcessor(processorArg: IMadvrProcessorState): IMadvrProcessorState {
|
||||
return this.cleanObject({
|
||||
...processorArg,
|
||||
incoming: this.cleanObject(processorArg.incoming || {}),
|
||||
outgoing: this.cleanObject(processorArg.outgoing || {}),
|
||||
temperatures: this.cleanObject(processorArg.temperatures || {}),
|
||||
raw: processorArg.raw,
|
||||
}) as IMadvrProcessorState;
|
||||
}
|
||||
|
||||
private cleanDisplay(displayArg: IMadvrDisplayState): IMadvrDisplayState {
|
||||
return this.cleanObject({
|
||||
...displayArg,
|
||||
aspect: this.cleanObject(displayArg.aspect || {}),
|
||||
masking: this.cleanObject(displayArg.masking || {}),
|
||||
}) as IMadvrDisplayState;
|
||||
}
|
||||
|
||||
private cleanObject<TValue extends object>(valueArg: TValue): Partial<TValue> {
|
||||
return Object.fromEntries(Object.entries(valueArg as Record<string, unknown>).filter(([, valueArg]) => {
|
||||
if (valueArg === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) && !Object.keys(valueArg).length) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})) as Partial<TValue>;
|
||||
}
|
||||
|
||||
private deviceInfoFromConfig(rawArg?: IMadvrRawState): IMadvrSnapshot['deviceInfo'] {
|
||||
return {
|
||||
host: this.config.host,
|
||||
port: this.port(),
|
||||
name: this.config.name || 'madVR Envy',
|
||||
manufacturer: this.config.manufacturer || 'madVR',
|
||||
model: this.config.model || 'Envy',
|
||||
macAddress: this.config.macAddress || this.stringRaw(rawArg, 'mac_address', 'macAddress'),
|
||||
serialNumber: this.config.serialNumber,
|
||||
};
|
||||
}
|
||||
|
||||
private applyLocalState(commandArg: IMadvrCommandRequest): void {
|
||||
const state = this.config.snapshot?.raw || this.config.state || {};
|
||||
if (commandArg.command === 'PowerOff' || commandArg.command === 'Standby') {
|
||||
state.is_on = false;
|
||||
}
|
||||
if (this.config.snapshot) {
|
||||
this.config.snapshot.raw = state;
|
||||
this.config.snapshot.processor.isOn = state.is_on;
|
||||
this.config.snapshot.processor.power = state.is_on === false ? 'off' : this.config.snapshot.processor.power;
|
||||
return;
|
||||
}
|
||||
this.config.state = state;
|
||||
}
|
||||
|
||||
private stringRaw(rawArg: IMadvrRawState | undefined, ...keysArg: string[]): string | undefined {
|
||||
const value = this.rawValue(rawArg, ...keysArg);
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private numberRaw(rawArg: IMadvrRawState | undefined, ...keysArg: string[]): number | undefined {
|
||||
const value = this.rawValue(rawArg, ...keysArg);
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private boolRaw(rawArg: IMadvrRawState | undefined, ...keysArg: string[]): boolean | undefined {
|
||||
const value = this.rawValue(rawArg, ...keysArg);
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === 'true' || normalized === 'on' || normalized === '1') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === 'off' || normalized === '0') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private rawValue(rawArg: IMadvrRawState | undefined, ...keysArg: string[]): unknown {
|
||||
if (!rawArg) {
|
||||
return undefined;
|
||||
}
|
||||
for (const key of keysArg) {
|
||||
if (rawArg[key] !== undefined && rawArg[key] !== null) {
|
||||
return rawArg[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private port(): number {
|
||||
return this.config.port || madvrDefaultPort;
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IMadvrSnapshot): IMadvrSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IMadvrSnapshot;
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IMadvrConfig } from './madvr.types.js';
|
||||
import { madvrDefaultPort } from './madvr.types.js';
|
||||
|
||||
export class MadvrConfigFlow implements IConfigFlow<IMadvrConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMadvrConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect madVR Envy',
|
||||
description: 'Configure the local madVR Envy TCP endpoint. Live control uses validated madVR IP-control commands; Wake-on-LAN requires a MAC address.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'TCP port', type: 'number' },
|
||||
{ name: 'macAddress', label: 'MAC address', type: 'text' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'model', label: 'Model', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', error: 'madVR Envy host is required.' };
|
||||
}
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'madVR Envy configured',
|
||||
config: {
|
||||
host,
|
||||
port: this.numberValue(valuesArg.port) || candidateArg.port || madvrDefaultPort,
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || 'madVR Envy',
|
||||
model: this.stringValue(valuesArg.model) || candidateArg.model || 'Envy',
|
||||
manufacturer: candidateArg.manufacturer || 'madVR',
|
||||
macAddress: this.stringValue(valuesArg.macAddress) || candidateArg.macAddress || this.stringMetadata(candidateArg, 'macAddress'),
|
||||
serialNumber: candidateArg.serialNumber,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const value = Number(valueArg);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined {
|
||||
const value = candidateArg.metadata?.[keyArg];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,187 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { MadvrClient } from './madvr.classes.client.js';
|
||||
import { MadvrConfigFlow } from './madvr.classes.configflow.js';
|
||||
import { createMadvrDiscoveryDescriptor } from './madvr.discovery.js';
|
||||
import { MadvrMapper } from './madvr.mapper.js';
|
||||
import type { IMadvrCommandRequest, IMadvrConfig } from './madvr.types.js';
|
||||
|
||||
export class HomeAssistantMadvrIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "madvr",
|
||||
displayName: "madVR Envy",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/madvr",
|
||||
"upstreamDomain": "madvr",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"py-madvr2==1.6.40"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@iloveicedgreentea"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class MadvrIntegration extends BaseIntegration<IMadvrConfig> {
|
||||
public readonly domain = 'madvr';
|
||||
public readonly displayName = 'madVR Envy';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createMadvrDiscoveryDescriptor();
|
||||
public readonly configFlow = new MadvrConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/madvr',
|
||||
upstreamDomain: 'madvr',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['py-madvr2==1.6.40'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@iloveicedgreentea'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/madvr',
|
||||
};
|
||||
|
||||
public async setup(configArg: IMadvrConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new MadvrRuntime(new MadvrClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantMadvrIntegration extends MadvrIntegration {}
|
||||
|
||||
class MadvrRuntime implements IIntegrationRuntime {
|
||||
public domain = 'madvr';
|
||||
|
||||
constructor(private readonly client: MadvrClient) {}
|
||||
|
||||
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return MadvrMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return MadvrMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'remote') {
|
||||
return await this.callRemoteService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'media_player') {
|
||||
return await this.callMediaPlayerService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'button' || requestArg.domain === 'select') {
|
||||
return await this.callWritableEntityService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'madvr') {
|
||||
return await this.callMadvrService(requestArg);
|
||||
}
|
||||
return { success: false, error: `Unsupported madVR Envy service domain: ${requestArg.domain}` };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return { success: true, data: await this.client.execute({ command: 'PowerOn', source: 'remote.turn_on', target: requestArg.target }) };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return { success: true, data: await this.client.execute({ command: 'PowerOff', source: 'remote.turn_off', target: requestArg.target }) };
|
||||
}
|
||||
if (requestArg.service !== 'send_command') {
|
||||
return { success: false, error: `Unsupported madVR Envy remote service: ${requestArg.service}` };
|
||||
}
|
||||
const command = requestArg.data?.command;
|
||||
if (!command) {
|
||||
return { success: false, error: 'madVR Envy remote.send_command requires data.command.' };
|
||||
}
|
||||
return { success: true, data: await this.client.executeInput(command) };
|
||||
}
|
||||
|
||||
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return { success: true, data: await this.client.execute({ command: 'PowerOn', source: 'media_player.turn_on', target: requestArg.target }) };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return { success: true, data: await this.client.execute({ command: 'PowerOff', source: 'media_player.turn_off', target: requestArg.target }) };
|
||||
}
|
||||
return { success: false, error: `Unsupported madVR Envy media_player service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callWritableEntityService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const entity = MadvrMapper.findTargetEntity(snapshot, requestArg);
|
||||
if (!entity) {
|
||||
return { success: false, error: `madVR Envy ${requestArg.domain}.${requestArg.service} target was not found.` };
|
||||
}
|
||||
const command = MadvrMapper.commandFromWritableEntity(entity, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `madVR Envy ${entity.platform} entity is not backed by a command for ${requestArg.service}.` };
|
||||
}
|
||||
return { success: true, data: await this.client.execute(command) };
|
||||
}
|
||||
|
||||
private async callMadvrService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'send_command' || requestArg.service === 'execute_command') {
|
||||
const command = requestArg.data?.command || requestArg.data?.commands;
|
||||
if (!command) {
|
||||
return { success: false, error: `madVR Envy ${requestArg.service} requires data.command.` };
|
||||
}
|
||||
return { success: true, data: await this.client.executeInput(command) };
|
||||
}
|
||||
|
||||
const command = this.commandForMadvrService(requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported madVR Envy service: ${requestArg.service}` };
|
||||
}
|
||||
return { success: true, data: await this.client.execute(command) };
|
||||
}
|
||||
|
||||
private commandForMadvrService(requestArg: IServiceCallRequest): IMadvrCommandRequest | undefined {
|
||||
if (requestArg.service === 'standby') {
|
||||
return { command: 'Standby', source: 'madvr.standby', target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'restart') {
|
||||
return { command: 'Restart', source: 'madvr.restart', target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'reload_software') {
|
||||
return { command: 'ReloadSoftware', source: 'madvr.reload_software', target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'hotplug') {
|
||||
return { command: 'Hotplug', source: 'madvr.hotplug', target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'refresh_license_info') {
|
||||
return { command: 'RefreshLicenseInfo', source: 'madvr.refresh_license_info', target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'force_1080p60_output') {
|
||||
return { command: 'Force1080p60Output', source: 'madvr.force_1080p60_output', target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'open_menu') {
|
||||
return { command: 'OpenMenu', args: [this.requiredString(requestArg, 'menu', 'madVR Envy open_menu requires data.menu.')], source: 'madvr.open_menu', target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'key_press' || requestArg.service === 'key_hold') {
|
||||
return { command: requestArg.service === 'key_hold' ? 'KeyHold' : 'KeyPress', args: [this.requiredString(requestArg, 'key', `madVR Envy ${requestArg.service} requires data.key.`)], source: `madvr.${requestArg.service}`, target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'activate_profile') {
|
||||
const group = this.requiredString(requestArg, 'profile_group', 'madVR Envy activate_profile requires data.profile_group.');
|
||||
const profileNumber = this.optionalString(requestArg, 'profile_number');
|
||||
return { command: 'ActivateProfile', args: profileNumber ? [group, profileNumber] : [group], source: 'madvr.activate_profile', target: requestArg.target };
|
||||
}
|
||||
if (requestArg.service === 'toggle') {
|
||||
return { command: 'Toggle', args: [this.requiredString(requestArg, 'option', 'madVR Envy toggle requires data.option.')], source: 'madvr.toggle', target: requestArg.target };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private requiredString(requestArg: IServiceCallRequest, keyArg: string, errorArg: string): string {
|
||||
const value = this.optionalString(requestArg, keyArg);
|
||||
if (!value) {
|
||||
throw new Error(errorArg);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private optionalString(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IMadvrManualEntry, IMadvrMdnsRecord } from './madvr.types.js';
|
||||
import { madvrDefaultPort } from './madvr.types.js';
|
||||
|
||||
export class MadvrManualMatcher implements IDiscoveryMatcher<IMadvrManualEntry> {
|
||||
public id = 'madvr-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual madVR Envy setup entries.';
|
||||
|
||||
public async matches(inputArg: IMadvrManualEntry): Promise<IDiscoveryMatch> {
|
||||
const matched = Boolean(inputArg.host || isMadvrHint(inputArg.manufacturer, inputArg.model, inputArg.name) || inputArg.metadata?.madvr);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain madVR Envy setup hints.' };
|
||||
}
|
||||
const id = inputArg.id || inputArg.macAddress || inputArg.serialNumber || inputArg.host;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start madVR Envy setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'madvr',
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port: inputArg.port || madvrDefaultPort,
|
||||
name: inputArg.name || 'madVR Envy',
|
||||
manufacturer: inputArg.manufacturer || 'madVR',
|
||||
model: inputArg.model || 'Envy',
|
||||
macAddress: inputArg.macAddress,
|
||||
serialNumber: inputArg.serialNumber,
|
||||
metadata: inputArg.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class MadvrMdnsMatcher implements IDiscoveryMatcher<IMadvrMdnsRecord> {
|
||||
public id = 'madvr-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize mDNS records that explicitly advertise madVR Envy metadata.';
|
||||
|
||||
public async matches(recordArg: IMadvrMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const properties = { ...(recordArg.txt || {}), ...(recordArg.properties || {}) };
|
||||
const type = normalize(recordArg.type);
|
||||
const name = cleanName(valueForKey(properties, 'name') || recordArg.name);
|
||||
const manufacturer = valueForKey(properties, 'manufacturer') || valueForKey(properties, 'mf');
|
||||
const model = valueForKey(properties, 'model') || valueForKey(properties, 'modelName') || valueForKey(properties, 'md');
|
||||
const matched = type.includes('madvr') || isMadvrHint(manufacturer, model, name);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record does not contain madVR Envy hints.' };
|
||||
}
|
||||
const macAddress = valueForKey(properties, 'mac') || valueForKey(properties, 'macAddress') || valueForKey(properties, 'mac_address');
|
||||
const id = valueForKey(properties, 'id') || macAddress || name;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: id ? 'high' : 'medium',
|
||||
reason: 'mDNS record matches madVR Envy metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: 'madvr',
|
||||
id,
|
||||
host: recordArg.host,
|
||||
port: recordArg.port || madvrDefaultPort,
|
||||
name: name || 'madVR Envy',
|
||||
manufacturer: manufacturer || 'madVR',
|
||||
model: model || 'Envy',
|
||||
macAddress,
|
||||
metadata: { mdnsType: recordArg.type, mdnsName: recordArg.name, txt: properties },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class MadvrCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'madvr-candidate-validator';
|
||||
public description = 'Validate madVR Envy discovery candidates.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const matched = candidateArg.integrationDomain === 'madvr'
|
||||
|| Boolean(candidateArg.metadata?.madvr)
|
||||
|| isMadvrHint(candidateArg.manufacturer, candidateArg.model, candidateArg.name);
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has madVR Envy metadata.' : 'Candidate is not madVR Envy.',
|
||||
candidate: matched ? {
|
||||
...candidateArg,
|
||||
integrationDomain: 'madvr',
|
||||
port: candidateArg.port || madvrDefaultPort,
|
||||
} : undefined,
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || candidateArg.host,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createMadvrDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'madvr', displayName: 'madVR Envy' })
|
||||
.addMatcher(new MadvrManualMatcher())
|
||||
.addMatcher(new MadvrMdnsMatcher())
|
||||
.addValidator(new MadvrCandidateValidator());
|
||||
};
|
||||
|
||||
const isMadvrHint = (...valuesArg: Array<string | undefined>): boolean => {
|
||||
const value = valuesArg.filter(Boolean).join(' ').toLowerCase();
|
||||
return value.includes('madvr') || value.includes('mad vr') || value.includes('envy');
|
||||
};
|
||||
|
||||
const normalize = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||
|
||||
const cleanName = (valueArg?: string): string | undefined => valueArg?.replace(/\._[^.]+\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
|
||||
|
||||
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerKey = keyArg.toLowerCase();
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (key.toLowerCase() === lowerKey && value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IMadvrButtonRecord,
|
||||
IMadvrCommandRequest,
|
||||
IMadvrRatioState,
|
||||
IMadvrSelectRecord,
|
||||
IMadvrSignalState,
|
||||
IMadvrSnapshot,
|
||||
TMadvrPowerState,
|
||||
} from './madvr.types.js';
|
||||
|
||||
const madvrDomain = 'madvr';
|
||||
const madvrManufacturer = 'madVR';
|
||||
|
||||
interface ISensorDescription {
|
||||
key: string;
|
||||
name: string;
|
||||
value: (snapshotArg: IMadvrSnapshot) => unknown;
|
||||
deviceClass?: string;
|
||||
stateClass?: string;
|
||||
unit?: string;
|
||||
options?: string[];
|
||||
enabledDefault?: boolean;
|
||||
}
|
||||
|
||||
interface IBinarySensorDescription {
|
||||
key: string;
|
||||
name: string;
|
||||
value: (snapshotArg: IMadvrSnapshot) => boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const enumValue = (valueArg: unknown, optionsArg: string[]): string | null => {
|
||||
return typeof valueArg === 'string' && optionsArg.includes(valueArg) ? valueArg : null;
|
||||
};
|
||||
|
||||
const signalOptions = ['2D', '3D'];
|
||||
const colorSpaceOptions = ['RGB', '444', '422', '420'];
|
||||
const bitDepthOptions = ['8bit', '10bit', '12bit'];
|
||||
const colorimetryOptions = ['SDR', 'HDR10', 'HLG 601', 'PAL', '709', 'DCI', '2020'];
|
||||
const blackLevelOptions = ['TV', 'PC'];
|
||||
const aspectOptions = ['16:9', '4:3'];
|
||||
|
||||
const sensors: ISensorDescription[] = [
|
||||
{ key: 'temp_gpu', name: 'GPU temperature', value: (snapshotArg) => validTemperature(snapshotArg.processor.temperatures?.gpu), deviceClass: 'temperature', stateClass: 'measurement', unit: 'C', enabledDefault: false },
|
||||
{ key: 'temp_hdmi', name: 'HDMI temperature', value: (snapshotArg) => validTemperature(snapshotArg.processor.temperatures?.hdmi), deviceClass: 'temperature', stateClass: 'measurement', unit: 'C', enabledDefault: false },
|
||||
{ key: 'temp_cpu', name: 'CPU temperature', value: (snapshotArg) => validTemperature(snapshotArg.processor.temperatures?.cpu), deviceClass: 'temperature', stateClass: 'measurement', unit: 'C', enabledDefault: false },
|
||||
{ key: 'temp_mainboard', name: 'Mainboard temperature', value: (snapshotArg) => validTemperature(snapshotArg.processor.temperatures?.mainboard), deviceClass: 'temperature', stateClass: 'measurement', unit: 'C', enabledDefault: false },
|
||||
{ key: 'incoming_res', name: 'Incoming resolution', value: (snapshotArg) => snapshotArg.processor.incoming?.resolution ?? null },
|
||||
{ key: 'incoming_signal_type', name: 'Incoming signal type', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.signalType, signalOptions), deviceClass: 'enum', options: signalOptions, enabledDefault: false },
|
||||
{ key: 'incoming_frame_rate', name: 'Incoming frame rate', value: (snapshotArg) => snapshotArg.processor.incoming?.frameRate ?? null },
|
||||
{ key: 'incoming_color_space', name: 'Incoming color space', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.colorSpace, colorSpaceOptions), deviceClass: 'enum', options: colorSpaceOptions },
|
||||
{ key: 'incoming_bit_depth', name: 'Incoming bit depth', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.bitDepth, bitDepthOptions), deviceClass: 'enum', options: bitDepthOptions },
|
||||
{ key: 'incoming_colorimetry', name: 'Incoming colorimetry', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.colorimetry, colorimetryOptions), deviceClass: 'enum', options: colorimetryOptions },
|
||||
{ key: 'incoming_black_levels', name: 'Incoming black levels', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.blackLevels, blackLevelOptions), deviceClass: 'enum', options: blackLevelOptions },
|
||||
{ key: 'incoming_aspect_ratio', name: 'Incoming aspect ratio', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.aspectRatio, aspectOptions), deviceClass: 'enum', options: aspectOptions, enabledDefault: false },
|
||||
{ key: 'outgoing_res', name: 'Outgoing resolution', value: (snapshotArg) => snapshotArg.processor.outgoing?.resolution ?? null },
|
||||
{ key: 'outgoing_signal_type', name: 'Outgoing signal type', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.signalType, signalOptions), deviceClass: 'enum', options: signalOptions, enabledDefault: false },
|
||||
{ key: 'outgoing_frame_rate', name: 'Outgoing frame rate', value: (snapshotArg) => snapshotArg.processor.outgoing?.frameRate ?? null },
|
||||
{ key: 'outgoing_color_space', name: 'Outgoing color space', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.colorSpace, colorSpaceOptions), deviceClass: 'enum', options: colorSpaceOptions },
|
||||
{ key: 'outgoing_bit_depth', name: 'Outgoing bit depth', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.bitDepth, bitDepthOptions), deviceClass: 'enum', options: bitDepthOptions },
|
||||
{ key: 'outgoing_colorimetry', name: 'Outgoing colorimetry', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.colorimetry, colorimetryOptions), deviceClass: 'enum', options: colorimetryOptions },
|
||||
{ key: 'outgoing_black_levels', name: 'Outgoing black levels', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.blackLevels, blackLevelOptions), deviceClass: 'enum', options: blackLevelOptions },
|
||||
{ key: 'aspect_res', name: 'Aspect resolution', value: (snapshotArg) => snapshotArg.display.aspect?.resolution ?? null, enabledDefault: false },
|
||||
{ key: 'aspect_dec', name: 'Aspect decimal', value: (snapshotArg) => snapshotArg.display.aspect?.decimal ?? null },
|
||||
{ key: 'aspect_int', name: 'Aspect integer', value: (snapshotArg) => snapshotArg.display.aspect?.integer ?? null, enabledDefault: false },
|
||||
{ key: 'aspect_name', name: 'Aspect name', value: (snapshotArg) => snapshotArg.display.aspect?.name ?? null, enabledDefault: false },
|
||||
{ key: 'masking_res', name: 'Masking resolution', value: (snapshotArg) => snapshotArg.display.masking?.resolution ?? null, enabledDefault: false },
|
||||
{ key: 'masking_dec', name: 'Masking decimal', value: (snapshotArg) => snapshotArg.display.masking?.decimal ?? null },
|
||||
{ key: 'masking_int', name: 'Masking integer', value: (snapshotArg) => snapshotArg.display.masking?.integer ?? null, enabledDefault: false },
|
||||
];
|
||||
|
||||
const binarySensors: IBinarySensorDescription[] = [
|
||||
{ key: 'power_state', name: 'Power state', value: (snapshotArg) => snapshotArg.processor.isOn === true, icon: 'mdi:power' },
|
||||
{ key: 'signal_state', name: 'Signal state', value: (snapshotArg) => snapshotArg.processor.isSignal === true, icon: 'mdi:signal' },
|
||||
{ key: 'hdr_flag', name: 'HDR flag', value: (snapshotArg) => snapshotArg.display.hdrFlag === true, icon: 'mdi:hdr' },
|
||||
{ key: 'outgoing_hdr_flag', name: 'Outgoing HDR flag', value: (snapshotArg) => snapshotArg.display.outgoingHdrFlag === true, icon: 'mdi:hdr' },
|
||||
];
|
||||
|
||||
export class MadvrMapper {
|
||||
public static toDevices(snapshotArg: IMadvrSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'signal', capability: 'sensor', name: 'Signal', readable: true, writable: false },
|
||||
{ id: 'incoming_res', capability: 'media', name: 'Incoming resolution', readable: true, writable: false },
|
||||
{ id: 'outgoing_res', capability: 'media', name: 'Outgoing resolution', readable: true, writable: false },
|
||||
{ id: 'aspect_ratio', capability: 'sensor', name: 'Aspect ratio', readable: true, writable: false },
|
||||
{ id: 'masking_ratio', capability: 'sensor', name: 'Masking ratio', readable: true, writable: false },
|
||||
{ id: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'power', value: this.powerState(snapshotArg), updatedAt },
|
||||
{ featureId: 'signal', value: snapshotArg.processor.isSignal ?? null, updatedAt },
|
||||
{ featureId: 'incoming_res', value: snapshotArg.processor.incoming?.resolution ?? null, updatedAt },
|
||||
{ featureId: 'outgoing_res', value: snapshotArg.processor.outgoing?.resolution ?? null, updatedAt },
|
||||
{ featureId: 'aspect_ratio', value: this.ratioLabel(snapshotArg.display.aspect), updatedAt },
|
||||
{ featureId: 'masking_ratio', value: this.ratioLabel(snapshotArg.display.masking), updatedAt },
|
||||
];
|
||||
|
||||
for (const sensor of sensors) {
|
||||
if (!features.some((featureArg) => featureArg.id === sensor.key)) {
|
||||
features.push({ id: sensor.key, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit });
|
||||
state.push({ featureId: sensor.key, value: this.deviceStateValue(sensor.value(snapshotArg)), updatedAt });
|
||||
}
|
||||
}
|
||||
for (const button of snapshotArg.buttons || []) {
|
||||
features.push({ id: `button_${button.key}`, capability: 'media', name: button.name, readable: false, writable: true });
|
||||
}
|
||||
for (const select of snapshotArg.selects || []) {
|
||||
features.push({ id: `select_${select.key}`, capability: 'media', name: select.name, readable: true, writable: true });
|
||||
state.push({ featureId: `select_${select.key}`, value: select.current ?? null, updatedAt });
|
||||
}
|
||||
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: madvrDomain,
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || madvrManufacturer,
|
||||
model: snapshotArg.deviceInfo.model || 'Envy',
|
||||
online: this.available(snapshotArg) && this.powerState(snapshotArg) !== 'off',
|
||||
features,
|
||||
state,
|
||||
metadata: this.cleanAttributes({
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
port: snapshotArg.deviceInfo.port,
|
||||
macAddress: snapshotArg.deviceInfo.macAddress,
|
||||
serialNumber: snapshotArg.deviceInfo.serialNumber,
|
||||
firmwareVersion: snapshotArg.deviceInfo.firmwareVersion,
|
||||
processor: snapshotArg.processor,
|
||||
projector: snapshotArg.projector,
|
||||
display: snapshotArg.display,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IMadvrSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
const usedIds = new Map<string, number>();
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const name = this.deviceName(snapshotArg);
|
||||
const available = this.available(snapshotArg);
|
||||
|
||||
entities.push(this.entity('media_player', name, deviceId, this.uniqueId(snapshotArg, 'remote'), this.mediaState(snapshotArg), usedIds, {
|
||||
deviceClass: 'receiver',
|
||||
power: this.powerState(snapshotArg),
|
||||
isOn: snapshotArg.processor.isOn,
|
||||
isSignal: snapshotArg.processor.isSignal,
|
||||
incoming: snapshotArg.processor.incoming,
|
||||
outgoing: snapshotArg.processor.outgoing,
|
||||
temperatures: snapshotArg.processor.temperatures,
|
||||
aspect: snapshotArg.display.aspect,
|
||||
masking: snapshotArg.display.masking,
|
||||
hdrFlag: snapshotArg.display.hdrFlag,
|
||||
outgoingHdrFlag: snapshotArg.display.outgoingHdrFlag,
|
||||
profileName: snapshotArg.processor.profileName || snapshotArg.projector?.profileName,
|
||||
profileNumber: snapshotArg.processor.profileNumber || snapshotArg.projector?.profileNumber,
|
||||
macAddress: snapshotArg.deviceInfo.macAddress,
|
||||
source: snapshotArg.processor.incoming?.resolution,
|
||||
mediaTitle: this.mediaTitle(snapshotArg),
|
||||
assumedState: true,
|
||||
lastError: snapshotArg.processor.lastError,
|
||||
}, available));
|
||||
|
||||
for (const sensor of sensors) {
|
||||
entities.push(this.entity('sensor', `${name} ${sensor.name}`, deviceId, this.uniqueId(snapshotArg, sensor.key), sensor.value(snapshotArg) ?? null, usedIds, {
|
||||
deviceClass: sensor.deviceClass,
|
||||
stateClass: sensor.stateClass,
|
||||
unit: sensor.unit,
|
||||
options: sensor.options,
|
||||
enabledDefault: sensor.enabledDefault,
|
||||
}, available));
|
||||
}
|
||||
|
||||
for (const binarySensor of binarySensors) {
|
||||
entities.push(this.entity('binary_sensor', `${name} ${binarySensor.name}`, deviceId, this.uniqueId(snapshotArg, binarySensor.key), binarySensor.value(snapshotArg), usedIds, {
|
||||
icon: binarySensor.icon,
|
||||
}, available));
|
||||
}
|
||||
|
||||
for (const button of snapshotArg.buttons || []) {
|
||||
entities.push(this.buttonEntity(snapshotArg, button, deviceId, name, usedIds, available));
|
||||
}
|
||||
|
||||
for (const select of snapshotArg.selects || []) {
|
||||
entities.push(this.selectEntity(snapshotArg, select, deviceId, name, usedIds, available));
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static findTargetEntity(snapshotArg: IMadvrSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||
const entities = this.toEntities(snapshotArg);
|
||||
if (requestArg.target.entityId) {
|
||||
return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
|
||||
}
|
||||
if (requestArg.target.deviceId) {
|
||||
return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain)
|
||||
|| entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.attributes?.writable === true);
|
||||
}
|
||||
return entities.find((entityArg) => entityArg.platform === requestArg.domain);
|
||||
}
|
||||
|
||||
public static commandFromWritableEntity(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): IMadvrCommandRequest | undefined {
|
||||
if (entityArg.platform === 'button' && requestArg.service === 'press' && isCommandRequest(entityArg.attributes?.madvrCommand)) {
|
||||
return entityArg.attributes.madvrCommand;
|
||||
}
|
||||
if (entityArg.platform === 'select' && requestArg.service === 'select_option') {
|
||||
const option = typeof requestArg.data?.option === 'string' ? requestArg.data.option : undefined;
|
||||
if (!option) {
|
||||
return undefined;
|
||||
}
|
||||
const commands = entityArg.attributes?.madvrSelectCommands;
|
||||
if (isCommandMap(commands) && isCommandRequest(commands[option])) {
|
||||
return commands[option];
|
||||
}
|
||||
if (isCommandRequest(entityArg.attributes?.madvrCommand)) {
|
||||
return { ...entityArg.attributes.madvrCommand, args: [...(entityArg.attributes.madvrCommand.args || []), option] };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IMadvrSnapshot): string {
|
||||
return `madvr.device.${this.slug(this.identity(snapshotArg))}`;
|
||||
}
|
||||
|
||||
public static uniqueId(snapshotArg: IMadvrSnapshot, keyArg: string): string {
|
||||
return `madvr_${this.slug(this.identity(snapshotArg))}_${this.slug(keyArg)}`;
|
||||
}
|
||||
|
||||
private static buttonEntity(snapshotArg: IMadvrSnapshot, buttonArg: IMadvrButtonRecord, deviceIdArg: string, nameArg: string, usedIdsArg: Map<string, number>, availableArg: boolean): IIntegrationEntity {
|
||||
return this.entity('button', `${nameArg} ${buttonArg.name}`, deviceIdArg, this.uniqueId(snapshotArg, `button_${buttonArg.key}`), 'idle', usedIdsArg, {
|
||||
writable: true,
|
||||
entityCategory: buttonArg.entityCategory,
|
||||
icon: buttonArg.icon,
|
||||
madvrCommand: normalizeEntityCommand(buttonArg.command),
|
||||
}, availableArg && buttonArg.available !== false);
|
||||
}
|
||||
|
||||
private static selectEntity(snapshotArg: IMadvrSnapshot, selectArg: IMadvrSelectRecord, deviceIdArg: string, nameArg: string, usedIdsArg: Map<string, number>, availableArg: boolean): IIntegrationEntity {
|
||||
return this.entity('select', `${nameArg} ${selectArg.name}`, deviceIdArg, this.uniqueId(snapshotArg, `select_${selectArg.key}`), selectArg.current ?? 'unknown', usedIdsArg, {
|
||||
writable: true,
|
||||
entityCategory: selectArg.entityCategory,
|
||||
options: selectArg.options,
|
||||
madvrCommand: selectArg.command ? normalizeEntityCommand(selectArg.command) : undefined,
|
||||
madvrSelectCommands: selectArg.commands ? Object.fromEntries(Object.entries(selectArg.commands).map(([keyArg, commandArg]) => [keyArg, normalizeEntityCommand(commandArg)])) : undefined,
|
||||
}, availableArg && selectArg.available !== false);
|
||||
}
|
||||
|
||||
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
|
||||
const baseId = `${platformArg}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`;
|
||||
const seen = usedIdsArg.get(baseId) || 0;
|
||||
usedIdsArg.set(baseId, seen + 1);
|
||||
return {
|
||||
id: seen ? `${baseId}_${seen + 1}` : baseId,
|
||||
uniqueId: uniqueIdArg,
|
||||
integrationDomain: madvrDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: this.cleanAttributes(attributesArg),
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static mediaState(snapshotArg: IMadvrSnapshot): string {
|
||||
if (!this.available(snapshotArg)) {
|
||||
return 'unavailable';
|
||||
}
|
||||
const power = this.powerState(snapshotArg);
|
||||
if (power === 'off') {
|
||||
return 'off';
|
||||
}
|
||||
if (power === 'unknown') {
|
||||
return 'unknown';
|
||||
}
|
||||
return snapshotArg.processor.isSignal === false ? 'idle' : 'on';
|
||||
}
|
||||
|
||||
private static powerState(snapshotArg: IMadvrSnapshot): TMadvrPowerState {
|
||||
if (snapshotArg.processor.power) {
|
||||
return snapshotArg.processor.power;
|
||||
}
|
||||
if (snapshotArg.processor.isOn === true) {
|
||||
return 'on';
|
||||
}
|
||||
if (snapshotArg.processor.isOn === false) {
|
||||
return 'off';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private static available(snapshotArg: IMadvrSnapshot): boolean {
|
||||
return snapshotArg.available !== false;
|
||||
}
|
||||
|
||||
private static mediaTitle(snapshotArg: IMadvrSnapshot): string | undefined {
|
||||
const incoming = this.signalLabel(snapshotArg.processor.incoming);
|
||||
const outgoing = this.signalLabel(snapshotArg.processor.outgoing);
|
||||
if (incoming && outgoing) {
|
||||
return `${incoming} to ${outgoing}`;
|
||||
}
|
||||
return incoming || outgoing;
|
||||
}
|
||||
|
||||
private static signalLabel(signalArg: IMadvrSignalState | undefined): string | undefined {
|
||||
if (!signalArg?.resolution) {
|
||||
return undefined;
|
||||
}
|
||||
return [signalArg.resolution, signalArg.frameRate, signalArg.colorimetry].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
private static ratioLabel(ratioArg: IMadvrRatioState | undefined): string | null {
|
||||
if (!ratioArg) {
|
||||
return null;
|
||||
}
|
||||
return ratioArg.name || ratioArg.resolution || (typeof ratioArg.decimal === 'number' ? String(ratioArg.decimal) : null);
|
||||
}
|
||||
|
||||
private static identity(snapshotArg: IMadvrSnapshot): string {
|
||||
return snapshotArg.deviceInfo.macAddress
|
||||
|| snapshotArg.deviceInfo.serialNumber
|
||||
|| snapshotArg.deviceInfo.host
|
||||
|| snapshotArg.deviceInfo.name
|
||||
|| 'madvr-envy';
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IMadvrSnapshot): string {
|
||||
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || 'madVR Envy';
|
||||
}
|
||||
|
||||
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
return valueArg === undefined ? null : String(valueArg);
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
|
||||
}
|
||||
|
||||
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'madvr';
|
||||
}
|
||||
}
|
||||
|
||||
const validTemperature = (valueArg: number | undefined): number | null => {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : null;
|
||||
};
|
||||
|
||||
const normalizeEntityCommand = (valueArg: unknown): IMadvrCommandRequest | undefined => {
|
||||
if (typeof valueArg === 'string') {
|
||||
const [command, ...args] = valueArg.split(',').map((partArg) => partArg.trim()).filter(Boolean);
|
||||
return command ? { command, args } : undefined;
|
||||
}
|
||||
if (Array.isArray(valueArg)) {
|
||||
const [command, ...args] = valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg));
|
||||
return command ? { command, args } : undefined;
|
||||
}
|
||||
if (isCommandRequest(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isCommandRequest = (valueArg: unknown): valueArg is IMadvrCommandRequest => {
|
||||
return typeof valueArg === 'object' && valueArg !== null && typeof (valueArg as IMadvrCommandRequest).command === 'string';
|
||||
};
|
||||
|
||||
const isCommandMap = (valueArg: unknown): valueArg is Record<string, IMadvrCommandRequest> => {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||
};
|
||||
@@ -1,4 +1,306 @@
|
||||
export interface IHomeAssistantMadvrConfig {
|
||||
// TODO: replace with the TypeScript-native config for madvr.
|
||||
export const madvrDefaultPort = 44077;
|
||||
|
||||
export type TMadvrPowerState = 'on' | 'off' | 'unknown';
|
||||
|
||||
export type TMadvrCommandName =
|
||||
| 'PowerOn'
|
||||
| 'PowerOff'
|
||||
| 'Standby'
|
||||
| 'Restart'
|
||||
| 'ReloadSoftware'
|
||||
| 'Bye'
|
||||
| 'ResetTemporary'
|
||||
| 'ActivateProfile'
|
||||
| 'OpenMenu'
|
||||
| 'CloseMenu'
|
||||
| 'KeyPress'
|
||||
| 'KeyHold'
|
||||
| 'GetIncomingSignalInfo'
|
||||
| 'GetOutgoingSignalInfo'
|
||||
| 'GetAspectRatio'
|
||||
| 'GetMaskingRatio'
|
||||
| 'GetTemperatures'
|
||||
| 'GetMacAddress'
|
||||
| 'Toggle'
|
||||
| 'ToneMapOn'
|
||||
| 'ToneMapOff'
|
||||
| 'Hotplug'
|
||||
| 'RefreshLicenseInfo'
|
||||
| 'Force1080p60Output';
|
||||
|
||||
export type TMadvrRemoteKey =
|
||||
| 'MENU'
|
||||
| 'UP'
|
||||
| 'DOWN'
|
||||
| 'LEFT'
|
||||
| 'RIGHT'
|
||||
| 'OK'
|
||||
| 'INPUT'
|
||||
| 'SETTINGS'
|
||||
| 'RED'
|
||||
| 'GREEN'
|
||||
| 'BLUE'
|
||||
| 'YELLOW'
|
||||
| 'POWER';
|
||||
|
||||
export type TMadvrMenu = 'Info' | 'Settings' | 'Configuration' | 'Profiles' | 'TestPatterns';
|
||||
|
||||
export type TMadvrProfileGroup = 'SOURCE' | 'DISPLAY' | 'CUSTOM';
|
||||
|
||||
export type TMadvrToggle =
|
||||
| 'ToneMap'
|
||||
| 'HighlightRecovery'
|
||||
| 'ContrastRecovery'
|
||||
| 'ShadowRecovery'
|
||||
| '3DLUT'
|
||||
| 'ScreenBoundaries'
|
||||
| 'Histogram'
|
||||
| 'DebugOSD';
|
||||
|
||||
export interface IMadvrSignalState {
|
||||
resolution?: string;
|
||||
frameRate?: string;
|
||||
signalType?: '2D' | '3D' | string;
|
||||
colorSpace?: 'RGB' | '444' | '422' | '420' | string;
|
||||
bitDepth?: '8bit' | '10bit' | '12bit' | string;
|
||||
hdr?: boolean;
|
||||
colorimetry?: 'SDR' | 'HDR10' | 'HLG 601' | 'PAL' | '709' | 'DCI' | '2020' | string;
|
||||
blackLevels?: 'TV' | 'PC' | string;
|
||||
aspectRatio?: '16:9' | '4:3' | string;
|
||||
}
|
||||
|
||||
export interface IMadvrTemperatures {
|
||||
gpu?: number;
|
||||
hdmi?: number;
|
||||
cpu?: number;
|
||||
mainboard?: number;
|
||||
}
|
||||
|
||||
export interface IMadvrRatioState {
|
||||
resolution?: string;
|
||||
decimal?: number;
|
||||
integer?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface IMadvrProcessorState {
|
||||
isOn?: boolean;
|
||||
isSignal?: boolean;
|
||||
power?: TMadvrPowerState;
|
||||
incoming?: IMadvrSignalState;
|
||||
outgoing?: IMadvrSignalState;
|
||||
temperatures?: IMadvrTemperatures;
|
||||
profileName?: string;
|
||||
profileNumber?: string;
|
||||
lastError?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMadvrProjectorState {
|
||||
name?: string;
|
||||
activeProfile?: string;
|
||||
profileName?: string;
|
||||
profileNumber?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMadvrDisplayState {
|
||||
hdrFlag?: boolean;
|
||||
outgoingHdrFlag?: boolean;
|
||||
aspect?: IMadvrRatioState;
|
||||
masking?: IMadvrRatioState;
|
||||
activeProfile?: string;
|
||||
}
|
||||
|
||||
export interface IMadvrDeviceInfo {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
macAddress?: string;
|
||||
serialNumber?: string;
|
||||
firmwareVersion?: string;
|
||||
}
|
||||
|
||||
export interface IMadvrRawState {
|
||||
is_on?: boolean;
|
||||
isOn?: boolean;
|
||||
is_signal?: boolean;
|
||||
isSignal?: boolean;
|
||||
hdr_flag?: boolean;
|
||||
hdrFlag?: boolean;
|
||||
outgoing_hdr_flag?: boolean;
|
||||
outgoingHdrFlag?: boolean;
|
||||
mac_address?: string;
|
||||
macAddress?: string;
|
||||
temp_gpu?: string | number | null;
|
||||
tempGpu?: string | number | null;
|
||||
temp_hdmi?: string | number | null;
|
||||
tempHdmi?: string | number | null;
|
||||
temp_cpu?: string | number | null;
|
||||
tempCpu?: string | number | null;
|
||||
temp_mainboard?: string | number | null;
|
||||
tempMainboard?: string | number | null;
|
||||
incoming_res?: string;
|
||||
incomingRes?: string;
|
||||
incoming_signal_type?: string;
|
||||
incomingSignalType?: string;
|
||||
incoming_frame_rate?: string;
|
||||
incomingFrameRate?: string;
|
||||
incoming_color_space?: string;
|
||||
incomingColorSpace?: string;
|
||||
incoming_bit_depth?: string;
|
||||
incomingBitDepth?: string;
|
||||
incoming_colorimetry?: string;
|
||||
incomingColorimetry?: string;
|
||||
incoming_black_levels?: string;
|
||||
incomingBlackLevels?: string;
|
||||
incoming_aspect_ratio?: string;
|
||||
incomingAspectRatio?: string;
|
||||
outgoing_res?: string;
|
||||
outgoingRes?: string;
|
||||
outgoing_signal_type?: string;
|
||||
outgoingSignalType?: string;
|
||||
outgoing_frame_rate?: string;
|
||||
outgoingFrameRate?: string;
|
||||
outgoing_color_space?: string;
|
||||
outgoingColorSpace?: string;
|
||||
outgoing_bit_depth?: string;
|
||||
outgoingBitDepth?: string;
|
||||
outgoing_colorimetry?: string;
|
||||
outgoingColorimetry?: string;
|
||||
outgoing_black_levels?: string;
|
||||
outgoingBlackLevels?: string;
|
||||
aspect_res?: string;
|
||||
aspectRes?: string;
|
||||
aspect_dec?: string | number | null;
|
||||
aspectDec?: string | number | null;
|
||||
aspect_int?: string;
|
||||
aspectInt?: string;
|
||||
aspect_name?: string;
|
||||
aspectName?: string;
|
||||
masking_res?: string;
|
||||
maskingRes?: string;
|
||||
masking_dec?: string | number | null;
|
||||
maskingDec?: string | number | null;
|
||||
masking_int?: string;
|
||||
maskingInt?: string;
|
||||
profile_name?: string;
|
||||
profileName?: string;
|
||||
profile_num?: string | number;
|
||||
profileNumber?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMadvrCommandRequest {
|
||||
command: TMadvrCommandName | string;
|
||||
args?: string[];
|
||||
source?: string;
|
||||
target?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TMadvrCommandInput = string | string[] | IMadvrCommandRequest;
|
||||
|
||||
export interface IMadvrCommandModel {
|
||||
request: IMadvrCommandRequest;
|
||||
transport: 'tcp' | 'wake_on_lan';
|
||||
wireCommand?: string;
|
||||
informational: boolean;
|
||||
requiresConnection: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IMadvrCommandResult {
|
||||
executed: boolean;
|
||||
command: IMadvrCommandModel;
|
||||
transport?: 'tcp' | 'wake_on_lan' | 'executor';
|
||||
response?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface IMadvrCommandExecutor {
|
||||
execute(commandArg: IMadvrCommandRequest, modelArg: IMadvrCommandModel): Promise<IMadvrCommandResult | string | void>;
|
||||
getSnapshot?(): Promise<IMadvrSnapshot>;
|
||||
destroy?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IMadvrButtonRecord {
|
||||
key: string;
|
||||
name: string;
|
||||
command: TMadvrCommandInput;
|
||||
entityCategory?: string;
|
||||
icon?: string;
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
export interface IMadvrSelectRecord {
|
||||
key: string;
|
||||
name: string;
|
||||
current?: string;
|
||||
options: string[];
|
||||
command?: TMadvrCommandInput;
|
||||
commands?: Record<string, TMadvrCommandInput>;
|
||||
entityCategory?: string;
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
export interface IMadvrSnapshot {
|
||||
deviceInfo: IMadvrDeviceInfo;
|
||||
processor: IMadvrProcessorState;
|
||||
projector?: IMadvrProjectorState;
|
||||
display: IMadvrDisplayState;
|
||||
buttons?: IMadvrButtonRecord[];
|
||||
selects?: IMadvrSelectRecord[];
|
||||
raw?: IMadvrRawState;
|
||||
available?: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IMadvrConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
macAddress?: string;
|
||||
serialNumber?: string;
|
||||
state?: IMadvrRawState;
|
||||
processor?: IMadvrProcessorState;
|
||||
projector?: IMadvrProjectorState;
|
||||
display?: IMadvrDisplayState;
|
||||
buttons?: IMadvrButtonRecord[];
|
||||
selects?: IMadvrSelectRecord[];
|
||||
snapshot?: IMadvrSnapshot;
|
||||
commandExecutor?: IMadvrCommandExecutor;
|
||||
connectTimeoutMs?: number;
|
||||
commandTimeoutMs?: number;
|
||||
snapshotCollectMs?: number;
|
||||
wolBroadcastAddress?: string;
|
||||
wolPort?: number;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantMadvrConfig extends IMadvrConfig {}
|
||||
|
||||
export interface IMadvrManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
macAddress?: string;
|
||||
serialNumber?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMadvrMdnsRecord {
|
||||
type?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export type TMadvrDiscoveryRecord = IMadvrManualEntry | IMadvrMdnsRecord;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './openrgb.classes.client.js';
|
||||
export * from './openrgb.classes.configflow.js';
|
||||
export * from './openrgb.classes.integration.js';
|
||||
export * from './openrgb.discovery.js';
|
||||
export * from './openrgb.mapper.js';
|
||||
export * from './openrgb.types.js';
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { OpenRGBMapper } from './openrgb.mapper.js';
|
||||
import type {
|
||||
IOpenRGBClientCommand,
|
||||
IOpenRGBCommandResult,
|
||||
IOpenRGBConfig,
|
||||
IOpenRGBDevice,
|
||||
IOpenRGBDeviceMetadata,
|
||||
IOpenRGBEvent,
|
||||
IOpenRGBLed,
|
||||
IOpenRGBMode,
|
||||
IOpenRGBRgbColor,
|
||||
IOpenRGBSegment,
|
||||
IOpenRGBSnapshot,
|
||||
IOpenRGBZone,
|
||||
TOpenRGBCommandOperation,
|
||||
} from './openrgb.types.js';
|
||||
import { openrgbDefaultClientName, openrgbDefaultPort } from './openrgb.types.js';
|
||||
|
||||
const protocolVersion = 4;
|
||||
const headerSize = 16;
|
||||
const packetTypes = {
|
||||
requestControllerCount: 0,
|
||||
requestControllerData: 1,
|
||||
requestProtocolVersion: 40,
|
||||
setClientName: 50,
|
||||
requestProfileList: 150,
|
||||
requestLoadProfile: 152,
|
||||
updateLeds: 1050,
|
||||
updateZoneLeds: 1051,
|
||||
updateMode: 1101,
|
||||
};
|
||||
|
||||
type TOpenRGBEventHandler = (eventArg: IOpenRGBEvent) => void;
|
||||
|
||||
export class OpenRGBClient {
|
||||
private readonly events: IOpenRGBEvent[] = [];
|
||||
private readonly eventHandlers = new Set<TOpenRGBEventHandler>();
|
||||
|
||||
constructor(private readonly config: IOpenRGBConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IOpenRGBSnapshot> {
|
||||
if (this.config.host) {
|
||||
try {
|
||||
const snapshot = await this.readLiveSnapshot();
|
||||
this.emit({ type: 'snapshot', data: { deviceCount: snapshot.devices.length }, timestamp: Date.now() });
|
||||
return OpenRGBMapper.toSnapshot({ ...this.config, snapshot, devices: undefined, device: undefined, manualEntries: undefined }, true, this.events);
|
||||
} catch (error) {
|
||||
this.emit({ type: 'error', data: { message: this.errorMessage(error) }, timestamp: Date.now() });
|
||||
return OpenRGBMapper.toSnapshot(this.config, false, this.events);
|
||||
}
|
||||
}
|
||||
return OpenRGBMapper.toSnapshot(this.config, undefined, this.events);
|
||||
}
|
||||
|
||||
public onEvent(handlerArg: TOpenRGBEventHandler): () => void {
|
||||
this.eventHandlers.add(handlerArg);
|
||||
return () => this.eventHandlers.delete(handlerArg);
|
||||
}
|
||||
|
||||
public async sendCommand(commandArg: IOpenRGBClientCommand): Promise<IOpenRGBCommandResult> {
|
||||
this.emit({
|
||||
type: 'command_mapped',
|
||||
command: commandArg,
|
||||
deviceId: commandArg.deviceId,
|
||||
entityId: commandArg.entityId,
|
||||
deviceKey: commandArg.deviceKey,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
let result: IOpenRGBCommandResult;
|
||||
if (this.config.commandExecutor) {
|
||||
result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
|
||||
} else if (this.config.host) {
|
||||
try {
|
||||
const sdk = new OpenRGBSdkClient(this.config);
|
||||
await sdk.connect();
|
||||
try {
|
||||
await sdk.executeCommand(commandArg);
|
||||
} finally {
|
||||
await sdk.disconnect();
|
||||
}
|
||||
result = { success: true, data: { command: commandArg } };
|
||||
} catch (error) {
|
||||
result = { success: false, error: this.errorMessage(error), data: { command: commandArg } };
|
||||
}
|
||||
} else {
|
||||
result = {
|
||||
success: false,
|
||||
error: this.unsupportedLiveControlMessage(),
|
||||
data: { command: commandArg },
|
||||
};
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: result.success ? 'command_executed' : 'command_failed',
|
||||
command: commandArg,
|
||||
data: result,
|
||||
deviceId: commandArg.deviceId,
|
||||
entityId: commandArg.entityId,
|
||||
deviceKey: commandArg.deviceKey,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.eventHandlers.clear();
|
||||
}
|
||||
|
||||
private async readLiveSnapshot(): Promise<IOpenRGBSnapshot> {
|
||||
const sdk = new OpenRGBSdkClient(this.config);
|
||||
await sdk.connect();
|
||||
try {
|
||||
return await sdk.readSnapshot();
|
||||
} finally {
|
||||
await sdk.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private emit(eventArg: IOpenRGBEvent): void {
|
||||
this.events.push(eventArg);
|
||||
for (const handler of this.eventHandlers) {
|
||||
handler(eventArg);
|
||||
}
|
||||
}
|
||||
|
||||
private commandResult(resultArg: unknown, commandArg: IOpenRGBClientCommand): IOpenRGBCommandResult {
|
||||
if (this.isCommandResult(resultArg)) {
|
||||
return resultArg;
|
||||
}
|
||||
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||
}
|
||||
|
||||
private isCommandResult(valueArg: unknown): valueArg is IOpenRGBCommandResult {
|
||||
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||
}
|
||||
|
||||
private unsupportedLiveControlMessage(): string {
|
||||
return 'OpenRGB live SDK control requires a configured host, or commandExecutor for snapshot/manual configs.';
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenRGBSdkClient {
|
||||
private socket?: plugins.net.Socket;
|
||||
private chunks: Buffer[] = [];
|
||||
private bufferedLength = 0;
|
||||
private pending?: {
|
||||
length: number;
|
||||
resolve: (valueArg: Buffer) => void;
|
||||
reject: (errorArg: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
private activeProtocolVersion = protocolVersion;
|
||||
|
||||
constructor(private readonly config: IOpenRGBConfig) {}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
const host = this.config.host;
|
||||
if (!host) {
|
||||
throw new Error('OpenRGB SDK host is required.');
|
||||
}
|
||||
const port = this.config.port || openrgbDefaultPort;
|
||||
const timeoutMs = this.timeoutMs();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const socket = plugins.net.createConnection({ host, port });
|
||||
const finish = (errorArg?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.removeListener('connect', onConnect);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
if (errorArg) {
|
||||
socket.destroy();
|
||||
reject(errorArg);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
const onConnect = () => finish();
|
||||
const onError = (errorArg: Error) => finish(errorArg);
|
||||
const onTimeout = () => finish(new Error(`Timed out connecting to OpenRGB SDK server at ${host}:${port}.`));
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once('connect', onConnect);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
this.socket = socket;
|
||||
});
|
||||
|
||||
this.socket?.setTimeout(0);
|
||||
this.socket?.on('data', (chunkArg) => this.onData(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)));
|
||||
this.socket?.on('error', (errorArg) => this.rejectPending(errorArg));
|
||||
this.socket?.on('close', () => this.rejectPending(new Error('OpenRGB SDK connection closed.')));
|
||||
await this.negotiateProtocolVersion();
|
||||
await this.setClientName();
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
const socket = this.socket;
|
||||
this.socket = undefined;
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
this.rejectPending(new Error('OpenRGB SDK connection closed.'));
|
||||
}
|
||||
|
||||
public async readSnapshot(): Promise<IOpenRGBSnapshot> {
|
||||
const countPacket = await this.sendAndRead(0, packetTypes.requestControllerCount);
|
||||
this.assertPacketType(countPacket, packetTypes.requestControllerCount);
|
||||
const controllerCount = countPacket.payload.readUInt32LE(0);
|
||||
const devices: IOpenRGBDevice[] = [];
|
||||
for (let index = 0; index < controllerCount; index++) {
|
||||
const requestPayload = Buffer.alloc(4);
|
||||
requestPayload.writeUInt32LE(this.activeProtocolVersion, 0);
|
||||
const dataPacket = await this.sendAndRead(index, packetTypes.requestControllerData, requestPayload);
|
||||
this.assertPacketType(dataPacket, packetTypes.requestControllerData);
|
||||
devices.push(this.parseControllerData(dataPacket.payload, index));
|
||||
}
|
||||
|
||||
const profiles = this.activeProtocolVersion >= 2 ? await this.readProfiles().catch(() => []) : [];
|
||||
return {
|
||||
connected: true,
|
||||
host: this.config.host,
|
||||
port: this.config.port || openrgbDefaultPort,
|
||||
name: this.config.name || 'OpenRGB SDK Server',
|
||||
protocolVersion: this.activeProtocolVersion,
|
||||
controller: {
|
||||
id: this.config.entryId || `${this.config.host}:${this.config.port || openrgbDefaultPort}`,
|
||||
name: this.config.name || 'OpenRGB SDK Server',
|
||||
host: this.config.host,
|
||||
port: this.config.port || openrgbDefaultPort,
|
||||
protocolVersion: this.activeProtocolVersion,
|
||||
manufacturer: 'OpenRGB',
|
||||
model: 'OpenRGB SDK Server',
|
||||
softwareVersion: `${this.activeProtocolVersion} (Protocol)`,
|
||||
},
|
||||
devices,
|
||||
profiles,
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
public async executeCommand(commandArg: IOpenRGBClientCommand): Promise<void> {
|
||||
for (const operation of commandArg.operations) {
|
||||
await this.executeOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeOperation(operationArg: TOpenRGBCommandOperation): Promise<void> {
|
||||
if (operationArg.action === 'setMode') {
|
||||
if (typeof operationArg.deviceIndex !== 'number') {
|
||||
throw new Error('OpenRGB setMode operation requires a numeric device index for live SDK writes.');
|
||||
}
|
||||
if (!operationArg.mode) {
|
||||
throw new Error(`OpenRGB setMode operation for ${operationArg.modeName} requires complete mode data.`);
|
||||
}
|
||||
const payload = this.packMode(operationArg.mode);
|
||||
await this.sendPacket(operationArg.deviceIndex, packetTypes.updateMode, payload);
|
||||
return;
|
||||
}
|
||||
if (operationArg.action === 'setColor') {
|
||||
if (typeof operationArg.deviceIndex !== 'number') {
|
||||
throw new Error('OpenRGB setColor operation requires a numeric device index for live SDK writes.');
|
||||
}
|
||||
if (operationArg.ledCount <= 0) {
|
||||
throw new Error('OpenRGB setColor operation requires a positive LED count.');
|
||||
}
|
||||
if (typeof operationArg.zoneIndex === 'number') {
|
||||
await this.sendPacket(operationArg.deviceIndex, packetTypes.updateZoneLeds, this.packZoneColors(operationArg.zoneIndex, operationArg.ledCount, operationArg.rgb));
|
||||
return;
|
||||
}
|
||||
await this.sendPacket(operationArg.deviceIndex, packetTypes.updateLeds, this.packDeviceColors(operationArg.ledCount, operationArg.rgb));
|
||||
return;
|
||||
}
|
||||
if (operationArg.action === 'loadProfile') {
|
||||
await this.sendPacket(0, packetTypes.requestLoadProfile, Buffer.from(`${operationArg.profile}\0`, 'utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
private async negotiateProtocolVersion(): Promise<void> {
|
||||
const payload = Buffer.alloc(4);
|
||||
const requestedVersion = this.config.protocolVersion && this.config.protocolVersion > 0 ? Math.min(this.config.protocolVersion, protocolVersion) : protocolVersion;
|
||||
payload.writeUInt32LE(requestedVersion, 0);
|
||||
const packet = await this.sendAndRead(0, packetTypes.requestProtocolVersion, payload);
|
||||
this.assertPacketType(packet, packetTypes.requestProtocolVersion);
|
||||
const serverVersion = packet.payload.length >= 4 ? packet.payload.readUInt32LE(0) : 0;
|
||||
this.activeProtocolVersion = Math.min(serverVersion || requestedVersion, requestedVersion, protocolVersion);
|
||||
}
|
||||
|
||||
private async setClientName(): Promise<void> {
|
||||
await this.sendPacket(0, packetTypes.setClientName, Buffer.from(`${this.config.clientName || openrgbDefaultClientName}\0`, 'utf8'));
|
||||
}
|
||||
|
||||
private async readProfiles(): Promise<string[]> {
|
||||
const packet = await this.sendAndRead(0, packetTypes.requestProfileList);
|
||||
this.assertPacketType(packet, packetTypes.requestProfileList);
|
||||
if (packet.payload.length <= 4) {
|
||||
return [];
|
||||
}
|
||||
const reader = new OpenRGBBinaryReader(packet.payload.subarray(4));
|
||||
const count = reader.uint16();
|
||||
const profiles: string[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
profiles.push(reader.string());
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
private async sendAndRead(deviceIdArg: number, packetTypeArg: number, payloadArg: Buffer = Buffer.alloc(0)): Promise<{ deviceId: number; type: number; payload: Buffer }> {
|
||||
await this.sendPacket(deviceIdArg, packetTypeArg, payloadArg);
|
||||
return this.readPacket();
|
||||
}
|
||||
|
||||
private async sendPacket(deviceIdArg: number, packetTypeArg: number, payloadArg: Buffer = Buffer.alloc(0)): Promise<void> {
|
||||
const socket = this.socket;
|
||||
if (!socket) {
|
||||
throw new Error('OpenRGB SDK socket is not connected.');
|
||||
}
|
||||
const header = Buffer.alloc(headerSize);
|
||||
header.write('ORGB', 0, 'ascii');
|
||||
header.writeUInt32LE(deviceIdArg, 4);
|
||||
header.writeUInt32LE(packetTypeArg, 8);
|
||||
header.writeUInt32LE(payloadArg.length, 12);
|
||||
await this.write(Buffer.concat([header, payloadArg]));
|
||||
}
|
||||
|
||||
private async write(dataArg: Buffer): Promise<void> {
|
||||
const socket = this.socket;
|
||||
if (!socket) {
|
||||
throw new Error('OpenRGB SDK socket is not connected.');
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.write(dataArg, (errorArg) => errorArg ? reject(errorArg) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
private async readPacket(): Promise<{ deviceId: number; type: number; payload: Buffer }> {
|
||||
const header = await this.readExact(headerSize);
|
||||
if (header.subarray(0, 4).toString('ascii') !== 'ORGB') {
|
||||
throw new Error('OpenRGB SDK response did not contain an ORGB header.');
|
||||
}
|
||||
const deviceId = header.readUInt32LE(4);
|
||||
const type = header.readUInt32LE(8);
|
||||
const size = header.readUInt32LE(12);
|
||||
const payload = size ? await this.readExact(size) : Buffer.alloc(0);
|
||||
return { deviceId, type, payload };
|
||||
}
|
||||
|
||||
private readExact(lengthArg: number): Promise<Buffer> {
|
||||
if (this.bufferedLength >= lengthArg) {
|
||||
return Promise.resolve(this.consume(lengthArg));
|
||||
}
|
||||
if (this.pending) {
|
||||
return Promise.reject(new Error('OpenRGB SDK already has a pending read.'));
|
||||
}
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pending?.length === lengthArg) {
|
||||
this.pending = undefined;
|
||||
reject(new Error(`Timed out waiting for ${lengthArg} bytes from OpenRGB SDK server.`));
|
||||
}
|
||||
}, this.timeoutMs());
|
||||
this.pending = { length: lengthArg, resolve, reject, timer };
|
||||
this.resolvePending();
|
||||
});
|
||||
}
|
||||
|
||||
private onData(chunkArg: Buffer): void {
|
||||
this.chunks.push(chunkArg);
|
||||
this.bufferedLength += chunkArg.length;
|
||||
this.resolvePending();
|
||||
}
|
||||
|
||||
private resolvePending(): void {
|
||||
const pending = this.pending;
|
||||
if (!pending || this.bufferedLength < pending.length) {
|
||||
return;
|
||||
}
|
||||
this.pending = undefined;
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(this.consume(pending.length));
|
||||
}
|
||||
|
||||
private rejectPending(errorArg: Error): void {
|
||||
if (!this.pending) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending;
|
||||
this.pending = undefined;
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(errorArg);
|
||||
}
|
||||
|
||||
private consume(lengthArg: number): Buffer {
|
||||
const output = Buffer.alloc(lengthArg);
|
||||
let offset = 0;
|
||||
while (offset < lengthArg) {
|
||||
const chunk = this.chunks[0];
|
||||
const needed = lengthArg - offset;
|
||||
if (chunk.length <= needed) {
|
||||
chunk.copy(output, offset);
|
||||
offset += chunk.length;
|
||||
this.chunks.shift();
|
||||
} else {
|
||||
chunk.copy(output, offset, 0, needed);
|
||||
this.chunks[0] = chunk.subarray(needed);
|
||||
offset += needed;
|
||||
}
|
||||
}
|
||||
this.bufferedLength -= lengthArg;
|
||||
return output;
|
||||
}
|
||||
|
||||
private parseControllerData(payloadArg: Buffer, indexArg: number): IOpenRGBDevice {
|
||||
const reader = new OpenRGBBinaryReader(payloadArg);
|
||||
reader.uint32();
|
||||
const rawType = reader.int32();
|
||||
const name = reader.string();
|
||||
const metadata = this.parseMetadata(reader);
|
||||
const modeCount = reader.uint16();
|
||||
const activeMode = reader.int32();
|
||||
const modes: IOpenRGBMode[] = [];
|
||||
for (let index = 0; index < modeCount; index++) {
|
||||
modes.push(this.parseMode(reader, index));
|
||||
}
|
||||
const zones = this.parseList(reader, () => this.parseZone(reader));
|
||||
const leds = this.parseList(reader, () => this.parseLed(reader));
|
||||
const colors = this.parseList(reader, () => this.parseColor(reader));
|
||||
let ledOffset = 0;
|
||||
for (const zone of zones) {
|
||||
zone.startIndex = ledOffset;
|
||||
zone.leds = leds.slice(ledOffset, ledOffset + (zone.numLeds || 0));
|
||||
zone.colors = colors.slice(ledOffset, ledOffset + (zone.numLeds || 0));
|
||||
for (let index = 0; index < zone.leds.length; index++) {
|
||||
zone.leds[index].color = zone.colors[index];
|
||||
}
|
||||
ledOffset += zone.numLeds || 0;
|
||||
}
|
||||
for (let index = 0; index < leds.length; index++) {
|
||||
leds[index].color = colors[index];
|
||||
}
|
||||
return {
|
||||
id: indexArg,
|
||||
name,
|
||||
type: rawType,
|
||||
typeName: this.deviceTypeName(rawType),
|
||||
metadata,
|
||||
modes,
|
||||
activeMode,
|
||||
zones,
|
||||
leds,
|
||||
colors,
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseMetadata(readerArg: OpenRGBBinaryReader): IOpenRGBDeviceMetadata {
|
||||
const vendor = this.activeProtocolVersion >= 1 ? readerArg.string() : undefined;
|
||||
return {
|
||||
vendor,
|
||||
description: readerArg.string(),
|
||||
version: readerArg.string(),
|
||||
serial: readerArg.string(),
|
||||
location: readerArg.string(),
|
||||
};
|
||||
}
|
||||
|
||||
private parseMode(readerArg: OpenRGBBinaryReader, indexArg: number): IOpenRGBMode {
|
||||
const name = readerArg.string();
|
||||
const value = readerArg.int32();
|
||||
const flags = readerArg.uint32();
|
||||
const speedMin = readerArg.uint32();
|
||||
const speedMax = readerArg.uint32();
|
||||
const brightnessMin = this.activeProtocolVersion >= 3 ? readerArg.uint32() : undefined;
|
||||
const brightnessMax = this.activeProtocolVersion >= 3 ? readerArg.uint32() : undefined;
|
||||
const colorsMin = readerArg.uint32();
|
||||
const colorsMax = readerArg.uint32();
|
||||
const speed = readerArg.uint32();
|
||||
const brightness = this.activeProtocolVersion >= 3 ? readerArg.uint32() : undefined;
|
||||
const direction = readerArg.uint32();
|
||||
const colorMode = readerArg.uint32();
|
||||
const colors = this.parseList(readerArg, () => this.parseColor(readerArg));
|
||||
return {
|
||||
id: indexArg,
|
||||
name,
|
||||
value,
|
||||
flags,
|
||||
speedMin: flags & (1 << 0) ? speedMin : undefined,
|
||||
speedMax: flags & (1 << 0) ? speedMax : undefined,
|
||||
brightnessMin: flags & (1 << 4) ? brightnessMin : undefined,
|
||||
brightnessMax: flags & (1 << 4) ? brightnessMax : undefined,
|
||||
colorsMin: colors.length ? colorsMin : undefined,
|
||||
colorsMax: colors.length ? colorsMax : undefined,
|
||||
speed: flags & (1 << 0) ? speed : undefined,
|
||||
brightness: flags & (1 << 4) ? brightness : undefined,
|
||||
direction: flags & ((1 << 1) | (1 << 2) | (1 << 3)) ? direction : undefined,
|
||||
colorMode,
|
||||
colors: colors.length ? colors : undefined,
|
||||
supportsColor: colorMode === 1,
|
||||
metadata: { colorModeName: this.colorModeName(colorMode) },
|
||||
};
|
||||
}
|
||||
|
||||
private parseZone(readerArg: OpenRGBBinaryReader): IOpenRGBZone {
|
||||
const name = readerArg.string();
|
||||
const rawType = readerArg.int32();
|
||||
const ledsMin = readerArg.uint32();
|
||||
const ledsMax = readerArg.uint32();
|
||||
const numLeds = readerArg.uint32();
|
||||
const matrixZoneSize = readerArg.uint16();
|
||||
let matrixHeight: number | undefined;
|
||||
let matrixWidth: number | undefined;
|
||||
let matrixMap: Array<Array<number | null>> | undefined;
|
||||
if (rawType === 2) {
|
||||
matrixHeight = readerArg.uint32();
|
||||
matrixWidth = readerArg.uint32();
|
||||
matrixMap = [];
|
||||
for (let y = 0; y < matrixHeight; y++) {
|
||||
const row: Array<number | null> = [];
|
||||
for (let x = 0; x < matrixWidth; x++) {
|
||||
const value = readerArg.uint32();
|
||||
row.push(value === 0xFFFFFFFF ? null : value);
|
||||
}
|
||||
matrixMap.push(row);
|
||||
}
|
||||
} else if (matrixZoneSize > 0) {
|
||||
readerArg.skip(matrixZoneSize);
|
||||
}
|
||||
const segments = this.activeProtocolVersion >= 4 ? this.parseList(readerArg, () => this.parseSegment(readerArg)) : undefined;
|
||||
return {
|
||||
name,
|
||||
type: rawType,
|
||||
typeName: this.zoneTypeName(rawType),
|
||||
ledsMin,
|
||||
ledsMax,
|
||||
numLeds,
|
||||
matrixHeight,
|
||||
matrixWidth,
|
||||
matrixMap,
|
||||
segments,
|
||||
};
|
||||
}
|
||||
|
||||
private parseSegment(readerArg: OpenRGBBinaryReader): IOpenRGBSegment {
|
||||
const name = readerArg.string();
|
||||
const rawType = readerArg.int32();
|
||||
return {
|
||||
name,
|
||||
type: rawType,
|
||||
typeName: this.zoneTypeName(rawType),
|
||||
startIndex: readerArg.uint32(),
|
||||
ledCount: readerArg.uint32(),
|
||||
};
|
||||
}
|
||||
|
||||
private parseLed(readerArg: OpenRGBBinaryReader): IOpenRGBLed {
|
||||
return {
|
||||
name: readerArg.string(),
|
||||
value: readerArg.uint32(),
|
||||
};
|
||||
}
|
||||
|
||||
private parseColor(readerArg: OpenRGBBinaryReader): IOpenRGBRgbColor {
|
||||
const red = readerArg.uint8();
|
||||
const green = readerArg.uint8();
|
||||
const blue = readerArg.uint8();
|
||||
readerArg.skip(1);
|
||||
return { red, green, blue };
|
||||
}
|
||||
|
||||
private parseList<TValue>(readerArg: OpenRGBBinaryReader, parserArg: () => TValue): TValue[] {
|
||||
const count = readerArg.uint16();
|
||||
const values: TValue[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
values.push(parserArg());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private packDeviceColors(ledCountArg: number, colorArg: IOpenRGBRgbColor): Buffer {
|
||||
const body = Buffer.concat([this.uint16(ledCountArg), ...Array.from({ length: ledCountArg }, () => this.packColor(colorArg))]);
|
||||
return Buffer.concat([this.uint32(body.length + 4), body]);
|
||||
}
|
||||
|
||||
private packZoneColors(zoneIndexArg: number, ledCountArg: number, colorArg: IOpenRGBRgbColor): Buffer {
|
||||
const body = Buffer.concat([
|
||||
this.int32(zoneIndexArg),
|
||||
this.uint16(ledCountArg),
|
||||
...Array.from({ length: ledCountArg }, () => this.packColor(colorArg)),
|
||||
]);
|
||||
return Buffer.concat([this.uint32(body.length + 4), body]);
|
||||
}
|
||||
|
||||
private packMode(modeArg: IOpenRGBMode): Buffer {
|
||||
if (typeof modeArg.id !== 'number' && typeof modeArg.value !== 'number') {
|
||||
throw new Error(`OpenRGB mode ${modeArg.name} is missing SDK id/value data.`);
|
||||
}
|
||||
const flags = modeArg.flags || 0;
|
||||
const chunks = [
|
||||
this.int32(modeArg.id ?? modeArg.value ?? 0),
|
||||
this.packString(modeArg.name),
|
||||
this.int32(modeArg.value ?? modeArg.id ?? 0),
|
||||
this.uint32(flags),
|
||||
this.uint32(modeArg.speedMin ?? 0),
|
||||
this.uint32(modeArg.speedMax ?? 0),
|
||||
];
|
||||
if (this.activeProtocolVersion >= 3) {
|
||||
chunks.push(this.uint32(modeArg.brightnessMin ?? 0), this.uint32(modeArg.brightnessMax ?? 0));
|
||||
}
|
||||
chunks.push(
|
||||
this.uint32(modeArg.colorsMin ?? 0),
|
||||
this.uint32(modeArg.colorsMax ?? 0),
|
||||
this.uint32(modeArg.speed ?? 0)
|
||||
);
|
||||
if (this.activeProtocolVersion >= 3) {
|
||||
chunks.push(this.uint32(modeArg.brightness ?? 0));
|
||||
}
|
||||
chunks.push(
|
||||
this.uint32(typeof modeArg.direction === 'number' ? modeArg.direction : 0),
|
||||
this.uint32(this.colorModeValue(modeArg.colorMode)),
|
||||
this.packColorList(modeArg.colors || [])
|
||||
);
|
||||
const body = Buffer.concat(chunks);
|
||||
return Buffer.concat([this.uint32(body.length + 4), body]);
|
||||
}
|
||||
|
||||
private packColorList(colorsArg: IOpenRGBRgbColor[]): Buffer {
|
||||
return Buffer.concat([this.uint16(colorsArg.length), ...colorsArg.map((colorArg) => this.packColor(colorArg))]);
|
||||
}
|
||||
|
||||
private packColor(colorArg: IOpenRGBRgbColor): Buffer {
|
||||
return Buffer.from([
|
||||
this.byte(colorArg.red),
|
||||
this.byte(colorArg.green),
|
||||
this.byte(colorArg.blue),
|
||||
0,
|
||||
]);
|
||||
}
|
||||
|
||||
private packString(valueArg: string): Buffer {
|
||||
const value = Buffer.from(valueArg, 'ascii');
|
||||
return Buffer.concat([this.uint16(value.length + 1), value, Buffer.from([0])]);
|
||||
}
|
||||
|
||||
private assertPacketType(packetArg: { type: number }, typeArg: number): void {
|
||||
if (packetArg.type !== typeArg) {
|
||||
throw new Error(`Unexpected OpenRGB SDK packet type ${packetArg.type}; expected ${typeArg}.`);
|
||||
}
|
||||
}
|
||||
|
||||
private colorModeValue(valueArg: IOpenRGBMode['colorMode']): number {
|
||||
if (typeof valueArg === 'number') {
|
||||
return valueArg;
|
||||
}
|
||||
const value = (valueArg || '').toLowerCase().replace(/[-\s]+/g, '_');
|
||||
if (value === 'per_led' || value === 'perled') return 1;
|
||||
if (value === 'mode_specific') return 2;
|
||||
if (value === 'random') return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private colorModeName(valueArg: number): string {
|
||||
return ['none', 'per_led', 'mode_specific', 'random'][valueArg] || 'unknown';
|
||||
}
|
||||
|
||||
private deviceTypeName(valueArg: number): string {
|
||||
return [
|
||||
'motherboard', 'dram', 'gpu', 'cooler', 'ledstrip', 'keyboard', 'mouse', 'mousemat', 'headset', 'headset_stand',
|
||||
'gamepad', 'light', 'speaker', 'virtual', 'storage', 'case', 'microphone', 'accessory', 'keypad', 'unknown',
|
||||
][valueArg] || 'unknown';
|
||||
}
|
||||
|
||||
private zoneTypeName(valueArg: number): string {
|
||||
return ['single', 'linear', 'matrix'][valueArg] || 'unknown';
|
||||
}
|
||||
|
||||
private int32(valueArg: number): Buffer {
|
||||
const buffer = Buffer.alloc(4);
|
||||
buffer.writeInt32LE(Math.trunc(valueArg), 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private uint32(valueArg: number): Buffer {
|
||||
const buffer = Buffer.alloc(4);
|
||||
buffer.writeUInt32LE(Math.max(0, Math.trunc(valueArg)), 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private uint16(valueArg: number): Buffer {
|
||||
const buffer = Buffer.alloc(2);
|
||||
buffer.writeUInt16LE(Math.max(0, Math.min(0xFFFF, Math.trunc(valueArg))), 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private byte(valueArg: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(valueArg)));
|
||||
}
|
||||
|
||||
private timeoutMs(): number {
|
||||
return typeof this.config.timeoutMs === 'number' && this.config.timeoutMs > 0 ? this.config.timeoutMs : 5000;
|
||||
}
|
||||
}
|
||||
|
||||
class OpenRGBBinaryReader {
|
||||
private offset = 0;
|
||||
|
||||
constructor(private readonly buffer: Buffer) {}
|
||||
|
||||
public uint8(): number {
|
||||
this.ensure(1);
|
||||
const value = this.buffer.readUInt8(this.offset);
|
||||
this.offset += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
public uint16(): number {
|
||||
this.ensure(2);
|
||||
const value = this.buffer.readUInt16LE(this.offset);
|
||||
this.offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
public uint32(): number {
|
||||
this.ensure(4);
|
||||
const value = this.buffer.readUInt32LE(this.offset);
|
||||
this.offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
public int32(): number {
|
||||
this.ensure(4);
|
||||
const value = this.buffer.readInt32LE(this.offset);
|
||||
this.offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
public string(): string {
|
||||
const length = this.uint16();
|
||||
this.ensure(length);
|
||||
const value = this.buffer.subarray(this.offset, this.offset + length).toString('utf8').replace(/\0+$/, '');
|
||||
this.offset += length;
|
||||
return value;
|
||||
}
|
||||
|
||||
public skip(lengthArg: number): void {
|
||||
this.ensure(lengthArg);
|
||||
this.offset += lengthArg;
|
||||
}
|
||||
|
||||
private ensure(lengthArg: number): void {
|
||||
if (this.offset + lengthArg > this.buffer.length) {
|
||||
throw new Error('OpenRGB SDK response ended before parsing completed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IOpenRGBConfig, IOpenRGBController, IOpenRGBSnapshot } from './openrgb.types.js';
|
||||
import { openrgbDefaultClientName, openrgbDefaultPort } from './openrgb.types.js';
|
||||
|
||||
export class OpenRGBConfigFlow implements IConfigFlow<IOpenRGBConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IOpenRGBConfig>> {
|
||||
void contextArg;
|
||||
const defaults = this.defaultsFromCandidate(candidateArg);
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect OpenRGB',
|
||||
description: 'Configure the local OpenRGB SDK server. OpenRGB uses TCP port 6742 by default.',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'host', label: 'Host or IP address', type: 'text', required: true },
|
||||
{ name: 'port', label: 'SDK port', type: 'number' },
|
||||
{ name: 'clientName', label: 'SDK client name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = this.stringValue(valuesArg.host) || defaults.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'Invalid OpenRGB config', error: 'OpenRGB setup requires a host or IP address.' };
|
||||
}
|
||||
const port = this.numberValue(valuesArg.port) || defaults.port || openrgbDefaultPort;
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return { kind: 'error', title: 'Invalid OpenRGB config', error: 'OpenRGB SDK port must be an integer between 1 and 65535.' };
|
||||
}
|
||||
const name = this.stringValue(valuesArg.name) || defaults.name || 'OpenRGB SDK Server';
|
||||
const clientName = this.stringValue(valuesArg.clientName) || defaults.clientName || openrgbDefaultClientName;
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'OpenRGB configured',
|
||||
config: {
|
||||
entryId: defaults.entryId,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
clientName,
|
||||
protocolVersion: defaults.protocolVersion,
|
||||
controller: {
|
||||
...defaults.controller,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
},
|
||||
snapshot: defaults.snapshot,
|
||||
devices: defaults.devices,
|
||||
profiles: defaults.profiles,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): {
|
||||
entryId?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
clientName?: string;
|
||||
protocolVersion?: number;
|
||||
controller?: IOpenRGBController;
|
||||
snapshot?: IOpenRGBSnapshot;
|
||||
devices?: IOpenRGBConfig['devices'];
|
||||
profiles?: string[];
|
||||
} {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = this.isRecord(metadata.snapshot) ? metadata.snapshot as unknown as IOpenRGBSnapshot : undefined;
|
||||
const controller = this.isRecord(metadata.controller) ? metadata.controller as unknown as IOpenRGBController : undefined;
|
||||
const devices = Array.isArray(metadata.devices) ? metadata.devices as IOpenRGBConfig['devices'] : undefined;
|
||||
const profiles = Array.isArray(metadata.profiles) ? metadata.profiles.filter((profileArg): profileArg is string => typeof profileArg === 'string') : undefined;
|
||||
return {
|
||||
entryId: candidateArg.id,
|
||||
host: candidateArg.host || snapshot?.host,
|
||||
port: candidateArg.port || snapshot?.port || openrgbDefaultPort,
|
||||
name: candidateArg.name || snapshot?.name || controller?.name,
|
||||
clientName: this.stringValue(metadata.clientName) || openrgbDefaultClientName,
|
||||
protocolVersion: this.numberValue(metadata.protocolVersion) || snapshot?.protocolVersion,
|
||||
controller,
|
||||
snapshot,
|
||||
devices,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,73 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { OpenRGBClient } from './openrgb.classes.client.js';
|
||||
import { OpenRGBConfigFlow } from './openrgb.classes.configflow.js';
|
||||
import { createOpenRGBDiscoveryDescriptor } from './openrgb.discovery.js';
|
||||
import { OpenRGBMapper } from './openrgb.mapper.js';
|
||||
import type { IOpenRGBConfig } from './openrgb.types.js';
|
||||
|
||||
export class HomeAssistantOpenrgbIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "openrgb",
|
||||
displayName: "OpenRGB",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/openrgb",
|
||||
"upstreamDomain": "openrgb",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "silver",
|
||||
"requirements": [
|
||||
"openrgb-python==0.3.6"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@felipecrs"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class OpenRGBIntegration extends BaseIntegration<IOpenRGBConfig> {
|
||||
public readonly domain = 'openrgb';
|
||||
public readonly displayName = 'OpenRGB';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createOpenRGBDiscoveryDescriptor();
|
||||
public readonly configFlow = new OpenRGBConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/openrgb',
|
||||
upstreamDomain: 'openrgb',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'silver',
|
||||
requirements: ['openrgb-python==0.3.6'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@felipecrs'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/openrgb',
|
||||
protocolDocumentation: 'https://gitlab.com/CalcProgrammer1/OpenRGB#openrgb-sdk',
|
||||
defaultPort: 6742,
|
||||
};
|
||||
|
||||
public async setup(configArg: IOpenRGBConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new OpenRGBRuntime(new OpenRGBClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantOpenrgbIntegration extends OpenRGBIntegration {}
|
||||
|
||||
class OpenRGBRuntime implements IIntegrationRuntime {
|
||||
public domain = 'openrgb';
|
||||
|
||||
constructor(private readonly client: OpenRGBClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return OpenRGBMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return OpenRGBMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(OpenRGBMapper.toIntegrationEvent(eventArg)));
|
||||
await this.client.getSnapshot();
|
||||
return async () => unsubscribe();
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = OpenRGBMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported OpenRGB service mapping: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
return this.client.sendCommand(command);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryProbe, IDiscoveryProbeResult, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IOpenRGBManualDiscoveryRecord } from './openrgb.types.js';
|
||||
import { openrgbDefaultPort } from './openrgb.types.js';
|
||||
|
||||
export class OpenRGBLocalSdkProbe implements IDiscoveryProbe {
|
||||
public id = 'openrgb-local-sdk-probe';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Check the default local OpenRGB SDK endpoint on 127.0.0.1:6742.';
|
||||
|
||||
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
|
||||
if (contextArg.abortSignal?.aborted) {
|
||||
return { candidates: [] };
|
||||
}
|
||||
const reachable = await this.canConnect('127.0.0.1', openrgbDefaultPort, 400);
|
||||
return {
|
||||
candidates: reachable ? [{
|
||||
source: 'manual',
|
||||
integrationDomain: 'openrgb',
|
||||
id: '127.0.0.1:6742',
|
||||
host: '127.0.0.1',
|
||||
port: openrgbDefaultPort,
|
||||
name: 'OpenRGB SDK Server',
|
||||
manufacturer: 'OpenRGB',
|
||||
model: 'OpenRGB SDK Server',
|
||||
metadata: { openrgb: true, discoveryProtocol: 'local-sdk' },
|
||||
}] : [],
|
||||
};
|
||||
}
|
||||
|
||||
private async canConnect(hostArg: string, portArg: number, timeoutMsArg: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const socket = plugins.net.createConnection({ host: hostArg, port: portArg });
|
||||
const finish = (valueArg: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(valueArg);
|
||||
};
|
||||
socket.setTimeout(timeoutMsArg);
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.once('timeout', () => finish(false));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenRGBManualMatcher implements IDiscoveryMatcher<IOpenRGBManualDiscoveryRecord> {
|
||||
public id = 'openrgb-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual OpenRGB SDK setup entries and fixture snapshots.';
|
||||
|
||||
public async matches(inputArg: IOpenRGBManualDiscoveryRecord): Promise<IDiscoveryMatch> {
|
||||
const metadata = inputArg.metadata || {};
|
||||
const snapshot = inputArg.snapshot || metadata.snapshot;
|
||||
const text = [inputArg.integrationDomain, inputArg.name, inputArg.manufacturer, inputArg.model, metadata.manufacturer, metadata.model]
|
||||
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const matched = inputArg.integrationDomain === 'openrgb'
|
||||
|| metadata.openrgb === true
|
||||
|| Boolean(snapshot)
|
||||
|| text.includes('openrgb')
|
||||
|| inputArg.port === openrgbDefaultPort
|
||||
|| Boolean(inputArg.host && !text);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain OpenRGB setup data.' };
|
||||
}
|
||||
const id = inputArg.id || (inputArg.host ? `${inputArg.host}:${inputArg.port || openrgbDefaultPort}` : 'openrgb-manual');
|
||||
return {
|
||||
matched: true,
|
||||
confidence: snapshot ? 'certain' : inputArg.host && inputArg.port === openrgbDefaultPort ? 'high' : inputArg.host ? 'medium' : 'low',
|
||||
reason: snapshot ? 'Manual entry includes an OpenRGB snapshot.' : 'Manual entry can start OpenRGB SDK setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'openrgb',
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port: inputArg.port || openrgbDefaultPort,
|
||||
name: inputArg.name || 'OpenRGB SDK Server',
|
||||
manufacturer: inputArg.manufacturer || 'OpenRGB',
|
||||
model: inputArg.model || 'OpenRGB SDK Server',
|
||||
metadata: {
|
||||
...metadata,
|
||||
openrgb: true,
|
||||
manual: true,
|
||||
controller: inputArg.controller,
|
||||
device: inputArg.device,
|
||||
devices: inputArg.devices,
|
||||
profiles: inputArg.profiles,
|
||||
snapshot,
|
||||
},
|
||||
},
|
||||
metadata: { snapshotConfigured: Boolean(snapshot) },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenRGBCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'openrgb-candidate-validator';
|
||||
public description = 'Validate OpenRGB SDK candidates before local setup.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, metadata.manufacturer, metadata.model]
|
||||
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === 'openrgb'
|
||||
|| metadata.openrgb === true
|
||||
|| metadata.snapshot !== undefined
|
||||
|| text.includes('openrgb')
|
||||
|| candidateArg.port === openrgbDefaultPort;
|
||||
const hasUsableEndpoint = Boolean(candidateArg.host && (!candidateArg.port || this.isValidPort(candidateArg.port)));
|
||||
return {
|
||||
matched: matched && (hasUsableEndpoint || metadata.snapshot !== undefined),
|
||||
confidence: matched && metadata.snapshot !== undefined ? 'certain' : matched && candidateArg.host && candidateArg.port === openrgbDefaultPort ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has OpenRGB SDK metadata or default port information.' : 'Candidate is not OpenRGB.',
|
||||
candidate: matched && (hasUsableEndpoint || metadata.snapshot !== undefined) ? candidateArg : undefined,
|
||||
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || openrgbDefaultPort}` : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
private isValidPort(valueArg: number): boolean {
|
||||
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
}
|
||||
}
|
||||
|
||||
export const createOpenRGBDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'openrgb', displayName: 'OpenRGB' })
|
||||
.addProbe(new OpenRGBLocalSdkProbe())
|
||||
.addMatcher(new OpenRGBManualMatcher())
|
||||
.addValidator(new OpenRGBCandidateValidator());
|
||||
};
|
||||
@@ -0,0 +1,857 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IOpenRGBClientCommand,
|
||||
IOpenRGBConfig,
|
||||
IOpenRGBDevice,
|
||||
IOpenRGBDeviceMetadata,
|
||||
IOpenRGBEvent,
|
||||
IOpenRGBManualEntry,
|
||||
IOpenRGBMode,
|
||||
IOpenRGBRgbColor,
|
||||
IOpenRGBSnapshot,
|
||||
IOpenRGBZone,
|
||||
TOpenRGBCommandOperation,
|
||||
} from './openrgb.types.js';
|
||||
import { openrgbDefaultPort, openrgbUidSeparator } from './openrgb.types.js';
|
||||
|
||||
const openrgbModeOff = 'Off';
|
||||
const openrgbModeStatic = 'Static';
|
||||
const openrgbModeDirect = 'Direct';
|
||||
const openrgbModeCustom = 'Custom';
|
||||
const effectOffModes = new Set([openrgbModeStatic, openrgbModeDirect, openrgbModeCustom].map((modeArg) => modeArg.toLowerCase()));
|
||||
const black: IOpenRGBRgbColor = { red: 0, green: 0, blue: 0 };
|
||||
const white: IOpenRGBRgbColor = { red: 255, green: 255, blue: 255 };
|
||||
|
||||
const deviceTypeNames = [
|
||||
'motherboard',
|
||||
'dram',
|
||||
'gpu',
|
||||
'cooler',
|
||||
'ledstrip',
|
||||
'keyboard',
|
||||
'mouse',
|
||||
'mousemat',
|
||||
'headset',
|
||||
'headset_stand',
|
||||
'gamepad',
|
||||
'light',
|
||||
'speaker',
|
||||
'virtual',
|
||||
'storage',
|
||||
'case',
|
||||
'microphone',
|
||||
'accessory',
|
||||
'keypad',
|
||||
'unknown',
|
||||
];
|
||||
|
||||
const zoneTypeNames = ['single', 'linear', 'matrix'];
|
||||
|
||||
interface IOpenRGBResolvedLight {
|
||||
entity: IIntegrationEntity;
|
||||
device: IOpenRGBDevice;
|
||||
zone?: IOpenRGBZone;
|
||||
info: IOpenRGBLightInfo;
|
||||
}
|
||||
|
||||
interface IOpenRGBLightInfo {
|
||||
activeMode?: IOpenRGBMode;
|
||||
modeName?: string;
|
||||
modeIsOff: boolean;
|
||||
supportsColor: boolean;
|
||||
supportedColorModes: string[];
|
||||
rgbColor?: IOpenRGBRgbColor;
|
||||
brightness?: number;
|
||||
colorMode?: string;
|
||||
isOn: boolean;
|
||||
effect?: string;
|
||||
effectList: string[];
|
||||
effectToMode: Map<string, IOpenRGBMode>;
|
||||
preferredNoEffectMode?: IOpenRGBMode;
|
||||
offMode?: IOpenRGBMode;
|
||||
supportsOffMode: boolean;
|
||||
}
|
||||
|
||||
export class OpenRGBMapper {
|
||||
public static toSnapshot(configArg: IOpenRGBConfig, connectedArg?: boolean, eventsArg: IOpenRGBEvent[] = []): IOpenRGBSnapshot {
|
||||
const source = configArg.snapshot;
|
||||
const host = configArg.host || source?.host;
|
||||
const port = configArg.port || source?.port || openrgbDefaultPort;
|
||||
const name = configArg.name || source?.name || source?.controller?.name || 'OpenRGB SDK Server';
|
||||
const protocolVersion = configArg.protocolVersion || source?.protocolVersion || source?.controller?.protocolVersion;
|
||||
const controller = {
|
||||
...source?.controller,
|
||||
...configArg.controller,
|
||||
id: configArg.controller?.id || source?.controller?.id || configArg.entryId || (host ? `${host}:${port}` : name),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
protocolVersion,
|
||||
manufacturer: configArg.controller?.manufacturer || source?.controller?.manufacturer || 'OpenRGB',
|
||||
model: configArg.controller?.model || source?.controller?.model || 'OpenRGB SDK Server',
|
||||
softwareVersion: configArg.controller?.softwareVersion || source?.controller?.softwareVersion || (protocolVersion === undefined ? undefined : `${protocolVersion} (Protocol)`),
|
||||
};
|
||||
|
||||
const serverKey = configArg.entryId || controller.id || (host ? `${host}:${port}` : name);
|
||||
const devices = this.uniqueDevices([
|
||||
...(source?.devices || []),
|
||||
...(configArg.devices || []),
|
||||
...(configArg.device ? [configArg.device] : []),
|
||||
...this.devicesFromManualEntries(configArg.manualEntries || []),
|
||||
], String(serverKey));
|
||||
|
||||
return {
|
||||
connected: connectedArg ?? source?.connected ?? false,
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
protocolVersion,
|
||||
controller,
|
||||
devices,
|
||||
profiles: [...new Set([...(source?.profiles || []), ...(configArg.profiles || []), ...this.profilesFromManualEntries(configArg.manualEntries || [])])],
|
||||
events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg],
|
||||
metadata: {
|
||||
...source?.metadata,
|
||||
...configArg.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static toDevices(snapshotArg: IOpenRGBSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.controllerDevice(snapshotArg, updatedAt)];
|
||||
for (const device of snapshotArg.devices) {
|
||||
devices.push(this.openrgbDevice(snapshotArg, device, updatedAt));
|
||||
for (const zone of device.zones || []) {
|
||||
devices.push(this.zoneDevice(snapshotArg, device, zone, updatedAt));
|
||||
}
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IOpenRGBSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
const usedIds = new Map<string, number>();
|
||||
if (snapshotArg.profiles.length) {
|
||||
const name = `${this.controllerName(snapshotArg)} Profile`;
|
||||
entities.push(this.entity('select', name, this.controllerDeviceId(snapshotArg), `openrgb_${this.slug(this.controllerDeviceId(snapshotArg))}_profile`, null, usedIds, {
|
||||
options: snapshotArg.profiles,
|
||||
currentOption: undefined,
|
||||
writable: true,
|
||||
}, snapshotArg.connected));
|
||||
}
|
||||
|
||||
for (const device of snapshotArg.devices) {
|
||||
const info = this.lightInfo(device);
|
||||
entities.push(this.lightEntity(snapshotArg, device, undefined, info, usedIds));
|
||||
for (const zone of device.zones || []) {
|
||||
const zoneInfo = this.lightInfo(device, zone);
|
||||
entities.push(this.lightEntity(snapshotArg, device, zone, zoneInfo, usedIds));
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static toIntegrationEvent(eventArg: IOpenRGBEvent): IIntegrationEvent {
|
||||
return {
|
||||
type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed',
|
||||
integrationDomain: 'openrgb',
|
||||
deviceId: eventArg.deviceId,
|
||||
entityId: eventArg.entityId,
|
||||
data: eventArg,
|
||||
timestamp: eventArg.timestamp || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IOpenRGBSnapshot, requestArg: IServiceCallRequest): IOpenRGBClientCommand | undefined {
|
||||
if (requestArg.domain === 'select' && requestArg.service === 'select_option') {
|
||||
const profile = this.stringFromData(requestArg.data, ['option', 'value', 'profile']);
|
||||
const targetEntity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
|
||||
if (profile && targetEntity?.platform === 'select' && targetEntity.attributes?.options && Array.isArray(targetEntity.attributes.options)) {
|
||||
return this.command(requestArg, targetEntity, undefined, undefined, [{ action: 'loadProfile', profile }], { profile });
|
||||
}
|
||||
}
|
||||
|
||||
const target = this.resolveLight(snapshotArg, requestArg);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (requestArg.domain !== 'light' && requestArg.domain !== 'select') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return this.turnOffCommand(requestArg, target);
|
||||
}
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return this.turnOnCommand(requestArg, target);
|
||||
}
|
||||
if (requestArg.service === 'set_brightness' || requestArg.service === 'set_percentage') {
|
||||
const brightness = this.brightnessFromData(requestArg.data, requestArg.service);
|
||||
if (brightness === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (brightness <= 0) {
|
||||
return this.turnOffCommand(requestArg, target);
|
||||
}
|
||||
return this.turnOnCommand({ ...requestArg, service: 'turn_on', data: { ...requestArg.data, brightness } }, target);
|
||||
}
|
||||
if (requestArg.service === 'set_color' || requestArg.service === 'set_rgb_color') {
|
||||
const rgb = this.rgbFromData(requestArg.data);
|
||||
if (!rgb) {
|
||||
return undefined;
|
||||
}
|
||||
return this.turnOnCommand({ ...requestArg, service: 'turn_on', data: { ...requestArg.data, rgb_color: [rgb.red, rgb.green, rgb.blue] } }, target);
|
||||
}
|
||||
if (requestArg.service === 'select_effect') {
|
||||
const effect = this.stringFromData(requestArg.data, ['effect', 'option', 'value']);
|
||||
if (!effect) {
|
||||
return undefined;
|
||||
}
|
||||
return this.turnOnCommand({ ...requestArg, service: 'turn_on', data: { ...requestArg.data, effect } }, target);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceKey(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice): string {
|
||||
return deviceArg.key || this.deviceKeyFor(String(snapshotArg.controller.id || snapshotArg.host || snapshotArg.name || 'openrgb'), deviceArg);
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice): string {
|
||||
return `openrgb.device.${this.slug(this.deviceKey(snapshotArg, deviceArg))}`;
|
||||
}
|
||||
|
||||
private static controllerDevice(snapshotArg: IOpenRGBSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg },
|
||||
];
|
||||
if (snapshotArg.protocolVersion !== undefined) {
|
||||
features.push({ id: 'protocol_version', capability: 'sensor', name: 'Protocol version', readable: true, writable: false });
|
||||
state.push({ featureId: 'protocol_version', value: snapshotArg.protocolVersion, updatedAt: updatedAtArg });
|
||||
}
|
||||
if (snapshotArg.profiles.length) {
|
||||
features.push({ id: 'profile', capability: 'sensor', name: 'Profile', readable: true, writable: true });
|
||||
state.push({ featureId: 'profile', value: null, updatedAt: updatedAtArg });
|
||||
}
|
||||
return {
|
||||
id: this.controllerDeviceId(snapshotArg),
|
||||
integrationDomain: 'openrgb',
|
||||
name: this.controllerName(snapshotArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: snapshotArg.controller.manufacturer || 'OpenRGB',
|
||||
model: snapshotArg.controller.model || 'OpenRGB SDK Server',
|
||||
online: snapshotArg.connected,
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
host: snapshotArg.host,
|
||||
port: snapshotArg.port || openrgbDefaultPort,
|
||||
protocolVersion: snapshotArg.protocolVersion,
|
||||
profiles: snapshotArg.profiles,
|
||||
...snapshotArg.controller.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static openrgbDevice(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||
const info = this.lightInfo(deviceArg);
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false },
|
||||
{ id: 'power', capability: 'light', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'active_mode', capability: 'sensor', name: 'Active mode', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'availability', value: deviceArg.available === false ? 'offline' : 'online', updatedAt: updatedAtArg },
|
||||
{ featureId: 'power', value: info.isOn, updatedAt: updatedAtArg },
|
||||
{ featureId: 'active_mode', value: info.modeName || null, updatedAt: updatedAtArg },
|
||||
];
|
||||
if (info.supportsColor) {
|
||||
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true });
|
||||
features.push({ id: 'rgb', capability: 'light', name: 'RGB color', readable: true, writable: true });
|
||||
this.pushState(state, 'brightness', info.brightness, updatedAtArg);
|
||||
this.pushState(state, 'rgb', info.rgbColor, updatedAtArg);
|
||||
}
|
||||
if (info.effectList.length) {
|
||||
features.push({ id: 'effect', capability: 'light', name: 'Effect', readable: true, writable: true });
|
||||
this.pushState(state, 'effect', info.effect, updatedAtArg);
|
||||
}
|
||||
if (deviceArg.zones?.length) {
|
||||
features.push({ id: 'zones', capability: 'sensor', name: 'Zones', readable: true, writable: false });
|
||||
state.push({ featureId: 'zones', value: deviceArg.zones.length, updatedAt: updatedAtArg });
|
||||
}
|
||||
const metadata = deviceArg.metadata || {};
|
||||
return {
|
||||
id: this.deviceId(snapshotArg, deviceArg),
|
||||
integrationDomain: 'openrgb',
|
||||
name: this.deviceName(deviceArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: metadata.vendor || 'OpenRGB',
|
||||
model: this.deviceModel(deviceArg),
|
||||
online: deviceArg.available !== false,
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
openrgbDeviceKey: this.deviceKey(snapshotArg, deviceArg),
|
||||
openrgbDeviceIndex: typeof deviceArg.id === 'number' ? deviceArg.id : undefined,
|
||||
openrgbType: this.deviceTypeName(deviceArg),
|
||||
firmwareVersion: metadata.version,
|
||||
serialNumber: metadata.serial,
|
||||
location: metadata.location,
|
||||
modes: (deviceArg.modes || []).map((modeArg) => modeArg.name),
|
||||
effects: info.effectList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static zoneDevice(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||
const info = this.lightInfo(deviceArg, zoneArg);
|
||||
const zoneId = this.zoneDeviceId(snapshotArg, deviceArg, zoneArg);
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false },
|
||||
{ id: 'power', capability: 'light', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'led_count', capability: 'sensor', name: 'LED count', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'availability', value: deviceArg.available === false ? 'offline' : 'online', updatedAt: updatedAtArg },
|
||||
{ featureId: 'power', value: info.isOn, updatedAt: updatedAtArg },
|
||||
{ featureId: 'led_count', value: this.zoneLedCount(zoneArg), updatedAt: updatedAtArg },
|
||||
];
|
||||
if (info.supportsColor) {
|
||||
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true });
|
||||
features.push({ id: 'rgb', capability: 'light', name: 'RGB color', readable: true, writable: true });
|
||||
this.pushState(state, 'brightness', info.brightness, updatedAtArg);
|
||||
this.pushState(state, 'rgb', info.rgbColor, updatedAtArg);
|
||||
}
|
||||
return {
|
||||
id: zoneId,
|
||||
integrationDomain: 'openrgb',
|
||||
name: this.zoneName(deviceArg, zoneArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: deviceArg.metadata?.vendor || 'OpenRGB',
|
||||
model: `OpenRGB zone (${zoneArg.typeName || this.zoneTypeName(zoneArg)})`,
|
||||
online: deviceArg.available !== false,
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
parentDeviceId: this.deviceId(snapshotArg, deviceArg),
|
||||
openrgbDeviceKey: this.deviceKey(snapshotArg, deviceArg),
|
||||
openrgbDeviceIndex: typeof deviceArg.id === 'number' ? deviceArg.id : undefined,
|
||||
openrgbZoneIndex: this.zoneIndex(deviceArg, zoneArg),
|
||||
openrgbZoneType: zoneArg.typeName || this.zoneTypeName(zoneArg),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static lightEntity(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone | undefined, infoArg: ReturnType<typeof OpenRGBMapper.lightInfo>, usedIdsArg: Map<string, number>): IIntegrationEntity {
|
||||
const deviceKey = this.deviceKey(snapshotArg, deviceArg);
|
||||
const deviceIndex = typeof deviceArg.id === 'number' ? deviceArg.id : undefined;
|
||||
const zoneIndex = zoneArg ? this.zoneIndex(deviceArg, zoneArg) : undefined;
|
||||
const name = zoneArg ? this.zoneName(deviceArg, zoneArg) : this.deviceName(deviceArg);
|
||||
const uniqueId = zoneArg
|
||||
? `openrgb_${this.slug(deviceKey)}_zone_${zoneIndex ?? this.slug(zoneArg.name || 'zone')}_light`
|
||||
: `openrgb_${this.slug(deviceKey)}_light`;
|
||||
return this.entity('light', name, zoneArg ? this.zoneDeviceId(snapshotArg, deviceArg, zoneArg) : this.deviceId(snapshotArg, deviceArg), uniqueId, infoArg.isOn ? 'on' : 'off', usedIdsArg, {
|
||||
openrgbDeviceKey: deviceKey,
|
||||
openrgbDeviceIndex: deviceIndex,
|
||||
openrgbZoneIndex: zoneIndex,
|
||||
openrgbMode: infoArg.modeName,
|
||||
brightness: infoArg.brightness,
|
||||
rgbColor: infoArg.rgbColor ? [infoArg.rgbColor.red, infoArg.rgbColor.green, infoArg.rgbColor.blue] : undefined,
|
||||
colorMode: infoArg.colorMode,
|
||||
effect: infoArg.effect,
|
||||
effectList: zoneArg ? [] : infoArg.effectList,
|
||||
supportedColorModes: infoArg.supportedColorModes,
|
||||
preferredNoEffectMode: infoArg.preferredNoEffectMode?.name,
|
||||
supportsOffMode: infoArg.supportsOffMode,
|
||||
writable: true,
|
||||
}, deviceArg.available !== false);
|
||||
}
|
||||
|
||||
private static turnOffCommand(requestArg: IServiceCallRequest, targetArg: IOpenRGBResolvedLight): IOpenRGBClientCommand | undefined {
|
||||
const operations: TOpenRGBCommandOperation[] = [];
|
||||
if (!targetArg.zone && targetArg.info.offMode) {
|
||||
operations.push(this.setModeOperation(targetArg.device, targetArg.info.offMode));
|
||||
} else {
|
||||
const colorOperation = this.setColorOperation(targetArg.device, targetArg.zone, black, 0);
|
||||
if (!colorOperation) {
|
||||
return undefined;
|
||||
}
|
||||
operations.push(colorOperation);
|
||||
}
|
||||
return this.command(requestArg, targetArg.entity, targetArg.device, targetArg.zone, operations, { state: false });
|
||||
}
|
||||
|
||||
private static turnOnCommand(requestArg: IServiceCallRequest, targetArg: IOpenRGBResolvedLight): IOpenRGBClientCommand | undefined {
|
||||
let mode: IOpenRGBMode | undefined;
|
||||
const effect = this.stringFromData(requestArg.data, ['effect', 'option', 'value']);
|
||||
if (effect) {
|
||||
if (targetArg.zone) {
|
||||
return undefined;
|
||||
}
|
||||
if (effect.toLowerCase() === 'off') {
|
||||
mode = targetArg.info.preferredNoEffectMode;
|
||||
} else {
|
||||
mode = targetArg.info.effectToMode.get(this.slug(effect));
|
||||
}
|
||||
if (!mode) {
|
||||
return undefined;
|
||||
}
|
||||
} else if (targetArg.info.modeIsOff || (targetArg.info.rgbColor === undefined && targetArg.info.brightness === undefined)) {
|
||||
mode = targetArg.info.preferredNoEffectMode;
|
||||
}
|
||||
|
||||
let modeForColor = mode || targetArg.info.activeMode;
|
||||
let modeSupportsColor = this.modeSupportsColor(modeForColor);
|
||||
const requestedBrightness = this.brightnessFromData(requestArg.data, requestArg.service);
|
||||
const requestedRgb = this.rgbFromData(requestArg.data);
|
||||
const colorOrBrightnessRequested = requestedBrightness !== undefined || requestedRgb !== undefined;
|
||||
if (colorOrBrightnessRequested && !modeSupportsColor) {
|
||||
return undefined;
|
||||
}
|
||||
let needColor = colorOrBrightnessRequested || (modeSupportsColor && (targetArg.info.rgbColor === undefined || targetArg.info.brightness === undefined));
|
||||
if (needColor && !modeSupportsColor) {
|
||||
mode = targetArg.info.preferredNoEffectMode;
|
||||
modeForColor = mode || modeForColor;
|
||||
modeSupportsColor = this.modeSupportsColor(modeForColor);
|
||||
if (!modeSupportsColor) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const operations: TOpenRGBCommandOperation[] = [];
|
||||
if (mode) {
|
||||
operations.push(this.setModeOperation(targetArg.device, mode));
|
||||
}
|
||||
if (needColor) {
|
||||
const brightness = requestedBrightness ?? targetArg.info.brightness ?? 255;
|
||||
const color = requestedRgb ?? targetArg.info.rgbColor ?? white;
|
||||
const colorOperation = this.setColorOperation(targetArg.device, targetArg.zone, color, brightness);
|
||||
if (!colorOperation) {
|
||||
return undefined;
|
||||
}
|
||||
operations.push(colorOperation);
|
||||
}
|
||||
if (!operations.length) {
|
||||
return undefined;
|
||||
}
|
||||
return this.command(requestArg, targetArg.entity, targetArg.device, targetArg.zone, operations, {
|
||||
state: true,
|
||||
effect,
|
||||
brightness: requestedBrightness,
|
||||
rgb: requestedRgb,
|
||||
});
|
||||
}
|
||||
|
||||
private static command(requestArg: IServiceCallRequest, entityArg: IIntegrationEntity, deviceArg: IOpenRGBDevice | undefined, zoneArg: IOpenRGBZone | undefined, operationsArg: TOpenRGBCommandOperation[], payloadArg: Record<string, unknown>): IOpenRGBClientCommand {
|
||||
return {
|
||||
type: 'openrgb.sdk_command',
|
||||
service: requestArg.service,
|
||||
deviceId: entityArg.deviceId,
|
||||
entityId: entityArg.id,
|
||||
deviceKey: typeof entityArg.attributes?.openrgbDeviceKey === 'string' ? entityArg.attributes.openrgbDeviceKey : deviceArg?.key,
|
||||
zoneId: zoneArg ? String(zoneArg.id ?? zoneArg.name ?? '') : undefined,
|
||||
target: requestArg.target,
|
||||
operations: operationsArg,
|
||||
payload: {
|
||||
...payloadArg,
|
||||
data: requestArg.data || {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static setModeOperation(deviceArg: IOpenRGBDevice, modeArg: IOpenRGBMode): TOpenRGBCommandOperation {
|
||||
return {
|
||||
action: 'setMode',
|
||||
deviceIndex: typeof deviceArg.id === 'number' ? deviceArg.id : undefined,
|
||||
deviceKey: deviceArg.key,
|
||||
modeName: modeArg.name,
|
||||
mode: modeArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static setColorOperation(deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone | undefined, colorArg: IOpenRGBRgbColor, brightnessArg: number): TOpenRGBCommandOperation | undefined {
|
||||
const ledCount = zoneArg ? this.zoneLedCount(zoneArg) : this.deviceLedCount(deviceArg);
|
||||
if (!ledCount) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
action: 'setColor',
|
||||
deviceIndex: typeof deviceArg.id === 'number' ? deviceArg.id : undefined,
|
||||
deviceKey: deviceArg.key,
|
||||
zoneIndex: zoneArg ? this.zoneIndex(deviceArg, zoneArg) : undefined,
|
||||
zoneName: zoneArg?.name,
|
||||
ledCount,
|
||||
rgb: this.scaleColor(colorArg, brightnessArg),
|
||||
brightness: brightnessArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static resolveLight(snapshotArg: IOpenRGBSnapshot, requestArg: IServiceCallRequest): IOpenRGBResolvedLight | undefined {
|
||||
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'light');
|
||||
let entity = requestArg.target.entityId
|
||||
? entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId)
|
||||
: undefined;
|
||||
if (!entity && requestArg.target.deviceId) {
|
||||
entity = entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId)
|
||||
|| entities.find((entityArg) => entityArg.attributes?.openrgbDeviceKey === requestArg.target.deviceId);
|
||||
}
|
||||
if (!entity) {
|
||||
entity = entities.find((entityArg) => entityArg.available && entityArg.attributes?.writable === true);
|
||||
}
|
||||
if (!entity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deviceKey = this.stringValue(entity.attributes?.openrgbDeviceKey);
|
||||
const device = snapshotArg.devices.find((deviceArg) => this.deviceKey(snapshotArg, deviceArg) === deviceKey || this.deviceId(snapshotArg, deviceArg) === entity?.deviceId);
|
||||
if (!device) {
|
||||
return undefined;
|
||||
}
|
||||
const zoneIndex = this.numberValue(entity.attributes?.openrgbZoneIndex);
|
||||
const zone = zoneIndex === undefined ? undefined : device.zones?.[zoneIndex];
|
||||
return { entity, device, zone, info: this.lightInfo(device, zone) };
|
||||
}
|
||||
|
||||
private static lightInfo(deviceArg: IOpenRGBDevice, zoneArg?: IOpenRGBZone): IOpenRGBLightInfo {
|
||||
const modes = deviceArg.modes || [];
|
||||
const activeMode = this.activeMode(deviceArg);
|
||||
const modeName = activeMode?.name;
|
||||
const modeIsOff = this.sameName(modeName, openrgbModeOff);
|
||||
const supportsColor = !modeIsOff && this.modeSupportsColor(activeMode);
|
||||
const colors = zoneArg?.colors?.length ? zoneArg.colors : deviceArg.colors || [];
|
||||
const color = colors.find((colorArg) => !this.isBlack(colorArg)) || black;
|
||||
const colorDetails = supportsColor && !this.isBlack(color) ? this.colorDetails(color) : {};
|
||||
const effectToMode = new Map<string, IOpenRGBMode>();
|
||||
for (const mode of modes) {
|
||||
if (!this.sameName(mode.name, openrgbModeOff) && !effectOffModes.has(mode.name.toLowerCase())) {
|
||||
effectToMode.set(this.slug(mode.name), mode);
|
||||
}
|
||||
}
|
||||
const effectList = [...effectToMode.keys()];
|
||||
const effect = !effectList.length || modeIsOff
|
||||
? undefined
|
||||
: modeName && effectOffModes.has(modeName.toLowerCase())
|
||||
? 'off'
|
||||
: modeName ? this.slug(modeName) : undefined;
|
||||
return {
|
||||
activeMode,
|
||||
modeName,
|
||||
modeIsOff,
|
||||
supportsColor,
|
||||
supportedColorModes: modes.filter((modeArg) => this.modeSupportsColor(modeArg)).map((modeArg) => modeArg.name),
|
||||
rgbColor: colorDetails.rgbColor,
|
||||
brightness: colorDetails.brightness,
|
||||
colorMode: supportsColor ? 'rgb' : modeIsOff ? undefined : 'onoff',
|
||||
isOn: modeIsOff ? false : supportsColor ? !this.isBlack(color) : true,
|
||||
effect,
|
||||
effectList: effectList.length ? ['off', ...effectList] : [],
|
||||
effectToMode,
|
||||
preferredNoEffectMode: this.preferredNoEffectMode(deviceArg),
|
||||
offMode: modes.find((modeArg) => this.sameName(modeArg.name, openrgbModeOff)),
|
||||
supportsOffMode: modes.some((modeArg) => this.sameName(modeArg.name, openrgbModeOff)),
|
||||
};
|
||||
}
|
||||
|
||||
private static devicesFromManualEntries(entriesArg: IOpenRGBManualEntry[]): IOpenRGBDevice[] {
|
||||
const devices: IOpenRGBDevice[] = [];
|
||||
for (const entry of entriesArg) {
|
||||
if (entry.snapshot) {
|
||||
devices.push(...entry.snapshot.devices);
|
||||
}
|
||||
if (entry.devices) {
|
||||
devices.push(...entry.devices);
|
||||
}
|
||||
if (entry.device) {
|
||||
devices.push(entry.device);
|
||||
}
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
private static profilesFromManualEntries(entriesArg: IOpenRGBManualEntry[]): string[] {
|
||||
return entriesArg.flatMap((entryArg) => entryArg.snapshot?.profiles || entryArg.profiles || []);
|
||||
}
|
||||
|
||||
private static uniqueDevices(devicesArg: IOpenRGBDevice[], serverKeyArg: string): IOpenRGBDevice[] {
|
||||
const devices = new Map<string, IOpenRGBDevice>();
|
||||
for (const device of devicesArg) {
|
||||
const key = device.key || this.deviceKeyFor(serverKeyArg, device);
|
||||
const previous = devices.get(key) || {};
|
||||
devices.set(key, { ...previous, ...device, key });
|
||||
}
|
||||
return [...devices.values()];
|
||||
}
|
||||
|
||||
private static controllerDeviceId(snapshotArg: IOpenRGBSnapshot): string {
|
||||
return `openrgb.controller.${this.slug(String(snapshotArg.controller.id || snapshotArg.host || snapshotArg.name || 'server'))}`;
|
||||
}
|
||||
|
||||
private static controllerName(snapshotArg: IOpenRGBSnapshot): string {
|
||||
return snapshotArg.name || snapshotArg.controller.name || 'OpenRGB SDK Server';
|
||||
}
|
||||
|
||||
private static zoneDeviceId(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone): string {
|
||||
return `openrgb.zone.${this.slug(this.deviceKey(snapshotArg, deviceArg))}.${this.zoneIndex(deviceArg, zoneArg) ?? this.slug(zoneArg.name || 'zone')}`;
|
||||
}
|
||||
|
||||
private static deviceKeyFor(serverKeyArg: string, deviceArg: IOpenRGBDevice): string {
|
||||
const metadata = deviceArg.metadata || {};
|
||||
return [
|
||||
serverKeyArg,
|
||||
this.deviceTypeName(deviceArg) || 'unknown',
|
||||
metadata.vendor || 'none',
|
||||
metadata.description || deviceArg.name || 'none',
|
||||
metadata.serial || 'none',
|
||||
metadata.location || 'none',
|
||||
].join(openrgbUidSeparator);
|
||||
}
|
||||
|
||||
private static deviceName(deviceArg: IOpenRGBDevice): string {
|
||||
return deviceArg.name || deviceArg.metadata?.description || (deviceArg.metadata?.serial ? `OpenRGB ${deviceArg.metadata.serial}` : 'OpenRGB device');
|
||||
}
|
||||
|
||||
private static zoneName(deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone): string {
|
||||
return `${this.deviceName(deviceArg)} ${zoneArg.name || `Zone ${this.zoneIndex(deviceArg, zoneArg) ?? ''}`.trim()}`;
|
||||
}
|
||||
|
||||
private static deviceModel(deviceArg: IOpenRGBDevice): string {
|
||||
const metadata = deviceArg.metadata || {};
|
||||
const description = metadata.description || this.deviceTypeName(deviceArg) || 'OpenRGB device';
|
||||
const type = this.deviceTypeName(deviceArg);
|
||||
return type ? `${description} (${type})` : description;
|
||||
}
|
||||
|
||||
private static deviceTypeName(deviceArg: IOpenRGBDevice): string | undefined {
|
||||
if (typeof deviceArg.typeName === 'string') {
|
||||
return deviceArg.typeName;
|
||||
}
|
||||
if (typeof deviceArg.type === 'string') {
|
||||
return deviceArg.type;
|
||||
}
|
||||
if (typeof deviceArg.type === 'number') {
|
||||
return deviceTypeNames[deviceArg.type] || 'unknown';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static zoneTypeName(zoneArg: IOpenRGBZone): string | undefined {
|
||||
if (typeof zoneArg.typeName === 'string') {
|
||||
return zoneArg.typeName;
|
||||
}
|
||||
if (typeof zoneArg.type === 'string') {
|
||||
return zoneArg.type;
|
||||
}
|
||||
if (typeof zoneArg.type === 'number') {
|
||||
return zoneTypeNames[zoneArg.type] || 'unknown';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static activeMode(deviceArg: IOpenRGBDevice): IOpenRGBMode | undefined {
|
||||
const active = deviceArg.activeMode;
|
||||
const modes = deviceArg.modes || [];
|
||||
if (typeof active === 'number') {
|
||||
return modes[active] || modes.find((modeArg) => modeArg.id === active);
|
||||
}
|
||||
if (typeof active === 'string') {
|
||||
return modes.find((modeArg) => this.sameName(modeArg.name, active));
|
||||
}
|
||||
return modes[0];
|
||||
}
|
||||
|
||||
private static preferredNoEffectMode(deviceArg: IOpenRGBDevice): IOpenRGBMode | undefined {
|
||||
const modes = deviceArg.modes || [];
|
||||
const findMode = (nameArg: string) => modes.find((modeArg) => this.sameName(modeArg.name, nameArg));
|
||||
if (deviceArg.metadata?.description === 'ASRock Polychrome USB Device') {
|
||||
return findMode(openrgbModeStatic) || findMode(openrgbModeDirect) || findMode(openrgbModeCustom);
|
||||
}
|
||||
return findMode(openrgbModeDirect) || findMode(openrgbModeCustom) || findMode(openrgbModeStatic);
|
||||
}
|
||||
|
||||
private static modeSupportsColor(modeArg: IOpenRGBMode | undefined): boolean {
|
||||
if (!modeArg) {
|
||||
return false;
|
||||
}
|
||||
if (modeArg.supportsColor === true) {
|
||||
return true;
|
||||
}
|
||||
if (typeof modeArg.colorMode === 'number') {
|
||||
return modeArg.colorMode === 1;
|
||||
}
|
||||
if (typeof modeArg.colorMode === 'string') {
|
||||
const colorMode = modeArg.colorMode.toLowerCase().replace(/[-\s]+/g, '_');
|
||||
return colorMode === 'per_led' || colorMode === 'perled';
|
||||
}
|
||||
return typeof modeArg.flags === 'number' && (modeArg.flags & (1 << 5)) !== 0;
|
||||
}
|
||||
|
||||
private static zoneIndex(deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone): number | undefined {
|
||||
if (typeof zoneArg.id === 'number') {
|
||||
return zoneArg.id;
|
||||
}
|
||||
const index = (deviceArg.zones || []).indexOf(zoneArg);
|
||||
return index >= 0 ? index : undefined;
|
||||
}
|
||||
|
||||
private static deviceLedCount(deviceArg: IOpenRGBDevice): number {
|
||||
if (deviceArg.colors?.length) {
|
||||
return deviceArg.colors.length;
|
||||
}
|
||||
if (deviceArg.leds?.length) {
|
||||
return deviceArg.leds.length;
|
||||
}
|
||||
return (deviceArg.zones || []).reduce((sumArg, zoneArg) => sumArg + this.zoneLedCount(zoneArg), 0);
|
||||
}
|
||||
|
||||
private static zoneLedCount(zoneArg: IOpenRGBZone): number {
|
||||
return zoneArg.numLeds || zoneArg.colors?.length || zoneArg.leds?.length || 0;
|
||||
}
|
||||
|
||||
private static colorDetails(colorArg: IOpenRGBRgbColor): { rgbColor?: IOpenRGBRgbColor; brightness?: number } {
|
||||
const brightness = Math.max(colorArg.red, colorArg.green, colorArg.blue);
|
||||
if (brightness <= 0) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
brightness,
|
||||
rgbColor: {
|
||||
red: this.clamp(Math.round(colorArg.red / brightness * 255), 0, 255),
|
||||
green: this.clamp(Math.round(colorArg.green / brightness * 255), 0, 255),
|
||||
blue: this.clamp(Math.round(colorArg.blue / brightness * 255), 0, 255),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static scaleColor(colorArg: IOpenRGBRgbColor, brightnessArg: number): IOpenRGBRgbColor {
|
||||
const factor = this.clamp(brightnessArg, 0, 255) / 255;
|
||||
return {
|
||||
red: this.clamp(Math.round(colorArg.red * factor), 0, 255),
|
||||
green: this.clamp(Math.round(colorArg.green * factor), 0, 255),
|
||||
blue: this.clamp(Math.round(colorArg.blue * factor), 0, 255),
|
||||
};
|
||||
}
|
||||
|
||||
private static rgbFromData(dataArg: Record<string, unknown> | undefined): IOpenRGBRgbColor | undefined {
|
||||
const value = this.valueFromData(dataArg, ['rgb_color', 'rgb', 'color']);
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const numbers = value.slice(0, 3).map((entryArg) => this.byteValue(entryArg));
|
||||
return numbers.every((entryArg) => entryArg !== undefined)
|
||||
? { red: numbers[0]!, green: numbers[1]!, blue: numbers[2]! }
|
||||
: undefined;
|
||||
}
|
||||
if (this.isRecord(value)) {
|
||||
const red = this.byteValue(value.red ?? value.r);
|
||||
const green = this.byteValue(value.green ?? value.g);
|
||||
const blue = this.byteValue(value.blue ?? value.b);
|
||||
return red !== undefined && green !== undefined && blue !== undefined ? { red, green, blue } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static brightnessFromData(dataArg: Record<string, unknown> | undefined, serviceArg: string): number | undefined {
|
||||
const percentage = this.numberFromData(dataArg, ['brightness_pct', 'percentage', 'percent']);
|
||||
if (percentage !== undefined) {
|
||||
return this.clamp(Math.round(percentage / 100 * 255), 0, 255);
|
||||
}
|
||||
const value = this.numberFromData(dataArg, serviceArg === 'set_percentage' ? ['value'] : ['brightness', 'value']);
|
||||
return value === undefined ? undefined : this.clamp(Math.round(value), 0, 255);
|
||||
}
|
||||
|
||||
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
|
||||
return {
|
||||
id: this.entityId(platformArg, nameArg, usedIdsArg),
|
||||
uniqueId: uniqueIdArg,
|
||||
integrationDomain: 'openrgb',
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: attributesArg,
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static entityId(platformArg: TEntityPlatform, nameArg: string, usedIdsArg: Map<string, number>): string {
|
||||
const base = `${platformArg}.${this.slug(nameArg)}`;
|
||||
const count = usedIdsArg.get(base) || 0;
|
||||
usedIdsArg.set(base, count + 1);
|
||||
return count ? `${base}_${count + 1}` : base;
|
||||
}
|
||||
|
||||
private static pushState(stateArg: plugins.shxInterfaces.data.IDeviceState[], featureIdArg: string, valueArg: unknown, updatedAtArg: string): void {
|
||||
if (valueArg === undefined) {
|
||||
return;
|
||||
}
|
||||
stateArg.push({ featureId: featureIdArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg });
|
||||
}
|
||||
|
||||
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (Array.isArray(valueArg)) {
|
||||
return { values: valueArg };
|
||||
}
|
||||
return valueArg === undefined ? null : String(valueArg);
|
||||
}
|
||||
|
||||
private static isBlack(colorArg: IOpenRGBRgbColor): boolean {
|
||||
return colorArg.red <= 0 && colorArg.green <= 0 && colorArg.blue <= 0;
|
||||
}
|
||||
|
||||
private static sameName(leftArg: string | undefined, rightArg: string): boolean {
|
||||
return (leftArg || '').toLowerCase() === rightArg.toLowerCase();
|
||||
}
|
||||
|
||||
private static valueFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): unknown {
|
||||
for (const key of keysArg) {
|
||||
if (dataArg && key in dataArg) {
|
||||
return dataArg[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static numberFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): number | undefined {
|
||||
const value = this.valueFromData(dataArg, keysArg);
|
||||
return this.numberValue(value);
|
||||
}
|
||||
|
||||
private static stringFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): string | undefined {
|
||||
const value = this.valueFromData(dataArg, keysArg);
|
||||
return this.stringValue(value);
|
||||
}
|
||||
|
||||
private static byteValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
return this.clamp(Math.round(valueArg), 0, 255);
|
||||
}
|
||||
|
||||
private static numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private static stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'openrgb';
|
||||
}
|
||||
|
||||
private static clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||
return Math.max(minArg, Math.min(maxArg, valueArg));
|
||||
}
|
||||
|
||||
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,255 @@
|
||||
export interface IHomeAssistantOpenrgbConfig {
|
||||
// TODO: replace with the TypeScript-native config for openrgb.
|
||||
import type { IServiceCallResult } from '../../core/types.js';
|
||||
|
||||
export const openrgbDefaultPort = 6742;
|
||||
export const openrgbDefaultClientName = 'Home Assistant';
|
||||
export const openrgbUidSeparator = '||';
|
||||
|
||||
export type TOpenRGBColorMode = 'none' | 'per_led' | 'mode_specific' | 'random' | string;
|
||||
export type TOpenRGBDeviceType =
|
||||
| 'motherboard'
|
||||
| 'dram'
|
||||
| 'gpu'
|
||||
| 'cooler'
|
||||
| 'ledstrip'
|
||||
| 'keyboard'
|
||||
| 'mouse'
|
||||
| 'mousemat'
|
||||
| 'headset'
|
||||
| 'headset_stand'
|
||||
| 'gamepad'
|
||||
| 'light'
|
||||
| 'speaker'
|
||||
| 'virtual'
|
||||
| 'storage'
|
||||
| 'case'
|
||||
| 'microphone'
|
||||
| 'accessory'
|
||||
| 'keypad'
|
||||
| 'unknown'
|
||||
| string;
|
||||
export type TOpenRGBZoneType = 'single' | 'linear' | 'matrix' | string;
|
||||
|
||||
export interface IOpenRGBConfig {
|
||||
entryId?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
clientName?: string;
|
||||
protocolVersion?: number;
|
||||
timeoutMs?: number;
|
||||
controller?: IOpenRGBController;
|
||||
device?: IOpenRGBDevice;
|
||||
devices?: IOpenRGBDevice[];
|
||||
snapshot?: IOpenRGBSnapshot;
|
||||
manualEntries?: IOpenRGBManualEntry[];
|
||||
profiles?: string[];
|
||||
events?: IOpenRGBEvent[];
|
||||
commandExecutor?: TOpenRGBCommandExecutor;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantOpenrgbConfig extends IOpenRGBConfig {}
|
||||
|
||||
export interface IOpenRGBController {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocolVersion?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
softwareVersion?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBSnapshot {
|
||||
connected: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
protocolVersion?: number;
|
||||
controller: IOpenRGBController;
|
||||
devices: IOpenRGBDevice[];
|
||||
profiles: string[];
|
||||
events: IOpenRGBEvent[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IOpenRGBDeviceMetadata {
|
||||
vendor?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
serial?: string;
|
||||
location?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBRgbColor {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
}
|
||||
|
||||
export interface IOpenRGBLed {
|
||||
id?: number;
|
||||
name?: string;
|
||||
value?: number;
|
||||
color?: IOpenRGBRgbColor;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBSegment {
|
||||
id?: number;
|
||||
name?: string;
|
||||
type?: number | TOpenRGBZoneType;
|
||||
typeName?: TOpenRGBZoneType;
|
||||
startIndex?: number;
|
||||
ledCount?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBZone {
|
||||
id?: number;
|
||||
name?: string;
|
||||
type?: number | TOpenRGBZoneType;
|
||||
typeName?: TOpenRGBZoneType;
|
||||
ledsMin?: number;
|
||||
ledsMax?: number;
|
||||
numLeds?: number;
|
||||
startIndex?: number;
|
||||
matrixHeight?: number;
|
||||
matrixWidth?: number;
|
||||
matrixMap?: Array<Array<number | null>>;
|
||||
segments?: IOpenRGBSegment[];
|
||||
leds?: IOpenRGBLed[];
|
||||
colors?: IOpenRGBRgbColor[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBMode {
|
||||
id?: number;
|
||||
name: string;
|
||||
value?: number;
|
||||
flags?: number;
|
||||
speedMin?: number;
|
||||
speedMax?: number;
|
||||
brightnessMin?: number;
|
||||
brightnessMax?: number;
|
||||
colorsMin?: number;
|
||||
colorsMax?: number;
|
||||
speed?: number;
|
||||
brightness?: number;
|
||||
direction?: number | string;
|
||||
colorMode?: number | TOpenRGBColorMode;
|
||||
colors?: IOpenRGBRgbColor[];
|
||||
supportsColor?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBDevice {
|
||||
id?: number | string;
|
||||
key?: string;
|
||||
name?: string;
|
||||
type?: number | TOpenRGBDeviceType;
|
||||
typeName?: TOpenRGBDeviceType;
|
||||
metadata?: IOpenRGBDeviceMetadata;
|
||||
leds?: IOpenRGBLed[];
|
||||
zones?: IOpenRGBZone[];
|
||||
modes?: IOpenRGBMode[];
|
||||
colors?: IOpenRGBRgbColor[];
|
||||
activeMode?: number | string;
|
||||
available?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
extra?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBManualEntry {
|
||||
entryId?: string;
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
protocolVersion?: number;
|
||||
controller?: IOpenRGBController;
|
||||
device?: IOpenRGBDevice;
|
||||
devices?: IOpenRGBDevice[];
|
||||
snapshot?: IOpenRGBSnapshot;
|
||||
profiles?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBEvent {
|
||||
type: 'snapshot' | 'command_mapped' | 'command_executed' | 'command_failed' | 'sdk_response' | 'error' | string;
|
||||
timestamp?: number;
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
deviceKey?: string;
|
||||
command?: IOpenRGBClientCommand;
|
||||
data?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenRGBClientCommand {
|
||||
type: string;
|
||||
service: string;
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
deviceKey?: string;
|
||||
zoneId?: string;
|
||||
target?: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
operations: TOpenRGBCommandOperation[];
|
||||
payload: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type TOpenRGBCommandOperation =
|
||||
| IOpenRGBSetModeOperation
|
||||
| IOpenRGBSetColorOperation
|
||||
| IOpenRGBLoadProfileOperation;
|
||||
|
||||
export interface IOpenRGBSetModeOperation {
|
||||
action: 'setMode';
|
||||
deviceIndex?: number;
|
||||
deviceKey?: string;
|
||||
modeName: string;
|
||||
mode?: IOpenRGBMode;
|
||||
}
|
||||
|
||||
export interface IOpenRGBSetColorOperation {
|
||||
action: 'setColor';
|
||||
deviceIndex?: number;
|
||||
deviceKey?: string;
|
||||
zoneIndex?: number;
|
||||
zoneName?: string;
|
||||
ledCount: number;
|
||||
rgb: IOpenRGBRgbColor;
|
||||
brightness?: number;
|
||||
}
|
||||
|
||||
export interface IOpenRGBLoadProfileOperation {
|
||||
action: 'loadProfile';
|
||||
profile: string;
|
||||
}
|
||||
|
||||
export interface IOpenRGBCommandResult extends IServiceCallResult {}
|
||||
|
||||
export type TOpenRGBCommandExecutor = (
|
||||
commandArg: IOpenRGBClientCommand
|
||||
) => Promise<IOpenRGBCommandResult | unknown> | IOpenRGBCommandResult | unknown;
|
||||
|
||||
export interface IOpenRGBManualDiscoveryRecord extends IOpenRGBManualEntry {
|
||||
integrationDomain?: string;
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './soundtouch.classes.integration.js';
|
||||
export * from './soundtouch.classes.client.js';
|
||||
export * from './soundtouch.classes.configflow.js';
|
||||
export * from './soundtouch.discovery.js';
|
||||
export * from './soundtouch.mapper.js';
|
||||
export * from './soundtouch.types.js';
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
import type {
|
||||
ISoundtouchCommandRequest,
|
||||
ISoundtouchConfig,
|
||||
ISoundtouchContentItem,
|
||||
ISoundtouchDeviceInfo,
|
||||
ISoundtouchPreset,
|
||||
ISoundtouchRawCommandRequest,
|
||||
ISoundtouchSnapshot,
|
||||
ISoundtouchStatus,
|
||||
ISoundtouchVolume,
|
||||
ISoundtouchZoneMember,
|
||||
ISoundtouchZoneStatus,
|
||||
TSoundtouchCommand,
|
||||
} from './soundtouch.types.js';
|
||||
import { soundtouchDefaultDlnaPort, soundtouchDefaultPort, soundtouchDefaultSourceList } from './soundtouch.types.js';
|
||||
|
||||
const keyCommands: Record<Exclude<TSoundtouchCommand, 'turn_on' | 'turn_off' | 'set_volume' | 'mute' | 'select_source' | 'play_media' | 'play_preset'>, string> = {
|
||||
play: 'PLAY',
|
||||
pause: 'PAUSE',
|
||||
play_pause: 'PLAY_PAUSE',
|
||||
previous_track: 'PREV_TRACK',
|
||||
next_track: 'NEXT_TRACK',
|
||||
volume_up: 'VOLUME_UP',
|
||||
volume_down: 'VOLUME_DOWN',
|
||||
};
|
||||
|
||||
export class SoundtouchClient {
|
||||
constructor(private readonly config: ISoundtouchConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<ISoundtouchSnapshot> {
|
||||
if (this.config.snapshot) {
|
||||
return this.normalizeSnapshot(this.cloneValue(this.config.snapshot));
|
||||
}
|
||||
if (!this.config.host && !this.config.commandExecutor) {
|
||||
return this.normalizeSnapshot(this.manualSnapshot(false, 'SoundTouch refresh requires config.host, config.snapshot, or commandExecutor.'));
|
||||
}
|
||||
|
||||
const infoXml = await this.fetchText('/info');
|
||||
const deviceInfo = this.parseDeviceInfo(infoXml);
|
||||
const [status, volume, presets, zone] = await Promise.all([
|
||||
this.optionalText('/now_playing').then((xmlArg) => xmlArg ? this.parseStatus(xmlArg) : undefined),
|
||||
this.optionalText('/volume').then((xmlArg) => xmlArg ? this.parseVolume(xmlArg) : undefined),
|
||||
this.optionalText('/presets').then((xmlArg) => xmlArg ? this.parsePresets(xmlArg) : []),
|
||||
this.optionalText('/getZone').then((xmlArg) => xmlArg ? this.parseZoneStatus(xmlArg) : undefined),
|
||||
]);
|
||||
|
||||
return this.normalizeSnapshot({
|
||||
config: deviceInfo,
|
||||
status,
|
||||
volume,
|
||||
presets,
|
||||
zone,
|
||||
sourceList: this.config.sourceList,
|
||||
available: true,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<ISoundtouchSnapshot> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
if (!snapshot.config.deviceId && !snapshot.config.name) {
|
||||
throw new Error('SoundTouch device did not provide a device identifier.');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async execute(requestArg: ISoundtouchCommandRequest): Promise<unknown> {
|
||||
this.ensureCommandTransport();
|
||||
|
||||
if (requestArg.command === 'turn_on') {
|
||||
return this.setPower(true);
|
||||
}
|
||||
if (requestArg.command === 'turn_off') {
|
||||
return this.setPower(false);
|
||||
}
|
||||
if (requestArg.command in keyCommands) {
|
||||
return this.sendKey(keyCommands[requestArg.command as keyof typeof keyCommands], requestArg.command);
|
||||
}
|
||||
if (requestArg.command === 'set_volume') {
|
||||
return this.setVolume(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'mute') {
|
||||
return this.setMute(requestArg.muted);
|
||||
}
|
||||
if (requestArg.command === 'select_source') {
|
||||
return this.selectSource(requestArg.source);
|
||||
}
|
||||
if (requestArg.command === 'play_preset') {
|
||||
return this.playPreset(requestArg);
|
||||
}
|
||||
if (requestArg.command === 'play_media') {
|
||||
return this.playMedia(requestArg);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported SoundTouch command: ${requestArg.command}`);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async setPower(poweredArg: boolean): Promise<unknown> {
|
||||
const snapshot = await this.getSnapshot().catch(() => undefined);
|
||||
const isStandby = snapshot?.status?.source === 'STANDBY';
|
||||
if (poweredArg && snapshot?.status && !isStandby) {
|
||||
return { skipped: true, reason: 'SoundTouch device is already on.' };
|
||||
}
|
||||
if (!poweredArg && isStandby) {
|
||||
return { skipped: true, reason: 'SoundTouch device is already off.' };
|
||||
}
|
||||
return this.sendKey('POWER', poweredArg ? 'turn_on' : 'turn_off');
|
||||
}
|
||||
|
||||
private async setVolume(requestArg: ISoundtouchCommandRequest): Promise<string> {
|
||||
const volume = this.volumePercent(requestArg);
|
||||
return this.requestText('POST', '/volume', 'set_volume', `<volume>${volume}</volume>`, { 'content-type': 'text/xml; charset="utf-8"' });
|
||||
}
|
||||
|
||||
private async setMute(mutedArg: boolean | undefined): Promise<unknown> {
|
||||
if (typeof mutedArg === 'boolean') {
|
||||
const snapshot = await this.getSnapshot().catch(() => undefined);
|
||||
if (typeof snapshot?.volume?.muted === 'boolean' && snapshot.volume.muted === mutedArg) {
|
||||
return { skipped: true, reason: `SoundTouch mute is already ${mutedArg ? 'on' : 'off'}.` };
|
||||
}
|
||||
}
|
||||
return this.sendKey('MUTE', 'mute');
|
||||
}
|
||||
|
||||
private async selectSource(sourceArg: string | undefined): Promise<string> {
|
||||
const source = sourceArg?.toUpperCase();
|
||||
if (source !== 'AUX' && source !== 'BLUETOOTH') {
|
||||
throw new Error('SoundTouch select_source supports AUX or BLUETOOTH.');
|
||||
}
|
||||
return this.sendSelectXml(this.contentItemXml({ source, sourceAccount: source === 'AUX' ? 'AUX' : undefined }), 'select_source');
|
||||
}
|
||||
|
||||
private async playPreset(requestArg: ISoundtouchCommandRequest): Promise<string> {
|
||||
const preset = requestArg.contentItem ? { presetId: requestArg.presetId || '', ...requestArg.contentItem } : await this.findPreset(requestArg);
|
||||
const body = preset.sourceXml || this.contentItemXml(preset);
|
||||
return this.sendSelectXml(body, 'play_preset');
|
||||
}
|
||||
|
||||
private async playMedia(requestArg: ISoundtouchCommandRequest): Promise<unknown> {
|
||||
const mediaId = requestArg.url || requestArg.mediaId;
|
||||
if (mediaId?.toLowerCase().startsWith('http://')) {
|
||||
return this.playUrl(mediaId);
|
||||
}
|
||||
if (mediaId?.toLowerCase().startsWith('https://')) {
|
||||
throw new Error('SoundTouch play_media supports only http:// URLs for DLNA playback.');
|
||||
}
|
||||
return this.playPreset({ ...requestArg, presetId: requestArg.presetId || mediaId });
|
||||
}
|
||||
|
||||
private async playUrl(urlArg: string): Promise<string> {
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID>0</InstanceID><CurrentURIMetaData></CurrentURIMetaData><CurrentURI>${escapeXml(urlArg)}</CurrentURI></u:SetAVTransportURI></s:Body></s:Envelope>`;
|
||||
return this.requestText('POST', '/AVTransport/Control', 'play_media', body, {
|
||||
'user-agent': 'smarthome.exchange',
|
||||
accept: '*/*',
|
||||
'content-type': 'text/xml; charset="utf-8"',
|
||||
host: this.config.host ? `${this.config.host}:${this.dlnaPort()}` : `:${this.dlnaPort()}`,
|
||||
soapaction: 'urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI',
|
||||
}, this.dlnaPort());
|
||||
}
|
||||
|
||||
private async sendKey(keyArg: string, commandArg: TSoundtouchCommand): Promise<string[]> {
|
||||
const body = (stateArg: 'press' | 'release') => `<key state="${stateArg}" sender="Gabbo">${keyArg}</key>`;
|
||||
return [
|
||||
await this.requestText('POST', '/key', commandArg, body('press'), { 'content-type': 'text/xml; charset="utf-8"' }),
|
||||
await this.requestText('POST', '/key', commandArg, body('release'), { 'content-type': 'text/xml; charset="utf-8"' }),
|
||||
];
|
||||
}
|
||||
|
||||
private async sendSelectXml(bodyArg: string, commandArg: TSoundtouchCommand): Promise<string> {
|
||||
return this.requestText('POST', '/select', commandArg, bodyArg, { 'content-type': 'text/xml; charset="utf-8"' });
|
||||
}
|
||||
|
||||
private async findPreset(requestArg: ISoundtouchCommandRequest): Promise<ISoundtouchPreset> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const preset = (snapshot.presets || []).find((presetArg) => {
|
||||
return Boolean(
|
||||
requestArg.presetId && String(presetArg.presetId) === String(requestArg.presetId)
|
||||
|| requestArg.presetId && presetArg.name === requestArg.presetId
|
||||
|| requestArg.presetName && presetArg.name === requestArg.presetName
|
||||
);
|
||||
});
|
||||
if (!preset) {
|
||||
throw new Error('SoundTouch play_preset requires a known preset id or name.');
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
private contentItemXml(itemArg: ISoundtouchContentItem): string {
|
||||
if (!itemArg.source) {
|
||||
throw new Error('SoundTouch ContentItem requires source.');
|
||||
}
|
||||
const attributes: Record<string, string | undefined> = {
|
||||
source: itemArg.source,
|
||||
type: itemArg.type,
|
||||
location: itemArg.location,
|
||||
sourceAccount: itemArg.sourceAccount,
|
||||
isPresetable: typeof itemArg.isPresetable === 'boolean' ? String(itemArg.isPresetable) : undefined,
|
||||
};
|
||||
const attrs = Object.entries(attributes)
|
||||
.filter((entryArg): entryArg is [string, string] => typeof entryArg[1] === 'string' && entryArg[1] !== '')
|
||||
.map(([keyArg, valueArg]) => `${keyArg}="${escapeXml(valueArg)}"`)
|
||||
.join(' ');
|
||||
const itemName = itemArg.name ? `<itemName>${escapeXml(itemArg.name)}</itemName>` : '';
|
||||
return `<ContentItem ${attrs}>${itemName}</ContentItem>`;
|
||||
}
|
||||
|
||||
private async fetchText(pathArg: string): Promise<string> {
|
||||
return this.requestText('GET', pathArg, 'refresh');
|
||||
}
|
||||
|
||||
private async optionalText(pathArg: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await this.fetchText(pathArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async requestText(methodArg: 'GET' | 'POST', pathArg: string, commandArg: TSoundtouchCommand | 'refresh', bodyArg?: string, headersArg?: Record<string, string>, portArg = this.port()): Promise<string> {
|
||||
const rawRequest = this.rawRequest(methodArg, pathArg, commandArg, bodyArg, headersArg, portArg);
|
||||
if (this.config.commandExecutor) {
|
||||
return this.executorResultToText(await this.config.commandExecutor.execute(rawRequest));
|
||||
}
|
||||
if (!rawRequest.host) {
|
||||
throw new Error('SoundTouch command transport requires config.host or commandExecutor.');
|
||||
}
|
||||
|
||||
const controller = this.config.timeoutMs ? new AbortController() : undefined;
|
||||
const timeout = controller ? globalThis.setTimeout(() => controller.abort(), this.config.timeoutMs) : undefined;
|
||||
try {
|
||||
const response = await globalThis.fetch(rawRequest.url, {
|
||||
method: rawRequest.method,
|
||||
headers: rawRequest.headers,
|
||||
body: rawRequest.body,
|
||||
signal: controller?.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`SoundTouch request ${pathArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
|
||||
}
|
||||
return text;
|
||||
} finally {
|
||||
if (timeout) {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rawRequest(methodArg: 'GET' | 'POST', pathArg: string, commandArg: TSoundtouchCommand | 'refresh', bodyArg: string | undefined, headersArg: Record<string, string> | undefined, portArg: number): ISoundtouchRawCommandRequest {
|
||||
const path = pathArg.startsWith('/') ? pathArg : `/${pathArg}`;
|
||||
return {
|
||||
command: commandArg,
|
||||
method: methodArg,
|
||||
path,
|
||||
url: `${this.baseUrl(portArg)}${path}`,
|
||||
host: this.config.host,
|
||||
port: portArg,
|
||||
body: bodyArg,
|
||||
headers: headersArg,
|
||||
};
|
||||
}
|
||||
|
||||
private parseDeviceInfo(xmlArg: string): ISoundtouchDeviceInfo {
|
||||
const infoElement = readFirstElement(xmlArg, 'info') || xmlArg;
|
||||
const infoAttrs = elementAttributes(infoElement, 'info');
|
||||
return {
|
||||
deviceId: infoAttrs.deviceID || this.config.deviceId,
|
||||
name: readTagValue(infoElement, 'name') || this.config.name,
|
||||
type: readTagValue(infoElement, 'type') || this.config.model,
|
||||
accountUuid: readTagValue(infoElement, 'margeAccountUUID'),
|
||||
moduleType: readTagValue(infoElement, 'moduleType'),
|
||||
variant: readTagValue(infoElement, 'variant'),
|
||||
variantMode: readTagValue(infoElement, 'variantMode'),
|
||||
countryCode: readTagValue(infoElement, 'countryCode'),
|
||||
regionCode: readTagValue(infoElement, 'regionCode'),
|
||||
host: this.config.host,
|
||||
port: this.port(),
|
||||
networks: readAllElements(infoElement, 'networkInfo').map((networkArg) => ({
|
||||
type: elementAttributes(networkArg, 'networkInfo').type,
|
||||
macAddress: readTagValue(networkArg, 'macAddress'),
|
||||
ipAddress: readTagValue(networkArg, 'ipAddress'),
|
||||
})),
|
||||
components: readAllElements(infoElement, 'component').map((componentArg) => ({
|
||||
category: readTagValue(componentArg, 'componentCategory'),
|
||||
softwareVersion: readTagValue(componentArg, 'softwareVersion'),
|
||||
serialNumber: readTagValue(componentArg, 'serialNumber'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private parseStatus(xmlArg: string): ISoundtouchStatus {
|
||||
const statusElement = readFirstElement(xmlArg, 'nowPlaying') || xmlArg;
|
||||
const statusAttrs = elementAttributes(statusElement, 'nowPlaying');
|
||||
const contentElement = readFirstElement(statusElement, 'ContentItem');
|
||||
const artElement = readFirstElement(statusElement, 'art');
|
||||
const artAttrs = artElement ? elementAttributes(artElement, 'art') : {};
|
||||
const timeElement = readFirstElement(statusElement, 'time');
|
||||
const timeAttrs = timeElement ? elementAttributes(timeElement, 'time') : {};
|
||||
return {
|
||||
source: statusAttrs.source,
|
||||
contentItem: contentElement ? this.parseContentItem(contentElement) : undefined,
|
||||
track: readTagValue(statusElement, 'track'),
|
||||
artist: readTagValue(statusElement, 'artist'),
|
||||
album: readTagValue(statusElement, 'album'),
|
||||
image: artAttrs.artImageStatus === 'IMAGE_PRESENT' ? readTagValue(statusElement, 'art') : undefined,
|
||||
duration: numberValue(timeAttrs.total),
|
||||
position: numberValue(timeElement ? innerText(timeElement, 'time') : undefined),
|
||||
playStatus: readTagValue(statusElement, 'playStatus'),
|
||||
shuffleSetting: readTagValue(statusElement, 'shuffleSetting'),
|
||||
repeatSetting: readTagValue(statusElement, 'repeatSetting'),
|
||||
streamType: readTagValue(statusElement, 'streamType'),
|
||||
trackId: readTagValue(statusElement, 'trackID'),
|
||||
stationName: readTagValue(statusElement, 'stationName'),
|
||||
description: readTagValue(statusElement, 'description'),
|
||||
stationLocation: readTagValue(statusElement, 'stationLocation'),
|
||||
};
|
||||
}
|
||||
|
||||
private parseContentItem(xmlArg: string): ISoundtouchContentItem {
|
||||
const attrs = elementAttributes(xmlArg, 'ContentItem');
|
||||
return {
|
||||
name: readTagValue(xmlArg, 'itemName'),
|
||||
source: attrs.source,
|
||||
type: attrs.type,
|
||||
location: attrs.location,
|
||||
sourceAccount: attrs.sourceAccount,
|
||||
isPresetable: booleanValue(attrs.isPresetable),
|
||||
};
|
||||
}
|
||||
|
||||
private parseVolume(xmlArg: string): ISoundtouchVolume {
|
||||
return {
|
||||
actual: numberValue(readTagValue(xmlArg, 'actualvolume')),
|
||||
target: numberValue(readTagValue(xmlArg, 'targetvolume')),
|
||||
muted: booleanValue(readTagValue(xmlArg, 'muteenabled')),
|
||||
};
|
||||
}
|
||||
|
||||
private parsePresets(xmlArg: string): ISoundtouchPreset[] {
|
||||
return readAllElements(xmlArg, 'preset').map((presetArg) => {
|
||||
const attrs = elementAttributes(presetArg, 'preset');
|
||||
const contentElement = readFirstElement(presetArg, 'ContentItem');
|
||||
const content = contentElement ? this.parseContentItem(contentElement) : {};
|
||||
return {
|
||||
...content,
|
||||
presetId: attrs.id || readTagValue(presetArg, 'id') || '',
|
||||
sourceXml: contentElement,
|
||||
};
|
||||
}).filter((presetArg) => presetArg.presetId);
|
||||
}
|
||||
|
||||
private parseZoneStatus(xmlArg: string): ISoundtouchZoneStatus | undefined {
|
||||
const zoneElement = readFirstElement(xmlArg, 'zone') || xmlArg;
|
||||
const members = readAllElements(zoneElement, 'member');
|
||||
if (!members.length) {
|
||||
return undefined;
|
||||
}
|
||||
const attrs = elementAttributes(zoneElement, 'zone');
|
||||
return {
|
||||
masterId: attrs.master,
|
||||
masterIp: attrs.senderIPAddress,
|
||||
isMaster: attrs.master && this.config.deviceId ? attrs.master === this.config.deviceId : attrs.senderIPAddress === undefined,
|
||||
slaves: members.map((memberArg): ISoundtouchZoneMember => {
|
||||
const memberAttrs = elementAttributes(memberArg, 'member');
|
||||
return {
|
||||
deviceId: stripTags(innerXml(memberArg, 'member')).trim() || undefined,
|
||||
ipAddress: memberAttrs.ipaddress,
|
||||
role: memberAttrs.role,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: ISoundtouchSnapshot): ISoundtouchSnapshot {
|
||||
const config = {
|
||||
...snapshotArg.config,
|
||||
deviceId: snapshotArg.config.deviceId || this.config.deviceId || this.config.macAddress || this.config.host,
|
||||
name: snapshotArg.config.name || this.config.name || this.config.host || 'Bose SoundTouch',
|
||||
type: snapshotArg.config.type || this.config.model || 'SoundTouch',
|
||||
host: snapshotArg.config.host || this.config.host,
|
||||
port: snapshotArg.config.port || this.config.port || (this.config.host ? soundtouchDefaultPort : undefined),
|
||||
networks: snapshotArg.config.networks?.length ? snapshotArg.config.networks : this.config.macAddress || this.config.host ? [{ macAddress: this.config.macAddress, ipAddress: this.config.host }] : [],
|
||||
};
|
||||
const zone = snapshotArg.zone ? {
|
||||
...snapshotArg.zone,
|
||||
isMaster: snapshotArg.zone.masterId && config.deviceId ? snapshotArg.zone.masterId === config.deviceId : snapshotArg.zone.isMaster,
|
||||
slaves: snapshotArg.zone.slaves || [],
|
||||
} : undefined;
|
||||
return {
|
||||
...snapshotArg,
|
||||
config,
|
||||
presets: snapshotArg.presets || [],
|
||||
zone,
|
||||
sourceList: snapshotArg.sourceList?.length ? snapshotArg.sourceList : [...soundtouchDefaultSourceList],
|
||||
available: snapshotArg.available ?? Boolean(this.config.host || this.config.commandExecutor),
|
||||
lastUpdated: snapshotArg.lastUpdated || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private manualSnapshot(availableArg: boolean, errorArg?: string): ISoundtouchSnapshot {
|
||||
return {
|
||||
config: {
|
||||
deviceId: this.config.deviceId || this.config.macAddress || this.config.host,
|
||||
name: this.config.name || this.config.host || 'Bose SoundTouch',
|
||||
type: this.config.model || 'SoundTouch',
|
||||
host: this.config.host,
|
||||
port: this.config.port || (this.config.host ? soundtouchDefaultPort : undefined),
|
||||
networks: this.config.macAddress || this.config.host ? [{ macAddress: this.config.macAddress, ipAddress: this.config.host }] : [],
|
||||
},
|
||||
presets: [],
|
||||
sourceList: this.config.sourceList || [...soundtouchDefaultSourceList],
|
||||
available: availableArg,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: errorArg,
|
||||
};
|
||||
}
|
||||
|
||||
private ensureCommandTransport(): void {
|
||||
if (!this.config.host && !this.config.commandExecutor) {
|
||||
throw new Error('SoundTouch command transport requires config.host or commandExecutor. Static snapshots are read-only.');
|
||||
}
|
||||
}
|
||||
|
||||
private async executorResultToText(resultArg: unknown): Promise<string> {
|
||||
if (typeof resultArg === 'string') {
|
||||
return resultArg;
|
||||
}
|
||||
if (isFetchResponse(resultArg)) {
|
||||
return resultArg.text();
|
||||
}
|
||||
if (isRecord(resultArg) && typeof resultArg.body === 'string') {
|
||||
return resultArg.body;
|
||||
}
|
||||
if (isRecord(resultArg) && typeof resultArg.text === 'string') {
|
||||
return resultArg.text;
|
||||
}
|
||||
return resultArg === undefined ? '' : JSON.stringify(resultArg);
|
||||
}
|
||||
|
||||
private volumePercent(requestArg: ISoundtouchCommandRequest): number {
|
||||
const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel <= 1 ? requestArg.volumeLevel * 100 : requestArg.volumeLevel : undefined);
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error('SoundTouch volume command requires volumeLevel or volume.');
|
||||
}
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
private port(): number {
|
||||
return this.config.port || soundtouchDefaultPort;
|
||||
}
|
||||
|
||||
private dlnaPort(): number {
|
||||
return this.config.dlnaPort || soundtouchDefaultDlnaPort;
|
||||
}
|
||||
|
||||
private baseUrl(portArg: number): string {
|
||||
const rawHost = this.config.host || 'localhost';
|
||||
if (/^https?:\/\//i.test(rawHost)) {
|
||||
const url = new URL(rawHost);
|
||||
if (!url.port) {
|
||||
url.port = String(portArg);
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
const host = rawHost.replace(/\/.*$/, '');
|
||||
const hostPort = /^([^:]+):(\d+)$/.exec(host);
|
||||
if (hostPort) {
|
||||
return `http://${hostPort[1]}:${hostPort[2]}`;
|
||||
}
|
||||
return `http://${formatHost(host)}:${portArg}`;
|
||||
}
|
||||
|
||||
private cloneValue<TValue>(valueArg: TValue): TValue {
|
||||
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
}
|
||||
|
||||
const readFirstElement = (xmlArg: string, tagArg: string): string | undefined => {
|
||||
const escapedTag = escapeRegExp(tagArg);
|
||||
const match = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>[\\s\\S]*?<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>`, 'i').exec(xmlArg)
|
||||
|| new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>`, 'i').exec(xmlArg);
|
||||
return match?.[0];
|
||||
};
|
||||
|
||||
const readAllElements = (xmlArg: string, tagArg: string): string[] => {
|
||||
const escapedTag = escapeRegExp(tagArg);
|
||||
const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>[\\s\\S]*?<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>|<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>`, 'gi');
|
||||
const values: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(xmlArg))) {
|
||||
values.push(match[0]);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const readTagValue = (xmlArg: string, tagArg: string): string | undefined => {
|
||||
const element = readFirstElement(xmlArg, tagArg);
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
return stripTags(innerXml(element, tagArg)).trim() || undefined;
|
||||
};
|
||||
|
||||
const innerText = (elementArg: string, tagArg: string): string | undefined => {
|
||||
return stripTags(innerXml(elementArg, tagArg)).trim() || undefined;
|
||||
};
|
||||
|
||||
const innerXml = (elementArg: string, tagArg: string): string => {
|
||||
const escapedTag = escapeRegExp(tagArg);
|
||||
return elementArg
|
||||
.replace(new RegExp(`^<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>`, 'i'), '')
|
||||
.replace(new RegExp(`<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>$`, 'i'), '')
|
||||
.replace(new RegExp(`^<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>$`, 'i'), '');
|
||||
};
|
||||
|
||||
const elementAttributes = (elementArg: string, tagArg: string): Record<string, string> => {
|
||||
const escapedTag = escapeRegExp(tagArg);
|
||||
const match = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b([^>]*)>`, 'i').exec(elementArg);
|
||||
return parseAttributes(match?.[1] || '');
|
||||
};
|
||||
|
||||
const parseAttributes = (valueArg: string): Record<string, string> => {
|
||||
const attrs: Record<string, string> = {};
|
||||
const regex = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(valueArg))) {
|
||||
attrs[match[1]] = unescapeXml(match[2] ?? match[3] ?? '');
|
||||
}
|
||||
return attrs;
|
||||
};
|
||||
|
||||
const stripTags = (valueArg: string): string => unescapeXml(valueArg.replace(/<[^>]+>/g, ''));
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const normalized = valueArg.toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const escapeXml = (valueArg: string): string => {
|
||||
return valueArg.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
};
|
||||
|
||||
const unescapeXml = (valueArg: string): string => {
|
||||
return valueArg.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&');
|
||||
};
|
||||
|
||||
const escapeRegExp = (valueArg: string): string => valueArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
|
||||
};
|
||||
|
||||
const isFetchResponse = (valueArg: unknown): valueArg is Response => {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && typeof (valueArg as Response).text === 'function');
|
||||
};
|
||||
|
||||
const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { ISoundtouchConfig } from './soundtouch.types.js';
|
||||
import { soundtouchDefaultDlnaPort, soundtouchDefaultPort } from './soundtouch.types.js';
|
||||
|
||||
export class SoundtouchConfigFlow implements IConfigFlow<ISoundtouchConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISoundtouchConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Bose SoundTouch',
|
||||
description: candidateArg.source === 'manual'
|
||||
? 'Configure a local Bose SoundTouch speaker.'
|
||||
: 'Confirm or adjust the discovered local Bose SoundTouch speaker.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = stringValue(valuesArg.host) || candidateArg.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'SoundTouch host required', error: 'SoundTouch setup requires a host.' };
|
||||
}
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Bose SoundTouch configured',
|
||||
config: {
|
||||
host,
|
||||
port: numberValue(valuesArg.port) || candidateArg.port || soundtouchDefaultPort,
|
||||
dlnaPort: soundtouchDefaultDlnaPort,
|
||||
name: stringValue(valuesArg.name) || candidateArg.name,
|
||||
deviceId: candidateArg.id,
|
||||
model: candidateArg.model,
|
||||
macAddress: candidateArg.macAddress,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
};
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,26 +1,178 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { SoundtouchClient } from './soundtouch.classes.client.js';
|
||||
import { SoundtouchConfigFlow } from './soundtouch.classes.configflow.js';
|
||||
import { createSoundtouchDiscoveryDescriptor } from './soundtouch.discovery.js';
|
||||
import { SoundtouchMapper } from './soundtouch.mapper.js';
|
||||
import type { ISoundtouchCommandRequest, ISoundtouchConfig, TSoundtouchCommand } from './soundtouch.types.js';
|
||||
|
||||
export class HomeAssistantSoundtouchIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "soundtouch",
|
||||
displayName: "Bose SoundTouch",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/soundtouch",
|
||||
"upstreamDomain": "soundtouch",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"libsoundtouch==0.8"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@kroimon"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class SoundtouchIntegration extends BaseIntegration<ISoundtouchConfig> {
|
||||
public readonly domain = 'soundtouch';
|
||||
public readonly displayName = 'Bose SoundTouch';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createSoundtouchDiscoveryDescriptor();
|
||||
public readonly configFlow = new SoundtouchConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/soundtouch',
|
||||
upstreamDomain: 'soundtouch',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['libsoundtouch==0.8'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@kroimon'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/soundtouch',
|
||||
zeroconf: ['_soundtouch._tcp.local.'],
|
||||
};
|
||||
|
||||
public async setup(configArg: ISoundtouchConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new SoundtouchRuntime(new SoundtouchClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantSoundtouchIntegration extends SoundtouchIntegration {}
|
||||
|
||||
class SoundtouchRuntime implements IIntegrationRuntime {
|
||||
public domain = 'soundtouch';
|
||||
|
||||
constructor(private readonly client: SoundtouchClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return SoundtouchMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return SoundtouchMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'media_player') {
|
||||
return await this.callMediaPlayerService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'soundtouch') {
|
||||
return await this.callSoundtouchService(requestArg);
|
||||
}
|
||||
return { success: false, error: `Unsupported SoundTouch service domain: ${requestArg.domain}` };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const command = this.commandFromMediaService(requestArg.service);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported SoundTouch media_player service: ${requestArg.service}` };
|
||||
}
|
||||
const result = await this.client.execute(this.commandRequest(command, requestArg));
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
private async callSoundtouchService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'play_preset' || requestArg.service === 'select_preset') {
|
||||
const presetId = this.stringData(requestArg, 'preset_id') || this.stringData(requestArg, 'preset') || this.stringData(requestArg, 'presetId') || this.numberStringData(requestArg, 'preset');
|
||||
const presetName = this.stringData(requestArg, 'preset_name') || this.stringData(requestArg, 'presetName') || this.stringData(requestArg, 'name');
|
||||
const result = await this.client.execute({ command: 'play_preset', presetId, presetName });
|
||||
return { success: true, data: result };
|
||||
}
|
||||
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'media_play' || requestArg.service === 'media_pause' || requestArg.service === 'volume_set' || requestArg.service === 'volume_mute' || requestArg.service === 'select_source' || requestArg.service === 'play_media') {
|
||||
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
||||
}
|
||||
return { success: false, error: `Unsupported SoundTouch service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private commandFromMediaService(serviceArg: string): TSoundtouchCommand | undefined {
|
||||
if (serviceArg === 'turn_on') {
|
||||
return 'turn_on';
|
||||
}
|
||||
if (serviceArg === 'turn_off') {
|
||||
return 'turn_off';
|
||||
}
|
||||
if (serviceArg === 'media_play' || serviceArg === 'play') {
|
||||
return 'play';
|
||||
}
|
||||
if (serviceArg === 'media_pause' || serviceArg === 'pause') {
|
||||
return 'pause';
|
||||
}
|
||||
if (serviceArg === 'media_play_pause' || serviceArg === 'play_pause') {
|
||||
return 'play_pause';
|
||||
}
|
||||
if (serviceArg === 'media_previous_track' || serviceArg === 'previous_track') {
|
||||
return 'previous_track';
|
||||
}
|
||||
if (serviceArg === 'media_next_track' || serviceArg === 'next_track') {
|
||||
return 'next_track';
|
||||
}
|
||||
if (serviceArg === 'volume_up') {
|
||||
return 'volume_up';
|
||||
}
|
||||
if (serviceArg === 'volume_down') {
|
||||
return 'volume_down';
|
||||
}
|
||||
if (serviceArg === 'volume_set' || serviceArg === 'set_volume') {
|
||||
return 'set_volume';
|
||||
}
|
||||
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
|
||||
return 'mute';
|
||||
}
|
||||
if (serviceArg === 'select_source' || serviceArg === 'select_input') {
|
||||
return 'select_source';
|
||||
}
|
||||
if (serviceArg === 'play_media') {
|
||||
return 'play_media';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private commandRequest(commandArg: TSoundtouchCommand, requestArg: IServiceCallRequest): ISoundtouchCommandRequest {
|
||||
const request: ISoundtouchCommandRequest = { command: commandArg };
|
||||
if (commandArg === 'set_volume') {
|
||||
request.volumeLevel = this.numberData(requestArg, 'volume_level') ?? this.numberData(requestArg, 'volumeLevel');
|
||||
request.volume = this.numberData(requestArg, 'volume');
|
||||
}
|
||||
if (commandArg === 'mute') {
|
||||
request.muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute') ?? this.boolData(requestArg, 'muted');
|
||||
}
|
||||
if (commandArg === 'select_source') {
|
||||
request.source = this.stringData(requestArg, 'source') || this.stringData(requestArg, 'input');
|
||||
}
|
||||
if (commandArg === 'play_media') {
|
||||
request.mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri') || this.numberStringData(requestArg, 'media_content_id');
|
||||
request.mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType');
|
||||
request.url = this.stringData(requestArg, 'url');
|
||||
request.presetId = this.stringData(requestArg, 'preset_id') || this.stringData(requestArg, 'presetId') || this.numberStringData(requestArg, 'preset');
|
||||
request.presetName = this.stringData(requestArg, 'preset_name') || this.stringData(requestArg, 'presetName');
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
private numberStringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = this.numberData(requestArg, keyArg);
|
||||
return typeof value === 'number' ? String(value) : undefined;
|
||||
}
|
||||
|
||||
private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { ISoundtouchManualEntry, ISoundtouchMdnsRecord } from './soundtouch.types.js';
|
||||
import { soundtouchDefaultPort } from './soundtouch.types.js';
|
||||
|
||||
const soundtouchDomain = 'soundtouch';
|
||||
const soundtouchMdnsType = '_soundtouch._tcp.local.';
|
||||
|
||||
export class SoundtouchMdnsMatcher implements IDiscoveryMatcher<ISoundtouchMdnsRecord> {
|
||||
public id = 'soundtouch-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Bose SoundTouch zeroconf advertisements.';
|
||||
|
||||
public async matches(recordArg: ISoundtouchMdnsRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const type = recordArg.type || '';
|
||||
const txt = recordArg.txt || {};
|
||||
const matched = normalizeMdnsType(type) === normalizeMdnsType(soundtouchMdnsType);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not a SoundTouch advertisement.' };
|
||||
}
|
||||
|
||||
const id = valueForKey(txt, 'deviceId') || valueForKey(txt, 'deviceID') || valueForKey(txt, 'id') || recordArg.name;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: recordArg.host ? 'certain' : 'high',
|
||||
reason: 'mDNS record matches Bose SoundTouch zeroconf metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: soundtouchDomain,
|
||||
id,
|
||||
host: recordArg.host,
|
||||
port: recordArg.port || soundtouchDefaultPort,
|
||||
name: recordArg.name,
|
||||
manufacturer: 'Bose Corporation',
|
||||
model: valueForKey(txt, 'type') || valueForKey(txt, 'model'),
|
||||
macAddress: valueForKey(txt, 'mac') || valueForKey(txt, 'macAddress'),
|
||||
metadata: { mdnsName: recordArg.name, mdnsType: type, txt },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SoundtouchManualMatcher implements IDiscoveryMatcher<ISoundtouchManualEntry> {
|
||||
public id = 'soundtouch-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Bose SoundTouch setup entries.';
|
||||
|
||||
public async matches(inputArg: ISoundtouchManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
|
||||
const matched = Boolean(inputArg.host || inputArg.metadata?.soundtouch || haystack.includes('soundtouch') || haystack.includes('bose'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain SoundTouch setup hints.' };
|
||||
}
|
||||
|
||||
const id = inputArg.deviceId || inputArg.macAddress || inputArg.id || (inputArg.host ? `${inputArg.host}:${inputArg.port || soundtouchDefaultPort}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start SoundTouch setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: soundtouchDomain,
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port: inputArg.port || soundtouchDefaultPort,
|
||||
name: inputArg.name,
|
||||
manufacturer: normalizedManufacturer(inputArg.manufacturer),
|
||||
model: inputArg.model,
|
||||
macAddress: inputArg.macAddress,
|
||||
metadata: inputArg.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SoundtouchCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'soundtouch-candidate-validator';
|
||||
public description = 'Validate Bose SoundTouch candidates.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const matched = candidateArg.integrationDomain === soundtouchDomain
|
||||
|| includesSoundtouch(candidateArg.name)
|
||||
|| includesSoundtouch(candidateArg.model)
|
||||
|| includesBose(candidateArg.manufacturer)
|
||||
|| Boolean(metadata.soundtouch)
|
||||
|| normalizeMdnsType(stringMetadata(metadata.mdnsType) || '') === normalizeMdnsType(soundtouchMdnsType);
|
||||
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has Bose SoundTouch metadata.' : 'Candidate is not SoundTouch.',
|
||||
candidate: matched ? { ...candidateArg, port: candidateArg.port || soundtouchDefaultPort } : undefined,
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.macAddress,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createSoundtouchDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: soundtouchDomain, displayName: 'Bose SoundTouch' })
|
||||
.addMatcher(new SoundtouchMdnsMatcher())
|
||||
.addMatcher(new SoundtouchManualMatcher())
|
||||
.addValidator(new SoundtouchCandidateValidator());
|
||||
};
|
||||
|
||||
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerKey = keyArg.toLowerCase();
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (key.toLowerCase() === lowerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.$/, '');
|
||||
|
||||
const normalizedManufacturer = (valueArg: string | undefined): string => {
|
||||
if (!valueArg || valueArg.toLowerCase().includes('bose')) {
|
||||
return 'Bose Corporation';
|
||||
}
|
||||
return valueArg;
|
||||
};
|
||||
|
||||
const includesSoundtouch = (valueArg: string | undefined): boolean => Boolean(valueArg?.toLowerCase().includes('soundtouch'));
|
||||
|
||||
const includesBose = (valueArg: string | undefined): boolean => Boolean(valueArg?.toLowerCase().includes('bose'));
|
||||
|
||||
const stringMetadata = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg : undefined;
|
||||
@@ -0,0 +1,217 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { ISoundtouchPreset, ISoundtouchSnapshot, ISoundtouchStatus } from './soundtouch.types.js';
|
||||
|
||||
export class SoundtouchMapper {
|
||||
public static toDevices(snapshotArg: ISoundtouchSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.lastUpdated || new Date().toISOString();
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
|
||||
{ id: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
|
||||
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
|
||||
{ id: 'presets', capability: 'media', name: 'Presets', readable: true, writable: true },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'playback', value: this.mediaState(snapshotArg.status), updatedAt },
|
||||
{ featureId: 'source', value: snapshotArg.status?.source || null, updatedAt },
|
||||
{ featureId: 'volume', value: typeof snapshotArg.volume?.actual === 'number' ? snapshotArg.volume.actual : null, updatedAt },
|
||||
{ featureId: 'muted', value: typeof snapshotArg.volume?.muted === 'boolean' ? snapshotArg.volume.muted : null, updatedAt },
|
||||
{ featureId: 'presets', value: snapshotArg.presets?.length || 0, updatedAt },
|
||||
];
|
||||
|
||||
if (this.mediaTitle(snapshotArg.status)) {
|
||||
features.push({ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false });
|
||||
state.push({ featureId: 'current_title', value: this.mediaTitle(snapshotArg.status) || null, updatedAt });
|
||||
}
|
||||
|
||||
return [{
|
||||
id: this.playerDeviceId(snapshotArg),
|
||||
integrationDomain: 'soundtouch',
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'http',
|
||||
manufacturer: 'Bose Corporation',
|
||||
model: snapshotArg.config.type,
|
||||
online: snapshotArg.available !== false,
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
deviceId: snapshotArg.config.deviceId,
|
||||
host: snapshotArg.config.host,
|
||||
port: snapshotArg.config.port,
|
||||
macAddress: this.macAddress(snapshotArg),
|
||||
accountUuid: snapshotArg.config.accountUuid,
|
||||
moduleType: snapshotArg.config.moduleType,
|
||||
variant: snapshotArg.config.variant,
|
||||
softwareVersions: snapshotArg.config.components?.map((componentArg) => componentArg.softwareVersion).filter(Boolean),
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: ISoundtouchSnapshot): IIntegrationEntity[] {
|
||||
const base = this.entityBase(snapshotArg);
|
||||
const entities: IIntegrationEntity[] = [{
|
||||
id: `media_player.${base}`,
|
||||
uniqueId: `soundtouch_${this.uniqueBase(snapshotArg)}`,
|
||||
integrationDomain: 'soundtouch',
|
||||
deviceId: this.playerDeviceId(snapshotArg),
|
||||
platform: 'media_player',
|
||||
name: this.deviceName(snapshotArg),
|
||||
state: this.mediaState(snapshotArg.status),
|
||||
attributes: {
|
||||
deviceClass: 'speaker',
|
||||
deviceId: snapshotArg.config.deviceId,
|
||||
model: snapshotArg.config.type,
|
||||
macAddress: this.macAddress(snapshotArg),
|
||||
volumeLevel: typeof snapshotArg.volume?.actual === 'number' ? Math.max(0, Math.min(1, snapshotArg.volume.actual / 100)) : undefined,
|
||||
volume: snapshotArg.volume?.actual,
|
||||
targetVolume: snapshotArg.volume?.target,
|
||||
isVolumeMuted: snapshotArg.volume?.muted,
|
||||
source: snapshotArg.status?.source,
|
||||
sourceList: snapshotArg.sourceList || [],
|
||||
presetList: this.presetList(snapshotArg),
|
||||
mediaContentType: snapshotArg.status?.contentItem?.type || snapshotArg.status?.streamType,
|
||||
mediaContentId: snapshotArg.status?.contentItem?.location || snapshotArg.status?.trackId,
|
||||
mediaTitle: this.mediaTitle(snapshotArg.status),
|
||||
mediaArtist: snapshotArg.status?.artist,
|
||||
mediaTrack: snapshotArg.status?.track,
|
||||
mediaAlbumName: snapshotArg.status?.album,
|
||||
mediaImageUrl: snapshotArg.status?.image,
|
||||
mediaDuration: snapshotArg.status?.duration,
|
||||
mediaPosition: snapshotArg.status?.position,
|
||||
shuffle: snapshotArg.status?.shuffleSetting,
|
||||
repeat: snapshotArg.status?.repeatSetting,
|
||||
stationName: snapshotArg.status?.stationName,
|
||||
stationLocation: snapshotArg.status?.stationLocation,
|
||||
soundtouchZone: this.zoneAttribute(snapshotArg),
|
||||
soundtouchGroup: this.groupAttribute(snapshotArg),
|
||||
},
|
||||
available: snapshotArg.available !== false,
|
||||
}];
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_soundtouch_presets`,
|
||||
uniqueId: `soundtouch_${this.uniqueBase(snapshotArg)}_presets`,
|
||||
integrationDomain: 'soundtouch',
|
||||
deviceId: this.playerDeviceId(snapshotArg),
|
||||
platform: 'sensor',
|
||||
name: `${this.deviceName(snapshotArg)} SoundTouch Presets`,
|
||||
state: snapshotArg.presets?.length || 0,
|
||||
attributes: { presets: this.presetList(snapshotArg) },
|
||||
available: snapshotArg.available !== false,
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_soundtouch_sources`,
|
||||
uniqueId: `soundtouch_${this.uniqueBase(snapshotArg)}_sources`,
|
||||
integrationDomain: 'soundtouch',
|
||||
deviceId: this.playerDeviceId(snapshotArg),
|
||||
platform: 'sensor',
|
||||
name: `${this.deviceName(snapshotArg)} SoundTouch Sources`,
|
||||
state: snapshotArg.sourceList?.length || 0,
|
||||
attributes: { sourceList: snapshotArg.sourceList || [] },
|
||||
available: snapshotArg.available !== false,
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_soundtouch_media`,
|
||||
uniqueId: `soundtouch_${this.uniqueBase(snapshotArg)}_media`,
|
||||
integrationDomain: 'soundtouch',
|
||||
deviceId: this.playerDeviceId(snapshotArg),
|
||||
platform: 'sensor',
|
||||
name: `${this.deviceName(snapshotArg)} SoundTouch Media`,
|
||||
state: this.mediaTitle(snapshotArg.status) || snapshotArg.status?.source || 'None',
|
||||
attributes: { status: snapshotArg.status },
|
||||
available: snapshotArg.available !== false,
|
||||
});
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static playerDeviceId(snapshotArg: ISoundtouchSnapshot): string {
|
||||
return `soundtouch.player.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string | undefined): string {
|
||||
return (valueArg || 'soundtouch').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'soundtouch';
|
||||
}
|
||||
|
||||
private static mediaState(statusArg: ISoundtouchStatus | undefined): string {
|
||||
if (!statusArg || statusArg.source === 'STANDBY' || statusArg.playStatus === 'STOP_STATE') {
|
||||
return 'off';
|
||||
}
|
||||
if (statusArg.source === 'INVALID_SOURCE') {
|
||||
return 'unknown';
|
||||
}
|
||||
if (statusArg.playStatus === 'PLAY_STATE' || statusArg.playStatus === 'BUFFERING_STATE') {
|
||||
return 'playing';
|
||||
}
|
||||
if (statusArg.playStatus === 'PAUSE_STATE') {
|
||||
return 'paused';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
private static mediaTitle(statusArg: ISoundtouchStatus | undefined): string | undefined {
|
||||
if (!statusArg) {
|
||||
return undefined;
|
||||
}
|
||||
if (statusArg.stationName) {
|
||||
return statusArg.stationName;
|
||||
}
|
||||
if (statusArg.artist && statusArg.track) {
|
||||
return `${statusArg.artist} - ${statusArg.track}`;
|
||||
}
|
||||
return statusArg.track || statusArg.contentItem?.name || statusArg.description;
|
||||
}
|
||||
|
||||
private static presetList(snapshotArg: ISoundtouchSnapshot): Array<Record<string, unknown>> {
|
||||
return (snapshotArg.presets || []).map((presetArg: ISoundtouchPreset) => ({
|
||||
presetId: presetArg.presetId,
|
||||
name: presetArg.name,
|
||||
source: presetArg.source,
|
||||
type: presetArg.type,
|
||||
location: presetArg.location,
|
||||
sourceAccount: presetArg.sourceAccount,
|
||||
isPresetable: presetArg.isPresetable,
|
||||
}));
|
||||
}
|
||||
|
||||
private static zoneAttribute(snapshotArg: ISoundtouchSnapshot): Record<string, unknown> | undefined {
|
||||
if (!snapshotArg.zone) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
master: snapshotArg.zone.masterId,
|
||||
masterIp: snapshotArg.zone.masterIp,
|
||||
isMaster: snapshotArg.zone.isMaster,
|
||||
slaves: snapshotArg.zone.slaves,
|
||||
};
|
||||
}
|
||||
|
||||
private static groupAttribute(snapshotArg: ISoundtouchSnapshot): string[] | undefined {
|
||||
if (!snapshotArg.zone) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
snapshotArg.zone.masterId || snapshotArg.zone.masterIp,
|
||||
...snapshotArg.zone.slaves.map((slaveArg) => slaveArg.deviceId || slaveArg.ipAddress),
|
||||
].filter((valueArg): valueArg is string => Boolean(valueArg));
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: ISoundtouchSnapshot): string {
|
||||
return snapshotArg.config.name || snapshotArg.config.type || 'Bose SoundTouch';
|
||||
}
|
||||
|
||||
private static entityBase(snapshotArg: ISoundtouchSnapshot): string {
|
||||
return this.slug(this.deviceName(snapshotArg));
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: ISoundtouchSnapshot): string {
|
||||
return this.slug(snapshotArg.config.deviceId || this.macAddress(snapshotArg) || snapshotArg.config.host || this.deviceName(snapshotArg));
|
||||
}
|
||||
|
||||
private static macAddress(snapshotArg: ISoundtouchSnapshot): string | undefined {
|
||||
return snapshotArg.config.networks?.find((networkArg) => networkArg.type === 'SMSC')?.macAddress || snapshotArg.config.networks?.[0]?.macAddress;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,199 @@
|
||||
export interface IHomeAssistantSoundtouchConfig {
|
||||
// TODO: replace with the TypeScript-native config for soundtouch.
|
||||
[key: string]: unknown;
|
||||
export const soundtouchDefaultPort = 8090;
|
||||
export const soundtouchDefaultDlnaPort = 8091;
|
||||
export const soundtouchDefaultSourceList = ['AUX', 'BLUETOOTH'] as const;
|
||||
|
||||
export type TSoundtouchPlayStatus = 'PLAY_STATE' | 'BUFFERING_STATE' | 'PAUSE_STATE' | 'STOP_STATE' | string;
|
||||
|
||||
export type TSoundtouchSource =
|
||||
| 'SLAVE_SOURCE'
|
||||
| 'INTERNET_RADIO'
|
||||
| 'PANDORA'
|
||||
| 'AIRPLAY'
|
||||
| 'STORED_MUSIC'
|
||||
| 'AUX'
|
||||
| 'OFF_SOURCE'
|
||||
| 'CURRATED_RADIO'
|
||||
| 'STANDBY'
|
||||
| 'UPDATE'
|
||||
| 'DEEZER'
|
||||
| 'SPOTIFY'
|
||||
| 'IHEART'
|
||||
| 'LOCAL_MUSIC'
|
||||
| 'BLUETOOTH'
|
||||
| 'INVALID_SOURCE'
|
||||
| string;
|
||||
|
||||
export type TSoundtouchContentType = 'uri' | 'track' | 'album' | 'playlist' | string;
|
||||
|
||||
export type TSoundtouchCommand =
|
||||
| 'turn_on'
|
||||
| 'turn_off'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'play_pause'
|
||||
| 'previous_track'
|
||||
| 'next_track'
|
||||
| 'volume_up'
|
||||
| 'volume_down'
|
||||
| 'set_volume'
|
||||
| 'mute'
|
||||
| 'select_source'
|
||||
| 'play_media'
|
||||
| 'play_preset';
|
||||
|
||||
export interface ISoundtouchConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
dlnaPort?: number;
|
||||
name?: string;
|
||||
deviceId?: string;
|
||||
model?: string;
|
||||
macAddress?: string;
|
||||
sourceList?: string[];
|
||||
timeoutMs?: number;
|
||||
snapshot?: ISoundtouchSnapshot;
|
||||
commandExecutor?: ISoundtouchCommandExecutor;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantSoundtouchConfig extends ISoundtouchConfig {}
|
||||
|
||||
export interface ISoundtouchCommandExecutor {
|
||||
execute(requestArg: ISoundtouchRawCommandRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ISoundtouchRawCommandRequest {
|
||||
command: TSoundtouchCommand | 'refresh';
|
||||
method: 'GET' | 'POST';
|
||||
path: string;
|
||||
url: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ISoundtouchCommandRequest {
|
||||
command: TSoundtouchCommand;
|
||||
source?: string;
|
||||
presetId?: string;
|
||||
presetName?: string;
|
||||
mediaId?: string;
|
||||
mediaType?: string;
|
||||
url?: string;
|
||||
volumeLevel?: number;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
contentItem?: ISoundtouchContentItem;
|
||||
}
|
||||
|
||||
export interface ISoundtouchSnapshot {
|
||||
config: ISoundtouchDeviceInfo;
|
||||
status?: ISoundtouchStatus;
|
||||
volume?: ISoundtouchVolume;
|
||||
presets?: ISoundtouchPreset[];
|
||||
zone?: ISoundtouchZoneStatus;
|
||||
sourceList?: string[];
|
||||
available?: boolean;
|
||||
lastUpdated?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ISoundtouchDeviceInfo {
|
||||
deviceId?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
accountUuid?: string;
|
||||
moduleType?: string;
|
||||
variant?: string;
|
||||
variantMode?: string;
|
||||
countryCode?: string;
|
||||
regionCode?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
networks?: ISoundtouchNetworkInfo[];
|
||||
components?: ISoundtouchComponentInfo[];
|
||||
}
|
||||
|
||||
export interface ISoundtouchNetworkInfo {
|
||||
type?: string;
|
||||
macAddress?: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export interface ISoundtouchComponentInfo {
|
||||
category?: string;
|
||||
softwareVersion?: string;
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
export interface ISoundtouchContentItem {
|
||||
name?: string;
|
||||
source?: TSoundtouchSource;
|
||||
type?: TSoundtouchContentType;
|
||||
location?: string;
|
||||
sourceAccount?: string;
|
||||
isPresetable?: boolean;
|
||||
}
|
||||
|
||||
export interface ISoundtouchStatus {
|
||||
source?: TSoundtouchSource;
|
||||
contentItem?: ISoundtouchContentItem;
|
||||
track?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
image?: string;
|
||||
duration?: number;
|
||||
position?: number;
|
||||
playStatus?: TSoundtouchPlayStatus;
|
||||
shuffleSetting?: string;
|
||||
repeatSetting?: string;
|
||||
streamType?: string;
|
||||
trackId?: string;
|
||||
stationName?: string;
|
||||
description?: string;
|
||||
stationLocation?: string;
|
||||
}
|
||||
|
||||
export interface ISoundtouchVolume {
|
||||
actual?: number;
|
||||
target?: number;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface ISoundtouchPreset extends ISoundtouchContentItem {
|
||||
presetId: string;
|
||||
sourceXml?: string;
|
||||
}
|
||||
|
||||
export interface ISoundtouchZoneStatus {
|
||||
masterId?: string;
|
||||
masterIp?: string;
|
||||
isMaster?: boolean;
|
||||
slaves: ISoundtouchZoneMember[];
|
||||
}
|
||||
|
||||
export interface ISoundtouchZoneMember {
|
||||
deviceId?: string;
|
||||
ipAddress?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ISoundtouchMdnsRecord {
|
||||
name?: string;
|
||||
type?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
txt?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ISoundtouchManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
deviceId?: string;
|
||||
macAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user