Add native local NAS and network service integrations

This commit is contained in:
2026-05-05 19:37:20 +00:00
parent a144ef687c
commit ae901a3308
69 changed files with 13245 additions and 183 deletions
@@ -0,0 +1,31 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SqueezeboxConfigFlow } from '../../ts/integrations/squeezebox/index.js';
tap.test('creates Squeezebox config from discovered host and credentials', async () => {
const flow = new SqueezeboxConfigFlow();
const step = await flow.start({
source: 'mdns',
integrationDomain: 'squeezebox',
id: 'server-uuid-1',
host: 'home-lms.local',
port: 9000,
name: 'Home LMS',
}, {});
expect(step.kind).toEqual('form');
const done = await step.submit!({ username: 'lms', password: 'secret', https: true, volumeStep: 10 });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('home-lms.local');
expect(done.config?.https).toBeTrue();
expect(done.config?.serverId).toEqual('server-uuid-1');
expect(done.config?.volumeStep).toEqual(10);
});
tap.test('requires an LMS host for manual setup', async () => {
const flow = new SqueezeboxConfigFlow();
const step = await flow.start({ source: 'manual', integrationDomain: 'squeezebox' }, {});
const result = await step.submit!({});
expect(result.kind).toEqual('error');
});
export default tap.start();
@@ -0,0 +1,40 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createSqueezeboxDiscoveryDescriptor } from '../../ts/integrations/squeezebox/index.js';
tap.test('matches LMS mDNS advertisements', async () => {
const descriptor = createSqueezeboxDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
name: 'Home LMS._squeezebox-jsonrpc._tcp.local.',
type: '_squeezebox-jsonrpc._tcp.local.',
host: 'home-lms.local',
port: 9000,
txt: { uuid: 'server-uuid-1', name: 'Home LMS' },
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('certain');
expect(result.normalizedDeviceId).toEqual('server-uuid-1');
expect(result.candidate?.host).toEqual('home-lms.local');
expect(result.candidate?.port).toEqual(9000);
});
tap.test('matches DHCP player hints and manual LMS candidates', async () => {
const descriptor = createSqueezeboxDiscoveryDescriptor();
const dhcp = await descriptor.getMatchers()[1].matches({
hostname: 'squeezebox-kitchen',
macaddress: '00:04:20:AA:BB:02',
ipAddress: '192.168.1.51',
}, {});
expect(dhcp.matched).toBeTrue();
expect(dhcp.candidate?.metadata?.playerDiscovery).toBeTrue();
const manual = await descriptor.getMatchers()[2].matches({ host: '192.168.1.40', name: 'Manual LMS' }, {});
expect(manual.matched).toBeTrue();
expect(manual.candidate?.port).toEqual(9000);
const validation = await descriptor.getValidators()[0].validate(manual.candidate!, {});
expect(validation.matched).toBeTrue();
});
export default tap.start();
@@ -0,0 +1,87 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SqueezeboxMapper, type ISqueezeboxSnapshot } from '../../ts/integrations/squeezebox/index.js';
const snapshot: ISqueezeboxSnapshot = {
server: {
id: 'lms-1',
uuid: 'server-uuid-1',
name: 'Home LMS',
host: '192.168.1.40',
version: '8.5.0',
playerCount: 2,
stats: { totalSongs: 1200, totalAlbums: 100 },
},
players: [{
playerId: '00:04:20:aa:bb:01',
name: 'Living Room',
model: 'Squeezebox Radio',
firmware: '8.5.0',
connected: true,
power: true,
mode: 'play',
volume: 45,
muting: false,
repeat: 'playlist',
shuffle: 'song',
title: 'Example Track',
artist: 'Example Artist',
album: 'Example Album',
url: 'http://radio.example/stream.mp3',
duration: 180,
time: 30,
syncGroup: ['00:04:20:aa:bb:02'],
}, {
playerId: '00:04:20:aa:bb:02',
name: 'Kitchen',
model: 'Squeezebox Touch',
connected: true,
power: true,
mode: 'pause',
volume: 25,
muting: true,
syncGroup: ['00:04:20:aa:bb:01'],
}],
favorites: [{
id: 'fav-1',
name: 'Jazz Radio',
url: 'http://radio.example/stream.mp3',
type: 'audio',
playable: true,
}],
syncGroups: [{
id: 'sync-main',
name: 'Downstairs',
leaderPlayerId: '00:04:20:aa:bb:01',
playerIds: ['00:04:20:aa:bb:01', '00:04:20:aa:bb:02'],
}],
online: true,
source: 'snapshot',
};
tap.test('maps Squeezebox server and players to devices', async () => {
const devices = SqueezeboxMapper.toDevices(snapshot);
const server = devices.find((deviceArg) => deviceArg.id === 'squeezebox.server.server_uuid_1');
const player = devices.find((deviceArg) => deviceArg.id === 'squeezebox.player.00_04_20_aa_bb_01');
expect(server?.state.some((stateArg) => stateArg.featureId === 'favorites' && stateArg.value === 1)).toBeTrue();
expect(player?.manufacturer).toEqual('Logitech');
expect(player?.state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Jazz Radio')).toBeTrue();
});
tap.test('maps Squeezebox media entities favorites and sync groups', async () => {
const entities = SqueezeboxMapper.toEntities(snapshot);
const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room');
const favorites = entities.find((entityArg) => entityArg.id === 'sensor.home_lms_favorites');
const syncGroups = entities.find((entityArg) => entityArg.id === 'sensor.home_lms_sync_groups');
const media = entities.find((entityArg) => entityArg.id === 'sensor.living_room_squeezebox_media');
expect(player?.state).toEqual('playing');
expect(player?.attributes?.volumeLevel).toEqual(0.45);
expect(player?.attributes?.source).toEqual('Jazz Radio');
expect(player?.attributes?.groupMembers).toEqual(['media_player.living_room', 'media_player.kitchen']);
expect(player?.attributes?.repeat).toEqual('all');
expect(favorites?.state).toEqual(1);
expect(syncGroups?.state).toEqual(1);
expect(media?.state).toEqual('Example Track');
});
export default tap.start();
@@ -0,0 +1,91 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SqueezeboxClient, SqueezeboxIntegration, type ISqueezeboxRawCommandRequest, type ISqueezeboxSnapshot } from '../../ts/integrations/squeezebox/index.js';
const snapshot: ISqueezeboxSnapshot = {
server: { id: 'lms-1', uuid: 'server-uuid-1', name: 'Home LMS', host: '192.168.1.40' },
players: [{
playerId: '00:04:20:aa:bb:01',
name: 'Living Room',
model: 'Squeezebox Radio',
connected: true,
power: true,
mode: 'pause',
volume: 20,
syncGroup: ['00:04:20:aa:bb:02'],
}, {
playerId: '00:04:20:aa:bb:02',
name: 'Kitchen',
model: 'Squeezebox Touch',
connected: true,
power: true,
mode: 'pause',
volume: 30,
syncGroup: ['00:04:20:aa:bb:01'],
}],
favorites: [{ id: 'fav-1', name: 'Jazz Radio', url: 'http://radio.example/stream.mp3', playable: true }],
syncGroups: [{ id: 'sync-main', playerIds: ['00:04:20:aa:bb:01', '00:04:20:aa:bb:02'], leaderPlayerId: '00:04:20:aa:bb:01' }],
online: true,
};
tap.test('models Squeezebox playback volume source and sync commands through an executor', async () => {
const commands: ISqueezeboxRawCommandRequest[] = [];
const runtime = await new SqueezeboxIntegration().setup({
snapshot,
commandExecutor: {
execute: async (requestArg) => {
commands.push(requestArg);
return { id: requestArg.body?.id, result: {} };
},
},
}, {});
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: '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(join.success).toBeTrue();
expect(unjoin.success).toBeTrue();
expect(commands.map((commandArg) => commandArg.command)).toEqual([
['play'],
['mixer', 'volume', '35'],
['playlist', 'play', 'http://radio.example/stream.mp3'],
['sync', '00:04:20:aa:bb:02'],
['sync', '-'],
]);
expect(commands[0].playerId).toEqual('00:04:20:aa:bb:01');
expect(commands[4].playerId).toEqual('00:04:20:aa:bb:02');
});
tap.test('does not report live command success for static snapshots without transport', async () => {
const runtime = await new SqueezeboxIntegration().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('uses native LMS JSON-RPC over HTTP for live commands', async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; body: ISqueezeboxRawCommandRequest['body']; authorization?: string }> = [];
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
const headers = initArg?.headers as Record<string, string> | undefined;
calls.push({ url: String(urlArg), body: JSON.parse(String(initArg?.body)), authorization: headers?.authorization });
return new Response(JSON.stringify({ id: 1, method: 'slim.request', result: {} }), { status: 200, headers: { 'content-type': 'application/json' } });
}) as typeof globalThis.fetch;
try {
await new SqueezeboxClient({ host: 'lms.local', username: 'user', password: 'pass' }).execute({ command: 'set_volume', playerId: '00:04:20:aa:bb:01', volumeLevel: 0.5 });
expect(calls[0].url).toEqual('http://lms.local:9000/jsonrpc.js');
expect(calls[0].body?.method).toEqual('slim.request');
expect(calls[0].body?.params).toEqual(['00:04:20:aa:bb:01', ['mixer', 'volume', '50']]);
expect(calls[0].authorization?.startsWith('Basic ')).toBeTrue();
} finally {
globalThis.fetch = originalFetch;
}
});
export default tap.start();