Add native camera and media service integrations
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createAxisDiscoveryDescriptor } from '../../ts/integrations/axis/index.js';
|
||||
|
||||
tap.test('matches Axis mDNS records by service type and OUI', async () => {
|
||||
const descriptor = createAxisDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'axis-mdns-match');
|
||||
const result = await matcher!.matches({
|
||||
type: '_axis-video._tcp.local.',
|
||||
name: 'AXIS P3265._axis-video._tcp.local.',
|
||||
host: 'axis-p3265.local',
|
||||
port: 80,
|
||||
txt: {
|
||||
macaddress: 'ACCC8E123456',
|
||||
},
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.normalizedDeviceId).toEqual('accc8e123456');
|
||||
expect(result.candidate?.integrationDomain).toEqual('axis');
|
||||
});
|
||||
|
||||
tap.test('matches Axis SSDP records by manufacturer', async () => {
|
||||
const descriptor = createAxisDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'axis-ssdp-match');
|
||||
const result = await matcher!.matches({
|
||||
manufacturer: 'AXIS',
|
||||
location: 'http://192.168.1.50:80/',
|
||||
upnp: {
|
||||
friendlyName: 'AXIS Door Station',
|
||||
serialNumber: '00408C654321',
|
||||
modelName: 'I8116-E',
|
||||
},
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.host).toEqual('192.168.1.50');
|
||||
expect(result.candidate?.model).toEqual('I8116-E');
|
||||
});
|
||||
|
||||
tap.test('validates manual Axis candidates', async () => {
|
||||
const descriptor = createAxisDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'axis-manual-match');
|
||||
const match = await matcher!.matches({ host: 'axis.local', protocol: 'http', model: 'AXIS P3265' }, {});
|
||||
expect(match.matched).toBeTrue();
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validation = await validator.validate(match.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.candidate?.manufacturer).toEqual('Axis Communications AB');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { AxisMapper, type IAxisSnapshot } from '../../ts/integrations/axis/index.js';
|
||||
|
||||
const snapshot: IAxisSnapshot = {
|
||||
deviceInfo: {
|
||||
id: 'accc8e123456',
|
||||
serialNumber: 'ACCC8E123456',
|
||||
macAddress: 'accc8e123456',
|
||||
name: 'Front Door Axis',
|
||||
manufacturer: 'Axis Communications AB',
|
||||
model: 'I8116-E',
|
||||
firmwareVersion: '11.10.0',
|
||||
host: '192.168.1.50',
|
||||
port: 80,
|
||||
protocol: 'http',
|
||||
},
|
||||
cameras: [{
|
||||
id: '1',
|
||||
name: 'Front Door Camera',
|
||||
enabled: true,
|
||||
videoSource: 1,
|
||||
snapshotUrl: 'http://192.168.1.50/axis-cgi/jpg/image.cgi?camera=1',
|
||||
mjpegUrl: 'http://192.168.1.50/axis-cgi/mjpg/video.cgi?camera=1',
|
||||
rtspUrl: 'rtsp://192.168.1.50/axis-media/media.amp?videocodec=h264&camera=1',
|
||||
supportsPtz: true,
|
||||
}],
|
||||
sensors: [{ id: 'firmware_version', name: 'Firmware version', value: '11.10.0' }],
|
||||
binarySensors: [{ id: 'port_0', name: 'Call button', isOn: false, deviceClass: 'connectivity', source: '0' }],
|
||||
events: [{ id: 'doorbell', name: 'Doorbell', topicBase: 'tns1:Device/tnsaxis:IO/Port', isTripped: false, deviceClass: 'doorbell' }],
|
||||
ports: [{ id: '0', name: 'Call button', direction: 'input', state: 'open', normalState: 'open' }],
|
||||
relays: [{ id: '1', name: 'Door strike', direction: 'output', state: 'open', normalState: 'open' }],
|
||||
switches: [{ id: '1', name: 'Door strike', direction: 'output', state: 'open', normalState: 'open' }],
|
||||
lights: [],
|
||||
apiDiscovery: [{ id: 'io-port-management', version: '1.0' }, { id: 'ptz-control', version: '1.0' }],
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
tap.test('maps Axis cameras, sensors, events, and relays', async () => {
|
||||
const devices = AxisMapper.toDevices(snapshot);
|
||||
const entities = AxisMapper.toEntities(snapshot);
|
||||
expect(devices.length).toEqual(1);
|
||||
expect(devices[0].features.some((featureArg) => featureArg.capability === 'camera')).toBeTrue();
|
||||
expect(entities.some((entityArg) => entityArg.id === 'camera.front_door_camera')).toBeTrue();
|
||||
expect(entities.some((entityArg) => entityArg.id === 'switch.door_strike')).toBeTrue();
|
||||
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.call_button')).toBeTrue();
|
||||
expect(entities.some((entityArg) => entityArg.id === 'event.doorbell')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps relay and PTZ service commands', async () => {
|
||||
const relayCommand = AxisMapper.relayCommandForService(snapshot, {
|
||||
domain: 'switch',
|
||||
service: 'turn_on',
|
||||
target: { entityId: 'switch.door_strike' },
|
||||
});
|
||||
expect(relayCommand).toEqual({ portId: '1', state: 'closed' });
|
||||
|
||||
const ptzCommand = AxisMapper.ptzCommandForService(snapshot, {
|
||||
domain: 'axis',
|
||||
service: 'ptz_control',
|
||||
target: {},
|
||||
data: { camera: '1', move: 'left', speed: 50 },
|
||||
});
|
||||
expect(ptzCommand?.move).toEqual('left');
|
||||
expect(ptzCommand?.speed).toEqual(50);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createBraviatvDiscoveryDescriptor } from '../../ts/integrations/braviatv/index.js';
|
||||
|
||||
tap.test('matches Sony Bravia ScalarWebAPI SSDP records', async () => {
|
||||
const descriptor = createBraviatvDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
st: 'urn:schemas-sony-com:service:ScalarWebAPI:1',
|
||||
usn: 'uuid:bravia-udn-123::urn:schemas-sony-com:service:ScalarWebAPI:1',
|
||||
location: 'http://192.168.1.80:52323/dmr.xml',
|
||||
headers: {
|
||||
manufacturer: 'Sony Corporation',
|
||||
},
|
||||
upnp: {
|
||||
friendlyName: 'Living Room Bravia',
|
||||
modelName: 'XR-55A80J',
|
||||
X_ScalarWebAPI_DeviceInfo: {
|
||||
X_ScalarWebAPI_BaseURL: 'http://192.168.1.80/sony',
|
||||
X_ScalarWebAPI_ServiceList: {
|
||||
X_ScalarWebAPI_ServiceType: ['guide', 'system', 'audio', 'avContent', 'appControl', 'videoScreen'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.host).toEqual('192.168.1.80');
|
||||
expect(result.normalizedDeviceId).toEqual('bravia-udn-123');
|
||||
expect((result.candidate?.metadata?.scalarWebApiServices as string[]).includes('videoScreen')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('matches Sony Bravia mDNS records', async () => {
|
||||
const descriptor = createBraviatvDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const result = await matcher.matches({
|
||||
type: '_sonyapilib._tcp.local.',
|
||||
name: 'Living Room Bravia._sonyapilib._tcp.local.',
|
||||
host: 'living-room-bravia.local',
|
||||
port: 80,
|
||||
txt: {
|
||||
manufacturer: 'Sony Corporation',
|
||||
model: 'BRAVIA XR',
|
||||
cid: 'sony-cid-123',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.host).toEqual('living-room-bravia.local');
|
||||
expect(result.candidate?.port).toEqual(80);
|
||||
expect(result.normalizedDeviceId).toEqual('sony-cid-123');
|
||||
});
|
||||
|
||||
tap.test('validates Sony Bravia candidates', async () => {
|
||||
const descriptor = createBraviatvDiscoveryDescriptor();
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const result = await validator.validate({
|
||||
source: 'manual',
|
||||
integrationDomain: 'braviatv',
|
||||
host: '192.168.1.81',
|
||||
manufacturer: 'Sony',
|
||||
model: 'BRAVIA',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.confidence).toEqual('high');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { BraviatvMapper } from '../../ts/integrations/braviatv/index.js';
|
||||
|
||||
const snapshot = {
|
||||
systemInfo: {
|
||||
cid: 'sony-cid-123',
|
||||
macAddr: 'AA:BB:CC:DD:EE:FF',
|
||||
name: 'Living Room Bravia',
|
||||
model: 'XR-55A80J',
|
||||
serial: '1000001',
|
||||
generation: '6.5.0',
|
||||
},
|
||||
state: {
|
||||
powerStatus: 'active' as const,
|
||||
power: 'on' as const,
|
||||
playback: 'playing' as const,
|
||||
source: 'HDMI 1',
|
||||
sourceUri: 'extInput:hdmi?port=1',
|
||||
volumeLevel: 0.35,
|
||||
muted: false,
|
||||
mediaTitle: 'Movie Night',
|
||||
},
|
||||
sources: [
|
||||
{ title: 'HDMI 1', uri: 'extInput:hdmi?port=1', type: 'input' as const },
|
||||
{ title: 'HDMI 2', uri: 'extInput:hdmi?port=2', type: 'input' as const },
|
||||
],
|
||||
apps: [
|
||||
{ title: 'Netflix', uri: 'com.sony.dtv.com.netflix.ninja', type: 'app' as const },
|
||||
{ title: 'YouTube', uri: 'com.sony.dtv.com.google.android.youtube.tv', type: 'app' as const },
|
||||
],
|
||||
channels: [],
|
||||
};
|
||||
|
||||
tap.test('maps Sony Bravia snapshots to media devices and entities', async () => {
|
||||
const devices = BraviatvMapper.toDevices(snapshot);
|
||||
const entities = BraviatvMapper.toEntities(snapshot);
|
||||
|
||||
expect(devices[0].id).toEqual('braviatv.device.sony_cid_123');
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'HDMI 1')).toBeTrue();
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'volume' && stateArg.value === 35)).toBeTrue();
|
||||
expect(entities[0].id).toEqual('media_player.living_room_bravia');
|
||||
expect(entities[0].platform).toEqual('media_player');
|
||||
expect(entities[0].state).toEqual('playing');
|
||||
expect((entities[0].attributes?.sourceList as string[]).includes('Netflix')).toBeTrue();
|
||||
expect(entities[0].attributes?.volumeLevel).toEqual(0.35);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createDlnaDmrDiscoveryDescriptor } from '../../ts/integrations/dlna_dmr/index.js';
|
||||
|
||||
tap.test('matches DLNA DMR SSDP records', async () => {
|
||||
const descriptor = createDlnaDmrDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
st: 'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||||
usn: 'uuid:renderer-1::urn:schemas-upnp-org:device:MediaRenderer:1',
|
||||
location: 'http://192.168.1.50:8000/description.xml',
|
||||
upnp: {
|
||||
friendlyName: 'Living Room Renderer',
|
||||
manufacturer: 'Example',
|
||||
modelName: 'Renderer',
|
||||
serviceList: {
|
||||
service: [
|
||||
{ serviceId: 'urn:upnp-org:serviceId:AVTransport' },
|
||||
{ serviceId: 'urn:upnp-org:serviceId:ConnectionManager' },
|
||||
{ serviceId: 'urn:upnp-org:serviceId:RenderingControl' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.normalizedDeviceId).toEqual('uuid:renderer-1');
|
||||
expect(result.candidate?.integrationDomain).toEqual('dlna_dmr');
|
||||
});
|
||||
|
||||
tap.test('matches manual DLNA DMR entries', async () => {
|
||||
const descriptor = createDlnaDmrDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const result = await matcher.matches({ url: 'http://renderer.local/device.xml', name: 'Manual Renderer' }, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.metadata?.location).toEqual('http://renderer.local/device.xml');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DlnaDmrMapper } from '../../ts/integrations/dlna_dmr/index.js';
|
||||
import type { IDlnaDmrSnapshot } from '../../ts/integrations/dlna_dmr/index.js';
|
||||
|
||||
tap.test('maps DLNA renderer snapshots to canonical devices and entities', async () => {
|
||||
const snapshot: IDlnaDmrSnapshot = {
|
||||
device: {
|
||||
location: 'http://192.168.1.50:8000/description.xml',
|
||||
udn: 'uuid:renderer-1',
|
||||
deviceType: 'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||||
friendlyName: 'Living Room Renderer',
|
||||
manufacturer: 'Example',
|
||||
modelName: 'DMR 1',
|
||||
services: {},
|
||||
},
|
||||
state: {
|
||||
online: true,
|
||||
transport: {
|
||||
currentTransportState: 'PLAYING',
|
||||
currentTransportActions: ['Play', 'Pause', 'Stop'],
|
||||
},
|
||||
rendering: {
|
||||
volume: 42,
|
||||
muted: false,
|
||||
presets: ['FactoryDefaults', 'Movie'],
|
||||
selectedPreset: 'Movie',
|
||||
},
|
||||
media: {
|
||||
currentTrackUri: 'http://media.local/song.mp3',
|
||||
metadata: {
|
||||
title: 'Test Song',
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
upnpClass: 'object.item.audioItem.musicTrack',
|
||||
},
|
||||
position: {
|
||||
trackDurationSeconds: 240,
|
||||
relativeTimeSeconds: 12,
|
||||
},
|
||||
},
|
||||
sinkProtocolInfo: ['http-get:*:audio/mpeg:*'],
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
const devices = DlnaDmrMapper.toDevices(snapshot);
|
||||
const entities = DlnaDmrMapper.toEntities(snapshot);
|
||||
expect(devices[0].id).toEqual('dlna_dmr.renderer.uuid_renderer_1');
|
||||
expect(devices[0].state.find((stateArg) => stateArg.featureId === 'playback')?.value).toEqual('playing');
|
||||
expect(entities[0].id).toEqual('media_player.living_room_renderer');
|
||||
expect(entities[0].state).toEqual('playing');
|
||||
expect(entities[0].attributes?.volumeLevel).toEqual(0.42);
|
||||
expect(entities[0].attributes?.mediaContentType).toEqual('music');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createJellyfinDiscoveryDescriptor } from '../../ts/integrations/jellyfin/index.js';
|
||||
|
||||
tap.test('matches manual Jellyfin server URLs', async () => {
|
||||
const descriptor = createJellyfinDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'jellyfin-manual-match');
|
||||
const result = await matcher?.matches({
|
||||
url: 'http://jellyfin.local:8096',
|
||||
name: 'Home Jellyfin',
|
||||
}, {});
|
||||
expect(result?.matched).toBeTrue();
|
||||
expect(result?.candidate?.host).toEqual('jellyfin.local');
|
||||
expect(result?.candidate?.port).toEqual(8096);
|
||||
});
|
||||
|
||||
tap.test('matches Jellyfin SSDP records', async () => {
|
||||
const descriptor = createJellyfinDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'jellyfin-ssdp-match');
|
||||
const result = await matcher?.matches({
|
||||
st: 'urn:schemas-upnp-org:device:MediaServer:1',
|
||||
usn: 'uuid:jellyfin-server-1::urn:schemas-upnp-org:device:MediaServer:1',
|
||||
location: 'http://192.168.1.20:8096/dlna/server/description.xml',
|
||||
server: 'Jellyfin/10.10.7 UPnP/1.0',
|
||||
headers: {
|
||||
manufacturer: 'Jellyfin',
|
||||
modelName: 'Jellyfin Server',
|
||||
},
|
||||
}, {});
|
||||
expect(result?.matched).toBeTrue();
|
||||
expect(result?.normalizedDeviceId).toEqual('jellyfin-server-1');
|
||||
expect(result?.candidate?.host).toEqual('192.168.1.20');
|
||||
});
|
||||
|
||||
tap.test('validates Jellyfin candidates', async () => {
|
||||
const descriptor = createJellyfinDiscoveryDescriptor();
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const result = await validator.validate({
|
||||
source: 'manual',
|
||||
integrationDomain: 'jellyfin',
|
||||
host: '192.168.1.20',
|
||||
port: 8096,
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.confidence).toEqual('high');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { JellyfinMapper, type IJellyfinSnapshot } from '../../ts/integrations/jellyfin/index.js';
|
||||
|
||||
const snapshot: IJellyfinSnapshot = {
|
||||
server: {
|
||||
Id: 'server-1',
|
||||
Name: 'Home Jellyfin',
|
||||
Version: '10.10.7',
|
||||
},
|
||||
online: true,
|
||||
updatedAt: '2026-05-05T12:00:00.000Z',
|
||||
sessions: [
|
||||
{
|
||||
Id: 'session-1',
|
||||
UserName: 'phil',
|
||||
Client: 'Jellyfin Web',
|
||||
DeviceName: 'Living Room Browser',
|
||||
DeviceId: 'device-1',
|
||||
ApplicationVersion: '10.10.7',
|
||||
IsActive: true,
|
||||
SupportsRemoteControl: true,
|
||||
Capabilities: {
|
||||
SupportsMediaControl: true,
|
||||
SupportsPersistentIdentifier: true,
|
||||
SupportedCommands: ['Pause', 'Unpause', 'Stop', 'SetVolume'],
|
||||
},
|
||||
LastPlaybackCheckIn: '2026-05-05T12:01:00.000Z',
|
||||
PlayState: {
|
||||
IsPaused: false,
|
||||
IsMuted: false,
|
||||
PositionTicks: 1800000000,
|
||||
VolumeLevel: 55,
|
||||
},
|
||||
NowPlayingItem: {
|
||||
Id: 'movie-1',
|
||||
Name: 'Example Movie',
|
||||
Type: 'Movie',
|
||||
RunTimeTicks: 72000000000,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
tap.test('maps active Jellyfin sessions to media devices', async () => {
|
||||
const devices = JellyfinMapper.toDevices(snapshot);
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'jellyfin.server.server_1')).toBeTrue();
|
||||
const sessionDevice = devices.find((deviceArg) => deviceArg.id === 'jellyfin.session.device_1');
|
||||
expect(sessionDevice?.name).toEqual('Living Room Browser');
|
||||
expect(sessionDevice?.features.some((featureArg) => featureArg.id === 'remote_command')).toBeTrue();
|
||||
expect(sessionDevice?.state.some((stateArg) => stateArg.featureId === 'current_title' && stateArg.value === 'Example Movie')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps active Jellyfin sessions to media player entities', async () => {
|
||||
const entities = JellyfinMapper.toEntities(snapshot);
|
||||
const player = entities.find((entityArg) => entityArg.platform === 'media_player');
|
||||
expect(player?.id).toEqual('media_player.living_room_browser');
|
||||
expect(player?.state).toEqual('playing');
|
||||
expect(player?.attributes?.volumeLevel).toEqual(0.55);
|
||||
expect(player?.attributes?.mediaContentType).toEqual('movie');
|
||||
expect(player?.attributes?.mediaDuration).toEqual(7200);
|
||||
expect(player?.attributes?.mediaPosition).toEqual(180);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createMpdDiscoveryDescriptor } from '../../ts/integrations/mpd/index.js';
|
||||
|
||||
tap.test('matches MPD mDNS records', async () => {
|
||||
const descriptor = createMpdDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
name: 'Living Room MPD',
|
||||
type: '_mpd._tcp.local.',
|
||||
host: 'mpd.local',
|
||||
port: 6600,
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('mpd');
|
||||
expect(result.candidate?.port).toEqual(6600);
|
||||
expect(result.metadata?.mdnsType).toEqual('_mpd._tcp');
|
||||
});
|
||||
|
||||
tap.test('matches and validates manual MPD entries', async () => {
|
||||
const descriptor = createMpdDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const result = await matcher.matches({ host: '192.168.1.50', name: 'Kitchen MPD' }, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.port).toEqual(6600);
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validation = await validator.validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.normalizedDeviceId).toEqual('192.168.1.50:6600');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MpdMapper, type IMpdSnapshot } from '../../ts/integrations/mpd/index.js';
|
||||
|
||||
const snapshot: IMpdSnapshot = {
|
||||
server: {
|
||||
id: 'mpd-living-room',
|
||||
host: '192.168.1.50',
|
||||
port: 6600,
|
||||
name: 'Living Room MPD',
|
||||
protocolVersion: '0.24.0',
|
||||
},
|
||||
status: {
|
||||
volume: '42',
|
||||
repeat: '1',
|
||||
single: '0',
|
||||
random: '1',
|
||||
playlist: '12',
|
||||
playlistlength: '7',
|
||||
state: 'play',
|
||||
songid: '23',
|
||||
elapsed: '31.2',
|
||||
duration: '180.5',
|
||||
audio: '44100:16:2',
|
||||
bitrate: '320',
|
||||
lastloadedplaylist: 'Favorites',
|
||||
},
|
||||
currentSong: {
|
||||
file: 'artist/album/example.flac',
|
||||
artist: ['Example Artist', 'Guest Artist'],
|
||||
album: 'Example Album',
|
||||
title: 'Example Track',
|
||||
time: '180',
|
||||
id: '23',
|
||||
},
|
||||
outputs: [{
|
||||
outputid: 0,
|
||||
outputname: 'Main ALSA',
|
||||
plugin: 'alsa',
|
||||
outputenabled: true,
|
||||
attributes: { dop: '0' },
|
||||
}, {
|
||||
outputid: 1,
|
||||
outputname: 'Headphones',
|
||||
plugin: 'pulse',
|
||||
outputenabled: false,
|
||||
}],
|
||||
commands: ['status', 'currentsong', 'play', 'pause', 'setvol', 'outputs'],
|
||||
playlists: [{ playlist: 'Favorites' }, { playlist: 'Radio' }],
|
||||
stats: { songs: '2000', uptime: '3600' },
|
||||
online: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
tap.test('maps MPD server and outputs to canonical devices', async () => {
|
||||
const devices = MpdMapper.toDevices(snapshot);
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'mpd.server.mpd_living_room')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'mpd.output.mpd_living_room.0')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps MPD status current song and outputs to entities', async () => {
|
||||
const entities = MpdMapper.toEntities(snapshot);
|
||||
const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room_mpd');
|
||||
const status = entities.find((entityArg) => entityArg.id === 'sensor.living_room_mpd_mpd_status');
|
||||
const output = entities.find((entityArg) => entityArg.id === 'switch.living_room_mpd_main_alsa_mpd_output');
|
||||
|
||||
expect(player?.platform).toEqual('media_player');
|
||||
expect(player?.state).toEqual('playing');
|
||||
expect(player?.attributes?.volumeLevel).toEqual(0.42);
|
||||
expect(player?.attributes?.mediaTitle).toEqual('Example Track');
|
||||
expect(player?.attributes?.mediaArtist).toEqual('Example Artist, Guest Artist');
|
||||
expect(player?.attributes?.sourceList).toEqual(['Favorites', 'Radio']);
|
||||
expect(status?.state).toEqual('play');
|
||||
expect(output?.platform).toEqual('switch');
|
||||
expect(output?.state).toEqual('on');
|
||||
expect(output?.attributes?.mpdOutputId).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createOnvifDiscoveryDescriptor } from '../../ts/integrations/onvif/index.js';
|
||||
|
||||
tap.test('matches ONVIF WS-Discovery camera records', async () => {
|
||||
const descriptor = createOnvifDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
epr: 'urn:uuid:camera-001',
|
||||
xaddrs: ['http://192.168.1.50:8899/onvif/device_service'],
|
||||
types: ['dn:NetworkVideoTransmitter'],
|
||||
scopes: [
|
||||
'onvif://www.onvif.org/Profile/Streaming',
|
||||
'onvif://www.onvif.org/name/Driveway%20Camera',
|
||||
'onvif://www.onvif.org/hardware/IPC-123',
|
||||
'onvif://www.onvif.org/mac/AA-BB-CC-11-22-33',
|
||||
],
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.host).toEqual('192.168.1.50');
|
||||
expect(result.candidate?.port).toEqual(8899);
|
||||
expect(result.candidate?.name).toEqual('Driveway Camera');
|
||||
expect(result.normalizedDeviceId).toEqual('aa:bb:cc:11:22:33');
|
||||
});
|
||||
|
||||
tap.test('matches ONVIF mDNS camera records', async () => {
|
||||
const descriptor = createOnvifDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const result = await matcher.matches({
|
||||
type: '_onvif._tcp.local.',
|
||||
name: 'Porch Camera._onvif._tcp.local.',
|
||||
host: 'porch-camera.local',
|
||||
port: 80,
|
||||
txt: {
|
||||
model: 'IPC-321',
|
||||
mac: '00:11:22:33:44:55',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('onvif');
|
||||
expect(result.normalizedDeviceId).toEqual('00:11:22:33:44:55');
|
||||
});
|
||||
|
||||
tap.test('validates manual ONVIF candidates', async () => {
|
||||
const descriptor = createOnvifDiscoveryDescriptor();
|
||||
const manualMatcher = descriptor.getMatchers()[2];
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const manual = await manualMatcher.matches({
|
||||
host: '192.168.1.51',
|
||||
port: 80,
|
||||
name: 'Garage Camera',
|
||||
deviceInfo: {
|
||||
manufacturer: 'ExampleCam',
|
||||
model: 'Model S',
|
||||
serialNumber: 'SN123',
|
||||
},
|
||||
profiles: [],
|
||||
}, {});
|
||||
const validated = await validator.validate(manual.candidate!, {});
|
||||
|
||||
expect(manual.matched).toBeTrue();
|
||||
expect(validated.matched).toBeTrue();
|
||||
expect(validated.metadata?.manualSupported).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { OnvifMapper, type IOnvifSnapshot } from '../../ts/integrations/onvif/index.js';
|
||||
|
||||
const snapshot: IOnvifSnapshot = {
|
||||
id: 'front-door',
|
||||
name: 'Front Door',
|
||||
host: '192.168.1.60',
|
||||
port: 80,
|
||||
transport: 'http',
|
||||
connected: true,
|
||||
configured: true,
|
||||
cameras: [
|
||||
{
|
||||
id: 'front-door',
|
||||
name: 'Front Door',
|
||||
host: '192.168.1.60',
|
||||
port: 80,
|
||||
online: true,
|
||||
deviceInfo: {
|
||||
manufacturer: 'ExampleCam',
|
||||
model: 'IPC-4K',
|
||||
firmwareVersion: '1.2.3',
|
||||
serialNumber: 'FD1234',
|
||||
macAddress: 'AA:BB:CC:DD:EE:FF',
|
||||
},
|
||||
capabilities: {
|
||||
snapshot: true,
|
||||
stream: true,
|
||||
ptz: true,
|
||||
events: true,
|
||||
},
|
||||
profiles: [
|
||||
{
|
||||
index: 0,
|
||||
token: 'profile_1',
|
||||
name: 'Main',
|
||||
video: {
|
||||
encoding: 'H264',
|
||||
resolution: { width: 1920, height: 1080 },
|
||||
},
|
||||
streamUri: 'rtsp://192.168.1.60/stream1',
|
||||
snapshotUri: 'http://192.168.1.60/snapshot.jpg',
|
||||
ptz: {
|
||||
relative: true,
|
||||
presets: ['1'],
|
||||
},
|
||||
},
|
||||
],
|
||||
streams: [
|
||||
{
|
||||
profileToken: 'profile_1',
|
||||
uri: 'rtsp://192.168.1.60/stream1',
|
||||
protocol: 'rtsp',
|
||||
encoding: 'H264',
|
||||
resolution: { width: 1920, height: 1080 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
uid: 'front_motion',
|
||||
name: 'Motion',
|
||||
platform: 'binary_sensor',
|
||||
deviceClass: 'motion',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
tap.test('maps ONVIF cameras and profiles to canonical devices and constrained entities', async () => {
|
||||
const devices = OnvifMapper.toDevices(snapshot);
|
||||
const entities = OnvifMapper.toEntities(snapshot);
|
||||
|
||||
expect(devices[0].id).toEqual('onvif.camera.aa_bb_cc_dd_ee_ff');
|
||||
expect(devices[0].features.some((featureArg) => featureArg.capability === 'camera')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.front_door_main_camera')?.platform).toEqual('sensor');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.front_door_main_camera')?.attributes?.capability).toEqual('camera');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.front_door_motion')?.state).toEqual('on');
|
||||
});
|
||||
|
||||
tap.test('maps camera stream, snapshot, and PTZ services to ONVIF commands', async () => {
|
||||
const streamCommand = OnvifMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'stream_metadata',
|
||||
target: { entityId: 'sensor.front_door_main_camera' },
|
||||
});
|
||||
const snapshotCommand = OnvifMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'snapshot_metadata',
|
||||
target: { entityId: 'sensor.front_door_main_camera' },
|
||||
});
|
||||
const ptzCommand = OnvifMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'ptz',
|
||||
target: { entityId: 'sensor.front_door_main_camera' },
|
||||
data: { move_mode: 'RelativeMove', pan: 'LEFT', distance: 0.1 },
|
||||
});
|
||||
|
||||
expect(streamCommand?.type).toEqual('stream_metadata');
|
||||
expect(snapshotCommand?.type).toEqual('snapshot_metadata');
|
||||
expect(ptzCommand?.ptz?.moveMode).toEqual('RelativeMove');
|
||||
expect(ptzCommand?.ptz?.pan).toEqual('LEFT');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createPlexDiscoveryDescriptor } from '../../ts/integrations/plex/index.js';
|
||||
|
||||
tap.test('matches Plex GDM server responses', async () => {
|
||||
const descriptor = createPlexDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
data: {
|
||||
'Content-Type': 'plex/media-server',
|
||||
Name: 'Media Box',
|
||||
Port: '32400',
|
||||
'Resource-Identifier': 'server-abc',
|
||||
Version: '1.41.0',
|
||||
},
|
||||
from: ['192.168.1.10', 32414],
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('plex');
|
||||
expect(result.candidate?.host).toEqual('192.168.1.10');
|
||||
expect(result.candidate?.port).toEqual(32400);
|
||||
expect(result.normalizedDeviceId).toEqual('server-abc');
|
||||
});
|
||||
|
||||
tap.test('matches Plex zeroconf records', async () => {
|
||||
const descriptor = createPlexDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const result = await matcher.matches({
|
||||
type: '_plexmediasvr._tcp.local.',
|
||||
name: 'Media Box._plexmediasvr._tcp.local.',
|
||||
host: 'media-box.local',
|
||||
port: 32400,
|
||||
txt: {
|
||||
machineIdentifier: 'server-abc',
|
||||
},
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.host).toEqual('media-box.local');
|
||||
expect(result.candidate?.metadata?.discoveryProtocol).toEqual('mdns');
|
||||
});
|
||||
|
||||
tap.test('matches Plex SSDP records when advertised', async () => {
|
||||
const descriptor = createPlexDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[2];
|
||||
const result = await matcher.matches({
|
||||
headers: {
|
||||
location: 'http://192.168.1.10:32400/description.xml',
|
||||
server: 'Plex UPnP/1.0',
|
||||
usn: 'uuid:server-abc::urn:schemas-upnp-org:device:MediaServer:1',
|
||||
},
|
||||
friendlyName: 'Media Box Plex',
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.host).toEqual('192.168.1.10');
|
||||
expect(result.candidate?.port).toEqual(32400);
|
||||
});
|
||||
|
||||
tap.test('matches and validates manual Plex entries', async () => {
|
||||
const descriptor = createPlexDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[3];
|
||||
const result = await matcher.matches({ host: '192.168.1.10', token: 'secret', name: 'Media Box' }, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.port).toEqual(32400);
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validation = await validator.validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.confidence).toEqual('certain');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PlexMapper, type IPlexSnapshot } from '../../ts/integrations/plex/index.js';
|
||||
|
||||
const snapshot: IPlexSnapshot = {
|
||||
capturedAt: '2026-01-01T00:00:00.000Z',
|
||||
source: 'manual',
|
||||
online: true,
|
||||
server: {
|
||||
machineIdentifier: 'server-abc',
|
||||
friendlyName: 'Media Box',
|
||||
version: '1.41.0',
|
||||
platform: 'Linux',
|
||||
url: 'http://192.168.1.10:32400',
|
||||
host: '192.168.1.10',
|
||||
port: 32400,
|
||||
online: true,
|
||||
},
|
||||
clients: [{
|
||||
machineIdentifier: 'client-abc',
|
||||
title: 'Living Room TV',
|
||||
product: 'Plex for Android TV',
|
||||
platform: 'Android',
|
||||
host: '192.168.1.55',
|
||||
port: 32500,
|
||||
protocolCapabilities: ['playback', 'timeline'],
|
||||
source: 'GDM',
|
||||
volumeLevel: 0.42,
|
||||
muted: false,
|
||||
}],
|
||||
sessions: [{
|
||||
sessionKey: '7',
|
||||
ratingKey: '1001',
|
||||
key: '/library/metadata/1001',
|
||||
title: 'The Test Episode',
|
||||
type: 'episode',
|
||||
summary: 'A test episode.',
|
||||
duration: 3600000,
|
||||
viewOffset: 125000,
|
||||
librarySectionTitle: 'TV Shows',
|
||||
grandparentTitle: 'Example Show',
|
||||
parentTitle: 'Season 1',
|
||||
parentIndex: 1,
|
||||
index: 2,
|
||||
thumb: '/library/metadata/1001/thumb/1',
|
||||
state: 'playing',
|
||||
mediaPositionUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
User: { id: '1', title: 'Owner' },
|
||||
Player: {
|
||||
machineIdentifier: 'client-abc',
|
||||
title: 'Living Room TV',
|
||||
product: 'Plex for Android TV',
|
||||
platform: 'Android',
|
||||
state: 'playing',
|
||||
protocolCapabilities: ['playback', 'timeline'],
|
||||
},
|
||||
Session: { id: 'session-1', bandwidth: 9000, location: 'lan' },
|
||||
}],
|
||||
libraries: [{
|
||||
key: '1',
|
||||
uuid: 'library-tv',
|
||||
title: 'TV Shows',
|
||||
type: 'show',
|
||||
itemCount: 123,
|
||||
counts: { show: 10, season: 20, episode: 123 },
|
||||
refreshing: false,
|
||||
lastAddedItem: 'Example Show - S01E02 - The Test Episode',
|
||||
lastAddedTimestamp: '2026-01-01T00:00:00.000Z',
|
||||
}],
|
||||
};
|
||||
|
||||
tap.test('maps Plex servers and media clients to canonical devices', async () => {
|
||||
const devices = PlexMapper.toDevices(snapshot);
|
||||
const server = devices.find((deviceArg) => deviceArg.id === 'plex.server.server_abc');
|
||||
const client = devices.find((deviceArg) => deviceArg.id === 'plex.client.server_abc.client_abc');
|
||||
|
||||
expect(server?.online).toBeTrue();
|
||||
expect(server?.state.some((stateArg) => stateArg.featureId === 'active_sessions' && stateArg.value === 1)).toBeTrue();
|
||||
expect(client?.manufacturer).toEqual('Android');
|
||||
expect(client?.state.some((stateArg) => stateArg.featureId === 'current_title' && stateArg.value === 'The Test Episode')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Plex activity, media players, and libraries to entities', async () => {
|
||||
const entities = PlexMapper.toEntities(snapshot);
|
||||
const activity = entities.find((entityArg) => entityArg.id === 'sensor.media_box_plex');
|
||||
const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room_tv_plex');
|
||||
const library = entities.find((entityArg) => entityArg.id === 'sensor.media_box_tv_shows_plex_library');
|
||||
|
||||
expect(activity?.state).toEqual(1);
|
||||
expect(activity?.attributes?.watching).toEqual({ 'Owner - Plex for Android TV': 'Example Show - S1:E2 - The Test Episode' });
|
||||
expect(player?.state).toEqual('playing');
|
||||
expect(player?.attributes?.mediaContentType).toEqual('tvshow');
|
||||
expect(player?.attributes?.mediaDuration).toEqual(3600);
|
||||
expect(player?.attributes?.mediaPosition).toEqual(125);
|
||||
expect(player?.attributes?.volumeLevel).toEqual(0.42);
|
||||
expect(library?.state).toEqual(123);
|
||||
expect(library?.attributes?.primaryType).toEqual('episode');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createRainbirdDiscoveryDescriptor } from '../../ts/integrations/rainbird/index.js';
|
||||
|
||||
tap.test('matches manual Rain Bird setup entries', async () => {
|
||||
const descriptor = createRainbirdDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'rainbird-manual-match');
|
||||
const result = await matcher!.matches({
|
||||
host: '192.168.1.40',
|
||||
protocol: 'http',
|
||||
macAddress: 'AA:BB:CC:12:34:56',
|
||||
model: 'Rain Bird ESP-TM2',
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.normalizedDeviceId).toEqual('aabbcc123456');
|
||||
expect(result.candidate?.integrationDomain).toEqual('rainbird');
|
||||
expect(result.candidate?.port).toEqual(80);
|
||||
});
|
||||
|
||||
tap.test('validates Rain Bird candidates', async () => {
|
||||
const descriptor = createRainbirdDiscoveryDescriptor();
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const result = await validator.validate({
|
||||
source: 'manual',
|
||||
integrationDomain: 'rainbird',
|
||||
host: 'rainbird.local',
|
||||
manufacturer: 'Rain Bird',
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.manufacturer).toEqual('Rain Bird');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { RainbirdMapper, type IRainbirdSnapshot } from '../../ts/integrations/rainbird/index.js';
|
||||
|
||||
const snapshot: IRainbirdSnapshot = {
|
||||
controller: {
|
||||
id: 'aabbcc123456',
|
||||
name: 'Backyard Controller',
|
||||
manufacturer: 'Rain Bird',
|
||||
modelName: 'ESP-TM2',
|
||||
macAddress: 'aabbcc123456',
|
||||
rainSensorActive: false,
|
||||
rainDelayDays: 2,
|
||||
host: '192.168.1.40',
|
||||
},
|
||||
zones: [
|
||||
{ id: 1, name: 'Front Lawn', active: true, defaultDurationMinutes: 10 },
|
||||
{ id: 2, name: 'Back Beds', active: false, defaultDurationMinutes: 8 },
|
||||
],
|
||||
programs: [{
|
||||
id: 0,
|
||||
name: 'PGM A',
|
||||
enabled: true,
|
||||
starts: ['06:00'],
|
||||
frequency: 'custom',
|
||||
zoneDurations: [{ zoneId: 1, durationMinutes: 10 }, { zoneId: 2, durationMinutes: 8 }],
|
||||
}],
|
||||
events: [],
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
tap.test('maps Rain Bird zones and programs to canonical devices and entities', async () => {
|
||||
const devices = RainbirdMapper.toDevices(snapshot);
|
||||
const entities = RainbirdMapper.toEntities(snapshot);
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'rainbird.controller.aabbcc123456')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'rainbird.zone.aabbcc123456.1')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'rainbird.program.aabbcc123456.0')).toBeTrue();
|
||||
expect(entities.some((entityArg) => entityArg.id === 'switch.front_lawn' && entityArg.state === 'on')).toBeTrue();
|
||||
expect(entities.some((entityArg) => entityArg.id === 'sensor.pgm_a')).toBeTrue();
|
||||
expect(entities.some((entityArg) => entityArg.id === 'sensor.raindelay' && entityArg.state === 2)).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Rain Bird services to controller commands', async () => {
|
||||
const startCommand = RainbirdMapper.commandForService(snapshot, {
|
||||
domain: 'rainbird',
|
||||
service: 'start_zone',
|
||||
target: {},
|
||||
data: { zoneId: 2, duration: 7 },
|
||||
});
|
||||
expect(startCommand).toEqual({ type: 'start_zone', zoneId: 2, durationMinutes: 7, entityId: undefined, deviceId: undefined });
|
||||
|
||||
const switchCommand = RainbirdMapper.commandForService(snapshot, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.front_lawn' },
|
||||
});
|
||||
expect(switchCommand).toEqual({ type: 'stop_zone', zoneId: 1, entityId: 'switch.front_lawn', deviceId: undefined });
|
||||
|
||||
const rainDelayCommand = RainbirdMapper.commandForService(snapshot, {
|
||||
domain: 'rainbird',
|
||||
service: 'set_rain_delay',
|
||||
target: {},
|
||||
data: { duration: 4 },
|
||||
});
|
||||
expect(rainDelayCommand).toEqual({ type: 'set_rain_delay', days: 4, entityId: undefined, deviceId: undefined });
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createSnapcastDiscoveryDescriptor } from '../../ts/integrations/snapcast/index.js';
|
||||
|
||||
tap.test('matches Snapcast mDNS control records', async () => {
|
||||
const descriptor = createSnapcastDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
name: 'Snapcast',
|
||||
type: '_snapcast-ctrl._tcp.local.',
|
||||
host: 'snapserver.local',
|
||||
port: 1705,
|
||||
}, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('snapcast');
|
||||
expect(result.candidate?.port).toEqual(1705);
|
||||
expect(result.metadata?.transport).toEqual('tcp');
|
||||
});
|
||||
|
||||
tap.test('matches and validates manual Snapcast entries', async () => {
|
||||
const descriptor = createSnapcastDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const result = await matcher.matches({ host: '192.168.1.20', transport: 'http' }, {});
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.port).toEqual(1780);
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validation = await validator.validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.normalizedDeviceId).toEqual('192.168.1.20:1780');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SnapcastMapper, type ISnapcastSnapshot } from '../../ts/integrations/snapcast/index.js';
|
||||
|
||||
const snapshot: ISnapcastSnapshot = {
|
||||
capturedAt: '2026-01-01T00:00:00.000Z',
|
||||
source: 'manual',
|
||||
server: {
|
||||
groups: [{
|
||||
id: 'group-1',
|
||||
muted: false,
|
||||
name: 'Kitchen Group',
|
||||
stream_id: 'music',
|
||||
clients: [{
|
||||
id: 'client-1',
|
||||
connected: true,
|
||||
host: { name: 'Kitchen', ip: '192.168.1.31', mac: '00:11:22:33:44:55' },
|
||||
config: {
|
||||
latency: 25,
|
||||
name: 'Kitchen',
|
||||
volume: { muted: false, percent: 42 },
|
||||
},
|
||||
snapclient: { name: 'Snapclient', version: '0.29.0', protocolVersion: 2 },
|
||||
}],
|
||||
}],
|
||||
streams: [{
|
||||
id: 'music',
|
||||
status: 'playing',
|
||||
uri: { raw: 'pipe:///tmp/snapfifo?name=music', scheme: 'pipe', query: { name: 'music' } },
|
||||
metadata: { title: 'Example Track', artist: ['Example Artist'], album: 'Example Album' },
|
||||
properties: { position: 12 },
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('maps Snapcast clients groups and streams to canonical devices', async () => {
|
||||
const devices = SnapcastMapper.toDevices(snapshot);
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'snapcast.client.client_1')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'snapcast.group.group_1')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'snapcast.stream.music')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Snapcast clients groups and streams to entities', async () => {
|
||||
const entities = SnapcastMapper.toEntities(snapshot);
|
||||
const clientEntity = entities.find((entityArg) => entityArg.id === 'media_player.kitchen_snapcast_client');
|
||||
const groupEntity = entities.find((entityArg) => entityArg.id === 'media_player.kitchen_group_snapcast_group');
|
||||
const streamEntity = entities.find((entityArg) => entityArg.id === 'sensor.music_snapcast_stream');
|
||||
|
||||
expect(clientEntity?.platform).toEqual('media_player');
|
||||
expect(clientEntity?.state).toEqual('playing');
|
||||
expect(clientEntity?.attributes?.volumeLevel).toEqual(0.42);
|
||||
expect(groupEntity?.attributes?.source).toEqual('music');
|
||||
expect(streamEntity?.state).toEqual('playing');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createVolumioDiscoveryDescriptor } from '../../ts/integrations/volumio/index.js';
|
||||
|
||||
tap.test('matches Volumio zeroconf records', async () => {
|
||||
const descriptor = createVolumioDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
type: '_Volumio._tcp.local.',
|
||||
name: 'Kitchen._Volumio._tcp.local.',
|
||||
host: 'kitchen-volumio.local',
|
||||
port: 3000,
|
||||
txt: {
|
||||
volumioName: 'Kitchen Volumio',
|
||||
UUID: 'volumio-uuid-123',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.confidence).toEqual('certain');
|
||||
expect(result.normalizedDeviceId).toEqual('volumio-uuid-123');
|
||||
expect(result.candidate?.integrationDomain).toEqual('volumio');
|
||||
expect(result.candidate?.host).toEqual('kitchen-volumio.local');
|
||||
expect(result.candidate?.port).toEqual(3000);
|
||||
expect(result.candidate?.name).toEqual('Kitchen Volumio');
|
||||
});
|
||||
|
||||
tap.test('matches manual Volumio host entries and validates candidates', async () => {
|
||||
const descriptor = createVolumioDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const matched = await matcher.matches({
|
||||
host: '192.168.1.81',
|
||||
name: 'Office Volumio',
|
||||
uuid: 'manual-volumio-1',
|
||||
}, {});
|
||||
|
||||
expect(matched.matched).toBeTrue();
|
||||
expect(matched.candidate?.port).toEqual(3000);
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validated = await validator.validate(matched.candidate!, {});
|
||||
expect(validated.matched).toBeTrue();
|
||||
expect(validated.confidence).toEqual('high');
|
||||
});
|
||||
|
||||
tap.test('rejects unrelated mDNS records', async () => {
|
||||
const descriptor = createVolumioDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
type: '_http._tcp.local.',
|
||||
name: 'Office Printer',
|
||||
host: 'printer.local',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { VolumioMapper, type IVolumioSnapshot } from '../../ts/integrations/volumio/index.js';
|
||||
|
||||
const snapshot: IVolumioSnapshot = {
|
||||
deviceInfo: {
|
||||
uuid: 'volumio-uuid-123',
|
||||
name: 'Kitchen Volumio',
|
||||
host: '192.168.1.81',
|
||||
port: 3000,
|
||||
manufacturer: 'Volumio',
|
||||
hardware: 'Raspberry Pi',
|
||||
systemVersion: '3.661',
|
||||
},
|
||||
systemInfo: {
|
||||
id: 'volumio-uuid-123',
|
||||
name: 'Kitchen Volumio',
|
||||
},
|
||||
systemVersion: {
|
||||
hardware: 'Raspberry Pi',
|
||||
systemversion: '3.661',
|
||||
},
|
||||
state: {
|
||||
status: 'play',
|
||||
title: 'Test Track',
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
albumart: 'http://192.168.1.81:3000/albumart?cacheid=1',
|
||||
uri: 'music-library/NAS/test.flac',
|
||||
trackType: 'flac',
|
||||
seek: 123000,
|
||||
duration: 245,
|
||||
volume: 42,
|
||||
mute: false,
|
||||
random: true,
|
||||
repeat: false,
|
||||
service: 'mpd',
|
||||
samplerate: '44100',
|
||||
bitdepth: '16',
|
||||
},
|
||||
playlists: [{ name: 'Morning' }, { name: 'Evening' }],
|
||||
online: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
tap.test('maps Volumio snapshots to media devices', async () => {
|
||||
const devices = VolumioMapper.toDevices(snapshot);
|
||||
expect(devices[0].id).toEqual('volumio.device.volumio_uuid_123');
|
||||
expect(devices[0].protocol).toEqual('http');
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'playback' && stateArg.value === 'playing')).toBeTrue();
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'volume' && stateArg.value === 42)).toBeTrue();
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'current_title' && stateArg.value === 'Test Track')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Volumio snapshots to media player entities', async () => {
|
||||
const entities = VolumioMapper.toEntities(snapshot);
|
||||
expect(entities[0].id).toEqual('media_player.kitchen_volumio');
|
||||
expect(entities[0].platform).toEqual('media_player');
|
||||
expect(entities[0].state).toEqual('playing');
|
||||
expect(entities[0].attributes?.volumeLevel).toEqual(0.42);
|
||||
expect(entities[0].attributes?.mediaTitle).toEqual('Test Track');
|
||||
expect(entities[0].attributes?.mediaArtist).toEqual('Test Artist');
|
||||
expect(entities[0].attributes?.mediaPosition).toEqual(123);
|
||||
expect(entities[0].attributes?.mediaDuration).toEqual(245);
|
||||
expect(entities[0].attributes?.sourceList).toEqual(['Morning', 'Evening']);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createYamahaMusiccastDiscoveryDescriptor } from '../../ts/integrations/yamaha_musiccast/index.js';
|
||||
|
||||
tap.test('matches Yamaha MusicCast mDNS records', async () => {
|
||||
const descriptor = createYamahaMusiccastDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
name: 'Living Room MusicCast',
|
||||
type: '_http._tcp.local.',
|
||||
host: 'yamaha-rx.local',
|
||||
port: 80,
|
||||
txt: {
|
||||
manufacturer: 'Yamaha Corporation',
|
||||
model: 'RX-V685',
|
||||
system_id: '03E88CF3',
|
||||
device_id: '4C1B86A6CBF5',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.normalizedDeviceId).toEqual('03E88CF3');
|
||||
expect(result.candidate?.integrationDomain).toEqual('yamaha_musiccast');
|
||||
expect(result.candidate?.host).toEqual('yamaha-rx.local');
|
||||
});
|
||||
|
||||
tap.test('matches manual Yamaha MusicCast entries and validates candidates', async () => {
|
||||
const descriptor = createYamahaMusiccastDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[2];
|
||||
const matched = await matcher.matches({
|
||||
host: '192.168.1.70',
|
||||
name: 'Kitchen MusicCast',
|
||||
model: 'WX-021',
|
||||
systemId: 'ABCD1234',
|
||||
}, {});
|
||||
|
||||
expect(matched.matched).toBeTrue();
|
||||
expect(matched.candidate?.port).toEqual(80);
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validated = await validator.validate(matched.candidate!, {});
|
||||
expect(validated.matched).toBeTrue();
|
||||
expect(validated.confidence).toEqual('high');
|
||||
});
|
||||
|
||||
tap.test('rejects unrelated mDNS records', async () => {
|
||||
const descriptor = createYamahaMusiccastDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
name: 'Office Printer',
|
||||
type: '_ipp._tcp.local.',
|
||||
host: 'printer.local',
|
||||
txt: { manufacturer: 'Brother' },
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { YamahaMusiccastMapper, type IYamahaMusiccastSnapshot } from '../../ts/integrations/yamaha_musiccast/index.js';
|
||||
|
||||
const snapshot: IYamahaMusiccastSnapshot = {
|
||||
deviceInfo: {
|
||||
model_name: 'RX-V685',
|
||||
device_id: '4C1B86A6CBF5',
|
||||
system_id: '03E88CF3',
|
||||
serial_number: 'Y459229YO',
|
||||
system_version: 1.96,
|
||||
api_version: 2.11,
|
||||
},
|
||||
networkStatus: {
|
||||
network_name: 'Living Room RX',
|
||||
ip_address: '192.168.1.70',
|
||||
mac_address: {
|
||||
wired_lan: '4C1B86A6CBF5',
|
||||
},
|
||||
},
|
||||
inputNames: {
|
||||
hdmi1: 'Apple TV',
|
||||
server: 'Media Server',
|
||||
tuner: 'Tuner',
|
||||
audio1: 'AUDIO1',
|
||||
},
|
||||
netusb: {
|
||||
input: 'server',
|
||||
playback: 'pause',
|
||||
repeat: 'all',
|
||||
shuffle: 'on',
|
||||
artist: 'Artist One',
|
||||
album: 'Album One',
|
||||
track: 'Track One',
|
||||
total_time: 240,
|
||||
play_time: 12,
|
||||
},
|
||||
distribution: {
|
||||
group_id: '00000000000000000000000000000000',
|
||||
role: 'none',
|
||||
},
|
||||
zones: [{
|
||||
zone: 'main',
|
||||
name: 'Main Zone',
|
||||
power: 'on',
|
||||
available: true,
|
||||
volume: 80,
|
||||
minVolume: 0,
|
||||
maxVolume: 160,
|
||||
muted: false,
|
||||
input: 'server',
|
||||
inputList: ['hdmi1', 'server', 'tuner'],
|
||||
soundProgram: 'straight',
|
||||
soundProgramList: ['straight', '7ch_stereo'],
|
||||
toneControl: { mode: 'manual', bass: 1, treble: -1 },
|
||||
toneControlModeList: ['manual', 'auto', 'bypass'],
|
||||
linkControl: 'standard',
|
||||
linkControlList: ['speed', 'standard', 'stability'],
|
||||
extraBass: true,
|
||||
enhancer: false,
|
||||
rangeStep: [
|
||||
{ id: 'volume', min: 0, max: 160, step: 1 },
|
||||
{ id: 'tone_control', min: -12, max: 12, step: 1 },
|
||||
],
|
||||
}, {
|
||||
zone: 'zone2',
|
||||
name: 'Patio',
|
||||
power: 'standby',
|
||||
available: true,
|
||||
volumeLevel: 0.25,
|
||||
muted: true,
|
||||
input: 'audio1',
|
||||
inputList: ['server', 'tuner', 'audio1'],
|
||||
}],
|
||||
};
|
||||
|
||||
tap.test('maps Yamaha MusicCast zones to canonical devices', async () => {
|
||||
const devices = YamahaMusiccastMapper.toDevices(snapshot);
|
||||
expect(devices.length).toEqual(2);
|
||||
expect(devices[0].id).toEqual('yamaha_musiccast.player.03e88cf3');
|
||||
expect(devices[0].manufacturer).toEqual('Yamaha Corporation');
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'server')).toBeTrue();
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'capability_extra_bass' && stateArg.value === true)).toBeTrue();
|
||||
expect(devices[1].metadata?.viaDeviceId).toEqual('yamaha_musiccast.player.03e88cf3');
|
||||
});
|
||||
|
||||
tap.test('maps Yamaha MusicCast zones to media, switch, select, and number entities', async () => {
|
||||
const entities = YamahaMusiccastMapper.toEntities(snapshot);
|
||||
const media = entities.find((entityArg) => entityArg.id === 'media_player.living_room_rx');
|
||||
const zone2 = entities.find((entityArg) => entityArg.id === 'media_player.living_room_rx_zone2');
|
||||
const extraBass = entities.find((entityArg) => entityArg.id === 'switch.living_room_rx_extra_bass');
|
||||
const linkControl = entities.find((entityArg) => entityArg.id === 'select.living_room_rx_link_control');
|
||||
const toneBass = entities.find((entityArg) => entityArg.id === 'number.living_room_rx_tone_control_bass');
|
||||
|
||||
expect(media?.state).toEqual('paused');
|
||||
expect(media?.attributes?.volumeLevel).toEqual(0.5);
|
||||
expect(media?.attributes?.source).toEqual('Media Server');
|
||||
expect(media?.attributes?.mediaTitle).toEqual('Track One');
|
||||
expect(zone2?.state).toEqual('off');
|
||||
expect(extraBass?.state).toEqual(true);
|
||||
expect(extraBass?.attributes?.capabilityId).toEqual('extra_bass');
|
||||
expect(linkControl?.state).toEqual('standard');
|
||||
expect(toneBass?.state).toEqual(1);
|
||||
expect(toneBass?.attributes?.nativeMinValue).toEqual(-12);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user