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();
|
||||||
+22
@@ -4,26 +4,37 @@ export * from './integrations/index.js';
|
|||||||
|
|
||||||
import { HueIntegration } from './integrations/hue/index.js';
|
import { HueIntegration } from './integrations/hue/index.js';
|
||||||
import { AndroidtvIntegration } from './integrations/androidtv/index.js';
|
import { AndroidtvIntegration } from './integrations/androidtv/index.js';
|
||||||
|
import { AxisIntegration } from './integrations/axis/index.js';
|
||||||
|
import { BraviatvIntegration } from './integrations/braviatv/index.js';
|
||||||
import { CastIntegration } from './integrations/cast/index.js';
|
import { CastIntegration } from './integrations/cast/index.js';
|
||||||
import { DeconzIntegration } from './integrations/deconz/index.js';
|
import { DeconzIntegration } from './integrations/deconz/index.js';
|
||||||
import { DenonavrIntegration } from './integrations/denonavr/index.js';
|
import { DenonavrIntegration } from './integrations/denonavr/index.js';
|
||||||
|
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
|
||||||
import { EsphomeIntegration } from './integrations/esphome/index.js';
|
import { EsphomeIntegration } from './integrations/esphome/index.js';
|
||||||
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
|
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
|
||||||
|
import { JellyfinIntegration } from './integrations/jellyfin/index.js';
|
||||||
import { KodiIntegration } from './integrations/kodi/index.js';
|
import { KodiIntegration } from './integrations/kodi/index.js';
|
||||||
import { MatterIntegration } from './integrations/matter/index.js';
|
import { MatterIntegration } from './integrations/matter/index.js';
|
||||||
import { MqttIntegration } from './integrations/mqtt/index.js';
|
import { MqttIntegration } from './integrations/mqtt/index.js';
|
||||||
|
import { MpdIntegration } from './integrations/mpd/index.js';
|
||||||
import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
|
import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
|
||||||
|
import { OnvifIntegration } from './integrations/onvif/index.js';
|
||||||
|
import { PlexIntegration } from './integrations/plex/index.js';
|
||||||
|
import { RainbirdIntegration } from './integrations/rainbird/index.js';
|
||||||
import { RokuIntegration } from './integrations/roku/index.js';
|
import { RokuIntegration } from './integrations/roku/index.js';
|
||||||
import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
|
import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
|
||||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||||
|
import { SnapcastIntegration } from './integrations/snapcast/index.js';
|
||||||
import { SonosIntegration } from './integrations/sonos/index.js';
|
import { SonosIntegration } from './integrations/sonos/index.js';
|
||||||
import { TplinkIntegration } from './integrations/tplink/index.js';
|
import { TplinkIntegration } from './integrations/tplink/index.js';
|
||||||
import { TradfriIntegration } from './integrations/tradfri/index.js';
|
import { TradfriIntegration } from './integrations/tradfri/index.js';
|
||||||
import { UnifiIntegration } from './integrations/unifi/index.js';
|
import { UnifiIntegration } from './integrations/unifi/index.js';
|
||||||
|
import { VolumioIntegration } from './integrations/volumio/index.js';
|
||||||
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
||||||
import { WizIntegration } from './integrations/wiz/index.js';
|
import { WizIntegration } from './integrations/wiz/index.js';
|
||||||
import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/index.js';
|
import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/index.js';
|
||||||
import { YeelightIntegration } from './integrations/yeelight/index.js';
|
import { YeelightIntegration } from './integrations/yeelight/index.js';
|
||||||
|
import { YamahaMusiccastIntegration } from './integrations/yamaha_musiccast/index.js';
|
||||||
import { ZhaIntegration } from './integrations/zha/index.js';
|
import { ZhaIntegration } from './integrations/zha/index.js';
|
||||||
import { ZwaveJsIntegration } from './integrations/zwave_js/index.js';
|
import { ZwaveJsIntegration } from './integrations/zwave_js/index.js';
|
||||||
import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
|
||||||
@@ -31,27 +42,38 @@ import { IntegrationRegistry } from './core/index.js';
|
|||||||
|
|
||||||
export const integrations = [
|
export const integrations = [
|
||||||
new AndroidtvIntegration(),
|
new AndroidtvIntegration(),
|
||||||
|
new AxisIntegration(),
|
||||||
|
new BraviatvIntegration(),
|
||||||
new CastIntegration(),
|
new CastIntegration(),
|
||||||
new DeconzIntegration(),
|
new DeconzIntegration(),
|
||||||
new DenonavrIntegration(),
|
new DenonavrIntegration(),
|
||||||
|
new DlnaDmrIntegration(),
|
||||||
new EsphomeIntegration(),
|
new EsphomeIntegration(),
|
||||||
new HomekitControllerIntegration(),
|
new HomekitControllerIntegration(),
|
||||||
new HueIntegration(),
|
new HueIntegration(),
|
||||||
|
new JellyfinIntegration(),
|
||||||
new KodiIntegration(),
|
new KodiIntegration(),
|
||||||
new MatterIntegration(),
|
new MatterIntegration(),
|
||||||
new MqttIntegration(),
|
new MqttIntegration(),
|
||||||
|
new MpdIntegration(),
|
||||||
new NanoleafIntegration(),
|
new NanoleafIntegration(),
|
||||||
|
new OnvifIntegration(),
|
||||||
|
new PlexIntegration(),
|
||||||
|
new RainbirdIntegration(),
|
||||||
new RokuIntegration(),
|
new RokuIntegration(),
|
||||||
new SamsungtvIntegration(),
|
new SamsungtvIntegration(),
|
||||||
new ShellyIntegration(),
|
new ShellyIntegration(),
|
||||||
|
new SnapcastIntegration(),
|
||||||
new SonosIntegration(),
|
new SonosIntegration(),
|
||||||
new TplinkIntegration(),
|
new TplinkIntegration(),
|
||||||
new TradfriIntegration(),
|
new TradfriIntegration(),
|
||||||
new UnifiIntegration(),
|
new UnifiIntegration(),
|
||||||
|
new VolumioIntegration(),
|
||||||
new WolfSmartsetIntegration(),
|
new WolfSmartsetIntegration(),
|
||||||
new WizIntegration(),
|
new WizIntegration(),
|
||||||
new XiaomiMiioIntegration(),
|
new XiaomiMiioIntegration(),
|
||||||
new YeelightIntegration(),
|
new YeelightIntegration(),
|
||||||
|
new YamahaMusiccastIntegration(),
|
||||||
new ZhaIntegration(),
|
new ZhaIntegration(),
|
||||||
new ZwaveJsIntegration(),
|
new ZwaveJsIntegration(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,830 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type {
|
||||||
|
IAxisApiDescription,
|
||||||
|
IAxisApiDiscoveryResponse,
|
||||||
|
IAxisBasicDeviceInfoResponse,
|
||||||
|
IAxisBinarySensor,
|
||||||
|
IAxisCameraStream,
|
||||||
|
IAxisConfig,
|
||||||
|
IAxisDeviceInfo,
|
||||||
|
IAxisLight,
|
||||||
|
IAxisLightInformationResponse,
|
||||||
|
IAxisParamTree,
|
||||||
|
IAxisPort,
|
||||||
|
IAxisPortManagementItem,
|
||||||
|
IAxisPortsResponse,
|
||||||
|
IAxisPtzCommand,
|
||||||
|
IAxisRelay,
|
||||||
|
IAxisSensor,
|
||||||
|
IAxisSnapshot,
|
||||||
|
IAxisSnapshotImage,
|
||||||
|
TAxisAuthScheme,
|
||||||
|
TAxisPortDirection,
|
||||||
|
TAxisPortState,
|
||||||
|
TAxisProtocol,
|
||||||
|
} from './axis.types.js';
|
||||||
|
|
||||||
|
const defaultProtocol: TAxisProtocol = 'https';
|
||||||
|
const defaultPort = 443;
|
||||||
|
const defaultTimeoutMs = 10000;
|
||||||
|
const defaultStreamProfile = 'No stream profile';
|
||||||
|
const defaultVideoSource = 'No video source';
|
||||||
|
const axisContext = 'smarthome.exchange';
|
||||||
|
|
||||||
|
export class AxisHttpError extends Error {
|
||||||
|
constructor(public readonly status: number, messageArg: string) {
|
||||||
|
super(messageArg);
|
||||||
|
this.name = 'AxisHttpError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AxisClient {
|
||||||
|
private snapshot?: IAxisSnapshot;
|
||||||
|
|
||||||
|
constructor(private readonly config: IAxisConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IAxisSnapshot> {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasManualSnapshotData()) {
|
||||||
|
this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.config.connected ?? true));
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config.host) {
|
||||||
|
this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig(false));
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snapshot = this.normalizeSnapshot(await this.fetchSnapshot());
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCameraSnapshot(cameraIdArg?: string | number): Promise<IAxisSnapshotImage> {
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new Error('Axis host is required to fetch a camera snapshot image.');
|
||||||
|
}
|
||||||
|
const snapshot = await this.getSnapshot();
|
||||||
|
const camera = this.findCamera(snapshot, cameraIdArg);
|
||||||
|
const path = `/axis-cgi/jpg/image.cgi${this.cameraQuery(camera, true)}`;
|
||||||
|
const response = await this.request(path, { method: 'GET' });
|
||||||
|
return {
|
||||||
|
contentType: response.headers.get('content-type') || 'image/jpeg',
|
||||||
|
data: new Uint8Array(await response.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setRelayState(portIdArg: string, stateArg: 'open' | 'closed'): Promise<void> {
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new Error('Axis host is required for relay commands.');
|
||||||
|
}
|
||||||
|
const snapshot = await this.getSnapshot().catch(() => undefined);
|
||||||
|
const hasPortManagement = snapshot?.apiDiscovery.some((apiArg) => apiArg.id === 'io-port-management');
|
||||||
|
if (hasPortManagement !== false) {
|
||||||
|
try {
|
||||||
|
await this.postAxisJson('/axis-cgi/io/portmanagement.cgi', {
|
||||||
|
apiVersion: '1.0',
|
||||||
|
context: axisContext,
|
||||||
|
method: 'setPorts',
|
||||||
|
params: { ports: [{ port: portIdArg, state: stateArg }] },
|
||||||
|
});
|
||||||
|
this.patchCachedRelay(portIdArg, stateArg);
|
||||||
|
return;
|
||||||
|
} catch (errorArg) {
|
||||||
|
if (hasPortManagement) {
|
||||||
|
throw errorArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.legacyPortAction(portIdArg, stateArg);
|
||||||
|
this.patchCachedRelay(portIdArg, stateArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ptzControl(commandArg: IAxisPtzCommand): Promise<void> {
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new Error('Axis host is required for PTZ commands.');
|
||||||
|
}
|
||||||
|
const data = this.ptzCommandData(commandArg);
|
||||||
|
if (!Object.keys(data).some((keyArg) => keyArg !== 'camera')) {
|
||||||
|
throw new Error('Axis PTZ command requires at least one movement, preset, focus, iris, brightness, or auxiliary parameter.');
|
||||||
|
}
|
||||||
|
await this.request('/axis-cgi/com/ptz.cgi', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(data).toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public streamSource(cameraArg: IAxisCameraStream): string | undefined {
|
||||||
|
return cameraArg.rtspUrl || cameraArg.mjpegUrl || cameraArg.snapshotUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private async fetchSnapshot(): Promise<IAxisSnapshot> {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const apiDiscovery = await this.fetchApiDiscovery().catch(() => []);
|
||||||
|
const basicInfo = await this.fetchBasicDeviceInfo().catch(() => undefined);
|
||||||
|
const [propertiesParams, imageParams, ioPortParams, ptzParams] = await Promise.all([
|
||||||
|
this.fetchParams('Properties').catch(() => undefined),
|
||||||
|
this.fetchParams('Image').catch(() => undefined),
|
||||||
|
this.fetchParams('IOPort').catch(() => undefined),
|
||||||
|
this.fetchParams('PTZ').catch(() => undefined),
|
||||||
|
]);
|
||||||
|
const deviceInfo = this.deviceInfoFromResponses(basicInfo, propertiesParams);
|
||||||
|
const ports = await this.fetchPorts(apiDiscovery, ioPortParams).catch(() => this.portsFromParams(ioPortParams));
|
||||||
|
const relays = ports.filter((portArg): portArg is IAxisRelay => portArg.direction === 'output');
|
||||||
|
const binarySensors = this.binarySensorsFromPorts(ports);
|
||||||
|
const lights = await this.fetchLights(apiDiscovery, propertiesParams).catch(() => []);
|
||||||
|
const cameras = this.camerasFromParams(imageParams, propertiesParams, ptzParams);
|
||||||
|
const sensors = this.sensorsFromDeviceInfo(deviceInfo, updatedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceInfo,
|
||||||
|
cameras,
|
||||||
|
sensors,
|
||||||
|
binarySensors,
|
||||||
|
events: this.config.events || [],
|
||||||
|
ports,
|
||||||
|
relays,
|
||||||
|
switches: relays,
|
||||||
|
lights,
|
||||||
|
apiDiscovery,
|
||||||
|
params: this.mergeParams(propertiesParams, imageParams, ioPortParams, ptzParams),
|
||||||
|
connected: true,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchApiDiscovery(): Promise<IAxisApiDescription[]> {
|
||||||
|
const data = await this.postAxisJson<IAxisApiDiscoveryResponse>('/axis-cgi/apidiscovery.cgi', {
|
||||||
|
apiVersion: '1.0',
|
||||||
|
context: axisContext,
|
||||||
|
method: 'getApiList',
|
||||||
|
});
|
||||||
|
return data.data?.apiList || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchBasicDeviceInfo(): Promise<Record<string, string | undefined> | undefined> {
|
||||||
|
const data = await this.postAxisJson<IAxisBasicDeviceInfoResponse>('/axis-cgi/basicdeviceinfo.cgi', {
|
||||||
|
apiVersion: '1.1',
|
||||||
|
context: axisContext,
|
||||||
|
method: 'getAllProperties',
|
||||||
|
});
|
||||||
|
return data.data?.propertyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchParams(groupArg: 'Properties' | 'Image' | 'IOPort' | 'PTZ'): Promise<IAxisParamTree> {
|
||||||
|
const query = new URLSearchParams({ action: 'list', group: `root.${groupArg}` }).toString();
|
||||||
|
const text = await this.requestText(`/axis-cgi/param.cgi?${query}`, { method: 'GET' });
|
||||||
|
return this.paramsToTree(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPorts(apiDiscoveryArg: IAxisApiDescription[], ioPortParamsArg: IAxisParamTree | undefined): Promise<IAxisPort[]> {
|
||||||
|
const supportsPortManagement = apiDiscoveryArg.some((apiArg) => apiArg.id === 'io-port-management');
|
||||||
|
if (!supportsPortManagement) {
|
||||||
|
return this.portsFromParams(ioPortParamsArg);
|
||||||
|
}
|
||||||
|
const data = await this.postAxisJson<IAxisPortsResponse>('/axis-cgi/io/portmanagement.cgi', {
|
||||||
|
apiVersion: '1.0',
|
||||||
|
context: axisContext,
|
||||||
|
method: 'getPorts',
|
||||||
|
});
|
||||||
|
return (data.data?.items || []).map((portArg) => this.portFromPortManagement(portArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchLights(apiDiscoveryArg: IAxisApiDescription[], propertiesParamsArg: IAxisParamTree | undefined): Promise<IAxisLight[]> {
|
||||||
|
const supportsLightControl = apiDiscoveryArg.some((apiArg) => apiArg.id === 'light-control') || this.booleanAt(this.group(propertiesParamsArg, 'Properties'), ['LightControl', 'LightControl2']) || this.booleanAt(this.group(propertiesParamsArg, 'Properties'), ['LightControl', 'LightControlAvailable']);
|
||||||
|
if (!supportsLightControl) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await this.postAxisJson<IAxisLightInformationResponse>('/axis-cgi/lightcontrol.cgi', {
|
||||||
|
apiVersion: '1.1',
|
||||||
|
context: axisContext,
|
||||||
|
method: 'getLightInformation',
|
||||||
|
});
|
||||||
|
return (data.data?.items || []).map((itemArg) => ({
|
||||||
|
id: itemArg.lightID || 'light',
|
||||||
|
name: itemArg.lightType ? `${itemArg.lightType} light` : itemArg.lightID,
|
||||||
|
enabled: itemArg.enabled,
|
||||||
|
isOn: itemArg.lightState,
|
||||||
|
lightType: itemArg.lightType,
|
||||||
|
numberOfLeds: itemArg.nrOfLEDs,
|
||||||
|
available: !itemArg.error,
|
||||||
|
attributes: {
|
||||||
|
automaticAngleOfIlluminationMode: itemArg.automaticAngleOfIlluminationMode,
|
||||||
|
automaticIntensityMode: itemArg.automaticIntensityMode,
|
||||||
|
synchronizeDayNightMode: itemArg.synchronizeDayNightMode,
|
||||||
|
errorInfo: itemArg.errorInfo,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotFromConfig(connectedArg: boolean): IAxisSnapshot {
|
||||||
|
const deviceInfo = {
|
||||||
|
...this.deviceInfoFromConfig(),
|
||||||
|
online: connectedArg,
|
||||||
|
};
|
||||||
|
const ports = this.config.ports || [];
|
||||||
|
const relays = [...(this.config.relays || []), ...(this.config.switches || [])];
|
||||||
|
const allPorts = this.uniquePorts([...ports, ...relays]);
|
||||||
|
return {
|
||||||
|
deviceInfo,
|
||||||
|
cameras: this.config.cameras || [],
|
||||||
|
sensors: this.config.sensors || [],
|
||||||
|
binarySensors: this.config.binarySensors || this.binarySensorsFromPorts(allPorts),
|
||||||
|
events: this.config.events || [],
|
||||||
|
ports: allPorts,
|
||||||
|
relays,
|
||||||
|
switches: relays,
|
||||||
|
lights: this.config.lights || [],
|
||||||
|
apiDiscovery: this.config.apiDiscovery || [],
|
||||||
|
params: this.config.params,
|
||||||
|
connected: connectedArg,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSnapshot(snapshotArg: IAxisSnapshot): IAxisSnapshot {
|
||||||
|
const deviceInfo = {
|
||||||
|
...this.deviceInfoFromConfig(),
|
||||||
|
...snapshotArg.deviceInfo,
|
||||||
|
};
|
||||||
|
const connected = snapshotArg.connected && deviceInfo.online !== false;
|
||||||
|
deviceInfo.online = connected;
|
||||||
|
const ports = this.uniquePorts(snapshotArg.ports || []);
|
||||||
|
const relays = this.uniquePorts([...(snapshotArg.relays || []), ...(snapshotArg.switches || []), ...ports.filter((portArg) => portArg.direction === 'output')]) as IAxisRelay[];
|
||||||
|
return {
|
||||||
|
...snapshotArg,
|
||||||
|
deviceInfo,
|
||||||
|
cameras: this.normalizeCameras(snapshotArg.cameras || []),
|
||||||
|
sensors: snapshotArg.sensors || [],
|
||||||
|
binarySensors: snapshotArg.binarySensors || [],
|
||||||
|
events: snapshotArg.events || [],
|
||||||
|
ports,
|
||||||
|
relays,
|
||||||
|
switches: relays,
|
||||||
|
lights: snapshotArg.lights || [],
|
||||||
|
apiDiscovery: snapshotArg.apiDiscovery || [],
|
||||||
|
connected,
|
||||||
|
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCameras(camerasArg: IAxisCameraStream[]): IAxisCameraStream[] {
|
||||||
|
return camerasArg.map((cameraArg, indexArg) => {
|
||||||
|
const id = cameraArg.id || String(indexArg + 1);
|
||||||
|
const streamCamera = { ...cameraArg, id, videoSource: cameraArg.videoSource || id };
|
||||||
|
return {
|
||||||
|
...streamCamera,
|
||||||
|
name: streamCamera.name || `Camera ${id}`,
|
||||||
|
enabled: streamCamera.enabled !== false,
|
||||||
|
snapshotUrl: streamCamera.snapshotUrl || this.cameraUrl('/axis-cgi/jpg/image.cgi', streamCamera, true),
|
||||||
|
mjpegUrl: streamCamera.mjpegUrl || this.cameraUrl('/axis-cgi/mjpg/video.cgi', streamCamera, false),
|
||||||
|
rtspUrl: streamCamera.rtspUrl || this.rtspUrl(streamCamera),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private deviceInfoFromResponses(basicInfoArg: Record<string, string | undefined> | undefined, propertiesParamsArg: IAxisParamTree | undefined): IAxisDeviceInfo {
|
||||||
|
const properties = this.group(propertiesParamsArg, 'Properties');
|
||||||
|
return {
|
||||||
|
...this.deviceInfoFromConfig(),
|
||||||
|
serialNumber: basicInfoArg?.SerialNumber || this.stringAt(properties, ['System', 'SerialNumber']) || this.config.deviceInfo?.serialNumber,
|
||||||
|
macAddress: normalizeMac(basicInfoArg?.SerialNumber || this.stringAt(properties, ['System', 'SerialNumber']) || this.config.deviceInfo?.macAddress),
|
||||||
|
name: this.config.name || this.config.deviceInfo?.name || basicInfoArg?.ProdShortName || basicInfoArg?.ProdNbr || this.config.host || 'Axis device',
|
||||||
|
model: this.config.model || this.config.deviceInfo?.model || basicInfoArg?.ProdNbr || basicInfoArg?.ProdShortName,
|
||||||
|
productNumber: basicInfoArg?.ProdNbr || this.config.deviceInfo?.productNumber,
|
||||||
|
productType: basicInfoArg?.ProdType || this.config.deviceInfo?.productType,
|
||||||
|
firmwareVersion: basicInfoArg?.Version || this.stringAt(properties, ['Firmware', 'Version']) || this.config.deviceInfo?.firmwareVersion,
|
||||||
|
hardwareId: basicInfoArg?.HardwareID || this.config.deviceInfo?.hardwareId,
|
||||||
|
online: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private deviceInfoFromConfig(): IAxisDeviceInfo {
|
||||||
|
const mac = normalizeMac(this.config.deviceInfo?.macAddress || this.config.deviceInfo?.serialNumber || this.config.uniqueId);
|
||||||
|
return {
|
||||||
|
...this.config.deviceInfo,
|
||||||
|
id: this.config.deviceInfo?.id || this.config.uniqueId || mac || this.config.host,
|
||||||
|
serialNumber: this.config.deviceInfo?.serialNumber || this.config.uniqueId,
|
||||||
|
macAddress: mac || this.config.deviceInfo?.macAddress,
|
||||||
|
name: this.config.deviceInfo?.name || this.config.name || this.config.host || 'Axis device',
|
||||||
|
manufacturer: this.config.deviceInfo?.manufacturer || 'Axis Communications AB',
|
||||||
|
model: this.config.deviceInfo?.model || this.config.model,
|
||||||
|
host: this.config.deviceInfo?.host || this.config.host,
|
||||||
|
port: this.config.deviceInfo?.port || this.config.port || (this.protocol() === 'https' ? defaultPort : 80),
|
||||||
|
protocol: this.config.deviceInfo?.protocol || this.protocol(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private camerasFromParams(imageParamsArg: IAxisParamTree | undefined, propertiesParamsArg: IAxisParamTree | undefined, ptzParamsArg: IAxisParamTree | undefined): IAxisCameraStream[] {
|
||||||
|
if (this.config.cameras?.length) {
|
||||||
|
return this.config.cameras;
|
||||||
|
}
|
||||||
|
const imageGroup = this.group(imageParamsArg, 'Image');
|
||||||
|
const propertyGroup = this.group(propertiesParamsArg, 'Properties');
|
||||||
|
const imageFormats = splitCsv(this.stringAt(propertyGroup, ['Image', 'Format']) || 'jpeg,mjpeg,h264');
|
||||||
|
const supportsPtz = this.booleanAt(propertyGroup, ['PTZ', 'PTZ']) || Boolean(this.group(ptzParamsArg, 'PTZ'));
|
||||||
|
const cameras: IAxisCameraStream[] = [];
|
||||||
|
for (const [key, value] of Object.entries(imageGroup || {})) {
|
||||||
|
if (!/^I\d+$/.test(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const raw = record(value);
|
||||||
|
if (!raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const source = String(Number(key.slice(1)) + 1);
|
||||||
|
const appearance = record(raw.Appearance);
|
||||||
|
cameras.push({
|
||||||
|
id: source,
|
||||||
|
name: stringValue(raw.Name) || `Camera ${source}`,
|
||||||
|
enabled: booleanValue(raw.Enabled) ?? true,
|
||||||
|
videoSource: source,
|
||||||
|
streamProfile: this.config.streamProfile,
|
||||||
|
resolution: stringValue(appearance?.Resolution),
|
||||||
|
imageFormats,
|
||||||
|
supportsPtz,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!cameras.length && imageFormats.length) {
|
||||||
|
cameras.push({
|
||||||
|
id: String(this.config.videoSource || 1),
|
||||||
|
name: this.config.name || this.config.host || 'Axis Camera',
|
||||||
|
enabled: true,
|
||||||
|
videoSource: this.config.videoSource || 1,
|
||||||
|
streamProfile: this.config.streamProfile,
|
||||||
|
imageFormats,
|
||||||
|
supportsPtz,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
private portsFromParams(ioPortParamsArg: IAxisParamTree | undefined): IAxisPort[] {
|
||||||
|
const ioPortGroup = this.group(ioPortParamsArg, 'IOPort');
|
||||||
|
const ports: IAxisPort[] = [];
|
||||||
|
for (const [key, value] of Object.entries(ioPortGroup || {})) {
|
||||||
|
if (!/^I?\d+$/.test(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const raw = record(value);
|
||||||
|
if (!raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = key.replace(/^I/, '');
|
||||||
|
const direction = portDirection(stringValue(raw.Direction));
|
||||||
|
const input = record(raw.Input);
|
||||||
|
const output = record(raw.Output);
|
||||||
|
ports.push({
|
||||||
|
id,
|
||||||
|
name: direction === 'input' ? stringValue(input?.Name) : stringValue(output?.Name) || stringValue(raw.Usage) || `Port ${id}`,
|
||||||
|
usage: stringValue(raw.Usage),
|
||||||
|
direction,
|
||||||
|
state: 'unknown',
|
||||||
|
normalState: direction === 'input' ? portState(stringValue(input?.Trig)) : portState(stringValue(output?.Active)),
|
||||||
|
configurable: booleanValue(raw.Configurable),
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
private binarySensorsFromPorts(portsArg: IAxisPort[]): IAxisBinarySensor[] {
|
||||||
|
return portsArg.filter((portArg) => portArg.direction === 'input').map((portArg) => ({
|
||||||
|
id: `port_${portArg.id}`,
|
||||||
|
name: portArg.name || `Input ${portArg.id}`,
|
||||||
|
isOn: portArg.state !== 'unknown' && portArg.normalState !== undefined ? portArg.state !== portArg.normalState : false,
|
||||||
|
deviceClass: 'connectivity',
|
||||||
|
source: portArg.id,
|
||||||
|
available: portArg.available !== false,
|
||||||
|
attributes: {
|
||||||
|
portId: portArg.id,
|
||||||
|
usage: portArg.usage,
|
||||||
|
state: portArg.state,
|
||||||
|
normalState: portArg.normalState,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sensorsFromDeviceInfo(deviceInfoArg: IAxisDeviceInfo, updatedAtArg: string): IAxisSensor[] {
|
||||||
|
void updatedAtArg;
|
||||||
|
const sensors: IAxisSensor[] = [];
|
||||||
|
if (deviceInfoArg.firmwareVersion) {
|
||||||
|
sensors.push({ id: 'firmware_version', name: 'Firmware version', value: deviceInfoArg.firmwareVersion, category: 'diagnostic', available: deviceInfoArg.online !== false });
|
||||||
|
}
|
||||||
|
if (deviceInfoArg.productType) {
|
||||||
|
sensors.push({ id: 'product_type', name: 'Product type', value: deviceInfoArg.productType, category: 'diagnostic', available: deviceInfoArg.online !== false });
|
||||||
|
}
|
||||||
|
return [...sensors, ...(this.config.sensors || [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
private portFromPortManagement(portArg: IAxisPortManagementItem): IAxisPort {
|
||||||
|
return {
|
||||||
|
id: portArg.port,
|
||||||
|
name: portArg.name || portArg.usage || `Port ${portArg.port}`,
|
||||||
|
usage: portArg.usage,
|
||||||
|
direction: portDirection(portArg.direction),
|
||||||
|
state: portState(portArg.state),
|
||||||
|
normalState: portState(portArg.normalState),
|
||||||
|
configurable: portArg.configurable,
|
||||||
|
readonly: portArg.readonly,
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postAxisJson<TResponse = Record<string, unknown>>(pathArg: string, payloadArg: Record<string, unknown>): Promise<TResponse> {
|
||||||
|
const response = await this.request(pathArg, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payloadArg),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) as TResponse & { error?: { code?: number; message?: string } } : {} as TResponse & { error?: { code?: number; message?: string } };
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`Axis VAPIX ${String(payloadArg.method || pathArg)} failed${typeof data.error.code === 'number' ? ` (${data.error.code})` : ''}: ${data.error.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
return data as TResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestText(pathArg: string, initArg: RequestInit): Promise<string> {
|
||||||
|
return (await this.request(pathArg, initArg)).text();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(pathArg: string, initArg: RequestInit): Promise<Response> {
|
||||||
|
const url = `${this.baseUrl()}${pathArg}`;
|
||||||
|
const headers = new Headers(initArg.headers);
|
||||||
|
if (this.authScheme() === 'basic') {
|
||||||
|
headers.set('authorization', this.basicAuthorization());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(url, { ...initArg, headers });
|
||||||
|
if (response.status === 401 && this.authScheme() !== 'basic') {
|
||||||
|
const challenge = response.headers.get('www-authenticate') || '';
|
||||||
|
const retryHeaders = new Headers(initArg.headers);
|
||||||
|
if (/digest/i.test(challenge)) {
|
||||||
|
retryHeaders.set('authorization', this.digestAuthorization(challenge, initArg.method || 'GET', new URL(url).pathname + new URL(url).search));
|
||||||
|
return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, headers: retryHeaders }), pathArg);
|
||||||
|
}
|
||||||
|
if (/basic/i.test(challenge) || this.authScheme() === 'auto') {
|
||||||
|
retryHeaders.set('authorization', this.basicAuthorization());
|
||||||
|
return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, headers: retryHeaders }), pathArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.checkedResponse(response, pathArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkedResponse(responseArg: Response, pathArg: string): Promise<Response> {
|
||||||
|
if (!responseArg.ok) {
|
||||||
|
const text = await responseArg.text().catch(() => '');
|
||||||
|
throw new AxisHttpError(responseArg.status, `Axis request ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
|
||||||
|
}
|
||||||
|
return responseArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
|
||||||
|
try {
|
||||||
|
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private digestAuthorization(challengeArg: string, methodArg: string, uriArg: string): string {
|
||||||
|
const challenge = parseDigestChallenge(challengeArg);
|
||||||
|
if (!challenge.realm || !challenge.nonce) {
|
||||||
|
throw new Error('Axis digest authentication challenge is missing realm or nonce.');
|
||||||
|
}
|
||||||
|
const algorithm = (challenge.algorithm || 'MD5').toUpperCase();
|
||||||
|
if (algorithm !== 'MD5' && algorithm !== 'MD5-SESS') {
|
||||||
|
throw new Error(`Axis digest authentication algorithm is unsupported: ${algorithm}`);
|
||||||
|
}
|
||||||
|
const qop = splitCsv(challenge.qop).includes('auth') ? 'auth' : undefined;
|
||||||
|
const cnonce = plugins.crypto.randomBytes(8).toString('hex');
|
||||||
|
const nc = '00000001';
|
||||||
|
const username = this.config.username || '';
|
||||||
|
const password = this.config.password || '';
|
||||||
|
const ha1Raw = md5(`${username}:${challenge.realm}:${password}`);
|
||||||
|
const ha1 = algorithm === 'MD5-SESS' ? md5(`${ha1Raw}:${challenge.nonce}:${cnonce}`) : ha1Raw;
|
||||||
|
const ha2 = md5(`${methodArg.toUpperCase()}:${uriArg}`);
|
||||||
|
const response = qop ? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${challenge.nonce}:${ha2}`);
|
||||||
|
const parts: Record<string, string> = {
|
||||||
|
username,
|
||||||
|
realm: challenge.realm,
|
||||||
|
nonce: challenge.nonce,
|
||||||
|
uri: uriArg,
|
||||||
|
response,
|
||||||
|
algorithm,
|
||||||
|
};
|
||||||
|
if (challenge.opaque) {
|
||||||
|
parts.opaque = challenge.opaque;
|
||||||
|
}
|
||||||
|
if (qop) {
|
||||||
|
parts.qop = qop;
|
||||||
|
parts.nc = nc;
|
||||||
|
parts.cnonce = cnonce;
|
||||||
|
}
|
||||||
|
return `Digest ${Object.entries(parts).map(([keyArg, valueArg]) => keyArg === 'qop' || keyArg === 'nc' || keyArg === 'algorithm' ? `${keyArg}=${valueArg}` : `${keyArg}="${valueArg.replace(/"/g, '\\"')}"`).join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private basicAuthorization(): string {
|
||||||
|
return `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`, 'utf8').toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private legacyPortAction(portIdArg: string, stateArg: 'open' | 'closed'): Promise<Response> {
|
||||||
|
const numericPort = Number(portIdArg);
|
||||||
|
const actionPort = Number.isFinite(numericPort) ? String(numericPort + 1) : portIdArg;
|
||||||
|
const action = stateArg === 'closed' ? '/' : '\\';
|
||||||
|
const query = new URLSearchParams({ action: `${actionPort}:${action}` }).toString();
|
||||||
|
return this.request(`/axis-cgi/io/port.cgi?${query}`, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private ptzCommandData(commandArg: IAxisPtzCommand): Record<string, string> {
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
this.setNumber(data, 'camera', commandArg.camera, 1, 9999);
|
||||||
|
this.setNumber(data, 'pan', commandArg.pan, -180, 180);
|
||||||
|
this.setNumber(data, 'tilt', commandArg.tilt, -180, 180);
|
||||||
|
this.setNumber(data, 'zoom', commandArg.zoom, 1, 9999);
|
||||||
|
this.setNumber(data, 'focus', commandArg.focus, 1, 9999);
|
||||||
|
this.setNumber(data, 'iris', commandArg.iris, 1, 9999);
|
||||||
|
this.setNumber(data, 'brightness', commandArg.brightness, 1, 9999);
|
||||||
|
this.setNumber(data, 'rpan', commandArg.rpan, -360, 360);
|
||||||
|
this.setNumber(data, 'rtilt', commandArg.rtilt, -360, 360);
|
||||||
|
this.setNumber(data, 'rzoom', commandArg.rzoom, -9999, 9999);
|
||||||
|
this.setNumber(data, 'rfocus', commandArg.rfocus, -9999, 9999);
|
||||||
|
this.setNumber(data, 'riris', commandArg.riris, -9999, 9999);
|
||||||
|
this.setNumber(data, 'rbrightness', commandArg.rbrightness, -9999, 9999);
|
||||||
|
this.setNumber(data, 'continuouszoommove', commandArg.continuouszoommove, -100, 100);
|
||||||
|
this.setNumber(data, 'continuousfocusmove', commandArg.continuousfocusmove, -100, 100);
|
||||||
|
this.setNumber(data, 'continuousirismove', commandArg.continuousirismove, -100, 100);
|
||||||
|
this.setNumber(data, 'continuousbrightnessmove', commandArg.continuousbrightnessmove, -100, 100);
|
||||||
|
this.setNumber(data, 'speed', commandArg.speed, 1, 100);
|
||||||
|
this.setNumber(data, 'imagewidth', commandArg.imagewidth, 1, 100000);
|
||||||
|
this.setNumber(data, 'imageheight', commandArg.imageheight, 1, 100000);
|
||||||
|
if (commandArg.move) {
|
||||||
|
data.move = commandArg.move;
|
||||||
|
}
|
||||||
|
if (commandArg.autofocus !== undefined) {
|
||||||
|
data.autofocus = commandArg.autofocus ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
if (commandArg.autoiris !== undefined) {
|
||||||
|
data.autoiris = commandArg.autoiris ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
if (commandArg.backlight !== undefined) {
|
||||||
|
data.backlight = commandArg.backlight ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
if (commandArg.imagerotation) {
|
||||||
|
data.imagerotation = commandArg.imagerotation;
|
||||||
|
}
|
||||||
|
if (commandArg.ircutfilter) {
|
||||||
|
data.ircutfilter = commandArg.ircutfilter;
|
||||||
|
}
|
||||||
|
if (commandArg.continuouspantiltmove) {
|
||||||
|
data.continuouspantiltmove = `${clamp(commandArg.continuouspantiltmove[0], -100, 100)},${clamp(commandArg.continuouspantiltmove[1], -100, 100)}`;
|
||||||
|
}
|
||||||
|
if (commandArg.center) {
|
||||||
|
data.center = `${Math.round(commandArg.center[0])},${Math.round(commandArg.center[1])}`;
|
||||||
|
}
|
||||||
|
if (commandArg.areazoom) {
|
||||||
|
data.areazoom = `${Math.round(commandArg.areazoom[0])},${Math.round(commandArg.areazoom[1])},${Math.max(1, Math.round(commandArg.areazoom[2]))}`;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries({
|
||||||
|
auxiliary: commandArg.auxiliary,
|
||||||
|
gotoserverpresetname: commandArg.gotoserverpresetname,
|
||||||
|
gotoserverpresetno: commandArg.gotoserverpresetno,
|
||||||
|
gotodevicepreset: commandArg.gotodevicepreset,
|
||||||
|
})) {
|
||||||
|
if (value !== undefined && value !== '') {
|
||||||
|
data[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setNumber(dataArg: Record<string, string>, keyArg: string, valueArg: unknown, minArg: number, maxArg: number): void {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
dataArg[keyArg] = String(clamp(valueArg, minArg, maxArg));
|
||||||
|
} else if (keyArg === 'camera' && typeof valueArg === 'string' && valueArg.trim()) {
|
||||||
|
dataArg[keyArg] = valueArg.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findCamera(snapshotArg: IAxisSnapshot, cameraIdArg: string | number | undefined): IAxisCameraStream {
|
||||||
|
const cameraId = String(cameraIdArg || '');
|
||||||
|
const camera = snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.videoSource) === cameraId) || snapshotArg.cameras[0];
|
||||||
|
if (!camera) {
|
||||||
|
throw new Error('Axis camera snapshot requires a configured or discovered camera.');
|
||||||
|
}
|
||||||
|
return camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cameraUrl(pathArg: string, cameraArg: IAxisCameraStream, skipStreamProfileArg: boolean): string | undefined {
|
||||||
|
if (!this.config.host) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return `${this.baseUrl()}${pathArg}${this.cameraQuery(cameraArg, skipStreamProfileArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rtspUrl(cameraArg: IAxisCameraStream): string | undefined {
|
||||||
|
if (!this.config.host) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('videocodec', 'h264');
|
||||||
|
if (cameraArg.streamProfile && cameraArg.streamProfile !== defaultStreamProfile) {
|
||||||
|
params.set('streamprofile', cameraArg.streamProfile);
|
||||||
|
}
|
||||||
|
const videoSource = cameraArg.videoSource || this.config.videoSource;
|
||||||
|
if (videoSource !== undefined && String(videoSource) !== defaultVideoSource) {
|
||||||
|
params.set('camera', String(videoSource));
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
return `rtsp://${this.config.host}/axis-media/media.amp${query ? `?${query}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cameraQuery(cameraArg: IAxisCameraStream, skipStreamProfileArg: boolean): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (!skipStreamProfileArg && cameraArg.streamProfile && cameraArg.streamProfile !== defaultStreamProfile) {
|
||||||
|
params.set('streamprofile', cameraArg.streamProfile);
|
||||||
|
}
|
||||||
|
const videoSource = cameraArg.videoSource || this.config.videoSource;
|
||||||
|
if (videoSource !== undefined && String(videoSource) !== defaultVideoSource) {
|
||||||
|
params.set('camera', String(videoSource));
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `?${query}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private paramsToTree(paramsArg: string): IAxisParamTree {
|
||||||
|
const tree: IAxisParamTree = {};
|
||||||
|
for (const line of paramsArg.split(/\r?\n/)) {
|
||||||
|
const [path, ...rest] = line.split('=');
|
||||||
|
if (!path || !rest.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
populatePath(tree, path.split('.'), convertParamValue(rest.join('=')));
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
private group(paramsArg: IAxisParamTree | undefined, groupArg: string): Record<string, unknown> | undefined {
|
||||||
|
return record(record(paramsArg?.root)?.[groupArg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringAt(recordArg: Record<string, unknown> | undefined, pathArg: string[]): string | undefined {
|
||||||
|
let current: unknown = recordArg;
|
||||||
|
for (const key of pathArg) {
|
||||||
|
current = record(current)?.[key];
|
||||||
|
}
|
||||||
|
return stringValue(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanAt(recordArg: Record<string, unknown> | undefined, pathArg: string[]): boolean | undefined {
|
||||||
|
let current: unknown = recordArg;
|
||||||
|
for (const key of pathArg) {
|
||||||
|
current = record(current)?.[key];
|
||||||
|
}
|
||||||
|
return booleanValue(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeParams(...paramsArgs: Array<IAxisParamTree | undefined>): IAxisParamTree | undefined {
|
||||||
|
const root: Record<string, unknown> = {};
|
||||||
|
for (const params of paramsArgs) {
|
||||||
|
const paramsRoot = record(params?.root);
|
||||||
|
if (paramsRoot) {
|
||||||
|
Object.assign(root, paramsRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(root).length ? { root } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private uniquePorts(portsArg: IAxisPort[]): IAxisPort[] {
|
||||||
|
const ports = new Map<string, IAxisPort>();
|
||||||
|
for (const port of portsArg) {
|
||||||
|
ports.set(port.id, { ...ports.get(port.id), ...port });
|
||||||
|
}
|
||||||
|
return [...ports.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasManualSnapshotData(): boolean {
|
||||||
|
return Boolean(this.config.deviceInfo || this.config.cameras?.length || this.config.ports?.length || this.config.relays?.length || this.config.switches?.length || this.config.sensors?.length || this.config.binarySensors?.length || this.config.events?.length || this.config.lights?.length || this.config.apiDiscovery?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchCachedRelay(portIdArg: string, stateArg: 'open' | 'closed'): void {
|
||||||
|
if (!this.snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const collection of [this.snapshot.ports, this.snapshot.relays, this.snapshot.switches]) {
|
||||||
|
for (const port of collection) {
|
||||||
|
if (port.id === portIdArg) {
|
||||||
|
port.state = stateArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseUrl(): string {
|
||||||
|
return `${this.protocol()}://${this.config.host}:${this.config.port || (this.protocol() === 'https' ? defaultPort : 80)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private protocol(): TAxisProtocol {
|
||||||
|
return this.config.protocol || defaultProtocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
private authScheme(): TAxisAuthScheme {
|
||||||
|
return this.config.authScheme || 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneSnapshot(snapshotArg: IAxisSnapshot): IAxisSnapshot {
|
||||||
|
return JSON.parse(JSON.stringify(snapshotArg)) as IAxisSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const md5 = (valueArg: string): string => plugins.crypto.createHash('md5').update(valueArg).digest('hex');
|
||||||
|
|
||||||
|
const clamp = (valueArg: number, minArg: number, maxArg: number): number => Math.max(minArg, Math.min(maxArg, Math.round(valueArg)));
|
||||||
|
|
||||||
|
const parseDigestChallenge = (valueArg: string): Record<string, string> => {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
const challenge = valueArg.replace(/^\s*Digest\s+/i, '');
|
||||||
|
const matcher = /([a-zA-Z0-9_-]+)=(?:"([^"]*)"|([^,\s]+))/g;
|
||||||
|
for (const match of challenge.matchAll(matcher)) {
|
||||||
|
result[match[1].toLowerCase()] = match[2] ?? match[3] ?? '';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMac = (valueArg: string | undefined): string | undefined => {
|
||||||
|
const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return cleaned.length === 12 ? cleaned : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitCsv = (valueArg: string | undefined): string[] => {
|
||||||
|
return (valueArg || '').split(',').map((entryArg) => entryArg.trim()).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const portDirection = (valueArg: unknown): TAxisPortDirection => {
|
||||||
|
return valueArg === 'input' || valueArg === 'output' ? valueArg : 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const portState = (valueArg: unknown): TAxisPortState => {
|
||||||
|
return valueArg === 'open' || valueArg === 'closed' ? valueArg : 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringValue = (valueArg: unknown): string | undefined => {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' || typeof valueArg === 'boolean' ? String(valueArg) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||||
|
if (typeof valueArg === 'boolean') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
if (['true', 'yes', 'on', '1'].includes(valueArg.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['false', 'no', 'off', '0'].includes(valueArg.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const record = (valueArg: unknown): Record<string, unknown> | undefined => {
|
||||||
|
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertParamValue = (valueArg: string): string | number | boolean => {
|
||||||
|
if (valueArg === 'true' || valueArg === 'yes') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (valueArg === 'false' || valueArg === 'no') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^-?\d+$/.test(valueArg)) {
|
||||||
|
return Number(valueArg);
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const populatePath = (storeArg: Record<string, unknown>, pathArg: string[], valueArg: unknown): void => {
|
||||||
|
const [head, ...tail] = pathArg;
|
||||||
|
if (!head) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tail.length) {
|
||||||
|
storeArg[head] = valueArg;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = record(storeArg[head]) || {};
|
||||||
|
storeArg[head] = next;
|
||||||
|
populatePath(next, tail, valueArg);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IAxisConfig, TAxisProtocol } from './axis.types.js';
|
||||||
|
|
||||||
|
const defaultProtocol: TAxisProtocol = 'https';
|
||||||
|
const defaultPort = 443;
|
||||||
|
const defaultTimeoutMs = 10000;
|
||||||
|
|
||||||
|
export class AxisConfigFlow implements IConfigFlow<IAxisConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAxisConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Axis device',
|
||||||
|
description: 'Configure the local Axis VAPIX endpoint.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'protocol', label: 'Protocol', type: 'select', required: true, options: [{ label: 'HTTPS', value: 'https' }, { label: 'HTTP', value: 'http' }] },
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'Port', type: 'number', required: true },
|
||||||
|
{ name: 'username', label: 'Username', type: 'text', required: true },
|
||||||
|
{ name: 'password', label: 'Password', type: 'password', required: true },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const protocol = this.protocolValue(valuesArg.protocol) || this.protocolMetadata(candidateArg) || defaultProtocol;
|
||||||
|
const port = this.numberValue(valuesArg.port) || candidateArg.port || (protocol === 'https' ? defaultPort : 80);
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Axis device configured',
|
||||||
|
config: {
|
||||||
|
protocol,
|
||||||
|
host: this.stringValue(valuesArg.host) || candidateArg.host || '',
|
||||||
|
port,
|
||||||
|
username: this.stringValue(valuesArg.username) || '',
|
||||||
|
password: this.stringValue(valuesArg.password) || '',
|
||||||
|
name: candidateArg.name,
|
||||||
|
uniqueId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber,
|
||||||
|
model: candidateArg.model,
|
||||||
|
timeoutMs: defaultTimeoutMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private protocolValue(valueArg: unknown): TAxisProtocol | undefined {
|
||||||
|
return valueArg === 'http' || valueArg === 'https' ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private protocolMetadata(candidateArg: IDiscoveryCandidate): TAxisProtocol | undefined {
|
||||||
|
const protocol = candidateArg.metadata?.protocol;
|
||||||
|
return protocol === 'http' || protocol === 'https' ? protocol : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,135 @@
|
|||||||
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 { AxisClient } from './axis.classes.client.js';
|
||||||
|
import { AxisConfigFlow } from './axis.classes.configflow.js';
|
||||||
|
import { createAxisDiscoveryDescriptor } from './axis.discovery.js';
|
||||||
|
import { AxisMapper } from './axis.mapper.js';
|
||||||
|
import type { IAxisConfig } from './axis.types.js';
|
||||||
|
|
||||||
export class HomeAssistantAxisIntegration extends DescriptorOnlyIntegration {
|
export class AxisIntegration extends BaseIntegration<IAxisConfig> {
|
||||||
constructor() {
|
public readonly domain = 'axis';
|
||||||
super({
|
public readonly displayName = 'Axis';
|
||||||
domain: "axis",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Axis",
|
public readonly discoveryDescriptor = createAxisDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new AxisConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/axis",
|
upstreamPath: 'homeassistant/components/axis',
|
||||||
"upstreamDomain": "axis",
|
upstreamDomain: 'axis',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
requirements: ['axis==69'],
|
||||||
"axis==69"
|
dependencies: [],
|
||||||
|
afterDependencies: ['mqtt'],
|
||||||
|
codeowners: ['@Kane610'],
|
||||||
|
configFlow: true,
|
||||||
|
documentation: 'https://www.home-assistant.io/integrations/axis',
|
||||||
|
vapix: {
|
||||||
|
implemented: [
|
||||||
|
'basic-device-info',
|
||||||
|
'api-discovery',
|
||||||
|
'param-cgi',
|
||||||
|
'video-streaming URLs',
|
||||||
|
'io-port-management getPorts/setPorts',
|
||||||
|
'legacy io/port.cgi output commands',
|
||||||
|
'light-control read model',
|
||||||
|
'ptz-control command CGI',
|
||||||
],
|
],
|
||||||
"dependencies": [],
|
explicitUnsupported: [
|
||||||
"afterDependencies": [
|
'RTSP/MJPEG proxying',
|
||||||
"mqtt"
|
'event stream subscription over RTSP/WebSocket/MQTT',
|
||||||
|
'writing snapshots to files',
|
||||||
],
|
],
|
||||||
"codeowners": [
|
|
||||||
"@Kane610"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
public async setup(configArg: IAxisConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
|
void contextArg;
|
||||||
|
return new AxisRuntime(new AxisClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantAxisIntegration extends AxisIntegration {}
|
||||||
|
|
||||||
|
class AxisRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'axis';
|
||||||
|
|
||||||
|
constructor(private readonly client: AxisClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return AxisMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return AxisMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
void handlerArg;
|
||||||
|
throw new Error('Axis live event streaming is not implemented in this TypeScript port; use snapshot/manual event data or poll entities.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
try {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const relayCommand = AxisMapper.relayCommandForService(snapshot, requestArg);
|
||||||
|
if (relayCommand) {
|
||||||
|
await this.client.setRelayState(relayCommand.portId, relayCommand.state);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ptzCommand = AxisMapper.ptzCommandForService(snapshot, requestArg);
|
||||||
|
if (ptzCommand) {
|
||||||
|
await this.client.ptzControl(ptzCommand);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((requestArg.domain === 'camera' && requestArg.service === 'snapshot') || (requestArg.domain === 'axis' && requestArg.service === 'snapshot')) {
|
||||||
|
if (typeof requestArg.data?.filename === 'string') {
|
||||||
|
return { success: false, error: 'Axis snapshot file writes are not implemented; request data as base64 without data.filename.' };
|
||||||
|
}
|
||||||
|
const cameraId = this.stringData(requestArg, 'cameraId') || this.stringData(requestArg, 'camera_id') || this.cameraIdFromTarget(snapshot, requestArg);
|
||||||
|
const image = await this.client.getCameraSnapshot(cameraId);
|
||||||
|
return { success: true, data: { contentType: image.contentType, dataBase64: Buffer.from(image.data).toString('base64') } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((requestArg.domain === 'camera' && requestArg.service === 'stream_source') || (requestArg.domain === 'axis' && requestArg.service === 'stream_source')) {
|
||||||
|
const cameraId = this.stringData(requestArg, 'cameraId') || this.stringData(requestArg, 'camera_id') || this.cameraIdFromTarget(snapshot, requestArg);
|
||||||
|
const camera = snapshot.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.videoSource) === cameraId) || snapshot.cameras[0];
|
||||||
|
if (!camera) {
|
||||||
|
return { success: false, error: 'Axis stream_source requires a configured or discovered camera.' };
|
||||||
|
}
|
||||||
|
return { success: true, data: { streamSource: this.client.streamSource(camera), cameraId: camera.id } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === 'axis' && ['subscribe_events', 'start_event_stream', 'enable_events'].includes(requestArg.service)) {
|
||||||
|
return { success: false, error: 'Axis live event streaming is not implemented in this TypeScript port.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: `Unsupported Axis service: ${requestArg.domain}.${requestArg.service}` };
|
||||||
|
} catch (errorArg) {
|
||||||
|
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cameraIdFromTarget(snapshotArg: Awaited<ReturnType<AxisClient['getSnapshot']>>, requestArg: IServiceCallRequest): string | undefined {
|
||||||
|
const entityId = requestArg.target.entityId;
|
||||||
|
if (!entityId) {
|
||||||
|
return snapshotArg.cameras[0]?.id;
|
||||||
|
}
|
||||||
|
const entity = AxisMapper.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityId || entityArg.uniqueId === entityId);
|
||||||
|
return typeof entity?.attributes?.cameraId === 'string' ? entity.attributes.cameraId : snapshotArg.cameras[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||||
|
const value = requestArg.data?.[keyArg];
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : typeof value === 'number' ? String(value) : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IAxisManualEntry, IAxisMdnsRecord, IAxisSsdpRecord, TAxisProtocol } from './axis.types.js';
|
||||||
|
|
||||||
|
const axisMdnsType = '_axis-video._tcp.local';
|
||||||
|
const axisOuis = ['00408c', 'accc8e', 'b8a44f', 'e82725'];
|
||||||
|
|
||||||
|
export class AxisMdnsMatcher implements IDiscoveryMatcher<IAxisMdnsRecord> {
|
||||||
|
public id = 'axis-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Axis _axis-video mDNS advertisements and Axis OUIs.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IAxisMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = normalizeType(recordArg.type);
|
||||||
|
const properties = { ...recordArg.txt, ...recordArg.properties };
|
||||||
|
const mac = normalizeMac(valueForKey(properties, 'macaddress') || valueForKey(properties, 'mac'));
|
||||||
|
const name = cleanName(recordArg.name || recordArg.hostname);
|
||||||
|
const matched = type === axisMdnsType || isAxisMac(mac) || name.toLowerCase().startsWith('axis-');
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not an Axis video advertisement.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: type === axisMdnsType || isAxisMac(mac) ? 'certain' : 'high',
|
||||||
|
reason: 'mDNS record matches Axis video metadata.',
|
||||||
|
normalizedDeviceId: mac,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'axis',
|
||||||
|
id: mac,
|
||||||
|
host: recordArg.host || recordArg.addresses?.[0],
|
||||||
|
port: recordArg.port || 80,
|
||||||
|
name: name || undefined,
|
||||||
|
manufacturer: 'Axis Communications AB',
|
||||||
|
macAddress: mac || undefined,
|
||||||
|
metadata: {
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt: properties,
|
||||||
|
protocol: 'http' satisfies TAxisProtocol,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AxisSsdpMatcher implements IDiscoveryMatcher<IAxisSsdpRecord> {
|
||||||
|
public id = 'axis-ssdp-match';
|
||||||
|
public source = 'ssdp' as const;
|
||||||
|
public description = 'Recognize Axis SSDP advertisements by manufacturer and UPnP metadata.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IAxisSsdpRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const upnp = { ...recordArg.headers, ...recordArg.upnp };
|
||||||
|
const manufacturer = recordArg.manufacturer || valueForKey(upnp, 'manufacturer') || '';
|
||||||
|
const location = recordArg.location || valueForKey(upnp, 'location') || valueForKey(upnp, 'presentationURL') || valueForKey(upnp, 'presentation_url');
|
||||||
|
const url = safeUrl(location);
|
||||||
|
const serial = valueForKey(upnp, 'serialNumber') || valueForKey(upnp, 'serial') || recordArg.usn;
|
||||||
|
const friendlyName = valueForKey(upnp, 'friendlyName') || valueForKey(upnp, 'upnp:friendlyName');
|
||||||
|
const model = valueForKey(upnp, 'modelName') || valueForKey(upnp, 'modelNumber');
|
||||||
|
const matched = manufacturer.toLowerCase().includes('axis') || (recordArg.server || '').toLowerCase().includes('axis');
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'SSDP record is not published by Axis.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: 'certain',
|
||||||
|
reason: 'SSDP record manufacturer is Axis.',
|
||||||
|
normalizedDeviceId: normalizeMac(serial) || serial,
|
||||||
|
candidate: {
|
||||||
|
source: 'ssdp',
|
||||||
|
integrationDomain: 'axis',
|
||||||
|
id: normalizeMac(serial) || serial,
|
||||||
|
host: url?.hostname,
|
||||||
|
port: url?.port ? Number(url.port) : undefined,
|
||||||
|
name: friendlyName,
|
||||||
|
manufacturer: 'Axis Communications AB',
|
||||||
|
model,
|
||||||
|
serialNumber: serial,
|
||||||
|
macAddress: normalizeMac(serial) || undefined,
|
||||||
|
metadata: {
|
||||||
|
protocol: url?.protocol === 'https:' ? 'https' : 'http',
|
||||||
|
location,
|
||||||
|
ssdp: upnp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AxisManualMatcher implements IDiscoveryMatcher<IAxisManualEntry> {
|
||||||
|
public id = 'axis-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Axis setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IAxisManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const mac = normalizeMac(inputArg.macAddress || inputArg.id || inputArg.serialNumber);
|
||||||
|
const haystack = `${inputArg.name || ''} ${inputArg.model || ''} ${inputArg.manufacturer || ''}`.toLowerCase();
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.metadata?.axis || haystack.includes('axis') || isAxisMac(mac));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Axis setup hints.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Axis setup.',
|
||||||
|
normalizedDeviceId: mac || inputArg.id || inputArg.serialNumber,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'axis',
|
||||||
|
id: mac || inputArg.id || inputArg.serialNumber,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || (inputArg.protocol === 'http' ? 80 : 443),
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: inputArg.manufacturer || 'Axis Communications AB',
|
||||||
|
model: inputArg.model,
|
||||||
|
serialNumber: inputArg.serialNumber,
|
||||||
|
macAddress: mac || undefined,
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
protocol: inputArg.protocol || 'https',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AxisCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'axis-candidate-validator';
|
||||||
|
public description = 'Validate that a discovery candidate can be configured as an Axis device.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const mac = normalizeMac(candidateArg.macAddress || candidateArg.id || candidateArg.serialNumber);
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const name = candidateArg.name?.toLowerCase() || '';
|
||||||
|
const matched = candidateArg.integrationDomain === 'axis'
|
||||||
|
|| manufacturer.includes('axis')
|
||||||
|
|| model.includes('axis')
|
||||||
|
|| name.startsWith('axis')
|
||||||
|
|| isAxisMac(mac)
|
||||||
|
|| Boolean(candidateArg.metadata?.axis);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has Axis metadata.' : 'Candidate is not Axis.',
|
||||||
|
candidate: matched ? {
|
||||||
|
...candidateArg,
|
||||||
|
integrationDomain: 'axis',
|
||||||
|
manufacturer: candidateArg.manufacturer || 'Axis Communications AB',
|
||||||
|
id: candidateArg.id || mac,
|
||||||
|
macAddress: candidateArg.macAddress || mac || undefined,
|
||||||
|
} : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.id || mac,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAxisDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'axis', displayName: 'Axis' })
|
||||||
|
.addMatcher(new AxisMdnsMatcher())
|
||||||
|
.addMatcher(new AxisSsdpMatcher())
|
||||||
|
.addMatcher(new AxisManualMatcher())
|
||||||
|
.addValidator(new AxisCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
|
||||||
|
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 cleanName = (valueArg: string | undefined): string => {
|
||||||
|
return valueArg?.replace(/\._axis-video\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMac = (valueArg: string | undefined): string => {
|
||||||
|
const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return cleaned.length === 12 ? cleaned : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAxisMac = (macArg: string): boolean => {
|
||||||
|
return Boolean(macArg && axisOuis.some((prefixArg) => macArg.startsWith(prefixArg)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeUrl = (valueArg: string | undefined): URL | undefined => {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(valueArg);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||||
|
import type { IAxisEvent, IAxisPtzCommand, IAxisRelayCommand, IAxisSnapshot } from './axis.types.js';
|
||||||
|
|
||||||
|
export class AxisMapper {
|
||||||
|
public static toDevices(snapshotArg: IAxisSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||||
|
const deviceInfo = snapshotArg.deviceInfo;
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const camera of snapshotArg.cameras) {
|
||||||
|
features.push({ id: `camera_${this.slug(camera.id)}`, capability: 'camera', name: camera.name || `Camera ${camera.id}`, readable: true, writable: Boolean(camera.supportsPtz) });
|
||||||
|
state.push({ featureId: `camera_${this.slug(camera.id)}`, value: camera.enabled !== false ? 'available' : 'disabled', updatedAt });
|
||||||
|
}
|
||||||
|
for (const relay of snapshotArg.relays) {
|
||||||
|
features.push({ id: `relay_${this.slug(relay.id)}`, capability: 'switch', name: relay.name || `Relay ${relay.id}`, readable: true, writable: true });
|
||||||
|
state.push({ featureId: `relay_${this.slug(relay.id)}`, value: relay.state === 'closed', updatedAt });
|
||||||
|
}
|
||||||
|
for (const sensor of snapshotArg.binarySensors) {
|
||||||
|
features.push({ id: `binary_${this.slug(sensor.id)}`, capability: 'sensor', name: sensor.name, readable: true, writable: false });
|
||||||
|
state.push({ featureId: `binary_${this.slug(sensor.id)}`, value: sensor.isOn, updatedAt });
|
||||||
|
}
|
||||||
|
for (const sensor of snapshotArg.sensors) {
|
||||||
|
features.push({ id: `sensor_${this.slug(sensor.id)}`, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit });
|
||||||
|
state.push({ featureId: `sensor_${this.slug(sensor.id)}`, value: this.deviceStateValue(sensor.value), updatedAt });
|
||||||
|
}
|
||||||
|
for (const light of snapshotArg.lights) {
|
||||||
|
features.push({ id: `light_${this.slug(light.id)}`, capability: 'light', name: light.name || `Light ${light.id}`, readable: true, writable: true });
|
||||||
|
state.push({ featureId: `light_${this.slug(light.id)}`, value: light.isOn ?? false, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: this.deviceId(snapshotArg),
|
||||||
|
integrationDomain: 'axis',
|
||||||
|
name: this.deviceName(snapshotArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: deviceInfo.manufacturer || 'Axis Communications AB',
|
||||||
|
model: deviceInfo.model || deviceInfo.productNumber || deviceInfo.productType,
|
||||||
|
online: snapshotArg.connected,
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
serialNumber: deviceInfo.serialNumber,
|
||||||
|
macAddress: deviceInfo.macAddress,
|
||||||
|
firmwareVersion: deviceInfo.firmwareVersion,
|
||||||
|
productType: deviceInfo.productType,
|
||||||
|
host: deviceInfo.host,
|
||||||
|
port: deviceInfo.port,
|
||||||
|
protocol: deviceInfo.protocol,
|
||||||
|
apiIds: snapshotArg.apiDiscovery.map((apiArg) => apiArg.id),
|
||||||
|
cameraStreams: snapshotArg.cameras.map((cameraArg) => ({
|
||||||
|
id: cameraArg.id,
|
||||||
|
snapshotUrl: cameraArg.snapshotUrl,
|
||||||
|
mjpegUrl: cameraArg.mjpegUrl,
|
||||||
|
rtspUrl: cameraArg.rtspUrl,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IAxisSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
const usedIds = new Map<string, number>();
|
||||||
|
const deviceId = this.deviceId(snapshotArg);
|
||||||
|
|
||||||
|
for (const camera of snapshotArg.cameras) {
|
||||||
|
entities.push(this.entity('camera' as TEntityPlatform, camera.name || `Camera ${camera.id}`, deviceId, `axis_${this.uniqueBase(snapshotArg)}_camera_${this.slug(camera.id)}`, camera.enabled !== false ? 'idle' : 'unavailable', usedIds, {
|
||||||
|
cameraId: camera.id,
|
||||||
|
videoSource: camera.videoSource,
|
||||||
|
streamProfile: camera.streamProfile,
|
||||||
|
resolution: camera.resolution,
|
||||||
|
imageFormats: camera.imageFormats,
|
||||||
|
snapshotUrl: camera.snapshotUrl,
|
||||||
|
mjpegUrl: camera.mjpegUrl,
|
||||||
|
rtspUrl: camera.rtspUrl,
|
||||||
|
supportedFeatures: camera.supportsPtz ? ['stream', 'ptz'] : ['stream'],
|
||||||
|
...camera.attributes,
|
||||||
|
}, camera.enabled !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relay of snapshotArg.relays) {
|
||||||
|
entities.push(this.entity('switch', relay.name || `Relay ${relay.id}`, deviceId, `axis_${this.uniqueBase(snapshotArg)}_relay_${this.slug(relay.id)}`, relay.state === 'closed' ? 'on' : 'off', usedIds, {
|
||||||
|
portId: relay.id,
|
||||||
|
usage: relay.usage,
|
||||||
|
normalState: relay.normalState,
|
||||||
|
readonly: relay.readonly,
|
||||||
|
...relay.attributes,
|
||||||
|
}, relay.available !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sensor of snapshotArg.binarySensors) {
|
||||||
|
entities.push(this.entity('binary_sensor', sensor.name, deviceId, `axis_${this.uniqueBase(snapshotArg)}_binary_${this.slug(sensor.id)}`, sensor.isOn ? 'on' : 'off', usedIds, {
|
||||||
|
deviceClass: sensor.deviceClass,
|
||||||
|
eventTopic: sensor.eventTopic,
|
||||||
|
source: sensor.source,
|
||||||
|
...sensor.attributes,
|
||||||
|
}, sensor.available !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sensor of snapshotArg.sensors) {
|
||||||
|
entities.push(this.entity('sensor', sensor.name, deviceId, `axis_${this.uniqueBase(snapshotArg)}_sensor_${this.slug(sensor.id)}`, sensor.value, usedIds, {
|
||||||
|
unit: sensor.unit,
|
||||||
|
deviceClass: sensor.deviceClass,
|
||||||
|
category: sensor.category,
|
||||||
|
...sensor.attributes,
|
||||||
|
}, sensor.available !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const light of snapshotArg.lights) {
|
||||||
|
entities.push(this.entity('light', light.name || `Light ${light.id}`, deviceId, `axis_${this.uniqueBase(snapshotArg)}_light_${this.slug(light.id)}`, light.isOn ? 'on' : 'off', usedIds, {
|
||||||
|
lightId: light.id,
|
||||||
|
lightType: light.lightType,
|
||||||
|
numberOfLeds: light.numberOfLeds,
|
||||||
|
brightness: light.brightness,
|
||||||
|
maxIntensity: light.maxIntensity,
|
||||||
|
...light.attributes,
|
||||||
|
}, light.available !== false && light.enabled !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of snapshotArg.events) {
|
||||||
|
entities.push(this.entity('event' as TEntityPlatform, event.name || this.eventName(event), deviceId, `axis_${this.uniqueBase(snapshotArg)}_event_${this.slug(event.id)}`, event.state || (event.isTripped ? 'on' : 'off'), usedIds, {
|
||||||
|
eventId: event.id,
|
||||||
|
topic: event.topic,
|
||||||
|
topicBase: event.topicBase,
|
||||||
|
group: event.group,
|
||||||
|
source: event.source,
|
||||||
|
sourceIndex: event.sourceIndex,
|
||||||
|
operation: event.operation,
|
||||||
|
deviceClass: event.deviceClass,
|
||||||
|
updatedAt: event.updatedAt,
|
||||||
|
data: event.data,
|
||||||
|
}, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static relayCommandForService(snapshotArg: IAxisSnapshot, requestArg: IServiceCallRequest): IAxisRelayCommand | undefined {
|
||||||
|
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off', 'toggle'].includes(requestArg.service)) {
|
||||||
|
const relay = this.findRelay(snapshotArg, requestArg);
|
||||||
|
if (!relay) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const state = requestArg.service === 'turn_on' ? 'closed' : requestArg.service === 'turn_off' ? 'open' : relay.state === 'closed' ? 'open' : 'closed';
|
||||||
|
return { portId: relay.id, state };
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'axis' && (requestArg.service === 'set_relay' || requestArg.service === 'set_port_state')) {
|
||||||
|
const portId = this.stringData(requestArg, 'portId') || this.stringData(requestArg, 'port_id') || this.findRelay(snapshotArg, requestArg)?.id;
|
||||||
|
if (!portId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const stateValue = requestArg.data?.state;
|
||||||
|
const onValue = requestArg.data?.on;
|
||||||
|
const state = stateValue === 'closed' || stateValue === 'open' ? stateValue : onValue === true ? 'closed' : onValue === false ? 'open' : undefined;
|
||||||
|
return state ? { portId, state } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ptzCommandForService(snapshotArg: IAxisSnapshot, requestArg: IServiceCallRequest): IAxisPtzCommand | undefined {
|
||||||
|
const supported = (requestArg.domain === 'axis' && ['ptz', 'ptz_control'].includes(requestArg.service))
|
||||||
|
|| (requestArg.domain === 'camera' && ['ptz', 'ptz_control'].includes(requestArg.service));
|
||||||
|
if (!supported) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const data = requestArg.data || {};
|
||||||
|
const camera = this.findCamera(snapshotArg, requestArg);
|
||||||
|
const command: IAxisPtzCommand = {
|
||||||
|
camera: this.stringData(requestArg, 'camera') || this.stringData(requestArg, 'cameraId') || this.stringData(requestArg, 'camera_id') || camera?.videoSource || camera?.id,
|
||||||
|
move: this.moveValue(data.move),
|
||||||
|
pan: this.numberValue(data.pan),
|
||||||
|
tilt: this.numberValue(data.tilt),
|
||||||
|
zoom: this.numberValue(data.zoom),
|
||||||
|
focus: this.numberValue(data.focus),
|
||||||
|
iris: this.numberValue(data.iris),
|
||||||
|
brightness: this.numberValue(data.brightness),
|
||||||
|
rpan: this.numberValue(data.rpan ?? data.relative_pan),
|
||||||
|
rtilt: this.numberValue(data.rtilt ?? data.relative_tilt),
|
||||||
|
rzoom: this.numberValue(data.rzoom ?? data.relative_zoom),
|
||||||
|
rfocus: this.numberValue(data.rfocus ?? data.relative_focus),
|
||||||
|
riris: this.numberValue(data.riris ?? data.relative_iris),
|
||||||
|
rbrightness: this.numberValue(data.rbrightness ?? data.relative_brightness),
|
||||||
|
autofocus: this.booleanValue(data.autofocus ?? data.auto_focus),
|
||||||
|
autoiris: this.booleanValue(data.autoiris ?? data.auto_iris),
|
||||||
|
continuouspantiltmove: this.numberPair(data.continuouspantiltmove ?? data.continuous_pan_tilt_move),
|
||||||
|
continuouszoommove: this.numberValue(data.continuouszoommove ?? data.continuous_zoom_move),
|
||||||
|
continuousfocusmove: this.numberValue(data.continuousfocusmove ?? data.continuous_focus_move),
|
||||||
|
continuousirismove: this.numberValue(data.continuousirismove ?? data.continuous_iris_move),
|
||||||
|
continuousbrightnessmove: this.numberValue(data.continuousbrightnessmove ?? data.continuous_brightness_move),
|
||||||
|
auxiliary: this.stringValue(data.auxiliary),
|
||||||
|
gotoserverpresetname: this.stringValue(data.gotoserverpresetname ?? data.preset ?? data.preset_name),
|
||||||
|
gotoserverpresetno: this.numberValue(data.gotoserverpresetno ?? data.preset_number),
|
||||||
|
gotodevicepreset: this.numberValue(data.gotodevicepreset ?? data.device_preset),
|
||||||
|
speed: this.numberValue(data.speed),
|
||||||
|
imagerotation: this.rotationValue(data.imagerotation ?? data.image_rotation),
|
||||||
|
ircutfilter: this.stateValue(data.ircutfilter ?? data.ir_cut_filter),
|
||||||
|
backlight: this.booleanValue(data.backlight),
|
||||||
|
center: this.numberPair(data.center),
|
||||||
|
areazoom: this.numberTriple(data.areazoom ?? data.area_zoom),
|
||||||
|
imagewidth: this.numberValue(data.imagewidth ?? data.image_width),
|
||||||
|
imageheight: this.numberValue(data.imageheight ?? data.image_height),
|
||||||
|
};
|
||||||
|
return Object.entries(command).some(([keyArg, valueArg]) => keyArg !== 'camera' && valueArg !== undefined) ? command : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toIntegrationEvent(eventArg: IAxisEvent): IIntegrationEvent {
|
||||||
|
return {
|
||||||
|
type: 'state_changed',
|
||||||
|
integrationDomain: 'axis',
|
||||||
|
entityId: eventArg.id,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.updatedAt ? Date.parse(eventArg.updatedAt) : Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static deviceId(snapshotArg: IAxisSnapshot): string {
|
||||||
|
return `axis.device.${this.uniqueBase(snapshotArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `${String(platformArg)}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`;
|
||||||
|
const seen = usedIdsArg.get(baseId) || 0;
|
||||||
|
usedIdsArg.set(baseId, seen + 1);
|
||||||
|
const id = seen ? `${baseId}_${seen + 1}` : baseId;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
uniqueId: uniqueIdArg,
|
||||||
|
integrationDomain: 'axis',
|
||||||
|
deviceId: deviceIdArg,
|
||||||
|
platform: platformArg,
|
||||||
|
name: nameArg,
|
||||||
|
state: stateArg,
|
||||||
|
attributes: this.cleanAttributes(attributesArg),
|
||||||
|
available: availableArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findRelay(snapshotArg: IAxisSnapshot, requestArg: IServiceCallRequest) {
|
||||||
|
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringData(requestArg, 'entityId') || this.stringData(requestArg, 'entity_id') || this.stringData(requestArg, 'portId') || this.stringData(requestArg, 'port_id');
|
||||||
|
if (!target) {
|
||||||
|
return snapshotArg.relays[0];
|
||||||
|
}
|
||||||
|
const entities = this.toEntities(snapshotArg);
|
||||||
|
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.portId === target);
|
||||||
|
const portId = entity?.attributes?.portId;
|
||||||
|
return snapshotArg.relays.find((relayArg) => relayArg.id === portId || relayArg.id === target || `axis.device.${this.uniqueBase(snapshotArg)}` === target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findCamera(snapshotArg: IAxisSnapshot, requestArg: IServiceCallRequest) {
|
||||||
|
const target = requestArg.target.entityId || this.stringData(requestArg, 'cameraId') || this.stringData(requestArg, 'camera_id') || this.stringData(requestArg, 'camera');
|
||||||
|
if (!target) {
|
||||||
|
return snapshotArg.cameras[0];
|
||||||
|
}
|
||||||
|
const entities = this.toEntities(snapshotArg);
|
||||||
|
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.cameraId === target);
|
||||||
|
const cameraId = entity?.attributes?.cameraId;
|
||||||
|
return snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.videoSource) === target || cameraArg.id === target) || snapshotArg.cameras[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static eventName(eventArg: IAxisEvent): string {
|
||||||
|
return eventArg.name || eventArg.topicBase?.split('/').pop() || eventArg.topic?.split('/').pop() || `Event ${eventArg.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceName(snapshotArg: IAxisSnapshot): string {
|
||||||
|
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.host || 'Axis device';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueBase(snapshotArg: IAxisSnapshot): string {
|
||||||
|
return this.slug(snapshotArg.deviceInfo.macAddress || snapshotArg.deviceInfo.serialNumber || snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||||
|
if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
|
||||||
|
return valueArg as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
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 stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||||
|
return this.stringValue(requestArg.data?.[keyArg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static booleanValue(valueArg: unknown): boolean | undefined {
|
||||||
|
if (typeof valueArg === 'boolean') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
if (['on', 'true', 'yes', '1'].includes(valueArg.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['off', 'false', 'no', '0'].includes(valueArg.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static moveValue(valueArg: unknown): IAxisPtzCommand['move'] | undefined {
|
||||||
|
const value = this.stringValue(valueArg);
|
||||||
|
const allowed = ['home', 'up', 'down', 'left', 'right', 'upleft', 'upright', 'downleft', 'downright', 'stop'];
|
||||||
|
return allowed.includes(value || '') ? value as IAxisPtzCommand['move'] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static rotationValue(valueArg: unknown): IAxisPtzCommand['imagerotation'] | undefined {
|
||||||
|
const value = this.stringValue(valueArg);
|
||||||
|
return value === '0' || value === '90' || value === '180' || value === '270' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stateValue(valueArg: unknown): IAxisPtzCommand['ircutfilter'] | undefined {
|
||||||
|
const value = this.stringValue(valueArg);
|
||||||
|
return value === 'on' || value === 'off' || value === 'auto' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberPair(valueArg: unknown): [number, number] | undefined {
|
||||||
|
if (Array.isArray(valueArg) && valueArg.length >= 2) {
|
||||||
|
const first = this.numberValue(valueArg[0]);
|
||||||
|
const second = this.numberValue(valueArg[1]);
|
||||||
|
return first !== undefined && second !== undefined ? [first, second] : undefined;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
const [first, second] = valueArg.split(',').map((value) => this.numberValue(value));
|
||||||
|
return first !== undefined && second !== undefined ? [first, second] : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberTriple(valueArg: unknown): [number, number, number] | undefined {
|
||||||
|
if (Array.isArray(valueArg) && valueArg.length >= 3) {
|
||||||
|
const first = this.numberValue(valueArg[0]);
|
||||||
|
const second = this.numberValue(valueArg[1]);
|
||||||
|
const third = this.numberValue(valueArg[2]);
|
||||||
|
return first !== undefined && second !== undefined && third !== undefined ? [first, second, third] : undefined;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
const [first, second, third] = valueArg.split(',').map((value) => this.numberValue(value));
|
||||||
|
return first !== undefined && second !== undefined && third !== undefined ? [first, second, third] : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'axis';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,318 @@
|
|||||||
export interface IHomeAssistantAxisConfig {
|
export type TAxisProtocol = 'http' | 'https';
|
||||||
// TODO: replace with the TypeScript-native config for axis.
|
export type TAxisAuthScheme = 'auto' | 'basic' | 'digest';
|
||||||
|
export type TAxisPortState = 'open' | 'closed' | 'unknown';
|
||||||
|
export type TAxisPortDirection = 'input' | 'output' | 'unknown';
|
||||||
|
export type TAxisPtzMove = 'home' | 'up' | 'down' | 'left' | 'right' | 'upleft' | 'upright' | 'downleft' | 'downright' | 'stop';
|
||||||
|
export type TAxisPtzState = 'on' | 'off' | 'auto';
|
||||||
|
|
||||||
|
export interface IAxisConfig {
|
||||||
|
protocol?: TAxisProtocol;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
authScheme?: TAxisAuthScheme;
|
||||||
|
timeoutMs?: number;
|
||||||
|
name?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
model?: string;
|
||||||
|
streamProfile?: string;
|
||||||
|
videoSource?: string | number;
|
||||||
|
deviceInfo?: IAxisDeviceInfo;
|
||||||
|
cameras?: IAxisCameraStream[];
|
||||||
|
sensors?: IAxisSensor[];
|
||||||
|
binarySensors?: IAxisBinarySensor[];
|
||||||
|
events?: IAxisEvent[];
|
||||||
|
ports?: IAxisPort[];
|
||||||
|
relays?: IAxisRelay[];
|
||||||
|
switches?: IAxisRelay[];
|
||||||
|
lights?: IAxisLight[];
|
||||||
|
apiDiscovery?: IAxisApiDescription[];
|
||||||
|
params?: IAxisParamTree;
|
||||||
|
connected?: boolean;
|
||||||
|
snapshot?: IAxisSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantAxisConfig extends IAxisConfig {}
|
||||||
|
|
||||||
|
export interface IAxisDeviceInfo {
|
||||||
|
id?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
productNumber?: string;
|
||||||
|
productType?: string;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
hardwareId?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
protocol?: TAxisProtocol;
|
||||||
|
online?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisCameraStream {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
videoSource?: string | number;
|
||||||
|
streamProfile?: string;
|
||||||
|
resolution?: string;
|
||||||
|
imageFormats?: string[];
|
||||||
|
snapshotUrl?: string;
|
||||||
|
mjpegUrl?: string;
|
||||||
|
rtspUrl?: string;
|
||||||
|
supportsPtz?: boolean;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisSensor<TValue = unknown> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: TValue;
|
||||||
|
unit?: string;
|
||||||
|
deviceClass?: string;
|
||||||
|
category?: string;
|
||||||
|
available?: boolean;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisBinarySensor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isOn: boolean;
|
||||||
|
deviceClass?: string;
|
||||||
|
eventTopic?: string;
|
||||||
|
source?: string;
|
||||||
|
available?: boolean;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisEvent {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
topic?: string;
|
||||||
|
topicBase?: string;
|
||||||
|
group?: 'input' | 'light' | 'motion' | 'output' | 'ptz' | 'sound' | 'none' | string;
|
||||||
|
source?: string;
|
||||||
|
sourceIndex?: string;
|
||||||
|
operation?: 'Initialized' | 'Changed' | 'Deleted' | 'Unknown' | string;
|
||||||
|
state?: string;
|
||||||
|
type?: string;
|
||||||
|
isTripped?: boolean;
|
||||||
|
deviceClass?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisPort {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
usage?: string;
|
||||||
|
direction: TAxisPortDirection;
|
||||||
|
state?: TAxisPortState;
|
||||||
|
normalState?: TAxisPortState;
|
||||||
|
configurable?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
available?: boolean;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisRelay extends IAxisPort {
|
||||||
|
direction: 'output';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisLight {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
isOn?: boolean;
|
||||||
|
lightType?: string;
|
||||||
|
numberOfLeds?: number;
|
||||||
|
brightness?: number;
|
||||||
|
maxIntensity?: number;
|
||||||
|
available?: boolean;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisApiDescription {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
status?: string;
|
||||||
|
docLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisParamTree {
|
||||||
|
root?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IAxisSnapshot {
|
||||||
|
deviceInfo: IAxisDeviceInfo;
|
||||||
|
cameras: IAxisCameraStream[];
|
||||||
|
sensors: IAxisSensor[];
|
||||||
|
binarySensors: IAxisBinarySensor[];
|
||||||
|
events: IAxisEvent[];
|
||||||
|
ports: IAxisPort[];
|
||||||
|
relays: IAxisRelay[];
|
||||||
|
switches: IAxisRelay[];
|
||||||
|
lights: IAxisLight[];
|
||||||
|
apiDiscovery: IAxisApiDescription[];
|
||||||
|
params?: IAxisParamTree;
|
||||||
|
connected: boolean;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisBasicDeviceInfoResponse {
|
||||||
|
apiVersion?: string;
|
||||||
|
context?: string;
|
||||||
|
method?: string;
|
||||||
|
data?: {
|
||||||
|
propertyList?: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
error?: IAxisApiError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisApiDiscoveryResponse {
|
||||||
|
apiVersion?: string;
|
||||||
|
context?: string;
|
||||||
|
method?: string;
|
||||||
|
data?: {
|
||||||
|
apiList?: IAxisApiDescription[];
|
||||||
|
};
|
||||||
|
error?: IAxisApiError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisPortsResponse {
|
||||||
|
apiVersion?: string;
|
||||||
|
context?: string;
|
||||||
|
method?: string;
|
||||||
|
data?: {
|
||||||
|
numberOfPorts?: number;
|
||||||
|
items?: IAxisPortManagementItem[];
|
||||||
|
};
|
||||||
|
error?: IAxisApiError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisPortManagementItem {
|
||||||
|
port: string;
|
||||||
|
state?: TAxisPortState | string;
|
||||||
|
configurable?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
usage?: string;
|
||||||
|
direction?: TAxisPortDirection | string;
|
||||||
|
name?: string;
|
||||||
|
normalState?: TAxisPortState | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisLightInformationResponse {
|
||||||
|
apiVersion?: string;
|
||||||
|
context?: string;
|
||||||
|
method?: string;
|
||||||
|
data?: {
|
||||||
|
items?: IAxisLightInformationItem[];
|
||||||
|
};
|
||||||
|
error?: IAxisApiError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisLightInformationItem {
|
||||||
|
enabled?: boolean;
|
||||||
|
lightID?: string;
|
||||||
|
lightState?: boolean;
|
||||||
|
lightType?: string;
|
||||||
|
nrOfLEDs?: number;
|
||||||
|
automaticAngleOfIlluminationMode?: boolean;
|
||||||
|
automaticIntensityMode?: boolean;
|
||||||
|
synchronizeDayNightMode?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
errorInfo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisApiError {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
errors?: IAxisApiError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisPtzCommand {
|
||||||
|
camera?: string | number;
|
||||||
|
move?: TAxisPtzMove;
|
||||||
|
pan?: number;
|
||||||
|
tilt?: number;
|
||||||
|
zoom?: number;
|
||||||
|
focus?: number;
|
||||||
|
iris?: number;
|
||||||
|
brightness?: number;
|
||||||
|
rpan?: number;
|
||||||
|
rtilt?: number;
|
||||||
|
rzoom?: number;
|
||||||
|
rfocus?: number;
|
||||||
|
riris?: number;
|
||||||
|
rbrightness?: number;
|
||||||
|
autofocus?: boolean;
|
||||||
|
autoiris?: boolean;
|
||||||
|
continuouspantiltmove?: [number, number];
|
||||||
|
continuouszoommove?: number;
|
||||||
|
continuousfocusmove?: number;
|
||||||
|
continuousirismove?: number;
|
||||||
|
continuousbrightnessmove?: number;
|
||||||
|
auxiliary?: string;
|
||||||
|
gotoserverpresetname?: string;
|
||||||
|
gotoserverpresetno?: number;
|
||||||
|
gotodevicepreset?: number;
|
||||||
|
speed?: number;
|
||||||
|
imagerotation?: '0' | '90' | '180' | '270';
|
||||||
|
ircutfilter?: TAxisPtzState;
|
||||||
|
backlight?: boolean;
|
||||||
|
center?: [number, number];
|
||||||
|
areazoom?: [number, number, number];
|
||||||
|
imagewidth?: number;
|
||||||
|
imageheight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisRelayCommand {
|
||||||
|
portId: string;
|
||||||
|
state: 'open' | 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisSnapshotImage {
|
||||||
|
contentType: string;
|
||||||
|
data: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
addresses?: string[];
|
||||||
|
hostname?: string;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
properties?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisSsdpRecord {
|
||||||
|
manufacturer?: string;
|
||||||
|
server?: string;
|
||||||
|
st?: string;
|
||||||
|
usn?: string;
|
||||||
|
location?: string;
|
||||||
|
upnp?: Record<string, string | undefined>;
|
||||||
|
headers?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAxisManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
protocol?: TAxisProtocol;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
export * from './axis.classes.integration.js';
|
export * from './axis.classes.integration.js';
|
||||||
|
export * from './axis.classes.client.js';
|
||||||
|
export * from './axis.classes.configflow.js';
|
||||||
|
export * from './axis.discovery.js';
|
||||||
|
export * from './axis.mapper.js';
|
||||||
export * from './axis.types.js';
|
export * from './axis.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,739 @@
|
|||||||
|
import type {
|
||||||
|
IBraviatvApp,
|
||||||
|
IBraviatvChannel,
|
||||||
|
IBraviatvConfig,
|
||||||
|
IBraviatvPlayingInfo,
|
||||||
|
IBraviatvSnapshot,
|
||||||
|
IBraviatvSource,
|
||||||
|
IBraviatvState,
|
||||||
|
IBraviatvSystemInfo,
|
||||||
|
IBraviatvVolumeInfo,
|
||||||
|
TBraviatvRestService,
|
||||||
|
TBraviatvSourceType,
|
||||||
|
} from './braviatv.types.js';
|
||||||
|
|
||||||
|
interface IBraviatvRestResponse {
|
||||||
|
result?: unknown[];
|
||||||
|
error?: [number, string] | unknown[];
|
||||||
|
id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 10000;
|
||||||
|
const defaultAudioTarget = 'speaker';
|
||||||
|
const defaultRestVersion = '1.0';
|
||||||
|
const powerOnCode = 'AAAAAQAAAAEAAAAuAw==';
|
||||||
|
|
||||||
|
const fallbackCommands: Record<string, string> = {
|
||||||
|
Power: 'AAAAAQAAAAEAAAAVAw==',
|
||||||
|
PowerOn: powerOnCode,
|
||||||
|
Input: 'AAAAAQAAAAEAAAAlAw==',
|
||||||
|
SyncMenu: 'AAAAAgAAABoAAABYAw==',
|
||||||
|
Hdmi1: 'AAAAAgAAABoAAABaAw==',
|
||||||
|
Hdmi2: 'AAAAAgAAABoAAABbAw==',
|
||||||
|
Hdmi3: 'AAAAAgAAABoAAABcAw==',
|
||||||
|
Hdmi4: 'AAAAAgAAABoAAABdAw==',
|
||||||
|
Num1: 'AAAAAQAAAAEAAAAAAw==',
|
||||||
|
Num2: 'AAAAAQAAAAEAAAABAw==',
|
||||||
|
Num3: 'AAAAAQAAAAEAAAACAw==',
|
||||||
|
Num4: 'AAAAAQAAAAEAAAADAw==',
|
||||||
|
Num5: 'AAAAAQAAAAEAAAAEAw==',
|
||||||
|
Num6: 'AAAAAQAAAAEAAAAFAw==',
|
||||||
|
Num7: 'AAAAAQAAAAEAAAAGAw==',
|
||||||
|
Num8: 'AAAAAQAAAAEAAAAHAw==',
|
||||||
|
Num9: 'AAAAAQAAAAEAAAAIAw==',
|
||||||
|
Num0: 'AAAAAQAAAAEAAAAJAw==',
|
||||||
|
Dot: 'AAAAAgAAAJcAAAAdAw==',
|
||||||
|
CC: 'AAAAAgAAAJcAAAAoAw==',
|
||||||
|
Red: 'AAAAAgAAAJcAAAAlAw==',
|
||||||
|
Green: 'AAAAAgAAAJcAAAAmAw==',
|
||||||
|
Yellow: 'AAAAAgAAAJcAAAAnAw==',
|
||||||
|
Blue: 'AAAAAgAAAJcAAAAkAw==',
|
||||||
|
Up: 'AAAAAQAAAAEAAAB0Aw==',
|
||||||
|
Down: 'AAAAAQAAAAEAAAB1Aw==',
|
||||||
|
Right: 'AAAAAQAAAAEAAAAzAw==',
|
||||||
|
Left: 'AAAAAQAAAAEAAAA0Aw==',
|
||||||
|
Confirm: 'AAAAAQAAAAEAAABlAw==',
|
||||||
|
Help: 'AAAAAgAAAMQAAABNAw==',
|
||||||
|
Display: 'AAAAAQAAAAEAAAA6Aw==',
|
||||||
|
Options: 'AAAAAgAAAJcAAAA2Aw==',
|
||||||
|
Back: 'AAAAAgAAAJcAAAAjAw==',
|
||||||
|
Home: 'AAAAAQAAAAEAAABgAw==',
|
||||||
|
VolumeUp: 'AAAAAQAAAAEAAAASAw==',
|
||||||
|
VolumeDown: 'AAAAAQAAAAEAAAATAw==',
|
||||||
|
Mute: 'AAAAAQAAAAEAAAAUAw==',
|
||||||
|
Audio: 'AAAAAQAAAAEAAAAXAw==',
|
||||||
|
ChannelUp: 'AAAAAQAAAAEAAAAQAw==',
|
||||||
|
ChannelDown: 'AAAAAQAAAAEAAAARAw==',
|
||||||
|
Play: 'AAAAAgAAAJcAAAAaAw==',
|
||||||
|
Pause: 'AAAAAgAAAJcAAAAZAw==',
|
||||||
|
Stop: 'AAAAAgAAAJcAAAAYAw==',
|
||||||
|
FlashPlus: 'AAAAAgAAAJcAAAB4Aw==',
|
||||||
|
FlashMinus: 'AAAAAgAAAJcAAAB5Aw==',
|
||||||
|
Prev: 'AAAAAgAAAJcAAAA8Aw==',
|
||||||
|
Next: 'AAAAAgAAAJcAAAA9Aw==',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BraviatvAuthError extends Error {
|
||||||
|
constructor(messageArg: string) {
|
||||||
|
super(messageArg);
|
||||||
|
this.name = 'BraviatvAuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BraviatvLiveControlError extends Error {
|
||||||
|
constructor(messageArg: string) {
|
||||||
|
super(messageArg);
|
||||||
|
this.name = 'BraviatvLiveControlError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BraviatvClient {
|
||||||
|
private commandCache?: Record<string, string>;
|
||||||
|
private irccEndpoint: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: IBraviatvConfig) {
|
||||||
|
this.irccEndpoint = config.irccEndpoint || 'ircc';
|
||||||
|
this.commandCache = config.commands ? { ...fallbackCommands, ...config.commands } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IBraviatvSnapshot> {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasLivePskConfig()) {
|
||||||
|
try {
|
||||||
|
return await this.getLiveSnapshot();
|
||||||
|
} catch (errorArg) {
|
||||||
|
if (errorArg instanceof BraviatvAuthError) {
|
||||||
|
throw errorArg;
|
||||||
|
}
|
||||||
|
return this.normalizeSnapshot(this.snapshotFromManualConfig(this.errorMessage(errorArg)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeSnapshot(this.snapshotFromManualConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async turnOn(): Promise<void> {
|
||||||
|
this.assertLivePskSupported('turn_on');
|
||||||
|
try {
|
||||||
|
await this.sendRestQuick('system', 'setPowerStatus', { status: true });
|
||||||
|
} catch {
|
||||||
|
await this.sendIrcc(powerOnCode);
|
||||||
|
}
|
||||||
|
this.updateLocalState({ powerStatus: 'active', power: 'on', available: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async turnOff(): Promise<void> {
|
||||||
|
await this.sendRestQuick('system', 'setPowerStatus', { status: false });
|
||||||
|
this.updateLocalState({ powerStatus: 'standby', power: 'off', playback: 'off' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setVolumeLevel(volumeLevelArg: number): Promise<void> {
|
||||||
|
const volume = Math.max(0, Math.min(100, Math.round(volumeLevelArg * 100)));
|
||||||
|
await this.sendRestQuick('audio', 'setAudioVolume', { target: defaultAudioTarget, volume: String(volume) });
|
||||||
|
this.updateLocalState({ volumeLevel: volume / 100, volumePercent: volume });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async volumeUp(): Promise<void> {
|
||||||
|
await this.stepVolume(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async volumeDown(): Promise<void> {
|
||||||
|
await this.stepVolume(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async muteVolume(mutedArg: boolean): Promise<void> {
|
||||||
|
await this.sendRestQuick('audio', 'setAudioMute', { status: mutedArg });
|
||||||
|
this.updateLocalState({ muted: mutedArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async mediaPlay(): Promise<void> {
|
||||||
|
await this.sendCommand('Play');
|
||||||
|
this.updateLocalState({ playback: 'playing' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async mediaPause(): Promise<void> {
|
||||||
|
await this.sendCommand('Pause');
|
||||||
|
this.updateLocalState({ playback: 'paused' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async mediaStop(): Promise<void> {
|
||||||
|
await this.sendCommand('Stop');
|
||||||
|
this.updateLocalState({ playback: 'idle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async nextTrack(): Promise<void> {
|
||||||
|
await this.sendCommand('Next');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async previousTrack(): Promise<void> {
|
||||||
|
await this.sendCommand('Prev');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async selectSource(sourceArg: string): Promise<void> {
|
||||||
|
const source = sourceArg.trim();
|
||||||
|
if (!source) {
|
||||||
|
throw new Error('Sony Bravia TV select_source requires a source name or URI.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await this.findSource(source);
|
||||||
|
await this.startSource(item.uri, item.type);
|
||||||
|
this.updateLocalState({ powerStatus: 'active', power: 'on', source: item.title, sourceUri: item.uri });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async playMedia(mediaTypeArg: string, mediaIdArg: string): Promise<void> {
|
||||||
|
const normalizedType = mediaTypeArg.toLowerCase();
|
||||||
|
if (normalizedType !== 'app' && normalizedType !== 'channel') {
|
||||||
|
throw new Error(`Sony Bravia TV play_media supports app or channel media only, not ${mediaTypeArg}.`);
|
||||||
|
}
|
||||||
|
const source = await this.findSource(mediaIdArg, normalizedType as TBraviatvSourceType);
|
||||||
|
await this.startSource(source.uri, source.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: string): Promise<void> {
|
||||||
|
const code = await this.commandCode(commandArg);
|
||||||
|
if (!code) {
|
||||||
|
throw new Error(`Unsupported Sony Bravia TV IRCC command: ${commandArg}`);
|
||||||
|
}
|
||||||
|
await this.sendIrcc(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommands(commandsArg: string[], repeatsArg = 1): Promise<void> {
|
||||||
|
const repeats = Math.max(1, Math.floor(repeatsArg));
|
||||||
|
for (let repeat = 0; repeat < repeats; repeat += 1) {
|
||||||
|
for (const command of commandsArg) {
|
||||||
|
await this.sendCommand(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reboot(): Promise<void> {
|
||||||
|
await this.sendRestQuick('system', 'requestReboot');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async terminateApps(): Promise<void> {
|
||||||
|
await this.sendRestQuick('appControl', 'terminateApps');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private async getLiveSnapshot(): Promise<IBraviatvSnapshot> {
|
||||||
|
const powerStatus = await this.getPowerStatus();
|
||||||
|
const systemInfo = await this.getSystemInfo().catch(() => this.systemInfoFromConfig());
|
||||||
|
const state: IBraviatvState = {
|
||||||
|
...this.config.state,
|
||||||
|
powerStatus,
|
||||||
|
power: this.powerFromStatus(powerStatus),
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sources = this.config.sources || [];
|
||||||
|
let apps = this.config.apps || [];
|
||||||
|
let channels = this.config.channels || [];
|
||||||
|
let commands = this.config.commands;
|
||||||
|
|
||||||
|
if (state.power === 'on') {
|
||||||
|
const [volumeInfo, playingInfo, liveSources, liveApps, liveChannels, liveCommands] = await Promise.all([
|
||||||
|
this.getVolumeInfo().catch(() => undefined),
|
||||||
|
this.getPlayingInfo().catch(() => undefined),
|
||||||
|
this.getExternalInputsStatus().catch(() => sources),
|
||||||
|
this.getAppList().catch(() => apps),
|
||||||
|
this.config.fetchChannels ? this.getContentListAll('tv').catch(() => channels) : Promise.resolve(channels),
|
||||||
|
this.getCommandList().catch(() => commands),
|
||||||
|
]);
|
||||||
|
sources = liveSources;
|
||||||
|
apps = liveApps;
|
||||||
|
channels = liveChannels;
|
||||||
|
commands = liveCommands;
|
||||||
|
this.applyVolumeInfo(state, volumeInfo);
|
||||||
|
this.applyPlayingInfo(state, playingInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeSnapshot({
|
||||||
|
systemInfo,
|
||||||
|
state,
|
||||||
|
sources,
|
||||||
|
apps,
|
||||||
|
channels,
|
||||||
|
commands,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPowerStatus(): Promise<string> {
|
||||||
|
const response = await this.sendRest('system', 'getPowerStatus', undefined, defaultRestVersion, 5000);
|
||||||
|
return this.firstResult<{ status?: string }>(response)?.status || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSystemInfo(): Promise<IBraviatvSystemInfo> {
|
||||||
|
const response = await this.sendRest('system', 'getSystemInformation');
|
||||||
|
const info = this.firstResult<IBraviatvSystemInfo>(response) || {};
|
||||||
|
this.config.macAddress = info.macAddr || this.config.macAddress;
|
||||||
|
this.config.cid = info.cid || this.config.cid;
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getVolumeInfo(targetArg = defaultAudioTarget): Promise<IBraviatvVolumeInfo | undefined> {
|
||||||
|
const response = await this.sendRest('audio', 'getVolumeInformation');
|
||||||
|
const outputs = this.firstResult<IBraviatvVolumeInfo[]>(response) || [];
|
||||||
|
return outputs.find((outputArg) => outputArg.target === targetArg) || outputs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPlayingInfo(): Promise<IBraviatvPlayingInfo | undefined> {
|
||||||
|
const response = await this.sendRest('avContent', 'getPlayingContentInfo');
|
||||||
|
return this.firstResult<IBraviatvPlayingInfo>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getExternalInputsStatus(): Promise<IBraviatvSource[]> {
|
||||||
|
const response = await this.sendRest('avContent', 'getCurrentExternalInputsStatus');
|
||||||
|
return (this.firstResult<IBraviatvSource[]>(response) || []).map((sourceArg) => ({ ...sourceArg, type: 'input' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAppList(): Promise<IBraviatvApp[]> {
|
||||||
|
const response = await this.sendRest('appControl', 'getApplicationList');
|
||||||
|
return (this.firstResult<IBraviatvApp[]>(response) || []).map((appArg) => ({ ...appArg, type: 'app' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSourceList(schemeArg: string): Promise<string[]> {
|
||||||
|
const response = await this.sendRest('avContent', 'getSourceList', { scheme: schemeArg });
|
||||||
|
return (this.firstResult<Array<{ source?: string }>>(response) || []).map((itemArg) => itemArg.source).filter((itemArg): itemArg is string => Boolean(itemArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getContentCount(sourceArg: string): Promise<number> {
|
||||||
|
const response = await this.sendRest('avContent', 'getContentCount', { source: sourceArg }, defaultRestVersion, 20000);
|
||||||
|
return this.firstResult<{ count?: number }>(response)?.count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getContentList(sourceArg: string, indexArg: number, countArg: number): Promise<IBraviatvChannel[]> {
|
||||||
|
const response = await this.sendRest('avContent', 'getContentList', { source: sourceArg, stIdx: indexArg, cnt: countArg }, defaultRestVersion, 20000);
|
||||||
|
return (this.firstResult<IBraviatvChannel[]>(response) || []).map((channelArg) => ({ ...channelArg, type: 'channel' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getContentListAll(schemeArg: string): Promise<IBraviatvChannel[]> {
|
||||||
|
const channels: IBraviatvChannel[] = [];
|
||||||
|
for (const source of await this.getSourceList(schemeArg)) {
|
||||||
|
const total = await this.getContentCount(source);
|
||||||
|
for (let index = 0; index < total; index += 50) {
|
||||||
|
channels.push(...await this.getContentList(source, index, Math.min(50, total - index)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCommandList(): Promise<Record<string, string>> {
|
||||||
|
if (this.commandCache) {
|
||||||
|
return this.commandCache;
|
||||||
|
}
|
||||||
|
const response = await this.sendRest('system', 'getRemoteControllerInfo');
|
||||||
|
const remoteCommands = Array.isArray(response.result?.[1]) ? response.result[1] as Array<{ name?: string; value?: string }> : [];
|
||||||
|
this.commandCache = {
|
||||||
|
...fallbackCommands,
|
||||||
|
...Object.fromEntries(remoteCommands.filter((itemArg) => itemArg.name && itemArg.value).map((itemArg) => [itemArg.name as string, itemArg.value as string])),
|
||||||
|
};
|
||||||
|
return this.commandCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stepVolume(stepArg: number): Promise<void> {
|
||||||
|
const prefix = stepArg > 0 ? '+' : '';
|
||||||
|
await this.sendRestQuick('audio', 'setAudioVolume', { target: defaultAudioTarget, volume: `${prefix}${stepArg}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findSource(queryArg: string, sourceTypeArg?: TBraviatvSourceType): Promise<Required<Pick<IBraviatvSource, 'title' | 'uri' | 'type'>>> {
|
||||||
|
const direct = this.directSource(queryArg, sourceTypeArg);
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await this.getSnapshot();
|
||||||
|
const items = [...snapshot.sources, ...snapshot.apps, ...(snapshot.channels || [])].map((itemArg) => ({
|
||||||
|
...itemArg,
|
||||||
|
type: itemArg.type || this.inferSourceType(itemArg.uri),
|
||||||
|
}));
|
||||||
|
const exact = items.find((itemArg) => (!sourceTypeArg || itemArg.type === sourceTypeArg) && (itemArg.uri === queryArg || itemArg.title.toLowerCase() === queryArg.toLowerCase()));
|
||||||
|
if (exact) {
|
||||||
|
return { title: exact.title, uri: exact.uri, type: exact.type };
|
||||||
|
}
|
||||||
|
|
||||||
|
const coarse = items.find((itemArg) => (!sourceTypeArg || itemArg.type === sourceTypeArg) && itemArg.title.toLowerCase().includes(queryArg.toLowerCase()));
|
||||||
|
if (coarse) {
|
||||||
|
return { title: coarse.title, uri: coarse.uri, type: coarse.type };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Sony Bravia TV source is not known: ${queryArg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private directSource(sourceArg: string, sourceTypeArg?: TBraviatvSourceType): Required<Pick<IBraviatvSource, 'title' | 'uri' | 'type'>> | undefined {
|
||||||
|
if (sourceArg.startsWith('extInput:') || sourceArg.startsWith('tv:')) {
|
||||||
|
return { title: sourceArg, uri: sourceArg, type: sourceArg.startsWith('tv:') ? 'channel' : 'input' };
|
||||||
|
}
|
||||||
|
if (sourceArg.startsWith('com.sony.dtv.') || sourceArg.startsWith('localapp:')) {
|
||||||
|
return { title: sourceArg, uri: sourceArg, type: sourceTypeArg || 'app' };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startSource(uriArg: string, sourceTypeArg: TBraviatvSourceType): Promise<void> {
|
||||||
|
if (sourceTypeArg === 'app') {
|
||||||
|
await this.sendRestQuick('appControl', 'setActiveApp', { uri: uriArg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sendRestQuick('avContent', 'setPlayContent', { uri: uriArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendRestQuick(serviceArg: TBraviatvRestService, methodArg: string, paramsArg?: unknown, versionArg = defaultRestVersion): Promise<boolean> {
|
||||||
|
const response = await this.sendRest(serviceArg, methodArg, paramsArg, versionArg);
|
||||||
|
return Boolean(response.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendRest(serviceArg: TBraviatvRestService, methodArg: string, paramsArg?: unknown, versionArg = defaultRestVersion, timeoutMsArg?: number): Promise<IBraviatvRestResponse> {
|
||||||
|
this.assertLivePskSupported(`${serviceArg}.${methodArg}`);
|
||||||
|
const params = paramsArg === undefined ? [] : Array.isArray(paramsArg) ? paramsArg : [paramsArg];
|
||||||
|
const response = await this.postJson(this.serviceUrl(serviceArg), {
|
||||||
|
method: methodArg,
|
||||||
|
params,
|
||||||
|
id: 1,
|
||||||
|
version: versionArg,
|
||||||
|
}, timeoutMsArg);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
const [code, message] = response.error;
|
||||||
|
const text = String(message || code || 'Unknown REST API error');
|
||||||
|
if (Number(code) === 401) {
|
||||||
|
throw new BraviatvAuthError(`Sony Bravia TV authentication failed for ${methodArg}: ${text}`);
|
||||||
|
}
|
||||||
|
if (text.includes('not power-on')) {
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV is turned off and rejected ${methodArg}.`);
|
||||||
|
}
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV REST ${methodArg} failed: ${text}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendIrcc(codeArg: string): Promise<boolean> {
|
||||||
|
this.assertLivePskSupported('IRCC command');
|
||||||
|
try {
|
||||||
|
return await this.postIrcc(this.irccEndpoint, codeArg);
|
||||||
|
} catch (errorArg) {
|
||||||
|
if (this.errorMessage(errorArg).includes('HTTP 404')) {
|
||||||
|
this.irccEndpoint = this.irccEndpoint === 'ircc' ? 'IRCC' : 'ircc';
|
||||||
|
return this.postIrcc(this.irccEndpoint, codeArg);
|
||||||
|
}
|
||||||
|
throw errorArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postIrcc(endpointArg: string, codeArg: string): Promise<boolean> {
|
||||||
|
const response = await this.postText(this.serviceUrl(endpointArg), this.irccBody(codeArg), {
|
||||||
|
SOAPACTION: '"urn:schemas-sony-com:service:IRCC:1#X_SendIRCC"',
|
||||||
|
'Content-Type': 'text/xml; charset=UTF-8',
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postJson(urlArg: string, bodyArg: unknown, timeoutMsArg?: number): Promise<IBraviatvRestResponse> {
|
||||||
|
const text = await this.request(urlArg, {
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
}, JSON.stringify(bodyArg), timeoutMsArg);
|
||||||
|
try {
|
||||||
|
return (text ? JSON.parse(text) : {}) as IBraviatvRestResponse;
|
||||||
|
} catch (errorArg) {
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV returned invalid JSON: ${this.errorMessage(errorArg)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postText(urlArg: string, bodyArg: string, headersArg: Record<string, string>): Promise<boolean> {
|
||||||
|
await this.request(urlArg, headersArg, bodyArg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(urlArg: string, headersArg: Record<string, string>, bodyArg: string, timeoutMsArg?: number): Promise<string> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMsArg || this.config.timeoutMs || defaultTimeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await globalThis.fetch(urlArg, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headersArg,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Auth-PSK': this.config.psk as string,
|
||||||
|
},
|
||||||
|
body: bodyArg,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new BraviatvAuthError(`Sony Bravia TV authentication failed with HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV request failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} catch (errorArg) {
|
||||||
|
if (errorArg instanceof BraviatvAuthError || errorArg instanceof BraviatvLiveControlError) {
|
||||||
|
throw errorArg;
|
||||||
|
}
|
||||||
|
if (errorArg instanceof Error && errorArg.name === 'AbortError') {
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV request timed out after ${timeoutMsArg || this.config.timeoutMs || defaultTimeoutMs}ms.`);
|
||||||
|
}
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV request failed: ${this.errorMessage(errorArg)}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertLivePskSupported(commandArg: string): void {
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV host is required for live ${commandArg}.`);
|
||||||
|
}
|
||||||
|
if (!this.config.psk) {
|
||||||
|
if (this.config.pin || this.config.usePsk === false) {
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV PIN pairing/cookie authentication is not implemented in this native TypeScript port. Configure a PSK to use live ${commandArg}.`);
|
||||||
|
}
|
||||||
|
throw new BraviatvLiveControlError(`Sony Bravia TV PSK is required for live ${commandArg}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasLivePskConfig(): boolean {
|
||||||
|
return Boolean(this.config.host && this.config.psk);
|
||||||
|
}
|
||||||
|
|
||||||
|
private serviceUrl(serviceArg: string): string {
|
||||||
|
const protocol = this.config.useSsl ? 'https' : 'http';
|
||||||
|
const port = this.config.port ? `:${this.config.port}` : '';
|
||||||
|
return `${protocol}://${this.config.host}${port}/sony/${serviceArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private irccBody(codeArg: string): string {
|
||||||
|
return `<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:X_SendIRCC xmlns:u="urn:schemas-sony-com:service:IRCC:1"><IRCCCode>${codeArg}</IRCCCode></u:X_SendIRCC></s:Body></s:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstResult<TResult>(responseArg: IBraviatvRestResponse): TResult | undefined {
|
||||||
|
return responseArg.result?.[0] as TResult | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async commandCode(commandArg: string): Promise<string | undefined> {
|
||||||
|
const command = commandArg.trim();
|
||||||
|
if (this.looksLikeIrccCode(command)) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = await this.getCommandList().catch(() => ({ ...fallbackCommands, ...(this.config.commands || {}) }));
|
||||||
|
const candidates = [command, this.normalizeCommand(command)].filter(Boolean);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (commands[candidate]) {
|
||||||
|
return commands[candidate];
|
||||||
|
}
|
||||||
|
const lowerCandidate = candidate.toLowerCase();
|
||||||
|
const entry = Object.entries(commands).find(([name]) => name.toLowerCase() === lowerCandidate);
|
||||||
|
if (entry) {
|
||||||
|
return entry[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCommand(commandArg: string): string {
|
||||||
|
const normalized = commandArg.replace(/[_\s-]+/g, '').toLowerCase();
|
||||||
|
const aliases: Record<string, string> = {
|
||||||
|
power: 'Power',
|
||||||
|
poweron: 'PowerOn',
|
||||||
|
input: 'Input',
|
||||||
|
source: 'Input',
|
||||||
|
hdmi1: 'Hdmi1',
|
||||||
|
hdmi2: 'Hdmi2',
|
||||||
|
hdmi3: 'Hdmi3',
|
||||||
|
hdmi4: 'Hdmi4',
|
||||||
|
up: 'Up',
|
||||||
|
down: 'Down',
|
||||||
|
left: 'Left',
|
||||||
|
right: 'Right',
|
||||||
|
ok: 'Confirm',
|
||||||
|
enter: 'Confirm',
|
||||||
|
select: 'Confirm',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
back: 'Back',
|
||||||
|
return: 'Back',
|
||||||
|
home: 'Home',
|
||||||
|
menu: 'Home',
|
||||||
|
options: 'Options',
|
||||||
|
info: 'Display',
|
||||||
|
display: 'Display',
|
||||||
|
volumeup: 'VolumeUp',
|
||||||
|
volup: 'VolumeUp',
|
||||||
|
volumedown: 'VolumeDown',
|
||||||
|
voldown: 'VolumeDown',
|
||||||
|
mute: 'Mute',
|
||||||
|
channelup: 'ChannelUp',
|
||||||
|
chup: 'ChannelUp',
|
||||||
|
channeldown: 'ChannelDown',
|
||||||
|
chdown: 'ChannelDown',
|
||||||
|
play: 'Play',
|
||||||
|
pause: 'Pause',
|
||||||
|
stop: 'Stop',
|
||||||
|
next: 'Next',
|
||||||
|
previous: 'Prev',
|
||||||
|
prev: 'Prev',
|
||||||
|
};
|
||||||
|
return aliases[normalized] || commandArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeIrccCode(valueArg: string): boolean {
|
||||||
|
return /^[A-Za-z0-9+/]+={0,2}$/.test(valueArg) && valueArg.length >= 16 && valueArg.includes('Aw');
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotFromManualConfig(errorMessageArg?: string): IBraviatvSnapshot {
|
||||||
|
const systemInfo = this.systemInfoFromConfig();
|
||||||
|
const state: IBraviatvState = {
|
||||||
|
powerStatus: this.config.state?.powerStatus || (this.config.state?.power === 'on' ? 'active' : this.config.state?.power === 'off' ? 'standby' : 'unknown'),
|
||||||
|
available: Boolean(this.config.snapshot || this.config.state || this.config.systemInfo || this.config.host),
|
||||||
|
...this.config.state,
|
||||||
|
lastError: errorMessageArg || this.config.state?.lastError,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
systemInfo,
|
||||||
|
state,
|
||||||
|
sources: [...(this.config.sources || [])],
|
||||||
|
apps: [...(this.config.apps || [])],
|
||||||
|
channels: [...(this.config.channels || [])],
|
||||||
|
commands: this.config.commands,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private systemInfoFromConfig(): IBraviatvSystemInfo {
|
||||||
|
return {
|
||||||
|
...this.config.systemInfo,
|
||||||
|
name: this.config.systemInfo?.name || this.config.name || this.config.host || 'Sony Bravia TV',
|
||||||
|
model: this.config.systemInfo?.model || this.config.model,
|
||||||
|
serial: this.config.systemInfo?.serial || this.config.serialNumber,
|
||||||
|
macAddr: this.config.systemInfo?.macAddr || this.config.macAddress,
|
||||||
|
cid: this.config.systemInfo?.cid || this.config.cid || this.config.macAddress || this.config.host,
|
||||||
|
product: this.config.systemInfo?.product || 'TV',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSnapshot(snapshotArg: IBraviatvSnapshot): IBraviatvSnapshot {
|
||||||
|
const systemInfo = {
|
||||||
|
...this.systemInfoFromConfig(),
|
||||||
|
...snapshotArg.systemInfo,
|
||||||
|
};
|
||||||
|
const state = this.normalizeState(snapshotArg.state);
|
||||||
|
return {
|
||||||
|
...snapshotArg,
|
||||||
|
systemInfo,
|
||||||
|
state,
|
||||||
|
sources: this.normalizeSources(snapshotArg.sources, 'input'),
|
||||||
|
apps: this.normalizeSources(snapshotArg.apps, 'app') as IBraviatvApp[],
|
||||||
|
channels: this.normalizeSources(snapshotArg.channels || [], 'channel') as IBraviatvChannel[],
|
||||||
|
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeState(stateArg: IBraviatvState): IBraviatvState {
|
||||||
|
const powerStatus = stateArg.powerStatus || (stateArg.power === 'on' ? 'active' : stateArg.power === 'off' ? 'standby' : 'unknown');
|
||||||
|
const power = stateArg.power || this.powerFromStatus(powerStatus);
|
||||||
|
let volumeLevel = stateArg.volumeLevel;
|
||||||
|
if (typeof volumeLevel === 'number' && volumeLevel > 1) {
|
||||||
|
volumeLevel /= 100;
|
||||||
|
}
|
||||||
|
if (typeof volumeLevel !== 'number' && typeof stateArg.volumePercent === 'number') {
|
||||||
|
volumeLevel = stateArg.volumePercent / 100;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...stateArg,
|
||||||
|
powerStatus,
|
||||||
|
power,
|
||||||
|
playback: stateArg.playback || (power === 'off' ? 'off' : 'idle'),
|
||||||
|
volumeLevel,
|
||||||
|
volumePercent: typeof stateArg.volumePercent === 'number' ? stateArg.volumePercent : typeof volumeLevel === 'number' ? Math.round(volumeLevel * 100) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSources(sourcesArg: IBraviatvSource[], typeArg: TBraviatvSourceType): IBraviatvSource[] {
|
||||||
|
return sourcesArg.filter((sourceArg) => sourceArg.title && sourceArg.uri).map((sourceArg) => ({
|
||||||
|
...sourceArg,
|
||||||
|
type: sourceArg.type || typeArg,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyVolumeInfo(stateArg: IBraviatvState, volumeInfoArg: IBraviatvVolumeInfo | undefined): void {
|
||||||
|
if (!volumeInfoArg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const volume = typeof volumeInfoArg.volume === 'number' ? volumeInfoArg.volume : Number(volumeInfoArg.volume);
|
||||||
|
if (Number.isFinite(volume)) {
|
||||||
|
stateArg.volumePercent = volume;
|
||||||
|
stateArg.volumeLevel = volume / 100;
|
||||||
|
}
|
||||||
|
stateArg.muted = volumeInfoArg.mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPlayingInfo(stateArg: IBraviatvState, playingInfoArg: IBraviatvPlayingInfo | undefined): void {
|
||||||
|
if (!playingInfoArg) {
|
||||||
|
stateArg.mediaTitle = stateArg.mediaTitle || 'Smart TV';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateArg.mediaTitle = playingInfoArg.programTitle || playingInfoArg.title || stateArg.mediaTitle;
|
||||||
|
stateArg.mediaContentId = playingInfoArg.uri || playingInfoArg.dispNum || stateArg.mediaContentId;
|
||||||
|
stateArg.sourceUri = playingInfoArg.uri || stateArg.sourceUri;
|
||||||
|
stateArg.mediaDuration = playingInfoArg.durationSec;
|
||||||
|
if (playingInfoArg.uri?.startsWith('extInput:')) {
|
||||||
|
stateArg.source = playingInfoArg.title || stateArg.source;
|
||||||
|
}
|
||||||
|
if (playingInfoArg.uri?.startsWith('tv:')) {
|
||||||
|
stateArg.mediaChannel = playingInfoArg.title || playingInfoArg.dispNum;
|
||||||
|
stateArg.mediaContentType = 'channel';
|
||||||
|
} else if (playingInfoArg.uri) {
|
||||||
|
stateArg.mediaContentType = this.inferSourceType(playingInfoArg.uri);
|
||||||
|
}
|
||||||
|
if (playingInfoArg.startDateTime) {
|
||||||
|
const startTime = Date.parse(playingInfoArg.startDateTime);
|
||||||
|
if (Number.isFinite(startTime)) {
|
||||||
|
stateArg.mediaPosition = Math.max(0, Math.floor((Date.now() - startTime) / 1000));
|
||||||
|
stateArg.mediaPositionUpdatedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferSourceType(uriArg: string): TBraviatvSourceType {
|
||||||
|
if (uriArg.startsWith('extInput:')) {
|
||||||
|
return 'input';
|
||||||
|
}
|
||||||
|
if (uriArg.startsWith('tv:')) {
|
||||||
|
return 'channel';
|
||||||
|
}
|
||||||
|
return 'app';
|
||||||
|
}
|
||||||
|
|
||||||
|
private powerFromStatus(statusArg: string): 'on' | 'off' | 'unknown' {
|
||||||
|
const status = statusArg.toLowerCase();
|
||||||
|
if (status === 'active') {
|
||||||
|
return 'on';
|
||||||
|
}
|
||||||
|
if (status === 'standby' || status === 'off') {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLocalState(stateArg: Partial<IBraviatvState>): void {
|
||||||
|
const state = this.config.snapshot?.state || this.config.state || {};
|
||||||
|
Object.assign(state, stateArg);
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
this.config.snapshot.state = state;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.config.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneSnapshot(snapshotArg: IBraviatvSnapshot): IBraviatvSnapshot {
|
||||||
|
return JSON.parse(JSON.stringify(snapshotArg)) as IBraviatvSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorMessage(errorArg: unknown): string {
|
||||||
|
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IBraviatvConfig } from './braviatv.types.js';
|
||||||
|
|
||||||
|
export class BraviatvConfigFlow implements IConfigFlow<IBraviatvConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBraviatvConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Sony Bravia TV',
|
||||||
|
description: 'Configure the local Sony Bravia REST/IRCC endpoint. This native port supports PSK authentication for live control; PIN pairing/cookie auth is reported as unsupported.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'HTTP port', type: 'number' },
|
||||||
|
{ name: 'useSsl', label: 'Use HTTPS', type: 'boolean' },
|
||||||
|
{ name: 'psk', label: 'Pre-shared key (PSK)', type: 'password' },
|
||||||
|
{ name: 'pin', label: 'PIN (pairing not implemented)', type: 'password' },
|
||||||
|
{ 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: 'Sony Bravia TV host is required.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const psk = this.stringValue(valuesArg.psk) || this.stringMetadata(candidateArg, 'psk');
|
||||||
|
const pin = this.stringValue(valuesArg.pin);
|
||||||
|
if (pin && !psk) {
|
||||||
|
return { kind: 'error', error: 'Sony Bravia TV PIN pairing/cookie authentication is not implemented in this native TypeScript port. Configure a PSK instead.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSsl = this.booleanValue(valuesArg.useSsl) ?? this.booleanMetadata(candidateArg, 'useSsl') ?? false;
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Sony Bravia TV configured',
|
||||||
|
config: {
|
||||||
|
host,
|
||||||
|
port: this.numberValue(valuesArg.port) || candidateArg.port || (useSsl ? 443 : 80),
|
||||||
|
useSsl,
|
||||||
|
psk,
|
||||||
|
pin,
|
||||||
|
usePsk: Boolean(psk),
|
||||||
|
name: this.stringValue(valuesArg.name) || candidateArg.name,
|
||||||
|
model: this.stringValue(valuesArg.model) || candidateArg.model,
|
||||||
|
manufacturer: candidateArg.manufacturer || 'Sony',
|
||||||
|
macAddress: candidateArg.macAddress,
|
||||||
|
serialNumber: candidateArg.serialNumber,
|
||||||
|
cid: candidateArg.id || this.stringMetadata(candidateArg, 'cid'),
|
||||||
|
scalarWebApiBaseUrl: this.stringMetadata(candidateArg, 'scalarWebApiBaseUrl'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 booleanValue(valueArg: unknown): boolean | undefined {
|
||||||
|
return typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined {
|
||||||
|
const value = candidateArg.metadata?.[keyArg];
|
||||||
|
return typeof value === 'string' && value ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): boolean | undefined {
|
||||||
|
const value = candidateArg.metadata?.[keyArg];
|
||||||
|
return typeof value === 'boolean' ? value : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,177 @@
|
|||||||
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 { BraviatvClient } from './braviatv.classes.client.js';
|
||||||
|
import { BraviatvConfigFlow } from './braviatv.classes.configflow.js';
|
||||||
|
import { createBraviatvDiscoveryDescriptor } from './braviatv.discovery.js';
|
||||||
|
import { BraviatvMapper } from './braviatv.mapper.js';
|
||||||
|
import type { IBraviatvConfig } from './braviatv.types.js';
|
||||||
|
|
||||||
export class HomeAssistantBraviatvIntegration extends DescriptorOnlyIntegration {
|
export class BraviatvIntegration extends BaseIntegration<IBraviatvConfig> {
|
||||||
constructor() {
|
public readonly domain = 'braviatv';
|
||||||
super({
|
public readonly displayName = 'Sony Bravia TV';
|
||||||
domain: "braviatv",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Sony Bravia TV",
|
public readonly discoveryDescriptor = createBraviatvDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new BraviatvConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/braviatv",
|
upstreamPath: 'homeassistant/components/braviatv',
|
||||||
"upstreamDomain": "braviatv",
|
upstreamDomain: 'braviatv',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_polling",
|
iotClass: 'local_polling',
|
||||||
"requirements": [
|
requirements: ['pybravia==0.4.1'],
|
||||||
"pybravia==0.4.1"
|
dependencies: ['ssdp'],
|
||||||
],
|
afterDependencies: [],
|
||||||
"dependencies": [],
|
codeowners: ['@bieniu', '@Drafteed'],
|
||||||
"afterDependencies": [],
|
configFlow: true,
|
||||||
"codeowners": [
|
documentation: 'https://www.home-assistant.io/integrations/braviatv',
|
||||||
"@bieniu",
|
};
|
||||||
"@Drafteed"
|
|
||||||
]
|
public async setup(configArg: IBraviatvConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
},
|
void contextArg;
|
||||||
});
|
return new BraviatvRuntime(new BraviatvClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantBraviatvIntegration extends BraviatvIntegration {}
|
||||||
|
|
||||||
|
class BraviatvRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'braviatv';
|
||||||
|
|
||||||
|
constructor(private readonly client: BraviatvClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return BraviatvMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return BraviatvMapper.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 === 'braviatv') {
|
||||||
|
return await this.callBraviatvService(requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain !== 'media_player') {
|
||||||
|
return { success: false, error: `Unsupported Sony Bravia TV 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 callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (requestArg.service !== 'send_command') {
|
||||||
|
return { success: false, error: `Unsupported Sony Bravia TV remote service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
const command = requestArg.data?.command;
|
||||||
|
const commands = typeof command === 'string' ? [command] : Array.isArray(command) ? command.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg)) : [];
|
||||||
|
if (!commands.length) {
|
||||||
|
return { success: false, error: 'Sony Bravia TV remote.send_command requires data.command.' };
|
||||||
|
}
|
||||||
|
const repeatsValue = requestArg.data?.num_repeats ?? requestArg.data?.numRepeats ?? 1;
|
||||||
|
const repeats = typeof repeatsValue === 'number' && Number.isFinite(repeatsValue) ? repeatsValue : 1;
|
||||||
|
await this.client.sendCommands(commands, repeats);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callBraviatvService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (requestArg.service === 'reboot') {
|
||||||
|
await this.client.reboot();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'terminate_apps') {
|
||||||
|
await this.client.terminateApps();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Sony Bravia TV service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (requestArg.service === 'turn_on') {
|
||||||
|
await this.client.turnOn();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'turn_off') {
|
||||||
|
await this.client.turnOff();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
|
||||||
|
await this.client.mediaPlay();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
|
||||||
|
await this.client.mediaPause();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
|
||||||
|
await this.client.sendCommand('Play');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
|
||||||
|
await this.client.mediaStop();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
|
||||||
|
await this.client.nextTrack();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
|
||||||
|
await this.client.previousTrack();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_set') {
|
||||||
|
const level = requestArg.data?.volume_level;
|
||||||
|
if (typeof level !== 'number') {
|
||||||
|
return { success: false, error: 'Sony Bravia TV volume_set requires data.volume_level.' };
|
||||||
|
}
|
||||||
|
await this.client.setVolumeLevel(level);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_up') {
|
||||||
|
await this.client.volumeUp();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_down') {
|
||||||
|
await this.client.volumeDown();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
|
||||||
|
const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted;
|
||||||
|
if (typeof muted !== 'boolean') {
|
||||||
|
return { success: false, error: 'Sony Bravia TV volume_mute requires data.is_volume_muted.' };
|
||||||
|
}
|
||||||
|
await this.client.muteVolume(muted);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'select_source') {
|
||||||
|
const source = requestArg.data?.source;
|
||||||
|
if (typeof source !== 'string' || !source) {
|
||||||
|
return { success: false, error: 'Sony Bravia TV select_source requires data.source.' };
|
||||||
|
}
|
||||||
|
await this.client.selectSource(source);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play_media') {
|
||||||
|
const mediaId = requestArg.data?.media_content_id;
|
||||||
|
const mediaType = requestArg.data?.media_content_type;
|
||||||
|
if (typeof mediaId !== 'string' || typeof mediaType !== 'string') {
|
||||||
|
return { success: false, error: 'Sony Bravia TV play_media requires data.media_content_type and data.media_content_id.' };
|
||||||
|
}
|
||||||
|
await this.client.playMedia(mediaType, mediaId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Sony Bravia TV media_player service: ${requestArg.service}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IBraviatvManualEntry, IBraviatvMdnsRecord, IBraviatvSsdpRecord } from './braviatv.types.js';
|
||||||
|
|
||||||
|
const scalarWebApiService = 'urn:schemas-sony-com:service:ScalarWebAPI:1';
|
||||||
|
|
||||||
|
export class BraviatvSsdpMatcher implements IDiscoveryMatcher<IBraviatvSsdpRecord> {
|
||||||
|
public id = 'braviatv-ssdp-match';
|
||||||
|
public source = 'ssdp' as const;
|
||||||
|
public description = 'Recognize Sony Bravia ScalarWebAPI SSDP advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IBraviatvSsdpRecord): 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 friendlyName = stringValue(recordArg, 'friendlyName', 'friendly_name', 'upnp:friendlyName');
|
||||||
|
const scalarInfo = scalarWebApiInfo(recordArg);
|
||||||
|
const services = scalarWebApiServices(scalarInfo);
|
||||||
|
const matchedBySt = st?.toLowerCase() === scalarWebApiService.toLowerCase();
|
||||||
|
const matchedByScalarInfo = services.includes('videoScreen') || services.includes('system') || Boolean(scalarBaseUrl(scalarInfo));
|
||||||
|
const matchedBySonyRenderer = startsSony(manufacturer) && String(st || '').toLowerCase().includes('mediarenderer');
|
||||||
|
if (!matchedBySt && !matchedByScalarInfo && !matchedBySonyRenderer) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'SSDP record is not a Sony Bravia ScalarWebAPI advertisement.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = scalarBaseUrl(scalarInfo);
|
||||||
|
const url = safeUrl(baseUrl) || safeUrl(location);
|
||||||
|
const id = stripUuid(usn || stringValue(recordArg, 'udn', 'UDN')) || stringValue(recordArg, 'cid', 'macAddr');
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : 'high',
|
||||||
|
reason: 'SSDP record matches Sony Bravia ScalarWebAPI metadata.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'ssdp',
|
||||||
|
integrationDomain: 'braviatv',
|
||||||
|
id,
|
||||||
|
host: url?.hostname,
|
||||||
|
port: portFromUrl(url),
|
||||||
|
name: friendlyName,
|
||||||
|
manufacturer: manufacturer || 'Sony',
|
||||||
|
model,
|
||||||
|
metadata: {
|
||||||
|
st,
|
||||||
|
usn,
|
||||||
|
location,
|
||||||
|
scalarWebApiBaseUrl: baseUrl,
|
||||||
|
scalarWebApiServices: services,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BraviatvMdnsMatcher implements IDiscoveryMatcher<IBraviatvMdnsRecord> {
|
||||||
|
public id = 'braviatv-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Sony Bravia mDNS setup hints.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IBraviatvMdnsRecord): 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('sonyapilib') || type.includes('bravia') || isSonyTvHint(manufacturer, model, name);
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record does not contain Sony Bravia hints.' };
|
||||||
|
}
|
||||||
|
const id = valueForKey(properties, 'cid') || valueForKey(properties, 'id') || valueForKey(properties, 'deviceid') || name;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : recordArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'mDNS record matches Sony Bravia metadata.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'braviatv',
|
||||||
|
id,
|
||||||
|
host: recordArg.host,
|
||||||
|
port: recordArg.port || 80,
|
||||||
|
name,
|
||||||
|
manufacturer: manufacturer || 'Sony',
|
||||||
|
model,
|
||||||
|
macAddress: valueForKey(properties, 'mac') || valueForKey(properties, 'macaddress') || valueForKey(properties, 'deviceid'),
|
||||||
|
metadata: {
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
txt: properties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BraviatvManualMatcher implements IDiscoveryMatcher<IBraviatvManualEntry> {
|
||||||
|
public id = 'braviatv-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Sony Bravia TV setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IBraviatvManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.metadata?.braviatv || inputArg.metadata?.scalarWebApiBaseUrl || isSonyTvHint(inputArg.manufacturer, inputArg.model, inputArg.name));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Sony Bravia setup hints.' };
|
||||||
|
}
|
||||||
|
const id = inputArg.cid || inputArg.id || inputArg.macAddress || inputArg.serialNumber;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Sony Bravia setup.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'braviatv',
|
||||||
|
id,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || (inputArg.useSsl ? 443 : 80),
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: inputArg.manufacturer || 'Sony',
|
||||||
|
model: inputArg.model,
|
||||||
|
serialNumber: inputArg.serialNumber,
|
||||||
|
macAddress: inputArg.macAddress,
|
||||||
|
metadata: {
|
||||||
|
...(inputArg.metadata || {}),
|
||||||
|
cid: inputArg.cid,
|
||||||
|
psk: inputArg.psk,
|
||||||
|
useSsl: inputArg.useSsl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BraviatvCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'braviatv-candidate-validator';
|
||||||
|
public description = 'Validate Sony Bravia TV discovery candidates.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const matched = candidateArg.integrationDomain === 'braviatv'
|
||||||
|
|| Boolean(candidateArg.metadata?.braviatv)
|
||||||
|
|| Boolean(candidateArg.metadata?.scalarWebApiBaseUrl)
|
||||||
|
|| isSonyTvHint(candidateArg.manufacturer, candidateArg.model, candidateArg.name);
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has Sony Bravia metadata.' : 'Candidate is not Sony Bravia TV.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || candidateArg.host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBraviatvDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'braviatv', displayName: 'Sony Bravia TV' })
|
||||||
|
.addMatcher(new BraviatvSsdpMatcher())
|
||||||
|
.addMatcher(new BraviatvMdnsMatcher())
|
||||||
|
.addMatcher(new BraviatvManualMatcher())
|
||||||
|
.addValidator(new BraviatvCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
const startsSony = (valueArg: string | undefined): boolean => Boolean(valueArg?.toLowerCase().startsWith('sony'));
|
||||||
|
|
||||||
|
const isSonyTvHint = (...valuesArg: Array<string | undefined>): boolean => {
|
||||||
|
const value = valuesArg.filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return value.includes('sony') || value.includes('bravia');
|
||||||
|
};
|
||||||
|
|
||||||
|
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: IBraviatvSsdpRecord, ...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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scalarWebApiInfo = (recordArg: IBraviatvSsdpRecord): Record<string, unknown> | undefined => {
|
||||||
|
const value = recordArg.upnp?.X_ScalarWebAPI_DeviceInfo || recordArg.upnp?.['x_scalarwebapi_deviceinfo'] || recordArg.X_ScalarWebAPI_DeviceInfo;
|
||||||
|
return value && typeof value === 'object' ? value as Record<string, unknown> : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scalarBaseUrl = (infoArg: Record<string, unknown> | undefined): string | undefined => {
|
||||||
|
const value = infoArg?.X_ScalarWebAPI_BaseURL || infoArg?.['x_scalarwebapi_baseurl'];
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scalarWebApiServices = (infoArg: Record<string, unknown> | undefined): string[] => {
|
||||||
|
const serviceList = infoArg?.X_ScalarWebAPI_ServiceList || infoArg?.['x_scalarwebapi_servicelist'];
|
||||||
|
if (!serviceList || typeof serviceList !== 'object') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const serviceTypes = (serviceList as Record<string, unknown>).X_ScalarWebAPI_ServiceType || (serviceList as Record<string, unknown>)['x_scalarwebapi_servicetype'];
|
||||||
|
if (Array.isArray(serviceTypes)) {
|
||||||
|
return serviceTypes.filter((itemArg): itemArg is string => typeof itemArg === 'string');
|
||||||
|
}
|
||||||
|
if (typeof serviceTypes === 'string') {
|
||||||
|
return [serviceTypes];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity } from '../../core/types.js';
|
||||||
|
import type { IBraviatvSnapshot, IBraviatvState } from './braviatv.types.js';
|
||||||
|
|
||||||
|
export class BraviatvMapper {
|
||||||
|
public static toDevices(snapshotArg: IBraviatvSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const state = this.state(snapshotArg);
|
||||||
|
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||||
|
return [{
|
||||||
|
id: this.deviceId(snapshotArg),
|
||||||
|
integrationDomain: 'braviatv',
|
||||||
|
name: this.deviceName(snapshotArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Sony',
|
||||||
|
model: snapshotArg.systemInfo.model,
|
||||||
|
online: state.available !== false && state.power !== 'off',
|
||||||
|
features: [
|
||||||
|
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||||
|
{ 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: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'power', value: state.power, updatedAt },
|
||||||
|
{ featureId: 'playback', value: state.playback, updatedAt },
|
||||||
|
{ featureId: 'source', value: this.source(snapshotArg) || null, updatedAt },
|
||||||
|
{ featureId: 'volume', value: state.volumePercent ?? null, updatedAt },
|
||||||
|
{ featureId: 'muted', value: state.muted ?? null, updatedAt },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
cid: snapshotArg.systemInfo.cid,
|
||||||
|
serialNumber: snapshotArg.systemInfo.serial,
|
||||||
|
macAddress: snapshotArg.systemInfo.macAddr,
|
||||||
|
generation: snapshotArg.systemInfo.generation,
|
||||||
|
sources: snapshotArg.sources.map((sourceArg) => ({ title: sourceArg.title, uri: sourceArg.uri, type: sourceArg.type })),
|
||||||
|
apps: snapshotArg.apps.map((appArg) => ({ title: appArg.title, uri: appArg.uri, icon: appArg.icon })),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IBraviatvSnapshot): IIntegrationEntity[] {
|
||||||
|
const state = this.state(snapshotArg);
|
||||||
|
return [{
|
||||||
|
id: `media_player.${this.slug(this.deviceName(snapshotArg))}`,
|
||||||
|
uniqueId: `braviatv_${this.slug(this.identity(snapshotArg))}`,
|
||||||
|
integrationDomain: 'braviatv',
|
||||||
|
deviceId: this.deviceId(snapshotArg),
|
||||||
|
platform: 'media_player',
|
||||||
|
name: this.deviceName(snapshotArg),
|
||||||
|
state: this.mediaState(state),
|
||||||
|
attributes: {
|
||||||
|
source: this.source(snapshotArg),
|
||||||
|
sourceUri: state.sourceUri,
|
||||||
|
sourceList: this.sourceList(snapshotArg),
|
||||||
|
volumeLevel: state.volumeLevel,
|
||||||
|
isVolumeMuted: state.muted,
|
||||||
|
mediaTitle: state.mediaTitle,
|
||||||
|
mediaChannel: state.mediaChannel,
|
||||||
|
mediaContentId: state.mediaContentId,
|
||||||
|
mediaContentType: state.mediaContentType,
|
||||||
|
mediaDuration: state.mediaDuration,
|
||||||
|
mediaPosition: state.mediaPosition,
|
||||||
|
mediaPositionUpdatedAt: state.mediaPositionUpdatedAt,
|
||||||
|
model: snapshotArg.systemInfo.model,
|
||||||
|
powerStatus: state.powerStatus,
|
||||||
|
},
|
||||||
|
available: state.available !== false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static deviceId(snapshotArg: IBraviatvSnapshot): string {
|
||||||
|
return `braviatv.device.${this.slug(this.identity(snapshotArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static state(snapshotArg: IBraviatvSnapshot): IBraviatvState & Required<Pick<IBraviatvState, 'power' | 'playback'>> {
|
||||||
|
const state = snapshotArg.state || {};
|
||||||
|
const power = state.power || this.powerFromStatus(state.powerStatus);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
power,
|
||||||
|
playback: state.playback || (power === 'off' ? 'off' : 'idle'),
|
||||||
|
volumePercent: typeof state.volumePercent === 'number' ? state.volumePercent : typeof state.volumeLevel === 'number' ? Math.round(state.volumeLevel * 100) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaState(stateArg: IBraviatvState): string {
|
||||||
|
if (stateArg.available === false) {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
if (stateArg.power === 'off') {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
if (stateArg.playback === 'playing') {
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
|
if (stateArg.playback === 'paused') {
|
||||||
|
return 'paused';
|
||||||
|
}
|
||||||
|
if (stateArg.power === 'unknown') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
return stateArg.source || stateArg.mediaTitle ? 'on' : 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static source(snapshotArg: IBraviatvSnapshot): string | undefined {
|
||||||
|
return snapshotArg.state.source || this.sourceTitle(snapshotArg, snapshotArg.state.sourceUri) || snapshotArg.state.appTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sourceList(snapshotArg: IBraviatvSnapshot): string[] {
|
||||||
|
const values = [...snapshotArg.sources, ...snapshotArg.apps, ...(snapshotArg.channels || [])]
|
||||||
|
.map((sourceArg) => sourceArg.title)
|
||||||
|
.filter(Boolean);
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sourceTitle(snapshotArg: IBraviatvSnapshot, uriArg: string | undefined): string | undefined {
|
||||||
|
if (!uriArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return [...snapshotArg.sources, ...snapshotArg.apps, ...(snapshotArg.channels || [])].find((sourceArg) => sourceArg.uri === uriArg)?.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static powerFromStatus(statusArg: string | undefined): 'on' | 'off' | 'unknown' {
|
||||||
|
const status = (statusArg || '').toLowerCase();
|
||||||
|
if (status === 'active') {
|
||||||
|
return 'on';
|
||||||
|
}
|
||||||
|
if (status === 'standby' || status === 'off') {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceName(snapshotArg: IBraviatvSnapshot): string {
|
||||||
|
return snapshotArg.systemInfo.name || snapshotArg.systemInfo.model || 'Sony Bravia TV';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static identity(snapshotArg: IBraviatvSnapshot): string {
|
||||||
|
return snapshotArg.systemInfo.cid || snapshotArg.systemInfo.macAddr || snapshotArg.systemInfo.serial || this.deviceName(snapshotArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'braviatv';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,194 @@
|
|||||||
export interface IHomeAssistantBraviatvConfig {
|
export type TBraviatvPowerStatus = 'active' | 'standby' | 'off' | 'unknown' | string;
|
||||||
// TODO: replace with the TypeScript-native config for braviatv.
|
|
||||||
|
export type TBraviatvPowerState = 'on' | 'off' | 'unknown';
|
||||||
|
|
||||||
|
export type TBraviatvPlaybackState = 'playing' | 'paused' | 'idle' | 'off' | 'unknown';
|
||||||
|
|
||||||
|
export type TBraviatvSourceType = 'input' | 'app' | 'channel';
|
||||||
|
|
||||||
|
export type TBraviatvRestService = 'accessControl' | 'appControl' | 'audio' | 'avContent' | 'guide' | 'system' | 'video' | 'videoScreen';
|
||||||
|
|
||||||
|
export interface IBraviatvConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
useSsl?: boolean;
|
||||||
|
psk?: string;
|
||||||
|
pin?: string;
|
||||||
|
usePsk?: boolean;
|
||||||
|
clientId?: string;
|
||||||
|
nickname?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
cid?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
irccEndpoint?: 'ircc' | 'IRCC' | string;
|
||||||
|
fetchChannels?: boolean;
|
||||||
|
systemInfo?: IBraviatvSystemInfo;
|
||||||
|
state?: IBraviatvState;
|
||||||
|
sources?: IBraviatvSource[];
|
||||||
|
apps?: IBraviatvApp[];
|
||||||
|
channels?: IBraviatvChannel[];
|
||||||
|
commands?: Record<string, string>;
|
||||||
|
snapshot?: IBraviatvSnapshot;
|
||||||
|
scalarWebApiBaseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantBraviatvConfig extends IBraviatvConfig {}
|
||||||
|
|
||||||
|
export interface IBraviatvSystemInfo {
|
||||||
|
product?: string;
|
||||||
|
region?: string;
|
||||||
|
language?: string;
|
||||||
|
model?: string;
|
||||||
|
serial?: string;
|
||||||
|
macAddr?: string;
|
||||||
|
name?: string;
|
||||||
|
generation?: string;
|
||||||
|
area?: string;
|
||||||
|
cid?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvVolumeInfo {
|
||||||
|
target?: string;
|
||||||
|
volume?: number | string;
|
||||||
|
minVolume?: number;
|
||||||
|
maxVolume?: number;
|
||||||
|
mute?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvPlayingInfo {
|
||||||
|
title?: string;
|
||||||
|
uri?: string;
|
||||||
|
source?: string;
|
||||||
|
programTitle?: string;
|
||||||
|
dispNum?: string;
|
||||||
|
durationSec?: number;
|
||||||
|
startDateTime?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvSource {
|
||||||
|
title: string;
|
||||||
|
uri: string;
|
||||||
|
type?: TBraviatvSourceType;
|
||||||
|
icon?: string;
|
||||||
|
label?: string;
|
||||||
|
connection?: string;
|
||||||
|
status?: string;
|
||||||
|
dispNum?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvApp extends IBraviatvSource {
|
||||||
|
type?: 'app';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvChannel extends IBraviatvSource {
|
||||||
|
type?: 'channel';
|
||||||
|
dispNum?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvState {
|
||||||
|
powerStatus?: TBraviatvPowerStatus;
|
||||||
|
power?: TBraviatvPowerState;
|
||||||
|
playback?: TBraviatvPlaybackState;
|
||||||
|
available?: boolean;
|
||||||
|
source?: string;
|
||||||
|
sourceUri?: string;
|
||||||
|
appTitle?: string;
|
||||||
|
volumeLevel?: number;
|
||||||
|
volumePercent?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
mediaTitle?: string;
|
||||||
|
mediaChannel?: string;
|
||||||
|
mediaContentId?: string;
|
||||||
|
mediaContentType?: string;
|
||||||
|
mediaDuration?: number;
|
||||||
|
mediaPosition?: number;
|
||||||
|
mediaPositionUpdatedAt?: string;
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvSnapshot {
|
||||||
|
systemInfo: IBraviatvSystemInfo;
|
||||||
|
state: IBraviatvState;
|
||||||
|
sources: IBraviatvSource[];
|
||||||
|
apps: IBraviatvApp[];
|
||||||
|
channels?: IBraviatvChannel[];
|
||||||
|
commands?: Record<string, string>;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvRestCommand {
|
||||||
|
type: 'rest';
|
||||||
|
service: TBraviatvRestService;
|
||||||
|
method: string;
|
||||||
|
params?: unknown[];
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvIrccCommand {
|
||||||
|
type: 'ircc';
|
||||||
|
command?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvSourceCommand {
|
||||||
|
type: 'source';
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TBraviatvCommand = IBraviatvRestCommand | IBraviatvIrccCommand | IBraviatvSourceCommand;
|
||||||
|
|
||||||
|
export interface IBraviatvEvent {
|
||||||
|
type: 'snapshot' | 'command' | 'error';
|
||||||
|
command?: TBraviatvCommand;
|
||||||
|
snapshot?: IBraviatvSnapshot;
|
||||||
|
message?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvSsdpRecord {
|
||||||
|
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 IBraviatvMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
properties?: Record<string, string | undefined>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBraviatvManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
model?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
cid?: string;
|
||||||
|
psk?: string;
|
||||||
|
useSsl?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TBraviatvDiscoveryRecord = IBraviatvSsdpRecord | IBraviatvMdnsRecord | IBraviatvManualEntry;
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
export * from './braviatv.classes.client.js';
|
||||||
|
export * from './braviatv.classes.configflow.js';
|
||||||
export * from './braviatv.classes.integration.js';
|
export * from './braviatv.classes.integration.js';
|
||||||
|
export * from './braviatv.discovery.js';
|
||||||
|
export * from './braviatv.mapper.js';
|
||||||
export * from './braviatv.types.js';
|
export * from './braviatv.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,448 @@
|
|||||||
|
import type {
|
||||||
|
IDlnaDmrConfig,
|
||||||
|
IDlnaDmrDeviceDescription,
|
||||||
|
IDlnaDmrMediaMetadata,
|
||||||
|
IDlnaDmrPositionInfo,
|
||||||
|
IDlnaDmrRendererState,
|
||||||
|
IDlnaDmrServiceDescription,
|
||||||
|
IDlnaDmrSnapshot,
|
||||||
|
IDlnaDmrSoapCommand,
|
||||||
|
TDlnaDmrServiceKey,
|
||||||
|
} from './dlna_dmr.types.js';
|
||||||
|
|
||||||
|
const dlnaDmrDeviceTypes = [
|
||||||
|
'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||||||
|
'urn:schemas-upnp-org:device:MediaRenderer:2',
|
||||||
|
'urn:schemas-upnp-org:device:MediaRenderer:3',
|
||||||
|
];
|
||||||
|
|
||||||
|
const serviceKeyById: Record<string, TDlnaDmrServiceKey> = {
|
||||||
|
'urn:upnp-org:serviceId:AVTransport': 'AVTransport',
|
||||||
|
'urn:upnp-org:serviceId:RenderingControl': 'RenderingControl',
|
||||||
|
'urn:upnp-org:serviceId:ConnectionManager': 'ConnectionManager',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DlnaDmrClient {
|
||||||
|
private deviceDescription?: IDlnaDmrDeviceDescription;
|
||||||
|
|
||||||
|
constructor(private readonly config: IDlnaDmrConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IDlnaDmrSnapshot> {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
return this.withDefaults(this.config.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await this.getDeviceDescription();
|
||||||
|
const state = await this.getRendererState(device);
|
||||||
|
return { device, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async play(): Promise<void> {
|
||||||
|
await this.sendCommand({ service: 'AVTransport', action: 'Play', args: { InstanceID: 0, Speed: '1' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
await this.sendCommand({ service: 'AVTransport', action: 'Pause', args: { InstanceID: 0 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
await this.sendCommand({ service: 'AVTransport', action: 'Stop', args: { InstanceID: 0 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setVolume(volumeArg: number): Promise<void> {
|
||||||
|
const volume = Math.max(0, Math.min(100, Math.round(volumeArg)));
|
||||||
|
await this.sendCommand({ service: 'RenderingControl', action: 'SetVolume', args: { InstanceID: 0, Channel: 'Master', DesiredVolume: volume } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setMute(mutedArg: boolean): Promise<void> {
|
||||||
|
await this.sendCommand({ service: 'RenderingControl', action: 'SetMute', args: { InstanceID: 0, Channel: 'Master', DesiredMute: mutedArg ? 1 : 0 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async selectSource(sourceArg: string): Promise<void> {
|
||||||
|
await this.sendCommand({ service: 'RenderingControl', action: 'SelectPreset', args: { InstanceID: 0, PresetName: sourceArg } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setUri(uriArg: string, metadataArg?: string, titleArg?: string, autoplayArg = false): Promise<void> {
|
||||||
|
const metadata = metadataArg ?? this.constructPlayMediaMetadata(uriArg, titleArg || uriArg);
|
||||||
|
await this.sendCommand({ service: 'AVTransport', action: 'SetAVTransportURI', args: { InstanceID: 0, CurrentURI: uriArg, CurrentURIMetaData: metadata } });
|
||||||
|
if (autoplayArg) {
|
||||||
|
await this.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IDlnaDmrSoapCommand): Promise<Record<string, string>> {
|
||||||
|
const device = await this.getDeviceDescription();
|
||||||
|
const service = device.services[commandArg.service];
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`DLNA DMR service is not available: ${commandArg.service}`);
|
||||||
|
}
|
||||||
|
return this.soap(service, commandArg.action, commandArg.args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public constructPlayMediaMetadata(uriArg: string, titleArg: string): string {
|
||||||
|
const mimeType = this.mimeType(uriArg);
|
||||||
|
const upnpClass = mimeType.startsWith('audio/') ? 'object.item.audioItem.musicTrack' : mimeType.startsWith('video/') ? 'object.item.videoItem' : mimeType.startsWith('image/') ? 'object.item.imageItem' : 'object.item';
|
||||||
|
return [
|
||||||
|
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">',
|
||||||
|
'<item id="0" parentID="-1" restricted="false">',
|
||||||
|
`<dc:title>${this.escapeXml(titleArg)}</dc:title>`,
|
||||||
|
`<upnp:class>${upnpClass}</upnp:class>`,
|
||||||
|
`<res protocolInfo="http-get:*:${mimeType}:*">${this.escapeXml(uriArg)}</res>`,
|
||||||
|
'</item>',
|
||||||
|
'</DIDL-Lite>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private async getRendererState(deviceArg: IDlnaDmrDeviceDescription): Promise<IDlnaDmrRendererState> {
|
||||||
|
const [transportInfo, mediaInfo, positionInfo, transportSettings, transportActions, volume, mute, presets, protocolInfo] = await Promise.all([
|
||||||
|
this.safeSoap(deviceArg.services.AVTransport, 'GetTransportInfo', { InstanceID: 0 }),
|
||||||
|
this.safeSoap(deviceArg.services.AVTransport, 'GetMediaInfo', { InstanceID: 0 }),
|
||||||
|
this.safeSoap(deviceArg.services.AVTransport, 'GetPositionInfo', { InstanceID: 0 }),
|
||||||
|
this.safeSoap(deviceArg.services.AVTransport, 'GetTransportSettings', { InstanceID: 0 }),
|
||||||
|
this.safeSoap(deviceArg.services.AVTransport, 'GetCurrentTransportActions', { InstanceID: 0 }),
|
||||||
|
this.safeSoap(deviceArg.services.RenderingControl, 'GetVolume', { InstanceID: 0, Channel: 'Master' }),
|
||||||
|
this.safeSoap(deviceArg.services.RenderingControl, 'GetMute', { InstanceID: 0, Channel: 'Master' }),
|
||||||
|
this.safeSoap(deviceArg.services.RenderingControl, 'ListPresets', { InstanceID: 0 }),
|
||||||
|
this.safeSoap(deviceArg.services.ConnectionManager, 'GetProtocolInfo', {}),
|
||||||
|
]);
|
||||||
|
const position = this.positionInfo(positionInfo);
|
||||||
|
const metadata = this.parseMetadata(position.trackMetaData || mediaInfo.CurrentURIMetaData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
online: true,
|
||||||
|
transport: {
|
||||||
|
currentTransportState: transportInfo.CurrentTransportState,
|
||||||
|
currentTransportStatus: transportInfo.CurrentTransportStatus,
|
||||||
|
currentSpeed: transportInfo.CurrentSpeed,
|
||||||
|
currentTransportActions: this.splitList(transportActions.Actions),
|
||||||
|
currentPlayMode: transportSettings.PlayMode,
|
||||||
|
},
|
||||||
|
rendering: {
|
||||||
|
volume: this.numberValue(volume.CurrentVolume),
|
||||||
|
muted: this.booleanValue(mute.CurrentMute),
|
||||||
|
presets: this.splitList(presets.CurrentPresetNameList),
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
currentUri: mediaInfo.CurrentURI,
|
||||||
|
currentUriMetaData: mediaInfo.CurrentURIMetaData,
|
||||||
|
currentTrackUri: position.trackUri,
|
||||||
|
mediaClass: metadata.upnpClass,
|
||||||
|
metadata,
|
||||||
|
position,
|
||||||
|
},
|
||||||
|
sinkProtocolInfo: this.splitList(protocolInfo.Sink),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDeviceDescription(): Promise<IDlnaDmrDeviceDescription> {
|
||||||
|
if (this.deviceDescription) {
|
||||||
|
return this.deviceDescription;
|
||||||
|
}
|
||||||
|
if (this.config.services || this.config.udn || this.config.deviceId) {
|
||||||
|
this.deviceDescription = this.deviceFromConfig();
|
||||||
|
return this.deviceDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = this.location();
|
||||||
|
if (!location) {
|
||||||
|
this.deviceDescription = this.deviceFromConfig();
|
||||||
|
return this.deviceDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(location, { signal: AbortSignal.timeout(this.config.timeoutMs || 10000) });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch DLNA DMR description ${location}: ${response.status}`);
|
||||||
|
}
|
||||||
|
const xml = await response.text();
|
||||||
|
this.deviceDescription = this.parseDeviceDescription(xml, location);
|
||||||
|
return this.deviceDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
private deviceFromConfig(): IDlnaDmrDeviceDescription {
|
||||||
|
const location = this.location();
|
||||||
|
return {
|
||||||
|
location,
|
||||||
|
udn: this.config.udn || this.config.deviceId || location || 'manual-dlna-dmr',
|
||||||
|
deviceType: this.config.deviceType || 'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||||||
|
friendlyName: this.config.name || 'DLNA Digital Media Renderer',
|
||||||
|
manufacturer: this.config.manufacturer,
|
||||||
|
modelName: this.config.model,
|
||||||
|
modelNumber: this.config.modelNumber,
|
||||||
|
serialNumber: this.config.serialNumber,
|
||||||
|
macAddress: this.config.macAddress,
|
||||||
|
services: this.config.services || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDeviceDescription(xmlArg: string, locationArg: string): IDlnaDmrDeviceDescription {
|
||||||
|
const deviceBlock = this.findRendererDeviceBlock(xmlArg) || this.extractBlocks(xmlArg, 'device')[0] || xmlArg;
|
||||||
|
const services: Partial<Record<TDlnaDmrServiceKey, IDlnaDmrServiceDescription>> = {};
|
||||||
|
for (const serviceBlock of this.extractBlocks(deviceBlock, 'service')) {
|
||||||
|
const serviceId = this.firstTag(serviceBlock, 'serviceId') || '';
|
||||||
|
const key = serviceKeyById[serviceId];
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
services[key] = {
|
||||||
|
serviceId,
|
||||||
|
serviceType: this.firstTag(serviceBlock, 'serviceType') || '',
|
||||||
|
controlUrl: this.absoluteUrl(locationArg, this.firstTag(serviceBlock, 'controlURL') || '') || '',
|
||||||
|
eventSubUrl: this.absoluteUrl(locationArg, this.firstTag(serviceBlock, 'eventSubURL') || ''),
|
||||||
|
scpdUrl: this.absoluteUrl(locationArg, this.firstTag(serviceBlock, 'SCPDURL') || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: locationArg,
|
||||||
|
udn: this.firstTag(deviceBlock, 'UDN') || this.config.udn || this.config.deviceId || locationArg,
|
||||||
|
deviceType: this.firstTag(deviceBlock, 'deviceType') || this.config.deviceType || 'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||||||
|
friendlyName: this.firstTag(deviceBlock, 'friendlyName') || this.config.name || 'DLNA Digital Media Renderer',
|
||||||
|
manufacturer: this.firstTag(deviceBlock, 'manufacturer') || this.config.manufacturer,
|
||||||
|
modelName: this.firstTag(deviceBlock, 'modelName') || this.config.model,
|
||||||
|
modelNumber: this.firstTag(deviceBlock, 'modelNumber') || this.config.modelNumber,
|
||||||
|
serialNumber: this.firstTag(deviceBlock, 'serialNumber') || this.config.serialNumber,
|
||||||
|
macAddress: this.config.macAddress,
|
||||||
|
services,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safeSoap(serviceArg: IDlnaDmrServiceDescription | undefined, actionArg: string, argsArg: Record<string, string | number | boolean>): Promise<Record<string, string>> {
|
||||||
|
if (!serviceArg) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.soap(serviceArg, actionArg, argsArg);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async soap(serviceArg: IDlnaDmrServiceDescription, actionArg: string, argsArg: Record<string, string | number | boolean | undefined>): Promise<Record<string, string>> {
|
||||||
|
const body = this.soapBody(serviceArg.serviceType, actionArg, argsArg);
|
||||||
|
const response = await fetch(serviceArg.controlUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
SOAPAction: `"${serviceArg.serviceType}#${actionArg}"`,
|
||||||
|
'Content-Type': 'text/xml; charset="utf-8"',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(this.config.timeoutMs || 10000),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DLNA DMR SOAP ${actionArg} failed: ${response.status} ${text}`);
|
||||||
|
}
|
||||||
|
return this.parseSoapResponse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private soapBody(serviceTypeArg: string, actionArg: string, argsArg: Record<string, string | number | boolean | undefined>): string {
|
||||||
|
const args = Object.entries(argsArg)
|
||||||
|
.filter((entryArg): entryArg is [string, string | number | boolean] => entryArg[1] !== undefined)
|
||||||
|
.map(([keyArg, valueArg]) => `<${keyArg}>${this.escapeXml(String(valueArg))}</${keyArg}>`)
|
||||||
|
.join('');
|
||||||
|
return `<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${actionArg} xmlns:u="${serviceTypeArg}">${args}</u:${actionArg}></s:Body></s:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSoapResponse(xmlArg: string): Record<string, string> {
|
||||||
|
const body = this.firstTagByLocalName(xmlArg, 'Body') || xmlArg;
|
||||||
|
const responseBlock = this.extractFirstChildBlock(body) || body;
|
||||||
|
const responseInner = this.stripOuterTag(responseBlock) || responseBlock;
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
const tagRegex = /<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g;
|
||||||
|
for (const match of responseInner.matchAll(tagRegex)) {
|
||||||
|
const localName = match[1].includes(':') ? match[1].split(':').pop() || match[1] : match[1];
|
||||||
|
if (localName.endsWith('Response')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[localName] = this.decodeXml(match[2]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private positionInfo(valueArg: Record<string, string>): IDlnaDmrPositionInfo {
|
||||||
|
return {
|
||||||
|
track: this.numberValue(valueArg.Track),
|
||||||
|
trackDuration: valueArg.TrackDuration,
|
||||||
|
trackDurationSeconds: this.seconds(valueArg.TrackDuration),
|
||||||
|
trackMetaData: valueArg.TrackMetaData,
|
||||||
|
trackUri: valueArg.TrackURI,
|
||||||
|
relativeTime: valueArg.RelTime,
|
||||||
|
relativeTimeSeconds: this.seconds(valueArg.RelTime),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMetadata(xmlArg: string | undefined): IDlnaDmrMediaMetadata {
|
||||||
|
if (!xmlArg || xmlArg === 'NOT_IMPLEMENTED') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: this.firstTagByLocalName(xmlArg, 'title'),
|
||||||
|
artist: this.firstTagByLocalName(xmlArg, 'artist'),
|
||||||
|
album: this.firstTagByLocalName(xmlArg, 'album'),
|
||||||
|
albumArtist: this.firstTagByLocalName(xmlArg, 'albumArtist') || this.firstTagByLocalName(xmlArg, 'album_artist'),
|
||||||
|
albumArtUri: this.firstTagByLocalName(xmlArg, 'albumArtURI'),
|
||||||
|
upnpClass: this.firstTagByLocalName(xmlArg, 'class'),
|
||||||
|
raw: xmlArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private withDefaults(snapshotArg: IDlnaDmrSnapshot): IDlnaDmrSnapshot {
|
||||||
|
return {
|
||||||
|
device: snapshotArg.device,
|
||||||
|
state: {
|
||||||
|
...snapshotArg.state,
|
||||||
|
online: snapshotArg.state.online,
|
||||||
|
transport: snapshotArg.state.transport || {},
|
||||||
|
rendering: snapshotArg.state.rendering || {},
|
||||||
|
media: snapshotArg.state.media || {},
|
||||||
|
updatedAt: snapshotArg.state.updatedAt || new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private location(): string | undefined {
|
||||||
|
if (this.config.location || this.config.url) {
|
||||||
|
return this.config.location || this.config.url;
|
||||||
|
}
|
||||||
|
if (!this.config.host) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const path = this.config.path || '/description.xml';
|
||||||
|
return `http://${this.config.host}:${this.config.port || 80}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findRendererDeviceBlock(xmlArg: string): string | undefined {
|
||||||
|
const blocks = this.extractBlocks(xmlArg, 'device');
|
||||||
|
for (const block of blocks) {
|
||||||
|
const deviceType = this.firstTag(block, 'deviceType');
|
||||||
|
if (deviceType && dlnaDmrDeviceTypes.includes(deviceType)) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const block of blocks) {
|
||||||
|
const inner = this.innerBlock(block, 'device');
|
||||||
|
const nested = inner ? this.findRendererDeviceBlock(inner) : undefined;
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBlocks(xmlArg: string, tagArg: string): string[] {
|
||||||
|
const regex = new RegExp(`<\\/?${tagArg}\\b[^>]*>`, 'gi');
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let start = -1;
|
||||||
|
for (const match of xmlArg.matchAll(regex)) {
|
||||||
|
const token = match[0];
|
||||||
|
if (!token.startsWith('</')) {
|
||||||
|
if (depth === 0) {
|
||||||
|
start = match.index || 0;
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
} else {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0 && start >= 0) {
|
||||||
|
blocks.push(xmlArg.slice(start, (match.index || 0) + token.length));
|
||||||
|
start = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private innerBlock(xmlArg: string, tagArg: string): string | undefined {
|
||||||
|
const start = xmlArg.match(new RegExp(`<${tagArg}\\b[^>]*>`, 'i'));
|
||||||
|
const end = xmlArg.match(new RegExp(`</${tagArg}>`, 'i'));
|
||||||
|
if (!start || !end || start.index === undefined || end.index === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return xmlArg.slice(start.index + start[0].length, end.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstTag(xmlArg: string, tagArg: string): string | undefined {
|
||||||
|
const escaped = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const match = xmlArg.match(new RegExp(`<${escaped}(?:\\s[^>]*)?>([\\s\\S]*?)</${escaped}>`, 'i'));
|
||||||
|
return match ? this.decodeXml(match[1].trim()) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstTagByLocalName(xmlArg: string, localNameArg: string): string | undefined {
|
||||||
|
const escaped = localNameArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const match = xmlArg.match(new RegExp(`<(?:[A-Za-z0-9_]+:)?${escaped}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[A-Za-z0-9_]+:)?${escaped}>`, 'i'));
|
||||||
|
return match ? this.decodeXml(match[1].trim()) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFirstChildBlock(xmlArg: string): string | undefined {
|
||||||
|
const match = xmlArg.match(/<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>[\s\S]*<\/\1>/);
|
||||||
|
return match?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private stripOuterTag(xmlArg: string): string | undefined {
|
||||||
|
const match = xmlArg.match(/^<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>([\s\S]*)<\/\1>$/);
|
||||||
|
return match?.[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
private absoluteUrl(baseArg: string, valueArg: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return new URL(valueArg, baseArg).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private splitList(valueArg: string | undefined): string[] {
|
||||||
|
if (!valueArg) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: string | undefined): number | undefined {
|
||||||
|
if (valueArg === undefined || valueArg === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = Number(valueArg);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanValue(valueArg: string | undefined): boolean | undefined {
|
||||||
|
if (valueArg === undefined || valueArg === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return valueArg === '1' || valueArg.toLowerCase() === 'true' || valueArg.toLowerCase() === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
private seconds(valueArg: string | undefined): number | undefined {
|
||||||
|
if (!valueArg || valueArg === 'NOT_IMPLEMENTED') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const match = valueArg.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mimeType(uriArg: string): string {
|
||||||
|
const path = uriArg.split('?')[0].toLowerCase();
|
||||||
|
if (path.endsWith('.mp3')) return 'audio/mpeg';
|
||||||
|
if (path.endsWith('.flac')) return 'audio/flac';
|
||||||
|
if (path.endsWith('.wav')) return 'audio/wav';
|
||||||
|
if (path.endsWith('.mp4') || path.endsWith('.m4v')) return 'video/mp4';
|
||||||
|
if (path.endsWith('.mkv')) return 'video/x-matroska';
|
||||||
|
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg';
|
||||||
|
if (path.endsWith('.png')) return 'image/png';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeXml(valueArg: string): string {
|
||||||
|
return valueArg.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeXml(valueArg: string): string {
|
||||||
|
return valueArg.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IDlnaDmrConfig } from './dlna_dmr.types.js';
|
||||||
|
|
||||||
|
export class DlnaDmrConfigFlow implements IConfigFlow<IDlnaDmrConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDlnaDmrConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
if (candidateArg.source === 'ssdp' && typeof candidateArg.metadata?.location === 'string') {
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: candidateArg.name || 'DLNA Digital Media Renderer configured',
|
||||||
|
config: {
|
||||||
|
location: candidateArg.metadata.location,
|
||||||
|
deviceId: candidateArg.id,
|
||||||
|
udn: candidateArg.id,
|
||||||
|
deviceType: typeof candidateArg.metadata.deviceType === 'string' ? candidateArg.metadata.deviceType : undefined,
|
||||||
|
name: candidateArg.name,
|
||||||
|
manufacturer: candidateArg.manufacturer,
|
||||||
|
model: candidateArg.model,
|
||||||
|
macAddress: candidateArg.macAddress,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Manual DLNA DMR device connection',
|
||||||
|
description: 'Enter the URL of the renderer device description XML file.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'url', label: 'Description URL', type: 'text', required: true },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const url = valuesArg.url;
|
||||||
|
if (typeof url !== 'string' || !url) {
|
||||||
|
return { kind: 'error', title: 'Invalid DLNA DMR configuration', error: 'A description URL is required.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'DLNA DMR configured',
|
||||||
|
config: {
|
||||||
|
location: url,
|
||||||
|
url,
|
||||||
|
deviceId: candidateArg.id,
|
||||||
|
udn: candidateArg.id,
|
||||||
|
name: candidateArg.name,
|
||||||
|
manufacturer: candidateArg.manufacturer,
|
||||||
|
model: candidateArg.model,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,118 @@
|
|||||||
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 { DlnaDmrClient } from './dlna_dmr.classes.client.js';
|
||||||
|
import { DlnaDmrConfigFlow } from './dlna_dmr.classes.configflow.js';
|
||||||
|
import { createDlnaDmrDiscoveryDescriptor } from './dlna_dmr.discovery.js';
|
||||||
|
import { DlnaDmrMapper } from './dlna_dmr.mapper.js';
|
||||||
|
import type { IDlnaDmrConfig } from './dlna_dmr.types.js';
|
||||||
|
|
||||||
export class HomeAssistantDlnaDmrIntegration extends DescriptorOnlyIntegration {
|
export class DlnaDmrIntegration extends BaseIntegration<IDlnaDmrConfig> {
|
||||||
constructor() {
|
public readonly domain = 'dlna_dmr';
|
||||||
super({
|
public readonly displayName = 'DLNA Digital Media Renderer';
|
||||||
domain: "dlna_dmr",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "DLNA Digital Media Renderer",
|
public readonly discoveryDescriptor = createDlnaDmrDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new DlnaDmrConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/dlna_dmr",
|
upstreamPath: 'homeassistant/components/dlna_dmr',
|
||||||
"upstreamDomain": "dlna_dmr",
|
upstreamDomain: 'dlna_dmr',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
requirements: ['async-upnp-client==0.46.2', 'getmac==0.9.5'],
|
||||||
"async-upnp-client==0.46.2",
|
dependencies: ['ssdp'],
|
||||||
"getmac==0.9.5"
|
afterDependencies: ['media_source'],
|
||||||
],
|
documentation: 'https://www.home-assistant.io/integrations/dlna_dmr',
|
||||||
"dependencies": [
|
codeowners: ['@chishm'],
|
||||||
"ssdp"
|
};
|
||||||
],
|
|
||||||
"afterDependencies": [
|
public async setup(configArg: IDlnaDmrConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"media_source"
|
void contextArg;
|
||||||
],
|
return new DlnaDmrRuntime(new DlnaDmrClient(configArg));
|
||||||
"codeowners": [
|
}
|
||||||
"@chishm"
|
|
||||||
]
|
public async destroy(): Promise<void> {}
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
export class HomeAssistantDlnaDmrIntegration extends DlnaDmrIntegration {}
|
||||||
|
|
||||||
|
class DlnaDmrRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'dlna_dmr';
|
||||||
|
|
||||||
|
constructor(private readonly client: DlnaDmrClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return DlnaDmrMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return DlnaDmrMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (requestArg.domain !== 'media_player') {
|
||||||
|
return { success: false, error: `Unsupported DLNA DMR service domain: ${requestArg.domain}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
|
||||||
|
await this.client.play();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
|
||||||
|
await this.client.pause();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
|
||||||
|
await this.client.stop();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume') {
|
||||||
|
const rawVolume = requestArg.data?.volume_level ?? requestArg.data?.volume;
|
||||||
|
const volume = typeof rawVolume === 'number' && rawVolume <= 1 ? rawVolume * 100 : rawVolume;
|
||||||
|
if (typeof volume !== 'number') {
|
||||||
|
return { success: false, error: 'DLNA DMR volume service requires data.volume_level or data.volume.' };
|
||||||
|
}
|
||||||
|
await this.client.setVolume(volume);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'volume_mute' || requestArg.service === 'set_mute' || requestArg.service === 'mute') {
|
||||||
|
const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted;
|
||||||
|
if (typeof muted !== 'boolean') {
|
||||||
|
return { success: false, error: 'DLNA DMR mute service requires data.is_volume_muted or data.muted.' };
|
||||||
|
}
|
||||||
|
await this.client.setMute(muted);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'select_source') {
|
||||||
|
const source = requestArg.data?.source;
|
||||||
|
if (typeof source !== 'string' || !source) {
|
||||||
|
return { success: false, error: 'DLNA DMR select_source requires data.source.' };
|
||||||
|
}
|
||||||
|
await this.client.selectSource(source);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'set_uri' || requestArg.service === 'play_media') {
|
||||||
|
const uri = requestArg.data?.media_content_id ?? requestArg.data?.uri;
|
||||||
|
if (typeof uri !== 'string' || !uri) {
|
||||||
|
return { success: false, error: 'DLNA DMR set_uri requires data.media_content_id or data.uri.' };
|
||||||
|
}
|
||||||
|
const title = typeof requestArg.data?.title === 'string' ? requestArg.data.title : undefined;
|
||||||
|
const metadata = typeof requestArg.data?.metadata === 'string' ? requestArg.data.metadata : undefined;
|
||||||
|
const autoplay = typeof requestArg.data?.autoplay === 'boolean' ? requestArg.data.autoplay : requestArg.service === 'play_media';
|
||||||
|
await this.client.setUri(uri, metadata, title, autoplay);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: `Unsupported DLNA DMR media_player service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IDlnaDmrManualEntry, IDlnaDmrSsdpRecord, IDlnaDmrSsdpService } from './dlna_dmr.types.js';
|
||||||
|
|
||||||
|
const dlnaDmrDeviceTypes = [
|
||||||
|
'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||||||
|
'urn:schemas-upnp-org:device:MediaRenderer:2',
|
||||||
|
'urn:schemas-upnp-org:device:MediaRenderer:3',
|
||||||
|
];
|
||||||
|
|
||||||
|
const requiredServiceIds = new Set([
|
||||||
|
'urn:upnp-org:serviceId:AVTransport',
|
||||||
|
'urn:upnp-org:serviceId:ConnectionManager',
|
||||||
|
'urn:upnp-org:serviceId:RenderingControl',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class DlnaDmrSsdpMatcher implements IDiscoveryMatcher<IDlnaDmrSsdpRecord> {
|
||||||
|
public id = 'dlna-dmr-ssdp-match';
|
||||||
|
public source = 'ssdp' as const;
|
||||||
|
public description = 'Recognize UPnP/DLNA MediaRenderer SSDP advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IDlnaDmrSsdpRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const headers = recordArg.headers || {};
|
||||||
|
const st = recordArg.st || headers.st || headers.ST;
|
||||||
|
const nt = recordArg.nt || headers.nt || headers.NT;
|
||||||
|
const usn = recordArg.usn || headers.usn || headers.USN;
|
||||||
|
const location = recordArg.location || headers.location || headers.LOCATION;
|
||||||
|
const deviceType = recordArg.deviceType || recordArg.upnp?.deviceType || st || nt;
|
||||||
|
const matchedDeviceType = typeof deviceType === 'string' && dlnaDmrDeviceTypes.includes(deviceType);
|
||||||
|
const matchedUsn = typeof usn === 'string' && dlnaDmrDeviceTypes.some((typeArg) => usn.includes(typeArg));
|
||||||
|
|
||||||
|
if (!matchedDeviceType && !matchedUsn) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'SSDP record is not a DLNA MediaRenderer.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceIds = this.serviceIds(recordArg);
|
||||||
|
const hasRequiredServices = serviceIds.length === 0 || [...requiredServiceIds].every((serviceIdArg) => serviceIds.includes(serviceIdArg));
|
||||||
|
if (!hasRequiredServices) {
|
||||||
|
return { matched: false, confidence: 'medium', reason: 'SSDP record lacks required DMR services.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = location ? new URL(location) : undefined;
|
||||||
|
const udn = recordArg.udn || usn?.split('::')[0];
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: udn && location ? 'certain' : 'high',
|
||||||
|
reason: 'SSDP record matches DLNA MediaRenderer metadata.',
|
||||||
|
normalizedDeviceId: udn,
|
||||||
|
candidate: {
|
||||||
|
source: 'ssdp',
|
||||||
|
integrationDomain: 'dlna_dmr',
|
||||||
|
id: udn,
|
||||||
|
host: url?.hostname,
|
||||||
|
port: url?.port ? Number(url.port) : undefined,
|
||||||
|
name: recordArg.upnp?.friendlyName,
|
||||||
|
manufacturer: recordArg.upnp?.manufacturer,
|
||||||
|
model: recordArg.upnp?.modelName,
|
||||||
|
metadata: { st, nt, usn, location, deviceType, serviceIds },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serviceIds(recordArg: IDlnaDmrSsdpRecord): string[] {
|
||||||
|
const service = recordArg.upnp?.serviceList?.service;
|
||||||
|
const services: IDlnaDmrSsdpService[] = Array.isArray(service) ? service : service ? [service] : [];
|
||||||
|
return services.map((serviceArg) => serviceArg.serviceId).filter((serviceIdArg): serviceIdArg is string => Boolean(serviceIdArg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DlnaDmrManualMatcher implements IDiscoveryMatcher<IDlnaDmrManualEntry> {
|
||||||
|
public id = 'dlna-dmr-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual DLNA DMR setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IDlnaDmrManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const location = inputArg.location || inputArg.url || this.locationFromHost(inputArg);
|
||||||
|
const model = inputArg.model?.toLowerCase() || '';
|
||||||
|
const manufacturer = inputArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const hinted = Boolean(inputArg.metadata?.dlnaDmr || inputArg.metadata?.dlna_dmr || model.includes('renderer') || manufacturer.includes('dlna'));
|
||||||
|
const matched = Boolean(location || inputArg.host || hinted);
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain DLNA DMR setup hints.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = location ? new URL(location) : undefined;
|
||||||
|
const id = inputArg.udn || inputArg.id;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: location ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start DLNA DMR setup.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'dlna_dmr',
|
||||||
|
id,
|
||||||
|
host: url?.hostname || inputArg.host,
|
||||||
|
port: url?.port ? Number(url.port) : inputArg.port,
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: inputArg.manufacturer,
|
||||||
|
model: inputArg.model,
|
||||||
|
metadata: { ...inputArg.metadata, location },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private locationFromHost(inputArg: IDlnaDmrManualEntry): string | undefined {
|
||||||
|
if (!inputArg.host) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const path = inputArg.path || '/description.xml';
|
||||||
|
return `http://${inputArg.host}:${inputArg.port || 80}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DlnaDmrCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'dlna-dmr-candidate-validator';
|
||||||
|
public description = 'Validate DLNA DMR candidate metadata.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const deviceType = typeof candidateArg.metadata?.deviceType === 'string' ? candidateArg.metadata.deviceType : undefined;
|
||||||
|
const matched = candidateArg.integrationDomain === 'dlna_dmr' || Boolean(deviceType && dlnaDmrDeviceTypes.includes(deviceType));
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has DLNA DMR metadata.' : 'Candidate is not a DLNA DMR renderer.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDlnaDmrDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'dlna_dmr', displayName: 'DLNA Digital Media Renderer' })
|
||||||
|
.addMatcher(new DlnaDmrSsdpMatcher())
|
||||||
|
.addMatcher(new DlnaDmrManualMatcher())
|
||||||
|
.addValidator(new DlnaDmrCandidateValidator());
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js';
|
||||||
|
import type { IDlnaDmrEvent, IDlnaDmrSnapshot, TDlnaDmrMediaPlayerState } from './dlna_dmr.types.js';
|
||||||
|
|
||||||
|
const mediaTypeMap: Record<string, string> = {
|
||||||
|
object: 'url',
|
||||||
|
'object.item': 'url',
|
||||||
|
'object.item.imageItem': 'image',
|
||||||
|
'object.item.imageItem.photo': 'image',
|
||||||
|
'object.item.audioItem': 'music',
|
||||||
|
'object.item.audioItem.musicTrack': 'music',
|
||||||
|
'object.item.audioItem.audioBroadcast': 'music',
|
||||||
|
'object.item.audioItem.audioBook': 'podcast',
|
||||||
|
'object.item.videoItem': 'video',
|
||||||
|
'object.item.videoItem.movie': 'movie',
|
||||||
|
'object.item.videoItem.videoBroadcast': 'tvshow',
|
||||||
|
'object.item.videoItem.musicVideoClip': 'video',
|
||||||
|
'object.item.playlistItem': 'playlist',
|
||||||
|
'object.container': 'playlist',
|
||||||
|
'object.container.album': 'album',
|
||||||
|
'object.container.album.musicAlbum': 'album',
|
||||||
|
'object.container.person.musicArtist': 'artist',
|
||||||
|
'object.container.genre.musicGenre': 'genre',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DlnaDmrMapper {
|
||||||
|
public static toDevices(snapshotArg: IDlnaDmrSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = snapshotArg.state.updatedAt || new Date().toISOString();
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ 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: 'current_uri', capability: 'media', name: 'Current URI', readable: true, writable: true },
|
||||||
|
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'playback', value: this.mediaState(snapshotArg), updatedAt },
|
||||||
|
{ featureId: 'volume', value: snapshotArg.state.rendering.volume ?? null, updatedAt },
|
||||||
|
{ featureId: 'muted', value: snapshotArg.state.rendering.muted ?? null, updatedAt },
|
||||||
|
{ featureId: 'current_uri', value: snapshotArg.state.media.currentTrackUri || snapshotArg.state.media.currentUri || null, updatedAt },
|
||||||
|
{ featureId: 'current_title', value: this.mediaTitle(snapshotArg) || null, updatedAt },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (snapshotArg.state.rendering.presets?.length) {
|
||||||
|
features.push({ id: 'source', capability: 'media', name: 'Source', readable: true, writable: true });
|
||||||
|
state.push({ featureId: 'source', value: snapshotArg.state.rendering.selectedPreset || null, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: this.deviceId(snapshotArg),
|
||||||
|
integrationDomain: 'dlna_dmr',
|
||||||
|
name: this.deviceName(snapshotArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: snapshotArg.device.manufacturer || 'DLNA',
|
||||||
|
model: snapshotArg.device.modelName || snapshotArg.device.modelNumber,
|
||||||
|
online: snapshotArg.state.online,
|
||||||
|
features,
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
udn: snapshotArg.device.udn,
|
||||||
|
deviceType: snapshotArg.device.deviceType,
|
||||||
|
location: snapshotArg.device.location,
|
||||||
|
serialNumber: snapshotArg.device.serialNumber,
|
||||||
|
macAddress: snapshotArg.device.macAddress,
|
||||||
|
sinkProtocolInfo: snapshotArg.state.sinkProtocolInfo,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IDlnaDmrSnapshot): IIntegrationEntity[] {
|
||||||
|
return [{
|
||||||
|
id: `media_player.${this.slug(this.deviceName(snapshotArg))}`,
|
||||||
|
uniqueId: `dlna_dmr_${this.uniqueBase(snapshotArg)}`,
|
||||||
|
integrationDomain: 'dlna_dmr',
|
||||||
|
deviceId: this.deviceId(snapshotArg),
|
||||||
|
platform: 'media_player',
|
||||||
|
name: this.deviceName(snapshotArg),
|
||||||
|
state: this.mediaState(snapshotArg),
|
||||||
|
attributes: {
|
||||||
|
volumeLevel: typeof snapshotArg.state.rendering.volume === 'number' ? snapshotArg.state.rendering.volume / 100 : undefined,
|
||||||
|
isVolumeMuted: snapshotArg.state.rendering.muted,
|
||||||
|
source: snapshotArg.state.rendering.selectedPreset,
|
||||||
|
sourceList: snapshotArg.state.rendering.presets,
|
||||||
|
mediaContentId: snapshotArg.state.media.currentTrackUri || snapshotArg.state.media.currentUri,
|
||||||
|
mediaContentType: this.mediaContentType(snapshotArg),
|
||||||
|
mediaDuration: snapshotArg.state.media.position?.trackDurationSeconds ?? snapshotArg.state.media.metadata?.duration,
|
||||||
|
mediaPosition: snapshotArg.state.media.position?.relativeTimeSeconds,
|
||||||
|
mediaTitle: this.mediaTitle(snapshotArg),
|
||||||
|
mediaArtist: snapshotArg.state.media.metadata?.artist,
|
||||||
|
mediaAlbumName: snapshotArg.state.media.metadata?.album,
|
||||||
|
mediaAlbumArtist: snapshotArg.state.media.metadata?.albumArtist,
|
||||||
|
mediaImageUrl: snapshotArg.state.media.metadata?.albumArtUri,
|
||||||
|
currentTransportActions: snapshotArg.state.transport.currentTransportActions,
|
||||||
|
currentPlayMode: snapshotArg.state.transport.currentPlayMode,
|
||||||
|
},
|
||||||
|
available: snapshotArg.state.online,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toIntegrationEvent(eventArg: IDlnaDmrEvent): IIntegrationEvent {
|
||||||
|
return {
|
||||||
|
type: 'state_changed',
|
||||||
|
integrationDomain: 'dlna_dmr',
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static mediaState(snapshotArg: IDlnaDmrSnapshot): TDlnaDmrMediaPlayerState {
|
||||||
|
if (!snapshotArg.state.online) {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
const state = snapshotArg.state.transport.currentTransportState;
|
||||||
|
if (state === 'PLAYING' || state === 'TRANSITIONING') {
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
|
if (state === 'PAUSED_PLAYBACK' || state === 'PAUSED_RECORDING') {
|
||||||
|
return 'paused';
|
||||||
|
}
|
||||||
|
if (!state) {
|
||||||
|
return 'on';
|
||||||
|
}
|
||||||
|
if (state === 'VENDOR_DEFINED') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaContentType(snapshotArg: IDlnaDmrSnapshot): string | undefined {
|
||||||
|
const upnpClass = snapshotArg.state.media.metadata?.upnpClass || snapshotArg.state.media.mediaClass;
|
||||||
|
return upnpClass ? mediaTypeMap[upnpClass] || 'url' : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaTitle(snapshotArg: IDlnaDmrSnapshot): string | undefined {
|
||||||
|
return snapshotArg.state.media.metadata?.title || snapshotArg.state.media.currentTrackUri || snapshotArg.state.media.currentUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceId(snapshotArg: IDlnaDmrSnapshot): string {
|
||||||
|
return `dlna_dmr.renderer.${this.uniqueBase(snapshotArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueBase(snapshotArg: IDlnaDmrSnapshot): string {
|
||||||
|
return this.slug(snapshotArg.device.udn || snapshotArg.device.location || this.deviceName(snapshotArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceName(snapshotArg: IDlnaDmrSnapshot): string {
|
||||||
|
return snapshotArg.device.friendlyName || 'DLNA Digital Media Renderer';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'dlna_dmr';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,212 @@
|
|||||||
export interface IHomeAssistantDlnaDmrConfig {
|
export type TDlnaDmrDeviceType =
|
||||||
// TODO: replace with the TypeScript-native config for dlna_dmr.
|
| 'urn:schemas-upnp-org:device:MediaRenderer:1'
|
||||||
[key: string]: unknown;
|
| 'urn:schemas-upnp-org:device:MediaRenderer:2'
|
||||||
|
| 'urn:schemas-upnp-org:device:MediaRenderer:3';
|
||||||
|
|
||||||
|
export type TDlnaDmrServiceKey = 'AVTransport' | 'RenderingControl' | 'ConnectionManager';
|
||||||
|
|
||||||
|
export type TDlnaDmrTransportState =
|
||||||
|
| 'STOPPED'
|
||||||
|
| 'PLAYING'
|
||||||
|
| 'TRANSITIONING'
|
||||||
|
| 'PAUSED_PLAYBACK'
|
||||||
|
| 'PAUSED_RECORDING'
|
||||||
|
| 'RECORDING'
|
||||||
|
| 'NO_MEDIA_PRESENT'
|
||||||
|
| 'VENDOR_DEFINED';
|
||||||
|
|
||||||
|
export type TDlnaDmrMediaPlayerState = 'off' | 'on' | 'idle' | 'playing' | 'paused' | 'unknown';
|
||||||
|
|
||||||
|
export type TDlnaDmrCommandName =
|
||||||
|
| 'play'
|
||||||
|
| 'pause'
|
||||||
|
| 'stop'
|
||||||
|
| 'set_volume'
|
||||||
|
| 'set_mute'
|
||||||
|
| 'select_source'
|
||||||
|
| 'set_uri';
|
||||||
|
|
||||||
|
export interface IDlnaDmrConfig {
|
||||||
|
location?: string;
|
||||||
|
url?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
path?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
udn?: string;
|
||||||
|
deviceType?: TDlnaDmrDeviceType | string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
modelNumber?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
services?: Partial<Record<TDlnaDmrServiceKey, IDlnaDmrServiceDescription>>;
|
||||||
|
snapshot?: IDlnaDmrSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantDlnaDmrConfig extends IDlnaDmrConfig {}
|
||||||
|
|
||||||
|
export interface IDlnaDmrServiceDescription {
|
||||||
|
serviceId: string;
|
||||||
|
serviceType: string;
|
||||||
|
controlUrl: string;
|
||||||
|
eventSubUrl?: string;
|
||||||
|
scpdUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrDeviceDescription {
|
||||||
|
location?: string;
|
||||||
|
udn: string;
|
||||||
|
deviceType: TDlnaDmrDeviceType | string;
|
||||||
|
friendlyName: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
modelName?: string;
|
||||||
|
modelNumber?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
services: Partial<Record<TDlnaDmrServiceKey, IDlnaDmrServiceDescription>>;
|
||||||
|
icons?: IDlnaDmrDeviceIcon[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrDeviceIcon {
|
||||||
|
mimeType?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
depth?: number;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrTransportInfo {
|
||||||
|
currentTransportState?: TDlnaDmrTransportState | string;
|
||||||
|
currentTransportStatus?: string;
|
||||||
|
currentSpeed?: string;
|
||||||
|
currentTransportActions?: string[];
|
||||||
|
currentPlayMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrRenderingState {
|
||||||
|
volume?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
presets?: string[];
|
||||||
|
selectedPreset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrMediaMetadata {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
albumArtist?: string;
|
||||||
|
albumArtUri?: string;
|
||||||
|
upnpClass?: string;
|
||||||
|
contentType?: string;
|
||||||
|
duration?: number;
|
||||||
|
raw?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrPositionInfo {
|
||||||
|
track?: number;
|
||||||
|
trackDuration?: string;
|
||||||
|
trackDurationSeconds?: number;
|
||||||
|
trackMetaData?: string;
|
||||||
|
trackUri?: string;
|
||||||
|
relativeTime?: string;
|
||||||
|
relativeTimeSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrMediaState {
|
||||||
|
currentUri?: string;
|
||||||
|
currentUriMetaData?: string;
|
||||||
|
currentTrackUri?: string;
|
||||||
|
mediaClass?: string;
|
||||||
|
metadata?: IDlnaDmrMediaMetadata;
|
||||||
|
position?: IDlnaDmrPositionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrRendererState {
|
||||||
|
online: boolean;
|
||||||
|
transport: IDlnaDmrTransportInfo;
|
||||||
|
rendering: IDlnaDmrRenderingState;
|
||||||
|
media: IDlnaDmrMediaState;
|
||||||
|
sinkProtocolInfo?: string[];
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrSnapshot {
|
||||||
|
device: IDlnaDmrDeviceDescription;
|
||||||
|
state: IDlnaDmrRendererState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrSoapCommand {
|
||||||
|
service: TDlnaDmrServiceKey;
|
||||||
|
action: string;
|
||||||
|
args: Record<string, string | number | boolean | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrCommand {
|
||||||
|
name: TDlnaDmrCommandName;
|
||||||
|
uri?: string;
|
||||||
|
metadata?: string;
|
||||||
|
title?: string;
|
||||||
|
volume?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
source?: string;
|
||||||
|
autoplay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrEvent {
|
||||||
|
service: TDlnaDmrServiceKey | string;
|
||||||
|
variables: Record<string, unknown>;
|
||||||
|
snapshot?: IDlnaDmrSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrSsdpRecord {
|
||||||
|
st?: string;
|
||||||
|
nt?: string;
|
||||||
|
usn?: string;
|
||||||
|
location?: string;
|
||||||
|
udn?: string;
|
||||||
|
deviceType?: string;
|
||||||
|
headers?: Record<string, string | undefined>;
|
||||||
|
upnp?: {
|
||||||
|
deviceType?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
modelName?: string;
|
||||||
|
serviceList?: IDlnaDmrSsdpServiceList;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrSsdpServiceList {
|
||||||
|
service?: IDlnaDmrSsdpService | IDlnaDmrSsdpService[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrSsdpService {
|
||||||
|
serviceId?: string;
|
||||||
|
serviceType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrManualEntry {
|
||||||
|
url?: string;
|
||||||
|
location?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
path?: string;
|
||||||
|
id?: string;
|
||||||
|
udn?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDlnaDmrDiscoveryMetadata {
|
||||||
|
st?: string;
|
||||||
|
nt?: string;
|
||||||
|
usn?: string;
|
||||||
|
location?: string;
|
||||||
|
deviceType?: string;
|
||||||
|
serviceIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
export * from './dlna_dmr.classes.client.js';
|
||||||
|
export * from './dlna_dmr.classes.configflow.js';
|
||||||
export * from './dlna_dmr.classes.integration.js';
|
export * from './dlna_dmr.classes.integration.js';
|
||||||
|
export * from './dlna_dmr.discovery.js';
|
||||||
|
export * from './dlna_dmr.mapper.js';
|
||||||
export * from './dlna_dmr.types.js';
|
export * from './dlna_dmr.types.js';
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ import { HomeAssistantAvionIntegration } from '../avion/index.js';
|
|||||||
import { HomeAssistantAwairIntegration } from '../awair/index.js';
|
import { HomeAssistantAwairIntegration } from '../awair/index.js';
|
||||||
import { HomeAssistantAwsIntegration } from '../aws/index.js';
|
import { HomeAssistantAwsIntegration } from '../aws/index.js';
|
||||||
import { HomeAssistantAwsS3Integration } from '../aws_s3/index.js';
|
import { HomeAssistantAwsS3Integration } from '../aws_s3/index.js';
|
||||||
import { HomeAssistantAxisIntegration } from '../axis/index.js';
|
|
||||||
import { HomeAssistantAzureDataExplorerIntegration } from '../azure_data_explorer/index.js';
|
import { HomeAssistantAzureDataExplorerIntegration } from '../azure_data_explorer/index.js';
|
||||||
import { HomeAssistantAzureDevopsIntegration } from '../azure_devops/index.js';
|
import { HomeAssistantAzureDevopsIntegration } from '../azure_devops/index.js';
|
||||||
import { HomeAssistantAzureEventHubIntegration } from '../azure_event_hub/index.js';
|
import { HomeAssistantAzureEventHubIntegration } from '../azure_event_hub/index.js';
|
||||||
@@ -148,7 +147,6 @@ import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js';
|
|||||||
import { HomeAssistantBoschShcIntegration } from '../bosch_shc/index.js';
|
import { HomeAssistantBoschShcIntegration } from '../bosch_shc/index.js';
|
||||||
import { HomeAssistantBrandsIntegration } from '../brands/index.js';
|
import { HomeAssistantBrandsIntegration } from '../brands/index.js';
|
||||||
import { HomeAssistantBrandtIntegration } from '../brandt/index.js';
|
import { HomeAssistantBrandtIntegration } from '../brandt/index.js';
|
||||||
import { HomeAssistantBraviatvIntegration } from '../braviatv/index.js';
|
|
||||||
import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js';
|
import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js';
|
||||||
import { HomeAssistantBringIntegration } from '../bring/index.js';
|
import { HomeAssistantBringIntegration } from '../bring/index.js';
|
||||||
import { HomeAssistantBroadlinkIntegration } from '../broadlink/index.js';
|
import { HomeAssistantBroadlinkIntegration } from '../broadlink/index.js';
|
||||||
@@ -258,7 +256,6 @@ import { HomeAssistantDiscogsIntegration } from '../discogs/index.js';
|
|||||||
import { HomeAssistantDiscordIntegration } from '../discord/index.js';
|
import { HomeAssistantDiscordIntegration } from '../discord/index.js';
|
||||||
import { HomeAssistantDiscovergyIntegration } from '../discovergy/index.js';
|
import { HomeAssistantDiscovergyIntegration } from '../discovergy/index.js';
|
||||||
import { HomeAssistantDlinkIntegration } from '../dlink/index.js';
|
import { HomeAssistantDlinkIntegration } from '../dlink/index.js';
|
||||||
import { HomeAssistantDlnaDmrIntegration } from '../dlna_dmr/index.js';
|
|
||||||
import { HomeAssistantDlnaDmsIntegration } from '../dlna_dms/index.js';
|
import { HomeAssistantDlnaDmsIntegration } from '../dlna_dms/index.js';
|
||||||
import { HomeAssistantDnsipIntegration } from '../dnsip/index.js';
|
import { HomeAssistantDnsipIntegration } from '../dnsip/index.js';
|
||||||
import { HomeAssistantDoodsIntegration } from '../doods/index.js';
|
import { HomeAssistantDoodsIntegration } from '../doods/index.js';
|
||||||
@@ -609,7 +606,6 @@ import { HomeAssistantItachIntegration } from '../itach/index.js';
|
|||||||
import { HomeAssistantItunesIntegration } from '../itunes/index.js';
|
import { HomeAssistantItunesIntegration } from '../itunes/index.js';
|
||||||
import { HomeAssistantIturanIntegration } from '../ituran/index.js';
|
import { HomeAssistantIturanIntegration } from '../ituran/index.js';
|
||||||
import { HomeAssistantIzoneIntegration } from '../izone/index.js';
|
import { HomeAssistantIzoneIntegration } from '../izone/index.js';
|
||||||
import { HomeAssistantJellyfinIntegration } from '../jellyfin/index.js';
|
|
||||||
import { HomeAssistantJewishCalendarIntegration } from '../jewish_calendar/index.js';
|
import { HomeAssistantJewishCalendarIntegration } from '../jewish_calendar/index.js';
|
||||||
import { HomeAssistantJoaoappsJoinIntegration } from '../joaoapps_join/index.js';
|
import { HomeAssistantJoaoappsJoinIntegration } from '../joaoapps_join/index.js';
|
||||||
import { HomeAssistantJuicenetIntegration } from '../juicenet/index.js';
|
import { HomeAssistantJuicenetIntegration } from '../juicenet/index.js';
|
||||||
@@ -777,7 +773,6 @@ import { HomeAssistantMotionBlindsIntegration } from '../motion_blinds/index.js'
|
|||||||
import { HomeAssistantMotionblindsBleIntegration } from '../motionblinds_ble/index.js';
|
import { HomeAssistantMotionblindsBleIntegration } from '../motionblinds_ble/index.js';
|
||||||
import { HomeAssistantMotioneyeIntegration } from '../motioneye/index.js';
|
import { HomeAssistantMotioneyeIntegration } from '../motioneye/index.js';
|
||||||
import { HomeAssistantMotionmountIntegration } from '../motionmount/index.js';
|
import { HomeAssistantMotionmountIntegration } from '../motionmount/index.js';
|
||||||
import { HomeAssistantMpdIntegration } from '../mpd/index.js';
|
|
||||||
import { HomeAssistantMqttEventstreamIntegration } from '../mqtt_eventstream/index.js';
|
import { HomeAssistantMqttEventstreamIntegration } from '../mqtt_eventstream/index.js';
|
||||||
import { HomeAssistantMqttJsonIntegration } from '../mqtt_json/index.js';
|
import { HomeAssistantMqttJsonIntegration } from '../mqtt_json/index.js';
|
||||||
import { HomeAssistantMqttRoomIntegration } from '../mqtt_room/index.js';
|
import { HomeAssistantMqttRoomIntegration } from '../mqtt_room/index.js';
|
||||||
@@ -872,7 +867,6 @@ import { HomeAssistantOnedriveIntegration } from '../onedrive/index.js';
|
|||||||
import { HomeAssistantOnedriveForBusinessIntegration } from '../onedrive_for_business/index.js';
|
import { HomeAssistantOnedriveForBusinessIntegration } from '../onedrive_for_business/index.js';
|
||||||
import { HomeAssistantOnewireIntegration } from '../onewire/index.js';
|
import { HomeAssistantOnewireIntegration } from '../onewire/index.js';
|
||||||
import { HomeAssistantOnkyoIntegration } from '../onkyo/index.js';
|
import { HomeAssistantOnkyoIntegration } from '../onkyo/index.js';
|
||||||
import { HomeAssistantOnvifIntegration } from '../onvif/index.js';
|
|
||||||
import { HomeAssistantOpenMeteoIntegration } from '../open_meteo/index.js';
|
import { HomeAssistantOpenMeteoIntegration } from '../open_meteo/index.js';
|
||||||
import { HomeAssistantOpenRouterIntegration } from '../open_router/index.js';
|
import { HomeAssistantOpenRouterIntegration } from '../open_router/index.js';
|
||||||
import { HomeAssistantOpenaiConversationIntegration } from '../openai_conversation/index.js';
|
import { HomeAssistantOpenaiConversationIntegration } from '../openai_conversation/index.js';
|
||||||
@@ -938,7 +932,6 @@ import { HomeAssistantPjlinkIntegration } from '../pjlink/index.js';
|
|||||||
import { HomeAssistantPlaatoIntegration } from '../plaato/index.js';
|
import { HomeAssistantPlaatoIntegration } from '../plaato/index.js';
|
||||||
import { HomeAssistantPlantIntegration } from '../plant/index.js';
|
import { HomeAssistantPlantIntegration } from '../plant/index.js';
|
||||||
import { HomeAssistantPlaystationNetworkIntegration } from '../playstation_network/index.js';
|
import { HomeAssistantPlaystationNetworkIntegration } from '../playstation_network/index.js';
|
||||||
import { HomeAssistantPlexIntegration } from '../plex/index.js';
|
|
||||||
import { HomeAssistantPlugwiseIntegration } from '../plugwise/index.js';
|
import { HomeAssistantPlugwiseIntegration } from '../plugwise/index.js';
|
||||||
import { HomeAssistantPlumLightpadIntegration } from '../plum_lightpad/index.js';
|
import { HomeAssistantPlumLightpadIntegration } from '../plum_lightpad/index.js';
|
||||||
import { HomeAssistantPocketcastsIntegration } from '../pocketcasts/index.js';
|
import { HomeAssistantPocketcastsIntegration } from '../pocketcasts/index.js';
|
||||||
@@ -998,7 +991,6 @@ import { HomeAssistantRadarrIntegration } from '../radarr/index.js';
|
|||||||
import { HomeAssistantRadioBrowserIntegration } from '../radio_browser/index.js';
|
import { HomeAssistantRadioBrowserIntegration } from '../radio_browser/index.js';
|
||||||
import { HomeAssistantRadioFrequencyIntegration } from '../radio_frequency/index.js';
|
import { HomeAssistantRadioFrequencyIntegration } from '../radio_frequency/index.js';
|
||||||
import { HomeAssistantRadiothermIntegration } from '../radiotherm/index.js';
|
import { HomeAssistantRadiothermIntegration } from '../radiotherm/index.js';
|
||||||
import { HomeAssistantRainbirdIntegration } from '../rainbird/index.js';
|
|
||||||
import { HomeAssistantRaincloudIntegration } from '../raincloud/index.js';
|
import { HomeAssistantRaincloudIntegration } from '../raincloud/index.js';
|
||||||
import { HomeAssistantRainforestEagleIntegration } from '../rainforest_eagle/index.js';
|
import { HomeAssistantRainforestEagleIntegration } from '../rainforest_eagle/index.js';
|
||||||
import { HomeAssistantRainforestRavenIntegration } from '../rainforest_raven/index.js';
|
import { HomeAssistantRainforestRavenIntegration } from '../rainforest_raven/index.js';
|
||||||
@@ -1136,7 +1128,6 @@ import { HomeAssistantSmhiIntegration } from '../smhi/index.js';
|
|||||||
import { HomeAssistantSmlightIntegration } from '../smlight/index.js';
|
import { HomeAssistantSmlightIntegration } from '../smlight/index.js';
|
||||||
import { HomeAssistantSmtpIntegration } from '../smtp/index.js';
|
import { HomeAssistantSmtpIntegration } from '../smtp/index.js';
|
||||||
import { HomeAssistantSmudIntegration } from '../smud/index.js';
|
import { HomeAssistantSmudIntegration } from '../smud/index.js';
|
||||||
import { HomeAssistantSnapcastIntegration } from '../snapcast/index.js';
|
|
||||||
import { HomeAssistantSnmpIntegration } from '../snmp/index.js';
|
import { HomeAssistantSnmpIntegration } from '../snmp/index.js';
|
||||||
import { HomeAssistantSnooIntegration } from '../snoo/index.js';
|
import { HomeAssistantSnooIntegration } from '../snoo/index.js';
|
||||||
import { HomeAssistantSnoozIntegration } from '../snooz/index.js';
|
import { HomeAssistantSnoozIntegration } from '../snooz/index.js';
|
||||||
@@ -1351,7 +1342,6 @@ import { HomeAssistantVodafoneStationIntegration } from '../vodafone_station/ind
|
|||||||
import { HomeAssistantVoicerssIntegration } from '../voicerss/index.js';
|
import { HomeAssistantVoicerssIntegration } from '../voicerss/index.js';
|
||||||
import { HomeAssistantVoipIntegration } from '../voip/index.js';
|
import { HomeAssistantVoipIntegration } from '../voip/index.js';
|
||||||
import { HomeAssistantVolkszaehlerIntegration } from '../volkszaehler/index.js';
|
import { HomeAssistantVolkszaehlerIntegration } from '../volkszaehler/index.js';
|
||||||
import { HomeAssistantVolumioIntegration } from '../volumio/index.js';
|
|
||||||
import { HomeAssistantVolvoIntegration } from '../volvo/index.js';
|
import { HomeAssistantVolvoIntegration } from '../volvo/index.js';
|
||||||
import { HomeAssistantVolvooncallIntegration } from '../volvooncall/index.js';
|
import { HomeAssistantVolvooncallIntegration } from '../volvooncall/index.js';
|
||||||
import { HomeAssistantW800rf32Integration } from '../w800rf32/index.js';
|
import { HomeAssistantW800rf32Integration } from '../w800rf32/index.js';
|
||||||
@@ -1410,7 +1400,6 @@ import { HomeAssistantYaleIntegration } from '../yale/index.js';
|
|||||||
import { HomeAssistantYaleSmartAlarmIntegration } from '../yale_smart_alarm/index.js';
|
import { HomeAssistantYaleSmartAlarmIntegration } from '../yale_smart_alarm/index.js';
|
||||||
import { HomeAssistantYalexsBleIntegration } from '../yalexs_ble/index.js';
|
import { HomeAssistantYalexsBleIntegration } from '../yalexs_ble/index.js';
|
||||||
import { HomeAssistantYamahaIntegration } from '../yamaha/index.js';
|
import { HomeAssistantYamahaIntegration } from '../yamaha/index.js';
|
||||||
import { HomeAssistantYamahaMusiccastIntegration } from '../yamaha_musiccast/index.js';
|
|
||||||
import { HomeAssistantYandexTransportIntegration } from '../yandex_transport/index.js';
|
import { HomeAssistantYandexTransportIntegration } from '../yandex_transport/index.js';
|
||||||
import { HomeAssistantYandexttsIntegration } from '../yandextts/index.js';
|
import { HomeAssistantYandexttsIntegration } from '../yandextts/index.js';
|
||||||
import { HomeAssistantYardianIntegration } from '../yardian/index.js';
|
import { HomeAssistantYardianIntegration } from '../yardian/index.js';
|
||||||
@@ -1543,7 +1532,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAvionIntegration())
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwairIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwairIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwsS3Integration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwsS3Integration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAxisIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureDataExplorerIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureDataExplorerIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureDevopsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureDevopsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureEventHubIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureEventHubIntegration());
|
||||||
@@ -1585,7 +1573,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegrati
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschShcIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschShcIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBraviatvIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBringIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBringIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBroadlinkIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBroadlinkIntegration());
|
||||||
@@ -1695,7 +1682,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscogsIntegration(
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscordIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscordIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscovergyIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscovergyIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlinkIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlinkIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlnaDmrIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlnaDmsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlnaDmsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDnsipIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDnsipIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoodsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoodsIntegration());
|
||||||
@@ -2046,7 +2032,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantItachIntegration())
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantItunesIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantItunesIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIturanIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIturanIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIzoneIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIzoneIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJellyfinIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJewishCalendarIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJewishCalendarIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJoaoappsJoinIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJoaoappsJoinIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJuicenetIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJuicenetIntegration());
|
||||||
@@ -2214,7 +2199,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionBlindsIntegra
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionblindsBleIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionblindsBleIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotioneyeIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotioneyeIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionmountIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionmountIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMpdIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttEventstreamIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttEventstreamIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttJsonIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttJsonIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttRoomIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttRoomIntegration());
|
||||||
@@ -2309,7 +2293,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnedriveIntegration
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnedriveForBusinessIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnedriveForBusinessIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnewireIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnewireIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnkyoIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnkyoIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnvifIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenMeteoIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenMeteoIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenRouterIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenRouterIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenaiConversationIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenaiConversationIntegration());
|
||||||
@@ -2375,7 +2358,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantPjlinkIntegration()
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlaatoIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlaatoIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlantIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlantIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlaystationNetworkIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlaystationNetworkIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlexIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlugwiseIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlugwiseIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlumLightpadIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlumLightpadIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPocketcastsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPocketcastsIntegration());
|
||||||
@@ -2435,7 +2417,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadarrIntegration()
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadioBrowserIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadioBrowserIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadioFrequencyIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadioFrequencyIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadiothermIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadiothermIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRainbirdIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRaincloudIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRaincloudIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRainforestEagleIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRainforestEagleIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRainforestRavenIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRainforestRavenIntegration());
|
||||||
@@ -2573,7 +2554,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmhiIntegration());
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmlightIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmlightIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmtpIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmtpIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmudIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmudIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnapcastIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnmpIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnmpIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnooIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnooIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnoozIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnoozIntegration());
|
||||||
@@ -2788,7 +2768,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantVodafoneStationInte
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVoicerssIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVoicerssIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVoipIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVoipIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolkszaehlerIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolkszaehlerIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolumioIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolvoIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolvoIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolvooncallIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolvooncallIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantW800rf32Integration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantW800rf32Integration());
|
||||||
@@ -2847,7 +2826,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantYaleIntegration());
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYaleSmartAlarmIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYaleSmartAlarmIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYalexsBleIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYalexsBleIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYamahaIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYamahaIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYamahaMusiccastIntegration());
|
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexTransportIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexTransportIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexttsIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexttsIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYardianIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYardianIntegration());
|
||||||
@@ -2874,28 +2852,39 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
|||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||||
|
|
||||||
export const generatedHomeAssistantPortCount = 1435;
|
export const generatedHomeAssistantPortCount = 1424;
|
||||||
export const handwrittenHomeAssistantPortDomains = [
|
export const handwrittenHomeAssistantPortDomains = [
|
||||||
"androidtv",
|
"androidtv",
|
||||||
|
"axis",
|
||||||
|
"braviatv",
|
||||||
"cast",
|
"cast",
|
||||||
"deconz",
|
"deconz",
|
||||||
"denonavr",
|
"denonavr",
|
||||||
|
"dlna_dmr",
|
||||||
"esphome",
|
"esphome",
|
||||||
"homekit_controller",
|
"homekit_controller",
|
||||||
"hue",
|
"hue",
|
||||||
|
"jellyfin",
|
||||||
"kodi",
|
"kodi",
|
||||||
"matter",
|
"matter",
|
||||||
|
"mpd",
|
||||||
"mqtt",
|
"mqtt",
|
||||||
"nanoleaf",
|
"nanoleaf",
|
||||||
|
"onvif",
|
||||||
|
"plex",
|
||||||
|
"rainbird",
|
||||||
"roku",
|
"roku",
|
||||||
"samsungtv",
|
"samsungtv",
|
||||||
"shelly",
|
"shelly",
|
||||||
|
"snapcast",
|
||||||
"sonos",
|
"sonos",
|
||||||
"tplink",
|
"tplink",
|
||||||
"tradfri",
|
"tradfri",
|
||||||
"unifi",
|
"unifi",
|
||||||
|
"volumio",
|
||||||
"wiz",
|
"wiz",
|
||||||
"xiaomi_miio",
|
"xiaomi_miio",
|
||||||
|
"yamaha_musiccast",
|
||||||
"yeelight",
|
"yeelight",
|
||||||
"zha",
|
"zha",
|
||||||
"zwave_js"
|
"zwave_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 './jellyfin.classes.client.js';
|
||||||
|
export * from './jellyfin.classes.configflow.js';
|
||||||
export * from './jellyfin.classes.integration.js';
|
export * from './jellyfin.classes.integration.js';
|
||||||
|
export * from './jellyfin.discovery.js';
|
||||||
|
export * from './jellyfin.mapper.js';
|
||||||
export * from './jellyfin.types.js';
|
export * from './jellyfin.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import type {
|
||||||
|
IJellyfinAuthenticationResult,
|
||||||
|
IJellyfinConfig,
|
||||||
|
IJellyfinGeneralCommandRequest,
|
||||||
|
IJellyfinServerInfo,
|
||||||
|
IJellyfinSession,
|
||||||
|
IJellyfinSnapshot,
|
||||||
|
TJellyfinGeneralCommand,
|
||||||
|
TJellyfinPlayCommand,
|
||||||
|
TJellyfinPlaystateCommand,
|
||||||
|
} from './jellyfin.types.js';
|
||||||
|
|
||||||
|
const defaultHttpPort = 8096;
|
||||||
|
const defaultHttpsPort = 8920;
|
||||||
|
const defaultTimeoutMs = 5000;
|
||||||
|
const clientName = 'smarthome.exchange';
|
||||||
|
const clientVersion = '0.1.0';
|
||||||
|
|
||||||
|
export class JellyfinUnsupportedLivePushError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Jellyfin live WebSocket push is not implemented in this native integration; use the polling snapshot runtime.');
|
||||||
|
this.name = 'JellyfinUnsupportedLivePushError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JellyfinClient {
|
||||||
|
private authenticatedAccessToken: string | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly config: IJellyfinConfig) {}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IJellyfinSnapshot> {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasBaseUrl()) {
|
||||||
|
return this.normalizeSnapshot({
|
||||||
|
server: this.serverInfoFromConfig(),
|
||||||
|
sessions: this.config.sessions || [],
|
||||||
|
users: this.config.users,
|
||||||
|
online: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [server, sessions] = await Promise.all([
|
||||||
|
this.getSystemInfo(),
|
||||||
|
this.getSessions(),
|
||||||
|
]);
|
||||||
|
return this.normalizeSnapshot({
|
||||||
|
server,
|
||||||
|
sessions,
|
||||||
|
users: this.config.users,
|
||||||
|
online: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSystemInfo(): Promise<IJellyfinServerInfo> {
|
||||||
|
if (this.config.server) {
|
||||||
|
return this.config.server;
|
||||||
|
}
|
||||||
|
if (!this.hasBaseUrl()) {
|
||||||
|
return this.serverInfoFromConfig();
|
||||||
|
}
|
||||||
|
if (this.hasAuthConfig()) {
|
||||||
|
return this.fetchJson<IJellyfinServerInfo>('/System/Info', { token: await this.ensureAccessToken() });
|
||||||
|
}
|
||||||
|
return this.fetchJson<IJellyfinServerInfo>('/System/Info/Public');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSessions(): Promise<IJellyfinSession[]> {
|
||||||
|
if (!this.hasAuthConfig()) {
|
||||||
|
return this.config.sessions || [];
|
||||||
|
}
|
||||||
|
const query: Record<string, string | number> = {};
|
||||||
|
if (this.config.userId) {
|
||||||
|
query.controllableByUserId = this.config.userId;
|
||||||
|
}
|
||||||
|
if (typeof this.config.activeWithinSeconds === 'number') {
|
||||||
|
query.activeWithinSeconds = this.config.activeWithinSeconds;
|
||||||
|
}
|
||||||
|
return this.fetchJson<IJellyfinSession[]>('/Sessions', { query, token: await this.ensureAccessToken() });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async authenticateByName(usernameArg: string, passwordArg: string): Promise<IJellyfinAuthenticationResult> {
|
||||||
|
const result = await this.fetchJson<IJellyfinAuthenticationResult>('/Users/AuthenticateByName', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
Username: usernameArg,
|
||||||
|
Pw: passwordArg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.AccessToken) {
|
||||||
|
this.authenticatedAccessToken = result.AccessToken;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pause(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendPlaystateCommand(sessionIdArg, 'Pause');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async play(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendPlaystateCommand(sessionIdArg, 'Unpause');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async playPause(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendPlaystateCommand(sessionIdArg, 'PlayPause');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendPlaystateCommand(sessionIdArg, 'Stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async nextTrack(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendPlaystateCommand(sessionIdArg, 'NextTrack');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async previousTrack(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendPlaystateCommand(sessionIdArg, 'PreviousTrack');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async seek(sessionIdArg: string, positionSecondsArg: number): Promise<void> {
|
||||||
|
await this.sendPlaystateCommand(sessionIdArg, 'Seek', {
|
||||||
|
seekPositionTicks: Math.max(0, Math.round(positionSecondsArg * 10000000)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setVolumeLevel(sessionIdArg: string, volumeLevelArg: number): Promise<void> {
|
||||||
|
const volume = Math.max(0, Math.min(100, Math.round(volumeLevelArg * 100)));
|
||||||
|
await this.sendFullGeneralCommand(sessionIdArg, 'SetVolume', { Volume: String(volume) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async volumeUp(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendGeneralCommand(sessionIdArg, 'VolumeUp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async volumeDown(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendGeneralCommand(sessionIdArg, 'VolumeDown');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async mute(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendGeneralCommand(sessionIdArg, 'Mute');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unmute(sessionIdArg: string): Promise<void> {
|
||||||
|
await this.sendGeneralCommand(sessionIdArg, 'Unmute');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async playMedia(sessionIdArg: string, itemIdsArg: string[], commandArg: TJellyfinPlayCommand = 'PlayNow'): Promise<void> {
|
||||||
|
await this.fetchNoContent(`/Sessions/${encodeURIComponent(sessionIdArg)}/Playing`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: await this.ensureAccessToken(),
|
||||||
|
query: {
|
||||||
|
playCommand: commandArg,
|
||||||
|
itemIds: itemIdsArg.join(','),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendGeneralCommand(sessionIdArg: string, commandArg: TJellyfinGeneralCommand): Promise<void> {
|
||||||
|
await this.fetchNoContent(`/Sessions/${encodeURIComponent(sessionIdArg)}/Command/${encodeURIComponent(commandArg)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: await this.ensureAccessToken(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendFullGeneralCommand(sessionIdArg: string, commandArg: TJellyfinGeneralCommand, argumentsArg?: Record<string, string>): Promise<void> {
|
||||||
|
const body: IJellyfinGeneralCommandRequest = {
|
||||||
|
Name: commandArg,
|
||||||
|
ControllingUserId: this.config.userId,
|
||||||
|
Arguments: argumentsArg,
|
||||||
|
};
|
||||||
|
await this.fetchNoContent(`/Sessions/${encodeURIComponent(sessionIdArg)}/Command`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: await this.ensureAccessToken(),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribeToLiveEvents(): Promise<never> {
|
||||||
|
throw new JellyfinUnsupportedLivePushError();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private async sendPlaystateCommand(sessionIdArg: string, commandArg: TJellyfinPlaystateCommand, optionsArg?: { seekPositionTicks?: number }): Promise<void> {
|
||||||
|
await this.fetchNoContent(`/Sessions/${encodeURIComponent(sessionIdArg)}/Playing/${encodeURIComponent(commandArg)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: await this.ensureAccessToken(),
|
||||||
|
query: optionsArg?.seekPositionTicks !== undefined ? { seekPositionTicks: optionsArg.seekPositionTicks } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureAccessToken(): Promise<string> {
|
||||||
|
const token = this.accessToken();
|
||||||
|
if (token) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
if (this.config.username && this.config.password !== undefined) {
|
||||||
|
const result = await this.authenticateByName(this.config.username, this.config.password);
|
||||||
|
if (result.AccessToken) {
|
||||||
|
return result.AccessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Jellyfin REST sessions and control require accessToken/apiToken or username/password authentication.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasAuthConfig(): boolean {
|
||||||
|
return Boolean(this.accessToken() || (this.config.username && this.config.password !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
private accessToken(): string | undefined {
|
||||||
|
return this.config.accessToken || this.config.apiToken || this.authenticatedAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson<T>(pathArg: string, optionsArg: IJellyfinRequestOptions = {}): Promise<T> {
|
||||||
|
const response = await this.fetchResponse(pathArg, optionsArg);
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Jellyfin request ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) as T : undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchNoContent(pathArg: string, optionsArg: IJellyfinRequestOptions): Promise<void> {
|
||||||
|
const response = await this.fetchResponse(pathArg, optionsArg);
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Jellyfin request ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchResponse(pathArg: string, optionsArg: IJellyfinRequestOptions): Promise<Response> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
|
||||||
|
try {
|
||||||
|
return await globalThis.fetch(this.requestUrl(pathArg, optionsArg.query), {
|
||||||
|
method: optionsArg.method || 'GET',
|
||||||
|
headers: this.headers(optionsArg.token, optionsArg.body !== undefined),
|
||||||
|
body: optionsArg.body !== undefined ? JSON.stringify(optionsArg.body) : undefined,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestUrl(pathArg: string, queryArg?: Record<string, string | number | boolean | undefined>): string {
|
||||||
|
const url = new URL(pathArg, `${this.baseUrl()}/`);
|
||||||
|
for (const [key, value] of Object.entries(queryArg || {})) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(tokenArg: string | undefined, hasBodyArg: boolean): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: this.authorizationHeader(tokenArg),
|
||||||
|
};
|
||||||
|
if (hasBodyArg) {
|
||||||
|
headers['content-type'] = 'application/json';
|
||||||
|
}
|
||||||
|
if (tokenArg) {
|
||||||
|
headers['x-emby-token'] = tokenArg;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private authorizationHeader(tokenArg: string | undefined): string {
|
||||||
|
const parts: Record<string, string> = {
|
||||||
|
Client: clientName,
|
||||||
|
Device: this.config.deviceName || 'smarthome.exchange',
|
||||||
|
DeviceId: this.clientDeviceId(),
|
||||||
|
Version: clientVersion,
|
||||||
|
};
|
||||||
|
if (tokenArg) {
|
||||||
|
parts.Token = tokenArg;
|
||||||
|
}
|
||||||
|
return `MediaBrowser ${Object.entries(parts).map(([key, value]) => `${key}=\"${value.replace(/\\/g, '\\\\').replace(/\"/g, '\\\"')}\"`).join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseUrl(): string {
|
||||||
|
if (this.config.url) {
|
||||||
|
return this.config.url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new Error('Jellyfin host or url is required for REST calls.');
|
||||||
|
}
|
||||||
|
const ssl = this.config.ssl === true;
|
||||||
|
const protocol = ssl ? 'https' : 'http';
|
||||||
|
const port = this.config.port || (ssl ? defaultHttpsPort : defaultHttpPort);
|
||||||
|
return `${protocol}://${this.config.host}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasBaseUrl(): boolean {
|
||||||
|
return Boolean(this.config.url || this.config.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clientDeviceId(): string {
|
||||||
|
if (this.config.clientDeviceId) {
|
||||||
|
return this.config.clientDeviceId;
|
||||||
|
}
|
||||||
|
if (this.config.uniqueId) {
|
||||||
|
return this.config.uniqueId;
|
||||||
|
}
|
||||||
|
return 'smarthome-exchange-jellyfin';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSnapshot(snapshotArg: IJellyfinSnapshot): IJellyfinSnapshot {
|
||||||
|
const server = {
|
||||||
|
...this.serverInfoFromConfig(),
|
||||||
|
...snapshotArg.server,
|
||||||
|
};
|
||||||
|
const ownDeviceId = this.config.clientDeviceId;
|
||||||
|
const sessions = (snapshotArg.sessions || []).filter((sessionArg) => {
|
||||||
|
if (ownDeviceId && sessionArg.DeviceId === ownDeviceId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return sessionArg.Client !== clientName;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...snapshotArg,
|
||||||
|
server,
|
||||||
|
sessions,
|
||||||
|
online: snapshotArg.online,
|
||||||
|
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serverInfoFromConfig(): IJellyfinServerInfo {
|
||||||
|
return {
|
||||||
|
...this.config.server,
|
||||||
|
Id: this.config.server?.Id || this.config.server?.ServerId || this.config.uniqueId || this.config.host || this.config.url || 'jellyfin',
|
||||||
|
Name: this.config.server?.Name || this.config.server?.ServerName || this.config.name || this.config.host || 'Jellyfin',
|
||||||
|
ProductName: this.config.server?.ProductName || 'Jellyfin Server',
|
||||||
|
LocalAddress: this.config.server?.LocalAddress || this.config.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneSnapshot(snapshotArg: IJellyfinSnapshot): IJellyfinSnapshot {
|
||||||
|
return JSON.parse(JSON.stringify(snapshotArg)) as IJellyfinSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IJellyfinRequestOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'DELETE';
|
||||||
|
query?: Record<string, string | number | boolean | undefined>;
|
||||||
|
token?: string;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IJellyfinConfig } from './jellyfin.types.js';
|
||||||
|
|
||||||
|
const defaultHttpPort = 8096;
|
||||||
|
const defaultHttpsPort = 8920;
|
||||||
|
const defaultTimeoutMs = 5000;
|
||||||
|
|
||||||
|
export class JellyfinConfigFlow implements IConfigFlow<IJellyfinConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IJellyfinConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Jellyfin',
|
||||||
|
description: 'Configure the local Jellyfin server endpoint. Use an access token for session polling and client control.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'url', label: 'Server URL', type: 'text', required: true },
|
||||||
|
{ name: 'accessToken', label: 'Access token or API key', type: 'password' },
|
||||||
|
{ name: 'username', label: 'Username', type: 'text' },
|
||||||
|
{ name: 'password', label: 'Password', type: 'password' },
|
||||||
|
{ name: 'name', label: 'Name', type: 'text' },
|
||||||
|
{ name: 'activeWithinSeconds', label: 'Session activity window', type: 'number' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => ({
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Jellyfin configured',
|
||||||
|
config: {
|
||||||
|
url: this.stringValue(valuesArg.url) || this.urlFromCandidate(candidateArg),
|
||||||
|
accessToken: this.stringValue(valuesArg.accessToken),
|
||||||
|
username: this.stringValue(valuesArg.username),
|
||||||
|
password: this.stringValue(valuesArg.password),
|
||||||
|
name: this.stringValue(valuesArg.name) || candidateArg.name,
|
||||||
|
uniqueId: candidateArg.id,
|
||||||
|
clientDeviceId: `smarthome-exchange-${candidateArg.id || candidateArg.host || 'jellyfin'}`,
|
||||||
|
activeWithinSeconds: this.numberValue(valuesArg.activeWithinSeconds),
|
||||||
|
timeoutMs: defaultTimeoutMs,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private urlFromCandidate(candidateArg: IDiscoveryCandidate): string {
|
||||||
|
const explicitUrl = candidateArg.metadata?.url;
|
||||||
|
if (typeof explicitUrl === 'string' && explicitUrl) {
|
||||||
|
return explicitUrl;
|
||||||
|
}
|
||||||
|
const ssl = candidateArg.metadata?.ssl === true;
|
||||||
|
const protocol = ssl ? 'https' : 'http';
|
||||||
|
const port = candidateArg.port || (ssl ? defaultHttpsPort : defaultHttpPort);
|
||||||
|
return candidateArg.host ? `${protocol}://${candidateArg.host}:${port}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,262 @@
|
|||||||
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 { JellyfinClient, JellyfinUnsupportedLivePushError } from './jellyfin.classes.client.js';
|
||||||
|
import { JellyfinConfigFlow } from './jellyfin.classes.configflow.js';
|
||||||
|
import { createJellyfinDiscoveryDescriptor } from './jellyfin.discovery.js';
|
||||||
|
import { JellyfinMapper } from './jellyfin.mapper.js';
|
||||||
|
import type { IJellyfinConfig, IJellyfinSession, IJellyfinSnapshot, TJellyfinPlayCommand } from './jellyfin.types.js';
|
||||||
|
|
||||||
export class HomeAssistantJellyfinIntegration extends DescriptorOnlyIntegration {
|
export class JellyfinIntegration extends BaseIntegration<IJellyfinConfig> {
|
||||||
constructor() {
|
public readonly domain = 'jellyfin';
|
||||||
super({
|
public readonly displayName = 'Jellyfin';
|
||||||
domain: "jellyfin",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Jellyfin",
|
public readonly discoveryDescriptor = createJellyfinDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new JellyfinConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/jellyfin",
|
upstreamPath: 'homeassistant/components/jellyfin',
|
||||||
"upstreamDomain": "jellyfin",
|
upstreamDomain: 'jellyfin',
|
||||||
"integrationType": "service",
|
integrationType: 'service',
|
||||||
"iotClass": "local_polling",
|
iotClass: 'local_polling',
|
||||||
"requirements": [
|
requirements: ['jellyfin-apiclient-python==1.11.0'],
|
||||||
"jellyfin-apiclient-python==1.11.0"
|
dependencies: [],
|
||||||
],
|
afterDependencies: [],
|
||||||
"dependencies": [],
|
codeowners: ['@RunC0deRun', '@ctalkington'],
|
||||||
"afterDependencies": [],
|
configFlow: true,
|
||||||
"codeowners": [
|
documentation: 'https://www.home-assistant.io/integrations/jellyfin',
|
||||||
"@RunC0deRun",
|
nativeRuntime: {
|
||||||
"@ctalkington"
|
polling: true,
|
||||||
]
|
restSessionsWithToken: true,
|
||||||
|
livePush: false,
|
||||||
|
unsupportedLivePushReason: 'Jellyfin WebSocket session push is not implemented in this runtime.',
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
public async setup(configArg: IJellyfinConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
|
void contextArg;
|
||||||
|
return new JellyfinRuntime(new JellyfinClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantJellyfinIntegration extends JellyfinIntegration {}
|
||||||
|
|
||||||
|
class JellyfinRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'jellyfin';
|
||||||
|
|
||||||
|
constructor(private readonly client: JellyfinClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return JellyfinMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return JellyfinMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
void handlerArg;
|
||||||
|
throw new JellyfinUnsupportedLivePushError();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
try {
|
||||||
|
if (requestArg.domain === 'media_player') {
|
||||||
|
return await this.callMediaPlayerService(requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'remote') {
|
||||||
|
return await this.callRemoteService(requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'jellyfin') {
|
||||||
|
return await this.callJellyfinService(requestArg);
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Jellyfin 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 session = await this.targetSession(requestArg);
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Jellyfin media_player service requires a target session when multiple sessions are active.' };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
|
||||||
|
await this.client.play(session.Id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
|
||||||
|
await this.client.pause(session.Id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
|
||||||
|
await this.client.playPause(session.Id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
|
||||||
|
await this.client.stop(session.Id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'next' || requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
|
||||||
|
await this.client.nextTrack(session.Id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'previous' || requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
|
||||||
|
await this.client.previousTrack(session.Id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'seek' || requestArg.service === 'media_seek') {
|
||||||
|
const position = requestArg.data?.seek_position ?? requestArg.data?.position;
|
||||||
|
if (typeof position !== 'number') {
|
||||||
|
return { success: false, error: 'Jellyfin seek requires data.seek_position.' };
|
||||||
|
}
|
||||||
|
await this.client.seek(session.Id, position);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume' || requestArg.service === 'volume_set') {
|
||||||
|
const level = requestArg.data?.volume_level ?? requestArg.data?.level ?? requestArg.data?.volume;
|
||||||
|
if (typeof level !== 'number') {
|
||||||
|
return { success: false, error: 'Jellyfin volume_set requires data.volume_level.' };
|
||||||
|
}
|
||||||
|
await this.client.setVolumeLevel(session.Id, level);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_up') {
|
||||||
|
await this.client.volumeUp(session.Id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_down') {
|
||||||
|
await this.client.volumeDown(session.Id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_mute') {
|
||||||
|
const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted;
|
||||||
|
if (typeof muted !== 'boolean') {
|
||||||
|
return { success: false, error: 'Jellyfin volume_mute requires data.is_volume_muted.' };
|
||||||
|
}
|
||||||
|
if (muted) {
|
||||||
|
await this.client.mute(session.Id);
|
||||||
|
} else {
|
||||||
|
await this.client.unmute(session.Id);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play_media') {
|
||||||
|
return await this.playMedia(session, requestArg, this.playCommandFromRequest(requestArg));
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Jellyfin media_player service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (requestArg.service !== 'send_command') {
|
||||||
|
return { success: false, error: `Unsupported Jellyfin remote service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
const session = await this.targetSession(requestArg);
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Jellyfin remote.send_command requires a target session when multiple sessions are active.' };
|
||||||
|
}
|
||||||
|
if (session.SupportsRemoteControl === false) {
|
||||||
|
return { success: false, error: 'Target Jellyfin session does not support remote control.' };
|
||||||
|
}
|
||||||
|
const commands = this.commandsFromRequest(requestArg);
|
||||||
|
if (!commands.length) {
|
||||||
|
return { success: false, error: 'Jellyfin remote.send_command requires data.command.' };
|
||||||
|
}
|
||||||
|
const repeats = this.numberValue(requestArg.data?.num_repeats) || 1;
|
||||||
|
const delayMs = (this.numberValue(requestArg.data?.delay_secs) || 0) * 1000;
|
||||||
|
for (let index = 0; index < repeats; index++) {
|
||||||
|
for (const command of commands) {
|
||||||
|
await this.client.sendGeneralCommand(session.Id, command);
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise((resolveArg) => setTimeout(resolveArg, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callJellyfinService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const session = await this.targetSession(requestArg);
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Jellyfin service requires a target session when multiple sessions are active.' };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play_media_shuffle') {
|
||||||
|
return await this.playMedia(session, requestArg, 'PlayShuffle');
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'send_command') {
|
||||||
|
return await this.callRemoteService({ ...requestArg, domain: 'remote', service: 'send_command' });
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Jellyfin service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async playMedia(sessionArg: IJellyfinSession, requestArg: IServiceCallRequest, commandArg: TJellyfinPlayCommand): Promise<IServiceCallResult> {
|
||||||
|
const mediaId = requestArg.data?.media_content_id ?? requestArg.data?.mediaId ?? requestArg.data?.itemId;
|
||||||
|
if (typeof mediaId !== 'string' || !mediaId) {
|
||||||
|
return { success: false, error: 'Jellyfin play_media requires data.media_content_id.' };
|
||||||
|
}
|
||||||
|
await this.client.playMedia(sessionArg.Id, [mediaId], commandArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async targetSession(requestArg: IServiceCallRequest): Promise<IJellyfinSession | undefined> {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const sessions = JellyfinMapper.activeSessions(snapshot);
|
||||||
|
const requestedSessionId = typeof requestArg.data?.sessionId === 'string' ? requestArg.data.sessionId : undefined;
|
||||||
|
if (requestedSessionId) {
|
||||||
|
return sessions.find((sessionArg) => sessionArg.Id === requestedSessionId);
|
||||||
|
}
|
||||||
|
const entityId = requestArg.target.entityId;
|
||||||
|
const deviceId = requestArg.target.deviceId;
|
||||||
|
if (entityId || deviceId) {
|
||||||
|
return this.findSessionByTarget(snapshot, sessions, entityId, deviceId);
|
||||||
|
}
|
||||||
|
return sessions.length === 1 ? sessions[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findSessionByTarget(snapshotArg: IJellyfinSnapshot, sessionsArg: IJellyfinSession[], entityIdArg: string | undefined, deviceIdArg: string | undefined): IJellyfinSession | undefined {
|
||||||
|
const entities = JellyfinMapper.toEntities(snapshotArg);
|
||||||
|
for (const session of sessionsArg) {
|
||||||
|
const sessionDeviceId = JellyfinMapper.sessionDeviceId(session);
|
||||||
|
const entity = entities.find((entityArg) => entityArg.deviceId === sessionDeviceId && entityArg.platform === 'media_player');
|
||||||
|
if (deviceIdArg && deviceIdArg === sessionDeviceId) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
if (entityIdArg && (entityIdArg === entity?.id || entityIdArg === `remote.${JellyfinMapper.slug(session.DeviceName || session.Client || session.Id)}`)) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandsFromRequest(requestArg: IServiceCallRequest): string[] {
|
||||||
|
const command = requestArg.data?.command ?? requestArg.data?.commands;
|
||||||
|
if (typeof command === 'string') {
|
||||||
|
return [command];
|
||||||
|
}
|
||||||
|
if (Array.isArray(command)) {
|
||||||
|
return command.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private playCommandFromRequest(requestArg: IServiceCallRequest): TJellyfinPlayCommand {
|
||||||
|
const enqueue = requestArg.data?.enqueue ?? requestArg.data?.media_enqueue;
|
||||||
|
if (enqueue === 'next') {
|
||||||
|
return 'PlayNext';
|
||||||
|
}
|
||||||
|
if (enqueue === 'add') {
|
||||||
|
return 'PlayLast';
|
||||||
|
}
|
||||||
|
return 'PlayNow';
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IJellyfinManualEntry, IJellyfinMdnsRecord, IJellyfinSsdpRecord } from './jellyfin.types.js';
|
||||||
|
|
||||||
|
const defaultHttpPort = 8096;
|
||||||
|
const defaultHttpsPort = 8920;
|
||||||
|
|
||||||
|
export class JellyfinManualMatcher implements IDiscoveryMatcher<IJellyfinManualEntry> {
|
||||||
|
public id = 'jellyfin-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manually supplied Jellyfin server endpoints.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IJellyfinManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const parsedUrl = parseUrl(inputArg.url);
|
||||||
|
const haystack = `${inputArg.name || ''} ${inputArg.model || ''} ${inputArg.manufacturer || ''}`.toLowerCase();
|
||||||
|
const matched = Boolean(inputArg.host || parsedUrl || inputArg.metadata?.jellyfin || haystack.includes('jellyfin'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Jellyfin setup hints.' };
|
||||||
|
}
|
||||||
|
const ssl = inputArg.ssl ?? parsedUrl?.ssl ?? false;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host || parsedUrl ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Jellyfin setup.',
|
||||||
|
normalizedDeviceId: inputArg.id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'jellyfin',
|
||||||
|
id: inputArg.id,
|
||||||
|
host: inputArg.host || parsedUrl?.host,
|
||||||
|
port: inputArg.port || parsedUrl?.port || (ssl ? defaultHttpsPort : defaultHttpPort),
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: inputArg.manufacturer || 'Jellyfin',
|
||||||
|
model: inputArg.model || 'Jellyfin Server',
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
url: inputArg.url,
|
||||||
|
ssl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JellyfinMdnsMatcher implements IDiscoveryMatcher<IJellyfinMdnsRecord> {
|
||||||
|
public id = 'jellyfin-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Jellyfin-like mDNS HTTP advertisements when present.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IJellyfinMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const properties = { ...recordArg.txt, ...recordArg.properties };
|
||||||
|
const haystack = `${recordArg.type || ''} ${recordArg.name || ''} ${recordArg.hostname || ''} ${Object.values(properties).join(' ')}`.toLowerCase();
|
||||||
|
const matched = haystack.includes('jellyfin') || properties.jellyfin === 'true';
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record does not advertise Jellyfin metadata.' };
|
||||||
|
}
|
||||||
|
const id = valueForKey(properties, 'id') || valueForKey(properties, 'serverid');
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : 'medium',
|
||||||
|
reason: 'mDNS record contains Jellyfin metadata.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'jellyfin',
|
||||||
|
id,
|
||||||
|
host: recordArg.host || recordArg.addresses?.[0],
|
||||||
|
port: recordArg.port || defaultHttpPort,
|
||||||
|
name: cleanName(recordArg.name),
|
||||||
|
manufacturer: 'Jellyfin',
|
||||||
|
model: 'Jellyfin Server',
|
||||||
|
metadata: {
|
||||||
|
mdnsType: recordArg.type,
|
||||||
|
txt: properties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JellyfinSsdpMatcher implements IDiscoveryMatcher<IJellyfinSsdpRecord> {
|
||||||
|
public id = 'jellyfin-ssdp-match';
|
||||||
|
public source = 'ssdp' as const;
|
||||||
|
public description = 'Recognize Jellyfin DLNA/UPnP SSDP advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IJellyfinSsdpRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const headers = recordArg.headers || {};
|
||||||
|
const st = recordArg.st || headerValue(headers, 'st');
|
||||||
|
const usn = recordArg.usn || headerValue(headers, 'usn');
|
||||||
|
const location = recordArg.location || headerValue(headers, 'location');
|
||||||
|
const server = recordArg.server || headerValue(headers, 'server');
|
||||||
|
const model = headerValue(headers, 'modelName') || headerValue(headers, 'modelname') || headerValue(headers, 'model');
|
||||||
|
const manufacturer = headerValue(headers, 'manufacturer');
|
||||||
|
const haystack = `${st || ''} ${usn || ''} ${location || ''} ${server || ''} ${model || ''} ${manufacturer || ''}`.toLowerCase();
|
||||||
|
const matched = haystack.includes('jellyfin');
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'SSDP record does not advertise Jellyfin metadata.' };
|
||||||
|
}
|
||||||
|
const parsedUrl = parseUrl(location);
|
||||||
|
const id = usn?.replace(/^uuid:/i, '').split('::')[0];
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id ? 'certain' : 'high',
|
||||||
|
reason: 'SSDP record matches Jellyfin server metadata.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'ssdp',
|
||||||
|
integrationDomain: 'jellyfin',
|
||||||
|
id,
|
||||||
|
host: parsedUrl?.host,
|
||||||
|
port: parsedUrl?.port || defaultHttpPort,
|
||||||
|
manufacturer: manufacturer || 'Jellyfin',
|
||||||
|
model: model || 'Jellyfin Server',
|
||||||
|
metadata: {
|
||||||
|
url: parsedUrl ? `${parsedUrl.ssl ? 'https' : 'http'}://${parsedUrl.host}:${parsedUrl.port || (parsedUrl.ssl ? defaultHttpsPort : defaultHttpPort)}` : location,
|
||||||
|
ssdpSt: st,
|
||||||
|
ssdpUsn: usn,
|
||||||
|
server,
|
||||||
|
ssl: parsedUrl?.ssl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JellyfinCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'jellyfin-candidate-validator';
|
||||||
|
public description = 'Validate Jellyfin server candidate metadata.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const haystack = `${candidateArg.integrationDomain || ''} ${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
|
||||||
|
const matched = haystack.includes('jellyfin') || Boolean(candidateArg.metadata?.jellyfin);
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.id ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has Jellyfin metadata.' : 'Candidate is not Jellyfin.',
|
||||||
|
candidate: matched ? candidateArg : undefined,
|
||||||
|
normalizedDeviceId: candidateArg.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createJellyfinDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'jellyfin', displayName: 'Jellyfin' })
|
||||||
|
.addMatcher(new JellyfinManualMatcher())
|
||||||
|
.addMatcher(new JellyfinMdnsMatcher())
|
||||||
|
.addMatcher(new JellyfinSsdpMatcher())
|
||||||
|
.addValidator(new JellyfinCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseUrl = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean } | undefined => {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(valueArg);
|
||||||
|
return {
|
||||||
|
host: url.hostname,
|
||||||
|
port: url.port ? Number(url.port) : undefined,
|
||||||
|
ssl: url.protocol === 'https:',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerValue = (headersArg: Record<string, string | undefined>, keyArg: string): string | undefined => {
|
||||||
|
const lowerKey = keyArg.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(headersArg)) {
|
||||||
|
if (key.toLowerCase() === lowerKey) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueForKey = (recordArg: Record<string, string | undefined>, keyArg: string): string | undefined => {
|
||||||
|
const lowerKey = keyArg.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(recordArg)) {
|
||||||
|
if (key.toLowerCase() === lowerKey) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanName = (valueArg: string | undefined): string | undefined => {
|
||||||
|
return valueArg?.replace(/\._http\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
|
||||||
|
};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity } from '../../core/types.js';
|
||||||
|
import type { IJellyfinMediaItem, IJellyfinSession, IJellyfinSnapshot } from './jellyfin.types.js';
|
||||||
|
|
||||||
|
const contentTypeMap: Record<string, string> = {
|
||||||
|
Audio: 'music',
|
||||||
|
Episode: 'tvshow',
|
||||||
|
Movie: 'movie',
|
||||||
|
Series: 'tvshow',
|
||||||
|
Season: 'season',
|
||||||
|
Video: 'video',
|
||||||
|
MusicAlbum: 'album',
|
||||||
|
MusicArtist: 'artist',
|
||||||
|
CollectionFolder: 'collection',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class JellyfinMapper {
|
||||||
|
public static toDevices(snapshotArg: IJellyfinSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [
|
||||||
|
{
|
||||||
|
id: this.serverDeviceId(snapshotArg),
|
||||||
|
integrationDomain: 'jellyfin',
|
||||||
|
name: this.serverName(snapshotArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Jellyfin',
|
||||||
|
model: snapshotArg.server.ProductName || 'Jellyfin Server',
|
||||||
|
online: snapshotArg.online,
|
||||||
|
features: [
|
||||||
|
{ id: 'active_clients', capability: 'sensor', name: 'Active clients', readable: true, writable: false },
|
||||||
|
{ id: 'playing_clients', capability: 'sensor', name: 'Playing clients', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'active_clients', value: this.activeSessions(snapshotArg).length, updatedAt },
|
||||||
|
{ featureId: 'playing_clients', value: this.nowPlayingCount(snapshotArg), updatedAt },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
serverId: this.serverId(snapshotArg),
|
||||||
|
version: snapshotArg.server.Version,
|
||||||
|
localAddress: snapshotArg.server.LocalAddress,
|
||||||
|
wanAddress: snapshotArg.server.WanAddress,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const session of this.activeSessions(snapshotArg)) {
|
||||||
|
const playState = session.PlayState;
|
||||||
|
const nowPlaying = session.NowPlayingItem;
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ 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: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
if (this.supportsRemote(session)) {
|
||||||
|
features.push({ id: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: this.sessionDeviceId(session),
|
||||||
|
integrationDomain: 'jellyfin',
|
||||||
|
name: this.sessionName(session),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Jellyfin',
|
||||||
|
model: session.Client,
|
||||||
|
online: snapshotArg.online && session.IsActive !== false,
|
||||||
|
features,
|
||||||
|
state: [
|
||||||
|
{ featureId: 'playback', value: this.playbackState(session, snapshotArg.online), updatedAt: this.sessionUpdatedAt(session, updatedAt) },
|
||||||
|
{ featureId: 'volume', value: typeof playState?.VolumeLevel === 'number' ? playState.VolumeLevel : null, updatedAt: this.sessionUpdatedAt(session, updatedAt) },
|
||||||
|
{ featureId: 'muted', value: typeof playState?.IsMuted === 'boolean' ? playState.IsMuted : null, updatedAt: this.sessionUpdatedAt(session, updatedAt) },
|
||||||
|
{ featureId: 'current_title', value: nowPlaying?.Name || null, updatedAt: this.sessionUpdatedAt(session, updatedAt) },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
serverId: this.serverId(snapshotArg),
|
||||||
|
sessionId: session.Id,
|
||||||
|
deviceId: session.DeviceId,
|
||||||
|
userId: session.UserId,
|
||||||
|
userName: session.UserName,
|
||||||
|
client: session.Client,
|
||||||
|
applicationVersion: session.ApplicationVersion,
|
||||||
|
supportsRemoteControl: this.supportsRemote(session),
|
||||||
|
supportedCommands: session.Capabilities?.SupportedCommands || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IJellyfinSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [
|
||||||
|
{
|
||||||
|
id: `sensor.${this.slug(this.serverName(snapshotArg))}_active_clients`,
|
||||||
|
uniqueId: `jellyfin_${this.slug(this.serverId(snapshotArg))}_active_clients`,
|
||||||
|
integrationDomain: 'jellyfin',
|
||||||
|
deviceId: this.serverDeviceId(snapshotArg),
|
||||||
|
platform: 'sensor',
|
||||||
|
name: `${this.serverName(snapshotArg)} Active clients`,
|
||||||
|
state: this.nowPlayingCount(snapshotArg),
|
||||||
|
attributes: {
|
||||||
|
activeSessions: this.activeSessions(snapshotArg).length,
|
||||||
|
},
|
||||||
|
available: snapshotArg.online,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const session of this.activeSessions(snapshotArg)) {
|
||||||
|
const item = session.NowPlayingItem;
|
||||||
|
entities.push({
|
||||||
|
id: `media_player.${this.slug(this.sessionName(session))}`,
|
||||||
|
uniqueId: `jellyfin_${this.slug(this.serverId(snapshotArg))}_${this.slug(session.Id || session.DeviceId || this.sessionName(session))}`,
|
||||||
|
integrationDomain: 'jellyfin',
|
||||||
|
deviceId: this.sessionDeviceId(session),
|
||||||
|
platform: 'media_player',
|
||||||
|
name: this.sessionName(session),
|
||||||
|
state: this.playbackState(session, snapshotArg.online),
|
||||||
|
attributes: {
|
||||||
|
sessionId: session.Id,
|
||||||
|
deviceId: session.DeviceId,
|
||||||
|
userName: session.UserName,
|
||||||
|
clientName: session.Client,
|
||||||
|
applicationVersion: session.ApplicationVersion,
|
||||||
|
supportsRemoteControl: this.supportsRemote(session),
|
||||||
|
supportedCommands: session.Capabilities?.SupportedCommands || [],
|
||||||
|
volumeLevel: typeof session.PlayState?.VolumeLevel === 'number' ? session.PlayState.VolumeLevel / 100 : undefined,
|
||||||
|
isVolumeMuted: session.PlayState?.IsMuted,
|
||||||
|
mediaContentId: item?.Id,
|
||||||
|
mediaContentType: this.mediaContentType(item),
|
||||||
|
mediaDuration: this.ticksToSeconds(item?.RunTimeTicks),
|
||||||
|
mediaPosition: this.ticksToSeconds(session.PlayState?.PositionTicks),
|
||||||
|
mediaPositionUpdatedAt: session.LastPlaybackCheckIn,
|
||||||
|
mediaTitle: item?.Name,
|
||||||
|
mediaSeriesTitle: item?.SeriesName,
|
||||||
|
mediaSeason: item?.ParentIndexNumber,
|
||||||
|
mediaEpisode: item?.IndexNumber,
|
||||||
|
mediaAlbumName: item?.Album,
|
||||||
|
mediaArtist: item?.Artists?.[0],
|
||||||
|
mediaAlbumArtist: item?.AlbumArtist,
|
||||||
|
mediaTrack: item?.IndexNumber,
|
||||||
|
},
|
||||||
|
available: snapshotArg.online && session.IsActive !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static activeSessions(snapshotArg: IJellyfinSnapshot): IJellyfinSession[] {
|
||||||
|
return (snapshotArg.sessions || []).filter((sessionArg) => {
|
||||||
|
return Boolean(sessionArg.Id || sessionArg.DeviceId) && (sessionArg.IsActive !== false || Boolean(sessionArg.NowPlayingItem));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static sessionDeviceId(sessionArg: IJellyfinSession): string {
|
||||||
|
return `jellyfin.session.${this.slug(sessionArg.DeviceId || sessionArg.Id || this.sessionName(sessionArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'jellyfin';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serverDeviceId(snapshotArg: IJellyfinSnapshot): string {
|
||||||
|
return `jellyfin.server.${this.slug(this.serverId(snapshotArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serverId(snapshotArg: IJellyfinSnapshot): string {
|
||||||
|
return snapshotArg.server.Id || snapshotArg.server.ServerId || snapshotArg.server.Name || snapshotArg.server.ServerName || 'jellyfin';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serverName(snapshotArg: IJellyfinSnapshot): string {
|
||||||
|
return snapshotArg.server.Name || snapshotArg.server.ServerName || 'Jellyfin';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sessionName(sessionArg: IJellyfinSession): string {
|
||||||
|
return sessionArg.DeviceName || sessionArg.Client || sessionArg.DeviceId || sessionArg.Id || 'Jellyfin Client';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static playbackState(sessionArg: IJellyfinSession, serverOnlineArg: boolean): string {
|
||||||
|
if (!serverOnlineArg || sessionArg.IsActive === false) {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
if (sessionArg.PlayState?.IsPaused) {
|
||||||
|
return 'paused';
|
||||||
|
}
|
||||||
|
if (sessionArg.NowPlayingItem) {
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static supportsRemote(sessionArg: IJellyfinSession): boolean {
|
||||||
|
return sessionArg.SupportsRemoteControl === true || Boolean(sessionArg.Capabilities?.SupportedCommands?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static nowPlayingCount(snapshotArg: IJellyfinSnapshot): number {
|
||||||
|
return this.activeSessions(snapshotArg).filter((sessionArg) => Boolean(sessionArg.NowPlayingItem)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaContentType(itemArg: IJellyfinMediaItem | undefined): string | undefined {
|
||||||
|
if (!itemArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const type = itemArg.Type || itemArg.MediaType;
|
||||||
|
return type ? contentTypeMap[type] || type.toLowerCase() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ticksToSeconds(ticksArg: number | undefined): number | undefined {
|
||||||
|
return typeof ticksArg === 'number' ? Math.floor(ticksArg / 10000000) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sessionUpdatedAt(sessionArg: IJellyfinSession, fallbackArg: string): string {
|
||||||
|
return sessionArg.LastPlaybackCheckIn || sessionArg.LastActivityDate || fallbackArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,212 @@
|
|||||||
export interface IHomeAssistantJellyfinConfig {
|
export interface IJellyfinConfig {
|
||||||
// TODO: replace with the TypeScript-native config for jellyfin.
|
url?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
ssl?: boolean;
|
||||||
|
name?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
apiToken?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
userId?: string;
|
||||||
|
clientDeviceId?: string;
|
||||||
|
deviceName?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
activeWithinSeconds?: number;
|
||||||
|
server?: IJellyfinServerInfo;
|
||||||
|
sessions?: IJellyfinSession[];
|
||||||
|
users?: IJellyfinUser[];
|
||||||
|
snapshot?: IJellyfinSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantJellyfinConfig extends IJellyfinConfig {}
|
||||||
|
|
||||||
|
export interface IJellyfinServerInfo {
|
||||||
|
Id?: string;
|
||||||
|
ServerId?: string;
|
||||||
|
Name?: string;
|
||||||
|
ServerName?: string;
|
||||||
|
Version?: string;
|
||||||
|
ProductName?: string;
|
||||||
|
OperatingSystem?: string;
|
||||||
|
LocalAddress?: string;
|
||||||
|
WanAddress?: string;
|
||||||
|
StartupWizardCompleted?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinUser {
|
||||||
|
Id: string;
|
||||||
|
Name?: string;
|
||||||
|
ServerId?: string;
|
||||||
|
PrimaryImageTag?: string;
|
||||||
|
HasPassword?: boolean;
|
||||||
|
HasConfiguredPassword?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinSessionCapabilities {
|
||||||
|
SupportsMediaControl?: boolean;
|
||||||
|
SupportsPersistentIdentifier?: boolean;
|
||||||
|
SupportsSync?: boolean;
|
||||||
|
SupportsContentUploading?: boolean;
|
||||||
|
SupportedCommands?: TJellyfinGeneralCommand[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinPlayState {
|
||||||
|
PositionTicks?: number;
|
||||||
|
CanSeek?: boolean;
|
||||||
|
IsPaused?: boolean;
|
||||||
|
IsMuted?: boolean;
|
||||||
|
VolumeLevel?: number;
|
||||||
|
AudioStreamIndex?: number;
|
||||||
|
SubtitleStreamIndex?: number;
|
||||||
|
MediaSourceId?: string;
|
||||||
|
PlayMethod?: 'Transcode' | 'DirectStream' | 'DirectPlay' | string;
|
||||||
|
RepeatMode?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinMediaSource {
|
||||||
|
Id?: string;
|
||||||
|
Path?: string;
|
||||||
|
Protocol?: string;
|
||||||
|
Container?: string;
|
||||||
|
Size?: number;
|
||||||
|
Bitrate?: number;
|
||||||
|
RunTimeTicks?: number;
|
||||||
|
VideoType?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinMediaItem {
|
||||||
|
Id?: string;
|
||||||
|
Name?: string;
|
||||||
|
Type?: string;
|
||||||
|
MediaType?: string;
|
||||||
|
RunTimeTicks?: number;
|
||||||
|
SeriesName?: string;
|
||||||
|
ParentIndexNumber?: number;
|
||||||
|
IndexNumber?: number;
|
||||||
|
Album?: string;
|
||||||
|
AlbumArtist?: string;
|
||||||
|
Artists?: string[];
|
||||||
|
Overview?: string;
|
||||||
|
ProductionYear?: number;
|
||||||
|
PremiereDate?: string;
|
||||||
|
CommunityRating?: number;
|
||||||
|
OfficialRating?: string;
|
||||||
|
ImageTags?: Record<string, string | undefined>;
|
||||||
|
ParentBackdropItemId?: string;
|
||||||
|
AlbumId?: string;
|
||||||
|
AlbumPrimaryImageTag?: string;
|
||||||
|
MediaSources?: IJellyfinMediaSource[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinSession {
|
||||||
|
Id: string;
|
||||||
|
UserId?: string;
|
||||||
|
UserName?: string;
|
||||||
|
Client?: string;
|
||||||
|
LastActivityDate?: string;
|
||||||
|
LastPlaybackCheckIn?: string;
|
||||||
|
DeviceName?: string;
|
||||||
|
DeviceId?: string;
|
||||||
|
ApplicationVersion?: string;
|
||||||
|
RemoteEndPoint?: string;
|
||||||
|
IsActive?: boolean;
|
||||||
|
SupportsRemoteControl?: boolean;
|
||||||
|
PlayState?: IJellyfinPlayState;
|
||||||
|
NowPlayingItem?: IJellyfinMediaItem;
|
||||||
|
NowViewingItem?: IJellyfinMediaItem;
|
||||||
|
TranscodingInfo?: Record<string, unknown>;
|
||||||
|
Capabilities?: IJellyfinSessionCapabilities;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinSnapshot {
|
||||||
|
server: IJellyfinServerInfo;
|
||||||
|
sessions: IJellyfinSession[];
|
||||||
|
users?: IJellyfinUser[];
|
||||||
|
online: boolean;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinAuthenticationResult {
|
||||||
|
AccessToken?: string;
|
||||||
|
ServerId?: string;
|
||||||
|
User?: IJellyfinUser;
|
||||||
|
SessionInfo?: IJellyfinSession;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TJellyfinPlaystateCommand =
|
||||||
|
| 'Stop'
|
||||||
|
| 'Pause'
|
||||||
|
| 'Unpause'
|
||||||
|
| 'NextTrack'
|
||||||
|
| 'PreviousTrack'
|
||||||
|
| 'Seek'
|
||||||
|
| 'Rewind'
|
||||||
|
| 'FastForward'
|
||||||
|
| 'PlayPause';
|
||||||
|
|
||||||
|
export type TJellyfinPlayCommand = 'PlayNow' | 'PlayNext' | 'PlayLast' | 'PlayInstantMix' | 'PlayShuffle';
|
||||||
|
|
||||||
|
export type TJellyfinGeneralCommand = string;
|
||||||
|
|
||||||
|
export interface IJellyfinGeneralCommandRequest {
|
||||||
|
Name: TJellyfinGeneralCommand;
|
||||||
|
ControllingUserId?: string;
|
||||||
|
Arguments?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinEvent {
|
||||||
|
type: 'sessions' | 'playstate' | 'general_command' | 'keepalive' | 'error' | string;
|
||||||
|
sessionId?: string;
|
||||||
|
data?: unknown;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinManualEntry {
|
||||||
|
url?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
ssl?: boolean;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
addresses?: string[];
|
||||||
|
hostname?: string;
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
properties?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinSsdpRecord {
|
||||||
|
st?: string;
|
||||||
|
usn?: string;
|
||||||
|
location?: string;
|
||||||
|
server?: string;
|
||||||
|
headers?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJellyfinDiscoveryMetadata {
|
||||||
|
url?: string;
|
||||||
|
ssl?: boolean;
|
||||||
|
ssdpSt?: string;
|
||||||
|
ssdpUsn?: string;
|
||||||
|
mdnsType?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 './mpd.classes.client.js';
|
||||||
|
export * from './mpd.classes.configflow.js';
|
||||||
export * from './mpd.classes.integration.js';
|
export * from './mpd.classes.integration.js';
|
||||||
|
export * from './mpd.discovery.js';
|
||||||
|
export * from './mpd.mapper.js';
|
||||||
export * from './mpd.types.js';
|
export * from './mpd.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type {
|
||||||
|
IMpdCommandRequest,
|
||||||
|
IMpdCommandResponse,
|
||||||
|
IMpdConfig,
|
||||||
|
IMpdCurrentSong,
|
||||||
|
IMpdOutput,
|
||||||
|
IMpdResponseLine,
|
||||||
|
IMpdServerInfo,
|
||||||
|
IMpdSnapshot,
|
||||||
|
IMpdStats,
|
||||||
|
IMpdStatus,
|
||||||
|
IMpdStoredPlaylist,
|
||||||
|
TMpdCommand,
|
||||||
|
} from './mpd.types.js';
|
||||||
|
import { mpdDefaultPort } from './mpd.types.js';
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 5000;
|
||||||
|
|
||||||
|
export class MpdCommandError extends Error {
|
||||||
|
constructor(public readonly command: string, messageArg: string) {
|
||||||
|
super(`MPD command ${command} failed: ${messageArg}`);
|
||||||
|
this.name = 'MpdCommandError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MpdClient {
|
||||||
|
private currentSnapshot?: IMpdSnapshot;
|
||||||
|
private restorePoint?: IMpdSnapshot;
|
||||||
|
|
||||||
|
constructor(private readonly config: IMpdConfig) {
|
||||||
|
this.currentSnapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IMpdSnapshot> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return this.normalizeSnapshot(this.cloneSnapshot(this.requireSnapshot()), this.currentSnapshot?.source || 'manual');
|
||||||
|
}
|
||||||
|
if (!this.config.host) {
|
||||||
|
return this.normalizeSnapshot({
|
||||||
|
server: this.serverInfo(),
|
||||||
|
status: { state: 'stop' },
|
||||||
|
outputs: [],
|
||||||
|
commands: [],
|
||||||
|
playlists: [],
|
||||||
|
online: false,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
source: 'runtime',
|
||||||
|
}, 'runtime');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await this.getStatus();
|
||||||
|
const [currentSong, outputs, commands, playlists, stats] = await Promise.all([
|
||||||
|
this.getCurrentSong().catch(() => undefined),
|
||||||
|
this.getOutputs().catch(() => []),
|
||||||
|
this.getCommands().catch(() => []),
|
||||||
|
this.getPlaylists().catch(() => []),
|
||||||
|
this.getStats().catch(() => undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.normalizeSnapshot({
|
||||||
|
server: {
|
||||||
|
...this.serverInfo(),
|
||||||
|
commands,
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
currentSong,
|
||||||
|
outputs,
|
||||||
|
commands,
|
||||||
|
playlists,
|
||||||
|
stats,
|
||||||
|
online: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
source: 'tcp',
|
||||||
|
}, 'tcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ping(): Promise<boolean> {
|
||||||
|
await this.command('ping');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStatus(): Promise<IMpdStatus> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return { ...this.requireSnapshot().status };
|
||||||
|
}
|
||||||
|
return this.recordFromResponse(await this.command('status')) as IMpdStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentSong(): Promise<IMpdCurrentSong | undefined> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return this.cloneValue(this.requireSnapshot().currentSong);
|
||||||
|
}
|
||||||
|
const song = this.recordFromResponse(await this.command('currentsong')) as IMpdCurrentSong;
|
||||||
|
return Object.keys(song).length ? song : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getOutputs(): Promise<IMpdOutput[]> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return this.cloneValue(this.requireSnapshot().outputs) || [];
|
||||||
|
}
|
||||||
|
return this.outputsFromResponse(await this.command('outputs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCommands(): Promise<TMpdCommand[]> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return [...this.requireSnapshot().commands];
|
||||||
|
}
|
||||||
|
return this.commandValues(await this.command('commands'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPlaylists(): Promise<IMpdStoredPlaylist[]> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return this.cloneValue(this.requireSnapshot().playlists) || [];
|
||||||
|
}
|
||||||
|
return this.playlistsFromResponse(await this.command('listplaylists'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStats(): Promise<IMpdStats> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return { ...(this.requireSnapshot().stats || {}) };
|
||||||
|
}
|
||||||
|
return this.recordFromResponse(await this.command('stats')) as IMpdStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async play(): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
this.requireSnapshot().status.state = 'play';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('play');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pause(pausedArg = true): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
this.requireSnapshot().status.state = pausedArg ? 'pause' : 'play';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('pause', [pausedArg ? 1 : 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
this.requireSnapshot().status.state = 'stop';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async next(): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('next');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async previous(): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('previous');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setVolumeLevel(volumeArg: number): Promise<void> {
|
||||||
|
const volume = Math.max(0, Math.min(100, Math.round(volumeArg <= 1 ? volumeArg * 100 : volumeArg)));
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
this.requireSnapshot().status.volume = volume;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('setvol', [volume]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async selectSource(sourceArg: string): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
const snapshot = this.requireSnapshot();
|
||||||
|
if (snapshot.playlists.length && !snapshot.playlists.some((itemArg) => itemArg.playlist === sourceArg)) {
|
||||||
|
throw new Error(`MPD playlist not found: ${sourceArg}`);
|
||||||
|
}
|
||||||
|
snapshot.status.lastloadedplaylist = sourceArg;
|
||||||
|
snapshot.status.state = 'play';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('clear');
|
||||||
|
await this.command('load', [sourceArg]);
|
||||||
|
await this.command('play');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async playMedia(uriArg: string): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
const snapshot = this.requireSnapshot();
|
||||||
|
snapshot.currentSong = { file: uriArg, title: uriArg.split('/').pop() || uriArg };
|
||||||
|
snapshot.status.state = 'play';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('clear');
|
||||||
|
await this.command('add', [uriArg]);
|
||||||
|
await this.command('play');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setOutput(outputIdArg: string | number, enabledArg: boolean): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
const output = this.requireOutput(outputIdArg);
|
||||||
|
output.outputenabled = enabledArg;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command(enabledArg ? 'enableoutput' : 'disableoutput', [outputIdArg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toggleOutput(outputIdArg: string | number): Promise<void> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
const output = this.requireOutput(outputIdArg);
|
||||||
|
output.outputenabled = !this.outputEnabled(output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.command('toggleoutput', [outputIdArg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async snapshot(): Promise<IMpdSnapshot> {
|
||||||
|
this.restorePoint = await this.getSnapshot();
|
||||||
|
return this.cloneSnapshot(this.restorePoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restore(snapshotArg = this.restorePoint): Promise<void> {
|
||||||
|
if (!snapshotArg) {
|
||||||
|
throw new Error('MPD restore requires a prior snapshot.');
|
||||||
|
}
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
this.currentSnapshot = this.cloneSnapshot(snapshotArg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const volume = this.numberValue(snapshotArg.status.volume);
|
||||||
|
if (volume !== undefined && volume >= 0) {
|
||||||
|
await this.setVolumeLevel(volume);
|
||||||
|
}
|
||||||
|
for (const output of snapshotArg.outputs) {
|
||||||
|
await this.setOutput(output.outputid, this.outputEnabled(output));
|
||||||
|
}
|
||||||
|
if (snapshotArg.status.state === 'play') {
|
||||||
|
await this.play();
|
||||||
|
} else if (snapshotArg.status.state === 'pause') {
|
||||||
|
await this.pause(true);
|
||||||
|
} else if (snapshotArg.status.state === 'stop') {
|
||||||
|
await this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async command(commandArg: TMpdCommand, argsArg: Array<string | number | boolean> = []): Promise<IMpdCommandResponse> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return this.snapshotCommand(commandArg, argsArg);
|
||||||
|
}
|
||||||
|
return this.requestTcp({ command: commandArg, args: argsArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private async requestTcp(requestArg: IMpdCommandRequest): Promise<IMpdCommandResponse> {
|
||||||
|
const host = this.config.host;
|
||||||
|
if (!host) {
|
||||||
|
throw new Error('MPD TCP command requires config.host.');
|
||||||
|
}
|
||||||
|
const port = this.config.port || mpdDefaultPort;
|
||||||
|
const timeoutMs = this.config.timeoutMs || defaultTimeoutMs;
|
||||||
|
|
||||||
|
return new Promise<IMpdCommandResponse>((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
let settled = false;
|
||||||
|
let protocolVersion: string | undefined;
|
||||||
|
let authenticated = !this.config.password;
|
||||||
|
let commandSent = false;
|
||||||
|
const rawLines: string[] = [];
|
||||||
|
const socket = plugins.net.createConnection({ host, port });
|
||||||
|
|
||||||
|
const finish = (errorArg?: Error, responseArg?: IMpdCommandResponse) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
if (errorArg) {
|
||||||
|
reject(errorArg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(responseArg as IMpdCommandResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendCommand = (commandArg: string, argsArg: Array<string | number | boolean> = []) => {
|
||||||
|
socket.write(`${this.commandLine(commandArg, argsArg)}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLine = (lineArg: string) => {
|
||||||
|
if (!protocolVersion) {
|
||||||
|
const match = /^OK MPD\s+(.+)$/.exec(lineArg);
|
||||||
|
if (!match) {
|
||||||
|
finish(new Error(`MPD greeting was not received: ${lineArg}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protocolVersion = match[1];
|
||||||
|
if (!authenticated && this.config.password) {
|
||||||
|
sendCommand('password', [this.config.password]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commandSent = true;
|
||||||
|
sendCommand(requestArg.command, requestArg.args || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
if (lineArg === 'OK') {
|
||||||
|
authenticated = true;
|
||||||
|
commandSent = true;
|
||||||
|
sendCommand(requestArg.command, requestArg.args || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lineArg.startsWith('ACK ')) {
|
||||||
|
finish(new MpdCommandError('password', lineArg));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commandSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lineArg === 'OK') {
|
||||||
|
finish(undefined, {
|
||||||
|
command: requestArg.command,
|
||||||
|
rawLines,
|
||||||
|
lines: this.parseLines(rawLines),
|
||||||
|
protocolVersion,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lineArg.startsWith('ACK ')) {
|
||||||
|
finish(new MpdCommandError(requestArg.command, lineArg));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rawLines.push(lineArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setEncoding('utf8');
|
||||||
|
socket.setTimeout(timeoutMs, () => finish(new Error(`MPD TCP command timed out after ${timeoutMs}ms.`)));
|
||||||
|
socket.on('error', (errorArg) => finish(errorArg));
|
||||||
|
socket.on('close', () => finish(new Error('MPD TCP connection closed before command completed.')));
|
||||||
|
socket.on('data', (chunkArg) => {
|
||||||
|
buffer += chunkArg;
|
||||||
|
const lines = buffer.split(/\r?\n/);
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
handleLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotCommand(commandArg: TMpdCommand, argsArg: Array<string | number | boolean>): IMpdCommandResponse {
|
||||||
|
const snapshot = this.requireSnapshot();
|
||||||
|
if (commandArg === 'ping') {
|
||||||
|
return this.response(commandArg, []);
|
||||||
|
}
|
||||||
|
if (commandArg === 'status') {
|
||||||
|
return this.response(commandArg, this.linesFromRecord(snapshot.status));
|
||||||
|
}
|
||||||
|
if (commandArg === 'currentsong') {
|
||||||
|
return this.response(commandArg, this.linesFromRecord(snapshot.currentSong || {}));
|
||||||
|
}
|
||||||
|
if (commandArg === 'outputs') {
|
||||||
|
return this.response(commandArg, snapshot.outputs.flatMap((outputArg) => this.linesFromOutput(outputArg)));
|
||||||
|
}
|
||||||
|
if (commandArg === 'commands') {
|
||||||
|
return this.response(commandArg, snapshot.commands.map((command) => `command: ${command}`));
|
||||||
|
}
|
||||||
|
if (commandArg === 'listplaylists') {
|
||||||
|
return this.response(commandArg, snapshot.playlists.flatMap((playlistArg) => playlistArg.lastModified ? [`playlist: ${playlistArg.playlist}`, `Last-Modified: ${playlistArg.lastModified}`] : [`playlist: ${playlistArg.playlist}`]));
|
||||||
|
}
|
||||||
|
if (commandArg === 'stats') {
|
||||||
|
return this.response(commandArg, this.linesFromRecord(snapshot.stats || {}));
|
||||||
|
}
|
||||||
|
if (commandArg === 'play') {
|
||||||
|
snapshot.status.state = 'play';
|
||||||
|
return this.response(commandArg, []);
|
||||||
|
}
|
||||||
|
if (commandArg === 'pause') {
|
||||||
|
snapshot.status.state = argsArg[0] === 0 || argsArg[0] === '0' || argsArg[0] === false ? 'play' : 'pause';
|
||||||
|
return this.response(commandArg, []);
|
||||||
|
}
|
||||||
|
if (commandArg === 'stop') {
|
||||||
|
snapshot.status.state = 'stop';
|
||||||
|
return this.response(commandArg, []);
|
||||||
|
}
|
||||||
|
if (commandArg === 'setvol') {
|
||||||
|
snapshot.status.volume = this.numberValue(argsArg[0]) ?? snapshot.status.volume;
|
||||||
|
return this.response(commandArg, []);
|
||||||
|
}
|
||||||
|
if (commandArg === 'enableoutput' || commandArg === 'disableoutput' || commandArg === 'toggleoutput') {
|
||||||
|
if (argsArg[0] === undefined) {
|
||||||
|
throw new Error(`MPD ${commandArg} requires an output id.`);
|
||||||
|
}
|
||||||
|
if (commandArg === 'toggleoutput') {
|
||||||
|
this.toggleOutput(argsArg[0] as string | number);
|
||||||
|
} else {
|
||||||
|
this.setOutput(argsArg[0] as string | number, commandArg === 'enableoutput');
|
||||||
|
}
|
||||||
|
return this.response(commandArg, []);
|
||||||
|
}
|
||||||
|
return this.response(commandArg, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private response(commandArg: TMpdCommand, rawLinesArg: string[]): IMpdCommandResponse {
|
||||||
|
return {
|
||||||
|
command: commandArg,
|
||||||
|
rawLines: rawLinesArg,
|
||||||
|
lines: this.parseLines(rawLinesArg),
|
||||||
|
protocolVersion: this.requireSnapshot().server.protocolVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLines(rawLinesArg: string[]): IMpdResponseLine[] {
|
||||||
|
return rawLinesArg.map((lineArg) => {
|
||||||
|
const separator = lineArg.indexOf(': ');
|
||||||
|
return separator >= 0 ? { key: lineArg.slice(0, separator), value: lineArg.slice(separator + 2) } : undefined;
|
||||||
|
}).filter((lineArg): lineArg is IMpdResponseLine => Boolean(lineArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordFromResponse(responseArg: IMpdCommandResponse): Record<string, unknown> {
|
||||||
|
const record: Record<string, unknown> = {};
|
||||||
|
for (const line of responseArg.lines) {
|
||||||
|
const key = line.key;
|
||||||
|
const existing = record[key];
|
||||||
|
if (existing === undefined) {
|
||||||
|
record[key] = line.value;
|
||||||
|
} else if (Array.isArray(existing)) {
|
||||||
|
existing.push(line.value);
|
||||||
|
} else {
|
||||||
|
record[key] = [existing, line.value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private outputsFromResponse(responseArg: IMpdCommandResponse): IMpdOutput[] {
|
||||||
|
const outputs: IMpdOutput[] = [];
|
||||||
|
let current: Partial<IMpdOutput> | undefined;
|
||||||
|
for (const line of responseArg.lines) {
|
||||||
|
if (line.key === 'outputid') {
|
||||||
|
if (current?.outputid !== undefined && current.outputname) {
|
||||||
|
outputs.push(current as IMpdOutput);
|
||||||
|
}
|
||||||
|
current = { outputid: this.numberValue(line.value) ?? line.value, outputname: line.value };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.key === 'outputname') {
|
||||||
|
current.outputname = line.value;
|
||||||
|
} else if (line.key === 'outputenabled') {
|
||||||
|
current.outputenabled = line.value === '1';
|
||||||
|
} else if (line.key === 'attribute') {
|
||||||
|
const [name, ...valueParts] = line.value.split('=');
|
||||||
|
current.attributes = current.attributes || {};
|
||||||
|
current.attributes[name] = valueParts.join('=');
|
||||||
|
} else {
|
||||||
|
current[line.key] = line.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current?.outputid !== undefined && current.outputname) {
|
||||||
|
outputs.push(current as IMpdOutput);
|
||||||
|
}
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private playlistsFromResponse(responseArg: IMpdCommandResponse): IMpdStoredPlaylist[] {
|
||||||
|
const playlists: IMpdStoredPlaylist[] = [];
|
||||||
|
let current: IMpdStoredPlaylist | undefined;
|
||||||
|
for (const line of responseArg.lines) {
|
||||||
|
if (line.key === 'playlist') {
|
||||||
|
if (current) {
|
||||||
|
playlists.push(current);
|
||||||
|
}
|
||||||
|
current = { playlist: line.value };
|
||||||
|
} else if (current && line.key.toLowerCase() === 'last-modified') {
|
||||||
|
current.lastModified = line.value;
|
||||||
|
} else if (current) {
|
||||||
|
current[line.key] = line.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
playlists.push(current);
|
||||||
|
}
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandValues(responseArg: IMpdCommandResponse): TMpdCommand[] {
|
||||||
|
return responseArg.lines.filter((lineArg) => lineArg.key === 'command').map((lineArg) => lineArg.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandLine(commandArg: string, argsArg: Array<string | number | boolean>): string {
|
||||||
|
return [commandArg, ...argsArg.map((arg) => this.argument(arg))].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private argument(valueArg: string | number | boolean): string {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return String(valueArg);
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'boolean') {
|
||||||
|
return valueArg ? '1' : '0';
|
||||||
|
}
|
||||||
|
const value = String(valueArg);
|
||||||
|
if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSnapshot(snapshotArg: IMpdSnapshot, sourceArg: IMpdSnapshot['source']): IMpdSnapshot {
|
||||||
|
const server = {
|
||||||
|
...this.serverInfo(),
|
||||||
|
...snapshotArg.server,
|
||||||
|
};
|
||||||
|
if (!server.name) {
|
||||||
|
server.name = this.config.name || server.host || 'Music Player Daemon';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...snapshotArg,
|
||||||
|
server,
|
||||||
|
status: snapshotArg.status || { state: 'stop' },
|
||||||
|
outputs: snapshotArg.outputs || [],
|
||||||
|
commands: snapshotArg.commands || server.commands || [],
|
||||||
|
playlists: snapshotArg.playlists || [],
|
||||||
|
online: snapshotArg.online,
|
||||||
|
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||||
|
source: snapshotArg.source || sourceArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serverInfo(): IMpdServerInfo {
|
||||||
|
return {
|
||||||
|
id: this.config.serverId || (this.config.host ? `${this.config.host}:${this.config.port || mpdDefaultPort}` : undefined),
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port || mpdDefaultPort,
|
||||||
|
name: this.config.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSnapshotMode(): boolean {
|
||||||
|
return Boolean(this.currentSnapshot) || this.config.transport === 'snapshot';
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireSnapshot(): IMpdSnapshot {
|
||||||
|
if (!this.currentSnapshot) {
|
||||||
|
this.currentSnapshot = this.normalizeSnapshot({
|
||||||
|
server: this.serverInfo(),
|
||||||
|
status: { state: 'stop' },
|
||||||
|
outputs: [],
|
||||||
|
commands: [],
|
||||||
|
playlists: [],
|
||||||
|
online: true,
|
||||||
|
source: 'manual',
|
||||||
|
}, 'manual');
|
||||||
|
}
|
||||||
|
return this.currentSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireOutput(outputIdArg: string | number): IMpdOutput {
|
||||||
|
const output = this.requireSnapshot().outputs.find((outputArg) => String(outputArg.outputid) === String(outputIdArg));
|
||||||
|
if (!output) {
|
||||||
|
throw new Error(`MPD output not found: ${outputIdArg}`);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private outputEnabled(outputArg: IMpdOutput): boolean {
|
||||||
|
return outputArg.outputenabled === true || outputArg.outputenabled === '1' || outputArg.outputenabled === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 linesFromRecord(recordArg: Record<string, unknown>): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(recordArg)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
lines.push(...value.map((itemArg) => `${key}: ${itemArg}`));
|
||||||
|
} else {
|
||||||
|
lines.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private linesFromOutput(outputArg: IMpdOutput): string[] {
|
||||||
|
const lines = [
|
||||||
|
`outputid: ${outputArg.outputid}`,
|
||||||
|
`outputname: ${outputArg.outputname}`,
|
||||||
|
];
|
||||||
|
if (outputArg.plugin) {
|
||||||
|
lines.push(`plugin: ${outputArg.plugin}`);
|
||||||
|
}
|
||||||
|
lines.push(`outputenabled: ${this.outputEnabled(outputArg) ? 1 : 0}`);
|
||||||
|
for (const [key, value] of Object.entries(outputArg.attributes || {})) {
|
||||||
|
lines.push(`attribute: ${key}=${value}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneSnapshot(snapshotArg: IMpdSnapshot): IMpdSnapshot {
|
||||||
|
return this.cloneValue(snapshotArg) as IMpdSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneValue<TValue>(valueArg: TValue): TValue {
|
||||||
|
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IMpdConfig } from './mpd.types.js';
|
||||||
|
import { mpdDefaultPort } from './mpd.types.js';
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 5000;
|
||||||
|
|
||||||
|
export class MpdConfigFlow implements IConfigFlow<IMpdConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMpdConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Music Player Daemon',
|
||||||
|
description: 'Configure the local MPD TCP control endpoint.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'TCP port', type: 'number' },
|
||||||
|
{ name: 'password', label: 'Password', type: 'password' },
|
||||||
|
{ name: 'name', label: 'Name', type: 'text' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const host = this.stringValue(valuesArg.host) || candidateArg.host || '';
|
||||||
|
if (!host) {
|
||||||
|
return { kind: 'error', title: 'MPD setup failed', error: 'MPD host is required.' };
|
||||||
|
}
|
||||||
|
const port = this.numberValue(valuesArg.port) || candidateArg.port || mpdDefaultPort;
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'MPD configured',
|
||||||
|
config: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
password: this.stringValue(valuesArg.password),
|
||||||
|
name: this.stringValue(valuesArg.name) || candidateArg.name,
|
||||||
|
serverId: candidateArg.id || `${host}:${port}`,
|
||||||
|
transport: 'tcp',
|
||||||
|
timeoutMs: defaultTimeoutMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,24 +1,212 @@
|
|||||||
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 { MpdClient } from './mpd.classes.client.js';
|
||||||
|
import { MpdConfigFlow } from './mpd.classes.configflow.js';
|
||||||
|
import { createMpdDiscoveryDescriptor } from './mpd.discovery.js';
|
||||||
|
import { MpdMapper } from './mpd.mapper.js';
|
||||||
|
import type { IMpdConfig, IMpdOutput, IMpdSnapshot } from './mpd.types.js';
|
||||||
|
|
||||||
export class HomeAssistantMpdIntegration extends DescriptorOnlyIntegration {
|
export class MpdIntegration extends BaseIntegration<IMpdConfig> {
|
||||||
constructor() {
|
public readonly domain = 'mpd';
|
||||||
super({
|
public readonly displayName = 'Music Player Daemon (MPD)';
|
||||||
domain: "mpd",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Music Player Daemon (MPD)",
|
public readonly discoveryDescriptor = createMpdDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new MpdConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/mpd",
|
upstreamPath: 'homeassistant/components/mpd',
|
||||||
"upstreamDomain": "mpd",
|
upstreamDomain: 'mpd',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_polling",
|
iotClass: 'local_polling',
|
||||||
"requirements": [
|
requirements: ['python-mpd2==3.1.1'],
|
||||||
"python-mpd2==3.1.1"
|
dependencies: [],
|
||||||
],
|
afterDependencies: [],
|
||||||
"dependencies": [],
|
codeowners: [],
|
||||||
"afterDependencies": [],
|
configFlow: true,
|
||||||
"codeowners": []
|
documentation: 'https://www.home-assistant.io/integrations/mpd',
|
||||||
},
|
};
|
||||||
});
|
|
||||||
|
public async setup(configArg: IMpdConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
|
void contextArg;
|
||||||
|
return new MpdRuntime(new MpdClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantMpdIntegration extends MpdIntegration {}
|
||||||
|
|
||||||
|
class MpdRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'mpd';
|
||||||
|
|
||||||
|
constructor(private readonly client: MpdClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return MpdMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return MpdMapper.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 === 'mpd') {
|
||||||
|
return await this.callMpdService(requestArg);
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported MPD 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 === 'play' || requestArg.service === 'media_play') {
|
||||||
|
await this.client.play();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
|
||||||
|
await this.client.pause(true);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
await this.client.pause(snapshot.status.state === 'play');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
|
||||||
|
await this.client.stop();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'next' || requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
|
||||||
|
await this.client.next();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'previous' || requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
|
||||||
|
await this.client.previous();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume') {
|
||||||
|
await this.client.setVolumeLevel(this.numberValue(requestArg.data?.volume_level ?? requestArg.data?.volume, 'MPD volume control requires data.volume_level or data.volume.'));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'select_source' || requestArg.service === 'source') {
|
||||||
|
await this.client.selectSource(this.stringValue(requestArg.data?.source ?? requestArg.data?.playlist, 'MPD source control requires data.source or data.playlist.'));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play_media') {
|
||||||
|
await this.client.playMedia(this.stringValue(requestArg.data?.media_content_id ?? requestArg.data?.uri, 'MPD play_media requires data.media_content_id or data.uri.'));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'enable_output' || requestArg.service === 'disable_output' || requestArg.service === 'toggle_output' || requestArg.service === 'set_output' || requestArg.service === 'output') {
|
||||||
|
await this.callOutputService(requestArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported MPD media_player service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callMpdService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (requestArg.service === 'snapshot') {
|
||||||
|
return { success: true, data: await this.client.snapshot() };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'restore') {
|
||||||
|
const snapshot = requestArg.data?.snapshot as IMpdSnapshot | undefined;
|
||||||
|
await this.client.restore(snapshot);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'command') {
|
||||||
|
const command = this.stringValue(requestArg.data?.command, 'MPD command service requires data.command.');
|
||||||
|
const args = this.commandArgs(requestArg.data?.args);
|
||||||
|
return { success: true, data: await this.client.command(command, args) };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'stop' || requestArg.service === 'next' || requestArg.service === 'previous' || requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume' || requestArg.service === 'select_source' || requestArg.service === 'source') {
|
||||||
|
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'enable_output' || requestArg.service === 'disable_output' || requestArg.service === 'toggle_output' || requestArg.service === 'set_output' || requestArg.service === 'output') {
|
||||||
|
await this.callOutputService(requestArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported MPD service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callOutputService(requestArg: IServiceCallRequest): Promise<void> {
|
||||||
|
const outputId = await this.outputIdFromRequest(requestArg);
|
||||||
|
if (requestArg.service === 'toggle_output' || requestArg.service === 'output' && requestArg.data?.enabled === undefined) {
|
||||||
|
await this.client.toggleOutput(outputId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const enabled = requestArg.service === 'enable_output'
|
||||||
|
? true
|
||||||
|
: requestArg.service === 'disable_output'
|
||||||
|
? false
|
||||||
|
: this.booleanValue(requestArg.data?.enabled, 'MPD set_output/output requires data.enabled.');
|
||||||
|
await this.client.setOutput(outputId, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async outputIdFromRequest(requestArg: IServiceCallRequest): Promise<string | number> {
|
||||||
|
const direct = requestArg.data?.output_id ?? requestArg.data?.outputId ?? requestArg.data?.id;
|
||||||
|
if (typeof direct === 'string' || typeof direct === 'number') {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
const targetId = requestArg.target.entityId || requestArg.target.deviceId;
|
||||||
|
if (!targetId) {
|
||||||
|
throw new Error('MPD output control requires data.output_id or a target output switch entity.');
|
||||||
|
}
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const entity = MpdMapper.toEntities(snapshot).find((entityArg) => entityArg.id === targetId || entityArg.deviceId === targetId);
|
||||||
|
const entityOutputId = entity?.attributes?.mpdOutputId;
|
||||||
|
if (typeof entityOutputId === 'string' || typeof entityOutputId === 'number') {
|
||||||
|
return entityOutputId;
|
||||||
|
}
|
||||||
|
const output = snapshot.outputs.find((outputArg) => MpdMapper.outputDeviceId(snapshot, outputArg) === targetId || outputEntityId(snapshot, outputArg) === targetId);
|
||||||
|
if (!output) {
|
||||||
|
throw new Error(`MPD output target was not found: ${targetId}`);
|
||||||
|
}
|
||||||
|
return output.outputid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandArgs(valueArg: unknown): Array<string | number | boolean> {
|
||||||
|
if (valueArg === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const values = Array.isArray(valueArg) ? valueArg : [valueArg];
|
||||||
|
if (values.some((itemArg) => typeof itemArg !== 'string' && typeof itemArg !== 'number' && typeof itemArg !== 'boolean')) {
|
||||||
|
throw new Error('MPD command args must be strings, numbers, or booleans.');
|
||||||
|
}
|
||||||
|
return values as Array<string | number | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown, errorArg: string): number {
|
||||||
|
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
|
||||||
|
throw new Error(errorArg);
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanValue(valueArg: unknown, errorArg: string): boolean {
|
||||||
|
if (typeof valueArg !== 'boolean') {
|
||||||
|
throw new Error(errorArg);
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown, errorArg: string): string {
|
||||||
|
if (typeof valueArg !== 'string' || !valueArg) {
|
||||||
|
throw new Error(errorArg);
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputEntityId = (snapshotArg: IMpdSnapshot, outputArg: IMpdOutput): string => {
|
||||||
|
const serverName = snapshotArg.server.name || snapshotArg.server.host || 'Music Player Daemon';
|
||||||
|
return `switch.${MpdMapper.slug(serverName)}_${MpdMapper.slug(outputArg.outputname)}_mpd_output`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IMpdManualEntry, IMpdMdnsRecord } from './mpd.types.js';
|
||||||
|
import { mpdDefaultPort } from './mpd.types.js';
|
||||||
|
|
||||||
|
const mpdMdnsTypes = new Set(['_mpd._tcp', '_mpd._tcp.local']);
|
||||||
|
|
||||||
|
export class MpdMdnsMatcher implements IDiscoveryMatcher<IMpdMdnsRecord> {
|
||||||
|
public id = 'mpd-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Music Player Daemon mDNS advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IMpdMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = normalizeType(recordArg.type || recordArg.serviceType || '');
|
||||||
|
const properties = { ...recordArg.txt, ...recordArg.properties };
|
||||||
|
const name = cleanName(recordArg.name || recordArg.hostname || properties.name) || 'Music Player Daemon';
|
||||||
|
const serviceMatch = mpdMdnsTypes.has(type);
|
||||||
|
const nameMatch = name.toLowerCase().includes('mpd') || name.toLowerCase().includes('music player daemon');
|
||||||
|
if (!serviceMatch && !nameMatch) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not an MPD service.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = recordArg.host || recordArg.addresses?.[0];
|
||||||
|
const port = recordArg.port || mpdDefaultPort;
|
||||||
|
const id = valueForKey(properties, 'id') || (host ? `${host}:${port}` : name);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: serviceMatch && host ? 'certain' : serviceMatch ? 'high' : 'medium',
|
||||||
|
reason: serviceMatch ? `mDNS service ${type} is an MPD service.` : 'mDNS name contains MPD hints.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
name,
|
||||||
|
manufacturer: 'Music Player Daemon',
|
||||||
|
model: 'MPD Server',
|
||||||
|
metadata: {
|
||||||
|
mdnsType: type,
|
||||||
|
txt: properties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
mdnsType: type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MpdManualMatcher implements IDiscoveryMatcher<IMpdManualEntry> {
|
||||||
|
public id = 'mpd-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual MPD setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IMpdManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.metadata?.mpd || haystack.includes('mpd') || haystack.includes('music player daemon'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain MPD setup hints.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = inputArg.port || mpdDefaultPort;
|
||||||
|
const id = inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start MPD setup.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
id,
|
||||||
|
host: inputArg.host,
|
||||||
|
port,
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: inputArg.manufacturer || 'Music Player Daemon',
|
||||||
|
model: inputArg.model || 'MPD Server',
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
password: inputArg.password ? true : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MpdCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'mpd-candidate-validator';
|
||||||
|
public description = 'Validate MPD candidates have host information and MPD metadata.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const name = candidateArg.name?.toLowerCase() || '';
|
||||||
|
const mdnsType = typeof candidateArg.metadata?.mdnsType === 'string' ? normalizeType(candidateArg.metadata.mdnsType) : '';
|
||||||
|
const matched = candidateArg.integrationDomain === 'mpd'
|
||||||
|
|| manufacturer.includes('music player daemon')
|
||||||
|
|| manufacturer.includes('mpd')
|
||||||
|
|| model.includes('mpd')
|
||||||
|
|| name.includes('mpd')
|
||||||
|
|| name.includes('music player daemon')
|
||||||
|
|| mpdMdnsTypes.has(mdnsType)
|
||||||
|
|| Boolean(candidateArg.metadata?.mpd);
|
||||||
|
|
||||||
|
if (!matched || !candidateArg.host) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'MPD candidate lacks host information.' : 'Candidate is not MPD.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = candidateArg.port || mpdDefaultPort;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: candidateArg.id ? 'certain' : 'high',
|
||||||
|
reason: 'Candidate has MPD metadata and host information.',
|
||||||
|
normalizedDeviceId: candidateArg.id || `${candidateArg.host}:${port}`,
|
||||||
|
candidate: {
|
||||||
|
...candidateArg,
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMpdDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'mpd', displayName: 'Music Player Daemon (MPD)' })
|
||||||
|
.addMatcher(new MpdMdnsMatcher())
|
||||||
|
.addMatcher(new MpdManualMatcher())
|
||||||
|
.addValidator(new MpdCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
|
||||||
|
|
||||||
|
const valueForKey = (recordArg: Record<string, string | undefined>, keyArg: string): string | undefined => {
|
||||||
|
const lowerKey = keyArg.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(recordArg)) {
|
||||||
|
if (key.toLowerCase() === lowerKey) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanName = (valueArg: string | undefined): string | undefined => {
|
||||||
|
return valueArg?.replace(/\._mpd\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
|
||||||
|
};
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity } from '../../core/types.js';
|
||||||
|
import type { IMpdCurrentSong, IMpdOutput, IMpdSnapshot, IMpdStatus } from './mpd.types.js';
|
||||||
|
|
||||||
|
export class MpdMapper {
|
||||||
|
public static toDevices(snapshotArg: IMpdSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
|
||||||
|
id: this.serverDeviceId(snapshotArg),
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
name: this.serverName(snapshotArg),
|
||||||
|
protocol: 'unknown',
|
||||||
|
manufacturer: 'Music Player Daemon',
|
||||||
|
model: 'MPD Server',
|
||||||
|
online: snapshotArg.online,
|
||||||
|
features: [
|
||||||
|
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
|
||||||
|
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
|
||||||
|
{ id: 'source', capability: 'media', name: 'Playlist source', readable: true, writable: true },
|
||||||
|
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||||
|
{ id: 'playlist_length', capability: 'sensor', name: 'Playlist length', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'playback', value: this.mediaState(snapshotArg.status, snapshotArg.online), updatedAt },
|
||||||
|
{ featureId: 'volume', value: this.numberValue(snapshotArg.status.volume) ?? null, updatedAt },
|
||||||
|
{ featureId: 'source', value: snapshotArg.status.lastloadedplaylist || null, updatedAt },
|
||||||
|
{ featureId: 'current_title', value: this.mediaTitle(snapshotArg.currentSong) || null, updatedAt },
|
||||||
|
{ featureId: 'playlist_length', value: this.numberValue(snapshotArg.status.playlistlength) ?? null, updatedAt },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
host: snapshotArg.server.host,
|
||||||
|
port: snapshotArg.server.port,
|
||||||
|
protocolVersion: snapshotArg.server.protocolVersion,
|
||||||
|
source: snapshotArg.source,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
|
||||||
|
for (const output of snapshotArg.outputs) {
|
||||||
|
devices.push(this.outputDevice(snapshotArg, output, updatedAt));
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IMpdSnapshot): IIntegrationEntity[] {
|
||||||
|
const serverName = this.serverName(snapshotArg);
|
||||||
|
const uniqueBase = this.uniqueBase(snapshotArg);
|
||||||
|
const song = snapshotArg.currentSong;
|
||||||
|
const entities: IIntegrationEntity[] = [{
|
||||||
|
id: `media_player.${this.slug(serverName)}`,
|
||||||
|
uniqueId: `mpd_${uniqueBase}`,
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
deviceId: this.serverDeviceId(snapshotArg),
|
||||||
|
platform: 'media_player',
|
||||||
|
name: serverName,
|
||||||
|
state: this.mediaState(snapshotArg.status, snapshotArg.online),
|
||||||
|
attributes: {
|
||||||
|
volumeLevel: this.volumeLevel(snapshotArg.status),
|
||||||
|
mediaContentId: song?.file,
|
||||||
|
mediaContentType: 'music',
|
||||||
|
mediaDuration: this.duration(snapshotArg.status, song),
|
||||||
|
mediaPosition: this.position(snapshotArg.status),
|
||||||
|
mediaTitle: this.mediaTitle(song),
|
||||||
|
mediaArtist: this.joinStrings(this.songValue(song, 'artist')),
|
||||||
|
mediaAlbumName: this.firstString(this.songValue(song, 'album')),
|
||||||
|
mediaAlbumArtist: this.joinStrings(this.songValue(song, 'albumartist')),
|
||||||
|
source: snapshotArg.status.lastloadedplaylist,
|
||||||
|
sourceList: snapshotArg.playlists.map((playlistArg) => playlistArg.playlist),
|
||||||
|
repeat: this.repeatMode(snapshotArg.status),
|
||||||
|
shuffle: this.booleanFlag(snapshotArg.status.random),
|
||||||
|
audio: snapshotArg.status.audio,
|
||||||
|
bitrate: this.numberValue(snapshotArg.status.bitrate),
|
||||||
|
mpdSongId: snapshotArg.status.songid,
|
||||||
|
mpdPlaylistVersion: snapshotArg.status.playlist,
|
||||||
|
commands: snapshotArg.commands,
|
||||||
|
},
|
||||||
|
available: snapshotArg.online,
|
||||||
|
}, {
|
||||||
|
id: `sensor.${this.slug(serverName)}_mpd_status`,
|
||||||
|
uniqueId: `mpd_${uniqueBase}_status`,
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
deviceId: this.serverDeviceId(snapshotArg),
|
||||||
|
platform: 'sensor',
|
||||||
|
name: `${serverName} MPD Status`,
|
||||||
|
state: snapshotArg.status.state || 'unknown',
|
||||||
|
attributes: {
|
||||||
|
status: snapshotArg.status,
|
||||||
|
stats: snapshotArg.stats,
|
||||||
|
outputCount: snapshotArg.outputs.length,
|
||||||
|
enabledOutputCount: snapshotArg.outputs.filter((outputArg) => this.outputEnabled(outputArg)).length,
|
||||||
|
},
|
||||||
|
available: snapshotArg.online,
|
||||||
|
}, {
|
||||||
|
id: `sensor.${this.slug(serverName)}_mpd_current_song`,
|
||||||
|
uniqueId: `mpd_${uniqueBase}_current_song`,
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
deviceId: this.serverDeviceId(snapshotArg),
|
||||||
|
platform: 'sensor',
|
||||||
|
name: `${serverName} MPD Current Song`,
|
||||||
|
state: this.mediaTitle(song) || 'None',
|
||||||
|
attributes: {
|
||||||
|
song,
|
||||||
|
file: song?.file,
|
||||||
|
artist: this.joinStrings(this.songValue(song, 'artist')),
|
||||||
|
album: this.firstString(this.songValue(song, 'album')),
|
||||||
|
duration: this.duration(snapshotArg.status, song),
|
||||||
|
},
|
||||||
|
available: snapshotArg.online,
|
||||||
|
}, {
|
||||||
|
id: `sensor.${this.slug(serverName)}_mpd_playlist_length`,
|
||||||
|
uniqueId: `mpd_${uniqueBase}_playlist_length`,
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
deviceId: this.serverDeviceId(snapshotArg),
|
||||||
|
platform: 'sensor',
|
||||||
|
name: `${serverName} MPD Playlist Length`,
|
||||||
|
state: this.numberValue(snapshotArg.status.playlistlength) ?? 0,
|
||||||
|
attributes: {
|
||||||
|
playlists: snapshotArg.playlists.map((playlistArg) => playlistArg.playlist),
|
||||||
|
playlistVersion: snapshotArg.status.playlist,
|
||||||
|
},
|
||||||
|
available: snapshotArg.online,
|
||||||
|
}];
|
||||||
|
|
||||||
|
for (const output of snapshotArg.outputs) {
|
||||||
|
entities.push({
|
||||||
|
id: `switch.${this.slug(serverName)}_${this.slug(output.outputname)}_mpd_output`,
|
||||||
|
uniqueId: `mpd_${uniqueBase}_output_${this.slug(String(output.outputid))}`,
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
deviceId: this.outputDeviceId(snapshotArg, output),
|
||||||
|
platform: 'switch',
|
||||||
|
name: `${output.outputname} MPD Output`,
|
||||||
|
state: this.outputEnabled(output) ? 'on' : 'off',
|
||||||
|
attributes: {
|
||||||
|
mpdOutputId: output.outputid,
|
||||||
|
mpdOutputName: output.outputname,
|
||||||
|
plugin: output.plugin,
|
||||||
|
attributes: output.attributes,
|
||||||
|
},
|
||||||
|
available: snapshotArg.online,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static serverDeviceId(snapshotArg: IMpdSnapshot): string {
|
||||||
|
return `mpd.server.${this.uniqueBase(snapshotArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static outputDeviceId(snapshotArg: IMpdSnapshot, outputArg: IMpdOutput): string {
|
||||||
|
return `mpd.output.${this.uniqueBase(snapshotArg)}.${this.slug(outputArg.outputid !== undefined ? String(outputArg.outputid) : outputArg.outputname)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'mpd';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static outputDevice(snapshotArg: IMpdSnapshot, outputArg: IMpdOutput, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
return {
|
||||||
|
id: this.outputDeviceId(snapshotArg, outputArg),
|
||||||
|
integrationDomain: 'mpd',
|
||||||
|
name: outputArg.outputname,
|
||||||
|
protocol: 'unknown',
|
||||||
|
manufacturer: 'Music Player Daemon',
|
||||||
|
model: outputArg.plugin || 'Audio Output',
|
||||||
|
online: snapshotArg.online,
|
||||||
|
features: [
|
||||||
|
{ id: 'enabled', capability: 'switch', name: 'Enabled', readable: true, writable: true },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'enabled', value: this.outputEnabled(outputArg), updatedAt: updatedAtArg },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
mpdOutputId: outputArg.outputid,
|
||||||
|
plugin: outputArg.plugin,
|
||||||
|
attributes: outputArg.attributes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaState(statusArg: IMpdStatus, onlineArg: boolean): string {
|
||||||
|
if (!onlineArg) {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
if (statusArg.state === 'play') {
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
|
if (statusArg.state === 'pause') {
|
||||||
|
return 'paused';
|
||||||
|
}
|
||||||
|
if (statusArg.state === 'stop') {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
return statusArg.state || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static repeatMode(statusArg: IMpdStatus): 'off' | 'one' | 'all' {
|
||||||
|
if (!this.booleanFlag(statusArg.repeat)) {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
return this.booleanFlag(statusArg.single) ? 'one' : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaTitle(songArg: IMpdCurrentSong | undefined): string | undefined {
|
||||||
|
const name = this.firstString(this.songValue(songArg, 'name'));
|
||||||
|
const title = this.firstString(this.songValue(songArg, 'title'));
|
||||||
|
const file = this.firstString(this.songValue(songArg, 'file'));
|
||||||
|
if (!name && !title) {
|
||||||
|
return file ? file.split('/').pop() || file : undefined;
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
if (!title) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return `${name}: ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static duration(statusArg: IMpdStatus, songArg: IMpdCurrentSong | undefined): number | undefined {
|
||||||
|
return this.numberValue(songArg?.duration)
|
||||||
|
?? this.numberValue(songArg?.time)
|
||||||
|
?? this.numberValue(statusArg.duration)
|
||||||
|
?? this.durationFromStatusTime(statusArg.time, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static position(statusArg: IMpdStatus): number | undefined {
|
||||||
|
return this.numberValue(statusArg.elapsed) ?? this.durationFromStatusTime(statusArg.time, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static durationFromStatusTime(valueArg: unknown, indexArg: 0 | 1): number | undefined {
|
||||||
|
if (typeof valueArg !== 'string' || !valueArg.includes(':')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.numberValue(valueArg.split(':')[indexArg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volumeLevel(statusArg: IMpdStatus): number | undefined {
|
||||||
|
const volume = this.numberValue(statusArg.volume);
|
||||||
|
return volume === undefined || volume < 0 ? undefined : volume / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static outputEnabled(outputArg: IMpdOutput): boolean {
|
||||||
|
return outputArg.outputenabled === true || outputArg.outputenabled === '1' || outputArg.outputenabled === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static booleanFlag(valueArg: unknown): boolean {
|
||||||
|
return valueArg === true || valueArg === '1' || valueArg === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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 static songValue(songArg: IMpdCurrentSong | undefined, keyArg: string): string | string[] | number | undefined {
|
||||||
|
if (!songArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const direct = songArg[keyArg];
|
||||||
|
if (direct !== undefined) {
|
||||||
|
return direct as string | string[] | number;
|
||||||
|
}
|
||||||
|
const lowerKey = keyArg.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(songArg)) {
|
||||||
|
if (key.toLowerCase() === lowerKey) {
|
||||||
|
return value as string | string[] | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static firstString(valueArg: string | string[] | number | undefined): string | undefined {
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return valueArg[0];
|
||||||
|
}
|
||||||
|
return valueArg === undefined ? undefined : String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static joinStrings(valueArg: string | string[] | number | undefined): string | undefined {
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return valueArg.join(', ');
|
||||||
|
}
|
||||||
|
return valueArg === undefined ? undefined : String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serverName(snapshotArg: IMpdSnapshot): string {
|
||||||
|
return snapshotArg.server.name || snapshotArg.server.host || 'Music Player Daemon';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueBase(snapshotArg: IMpdSnapshot): string {
|
||||||
|
return this.slug(snapshotArg.server.id || snapshotArg.server.host || this.serverName(snapshotArg));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,211 @@
|
|||||||
export interface IHomeAssistantMpdConfig {
|
export const mpdDefaultPort = 6600;
|
||||||
// TODO: replace with the TypeScript-native config for mpd.
|
|
||||||
|
export type TMpdTransport = 'tcp' | 'snapshot';
|
||||||
|
|
||||||
|
export type TMpdPlaybackState = 'play' | 'pause' | 'stop' | 'unknown' | (string & {});
|
||||||
|
|
||||||
|
export type TMpdSnapshotSource = 'manual' | 'snapshot' | 'tcp' | 'runtime';
|
||||||
|
|
||||||
|
export type TMpdServiceCommand =
|
||||||
|
| 'play'
|
||||||
|
| 'pause'
|
||||||
|
| 'stop'
|
||||||
|
| 'next'
|
||||||
|
| 'previous'
|
||||||
|
| 'set_volume'
|
||||||
|
| 'volume'
|
||||||
|
| 'volume_set'
|
||||||
|
| 'source'
|
||||||
|
| 'select_source'
|
||||||
|
| 'output'
|
||||||
|
| 'enable_output'
|
||||||
|
| 'disable_output'
|
||||||
|
| 'toggle_output'
|
||||||
|
| 'set_output'
|
||||||
|
| 'snapshot'
|
||||||
|
| 'restore'
|
||||||
|
| 'command';
|
||||||
|
|
||||||
|
export interface IMpdConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
password?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
name?: string;
|
||||||
|
serverId?: string;
|
||||||
|
transport?: TMpdTransport;
|
||||||
|
snapshot?: IMpdSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantMpdConfig extends IMpdConfig {}
|
||||||
|
|
||||||
|
export interface IMpdServerInfo {
|
||||||
|
id?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
name?: string;
|
||||||
|
protocolVersion?: string;
|
||||||
|
commands?: TMpdCommand[];
|
||||||
|
authenticated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdStatus {
|
||||||
|
partition?: string;
|
||||||
|
volume?: string | number;
|
||||||
|
repeat?: string | number;
|
||||||
|
random?: string | number;
|
||||||
|
single?: string | number;
|
||||||
|
consume?: string | number;
|
||||||
|
playlist?: string | number;
|
||||||
|
playlistlength?: string | number;
|
||||||
|
state?: TMpdPlaybackState;
|
||||||
|
song?: string | number;
|
||||||
|
songid?: string | number;
|
||||||
|
nextsong?: string | number;
|
||||||
|
nextsongid?: string | number;
|
||||||
|
time?: string;
|
||||||
|
elapsed?: string | number;
|
||||||
|
duration?: string | number;
|
||||||
|
bitrate?: string | number;
|
||||||
|
xfade?: string | number;
|
||||||
|
mixrampdb?: string | number;
|
||||||
|
mixrampdelay?: string | number;
|
||||||
|
audio?: string;
|
||||||
|
updating_db?: string | number;
|
||||||
|
error?: string;
|
||||||
|
lastloadedplaylist?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IMpdCurrentSong {
|
||||||
|
file?: string;
|
||||||
|
artist?: string | string[];
|
||||||
|
artistsort?: string | string[];
|
||||||
|
album?: string;
|
||||||
|
albumsort?: string;
|
||||||
|
albumartist?: string | string[];
|
||||||
|
albumartistsort?: string | string[];
|
||||||
|
title?: string;
|
||||||
|
titlesort?: string;
|
||||||
|
track?: string | number;
|
||||||
|
name?: string;
|
||||||
|
genre?: string | string[];
|
||||||
|
date?: string;
|
||||||
|
originaldate?: string;
|
||||||
|
composer?: string | string[];
|
||||||
|
performer?: string | string[];
|
||||||
|
disc?: string | number;
|
||||||
|
time?: string | number;
|
||||||
|
duration?: string | number;
|
||||||
|
pos?: string | number;
|
||||||
|
id?: string | number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdOutput {
|
||||||
|
outputid: string | number;
|
||||||
|
outputname: string;
|
||||||
|
plugin?: string;
|
||||||
|
outputenabled: boolean | string | number;
|
||||||
|
attributes?: Record<string, string>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdStoredPlaylist {
|
||||||
|
playlist: string;
|
||||||
|
lastModified?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdStats {
|
||||||
|
artists?: string | number;
|
||||||
|
albums?: string | number;
|
||||||
|
songs?: string | number;
|
||||||
|
uptime?: string | number;
|
||||||
|
db_playtime?: string | number;
|
||||||
|
db_update?: string | number;
|
||||||
|
playtime?: string | number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TMpdCommand =
|
||||||
|
| 'add'
|
||||||
|
| 'clear'
|
||||||
|
| 'commands'
|
||||||
|
| 'currentsong'
|
||||||
|
| 'disableoutput'
|
||||||
|
| 'enableoutput'
|
||||||
|
| 'listplaylists'
|
||||||
|
| 'next'
|
||||||
|
| 'outputs'
|
||||||
|
| 'password'
|
||||||
|
| 'pause'
|
||||||
|
| 'play'
|
||||||
|
| 'previous'
|
||||||
|
| 'setvol'
|
||||||
|
| 'status'
|
||||||
|
| 'stop'
|
||||||
|
| 'toggleoutput'
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export interface IMpdCommandRequest {
|
||||||
|
command: TMpdCommand;
|
||||||
|
args?: Array<string | number | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdResponseLine {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdCommandResponse {
|
||||||
|
command: TMpdCommand;
|
||||||
|
rawLines: string[];
|
||||||
|
lines: IMpdResponseLine[];
|
||||||
|
protocolVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdSnapshot {
|
||||||
|
server: IMpdServerInfo;
|
||||||
|
status: IMpdStatus;
|
||||||
|
currentSong?: IMpdCurrentSong;
|
||||||
|
outputs: IMpdOutput[];
|
||||||
|
commands: TMpdCommand[];
|
||||||
|
playlists: IMpdStoredPlaylist[];
|
||||||
|
stats?: IMpdStats;
|
||||||
|
online: boolean;
|
||||||
|
updatedAt?: string;
|
||||||
|
source?: TMpdSnapshotSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdMdnsRecord {
|
||||||
|
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 IMpdManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
password?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMpdDiscoveryRecord {
|
||||||
|
source: 'mdns' | 'manual';
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
mdnsType?: 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 './onvif.classes.integration.js';
|
export * from './onvif.classes.integration.js';
|
||||||
|
export * from './onvif.classes.client.js';
|
||||||
|
export * from './onvif.classes.configflow.js';
|
||||||
|
export * from './onvif.discovery.js';
|
||||||
|
export * from './onvif.mapper.js';
|
||||||
export * from './onvif.types.js';
|
export * from './onvif.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { OnvifMapper } from './onvif.mapper.js';
|
||||||
|
import type {
|
||||||
|
IOnvifCameraSnapshot,
|
||||||
|
IOnvifClientCommand,
|
||||||
|
IOnvifConfig,
|
||||||
|
IOnvifDeviceInfo,
|
||||||
|
IOnvifEvent,
|
||||||
|
IOnvifProfile,
|
||||||
|
IOnvifServiceDescriptor,
|
||||||
|
IOnvifSnapshot,
|
||||||
|
IOnvifStream,
|
||||||
|
} from './onvif.types.js';
|
||||||
|
import { onvifDefaultDeviceServicePath, onvifDefaultPort } from './onvif.types.js';
|
||||||
|
|
||||||
|
type TEventHandler = (eventArg: IOnvifEvent) => void;
|
||||||
|
|
||||||
|
const namespaces = {
|
||||||
|
device: 'http://www.onvif.org/ver10/device/wsdl',
|
||||||
|
media: 'http://www.onvif.org/ver10/media/wsdl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OnvifClient {
|
||||||
|
private readonly eventHandlers = new Set<TEventHandler>();
|
||||||
|
private readonly events: IOnvifEvent[] = [];
|
||||||
|
private snapshot?: IOnvifSnapshot;
|
||||||
|
private liveProbeAttempted = false;
|
||||||
|
|
||||||
|
constructor(private readonly config: IOnvifConfig) {
|
||||||
|
this.snapshot = config.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(forceRefreshArg = false): Promise<IOnvifSnapshot> {
|
||||||
|
if (forceRefreshArg) {
|
||||||
|
this.snapshot = undefined;
|
||||||
|
this.liveProbeAttempted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.snapshot && !forceRefreshArg) {
|
||||||
|
this.snapshot = OnvifMapper.toSnapshot({ ...this.config, snapshot: this.snapshot }, this.snapshot.connected, this.events);
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.host && this.config.soap?.liveProbe === true && !this.liveProbeAttempted) {
|
||||||
|
this.liveProbeAttempted = true;
|
||||||
|
try {
|
||||||
|
this.snapshot = await this.fetchLiveSnapshot();
|
||||||
|
return this.snapshot;
|
||||||
|
} catch (error) {
|
||||||
|
this.snapshot = OnvifMapper.toSnapshot({
|
||||||
|
...this.config,
|
||||||
|
metadata: {
|
||||||
|
...this.config.metadata,
|
||||||
|
lastLiveProbeError: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
}, false, this.events);
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snapshot = OnvifMapper.toSnapshot(this.config, false, this.events);
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execute(commandArg: IOnvifClientCommand): Promise<unknown> {
|
||||||
|
if (commandArg.type === 'snapshot' || commandArg.type === 'refresh') {
|
||||||
|
return this.getSnapshot(commandArg.type === 'refresh');
|
||||||
|
}
|
||||||
|
if (commandArg.type === 'stream_metadata') {
|
||||||
|
return this.streamMetadata(commandArg);
|
||||||
|
}
|
||||||
|
if (commandArg.type === 'snapshot_metadata') {
|
||||||
|
return this.snapshotMetadata(commandArg);
|
||||||
|
}
|
||||||
|
if (commandArg.type === 'ptz') {
|
||||||
|
this.throwUnsupportedLiveOperation('PTZ commands');
|
||||||
|
}
|
||||||
|
if (commandArg.type === 'subscribe_events') {
|
||||||
|
this.throwUnsupportedLiveOperation('event subscriptions');
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported ONVIF command: ${commandArg.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
for (const event of this.events) {
|
||||||
|
handlerArg(event);
|
||||||
|
}
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async streamMetadata(commandArg: IOnvifClientCommand): Promise<unknown> {
|
||||||
|
const snapshot = await this.getSnapshot();
|
||||||
|
const target = this.findProfile(snapshot, commandArg.profileToken);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('ONVIF stream metadata requires a mapped profile token or a cached camera profile.');
|
||||||
|
}
|
||||||
|
const stream = this.streamForProfile(target.camera, target.profile);
|
||||||
|
if (!stream?.uri && this.config.soap?.liveProbe !== true) {
|
||||||
|
throw new Error('ONVIF stream URI is not cached. Enable soap.liveProbe for basic live GetStreamUri probing, or provide profiles/streams in the snapshot config.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profileToken: target.profile.token,
|
||||||
|
profileName: target.profile.name,
|
||||||
|
uri: stream?.uri || target.profile.streamUri,
|
||||||
|
protocol: stream?.protocol || this.protocolForUri(stream?.uri || target.profile.streamUri),
|
||||||
|
encoding: stream?.encoding || target.profile.video?.encoding,
|
||||||
|
resolution: stream?.resolution || target.profile.video?.resolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async snapshotMetadata(commandArg: IOnvifClientCommand): Promise<unknown> {
|
||||||
|
const snapshot = await this.getSnapshot();
|
||||||
|
const target = this.findProfile(snapshot, commandArg.profileToken);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('ONVIF snapshot metadata requires a mapped profile token or a cached camera profile.');
|
||||||
|
}
|
||||||
|
if (commandArg.snapshot?.fetchImage) {
|
||||||
|
if (!target.profile.snapshotUri) {
|
||||||
|
throw new Error('ONVIF snapshot image fetch requires a cached snapshot URI. Live snapshot image authentication is not implemented in this native port.');
|
||||||
|
}
|
||||||
|
return this.fetchSnapshotImage(target.profile.snapshotUri);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profileToken: target.profile.token,
|
||||||
|
profileName: target.profile.name,
|
||||||
|
uri: target.profile.snapshotUri || null,
|
||||||
|
available: Boolean(target.profile.snapshotUri || target.camera.capabilities?.snapshot),
|
||||||
|
fetchImageImplemented: Boolean(target.profile.snapshotUri),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchSnapshotImage(uriArg: string): Promise<unknown> {
|
||||||
|
const response = await fetch(uriArg, {
|
||||||
|
headers: this.config.soap?.basicAuth && this.config.username ? {
|
||||||
|
Authorization: `Basic ${Buffer.from(`${this.config.username}:${this.config.password || ''}`).toString('base64')}`,
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`ONVIF snapshot image fetch failed with HTTP ${response.status}. Digest-authenticated snapshot fetches are not implemented.`);
|
||||||
|
}
|
||||||
|
const body = Buffer.from(await response.arrayBuffer());
|
||||||
|
return {
|
||||||
|
contentType: response.headers.get('content-type') || 'application/octet-stream',
|
||||||
|
byteLength: body.byteLength,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwUnsupportedLiveOperation(operationArg: string): never {
|
||||||
|
throw new Error(`Live ONVIF ${operationArg} are not implemented in this native port. Cached PTZ/event metadata is mapped, but secure/live PTZ and PullPoint/webhook event operations require a full ONVIF service implementation.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchLiveSnapshot(): Promise<IOnvifSnapshot> {
|
||||||
|
const deviceServiceUrl = this.deviceServiceUrl();
|
||||||
|
const servicesXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetServices', '<tds:GetServices><tds:IncludeCapability>false</tds:IncludeCapability></tds:GetServices>');
|
||||||
|
const services = this.parseServices(servicesXml);
|
||||||
|
const deviceInfoXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetDeviceInformation', '<tds:GetDeviceInformation/>');
|
||||||
|
const capabilitiesXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetCapabilities', '<tds:GetCapabilities><tds:Category>All</tds:Category></tds:GetCapabilities>');
|
||||||
|
const mediaServiceUrl = this.serviceUrl(services, namespaces.media) || this.firstCapabilityXaddr(capabilitiesXml, 'Media');
|
||||||
|
const deviceInfo = this.parseDeviceInfo(deviceInfoXml);
|
||||||
|
const capabilities = {
|
||||||
|
media: Boolean(mediaServiceUrl),
|
||||||
|
stream: Boolean(mediaServiceUrl),
|
||||||
|
snapshot: /SnapshotUri\s*=\s*['"]true['"]/i.test(capabilitiesXml),
|
||||||
|
ptz: /<[^>]*PTZ\b/i.test(capabilitiesXml) || services.some((serviceArg) => /ptz/i.test(serviceArg.namespace)),
|
||||||
|
events: /<[^>]*Events\b/i.test(capabilitiesXml) || services.some((serviceArg) => /event/i.test(serviceArg.namespace)),
|
||||||
|
pullPointEvents: /WSPullPointSupport\s*=\s*['"]true['"]/i.test(capabilitiesXml),
|
||||||
|
raw: { capabilitiesXmlLength: capabilitiesXml.length },
|
||||||
|
};
|
||||||
|
const profiles = mediaServiceUrl ? await this.fetchProfiles(mediaServiceUrl) : [];
|
||||||
|
const streams: IOnvifStream[] = [];
|
||||||
|
|
||||||
|
if (mediaServiceUrl && this.config.soap?.fetchStreamUris !== false) {
|
||||||
|
for (const profile of profiles) {
|
||||||
|
try {
|
||||||
|
const uri = await this.fetchStreamUri(mediaServiceUrl, profile.token);
|
||||||
|
profile.streamUri = uri;
|
||||||
|
streams.push({ profileToken: profile.token, uri, protocol: this.protocolForUri(uri), encoding: profile.video?.encoding, resolution: profile.video?.resolution });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaServiceUrl && this.config.soap?.fetchSnapshotUris !== false) {
|
||||||
|
for (const profile of profiles) {
|
||||||
|
try {
|
||||||
|
profile.snapshotUri = await this.fetchSnapshotUri(mediaServiceUrl, profile.token);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera: IOnvifCameraSnapshot = {
|
||||||
|
id: this.config.id || deviceInfo.macAddress || deviceInfo.serialNumber || this.config.host,
|
||||||
|
name: this.config.name || deviceInfo.name || this.config.host,
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port || onvifDefaultPort,
|
||||||
|
transport: this.config.transport || 'http',
|
||||||
|
online: true,
|
||||||
|
deviceInfo,
|
||||||
|
capabilities,
|
||||||
|
profiles,
|
||||||
|
streams,
|
||||||
|
events: this.config.events || [],
|
||||||
|
services,
|
||||||
|
metadata: {
|
||||||
|
deviceServiceUrl,
|
||||||
|
mediaServiceUrl,
|
||||||
|
liveProbeImplemented: true,
|
||||||
|
livePtzImplemented: false,
|
||||||
|
liveEventsImplemented: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: camera.id,
|
||||||
|
name: camera.name,
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port || onvifDefaultPort,
|
||||||
|
transport: this.config.transport || 'http',
|
||||||
|
connected: true,
|
||||||
|
configured: true,
|
||||||
|
deviceInfo,
|
||||||
|
capabilities,
|
||||||
|
profiles,
|
||||||
|
streams,
|
||||||
|
events: this.config.events || [],
|
||||||
|
services,
|
||||||
|
cameras: [camera],
|
||||||
|
discovery: this.config.discovery,
|
||||||
|
metadata: {
|
||||||
|
liveProbeImplemented: true,
|
||||||
|
livePtzImplemented: false,
|
||||||
|
liveEventsImplemented: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchProfiles(mediaServiceUrlArg: string): Promise<IOnvifProfile[]> {
|
||||||
|
const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetProfiles', '<trt:GetProfiles/>');
|
||||||
|
return this.parseProfiles(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchStreamUri(mediaServiceUrlArg: string, profileTokenArg: string): Promise<string> {
|
||||||
|
const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetStreamUri', `<trt:GetStreamUri><trt:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup><trt:ProfileToken>${this.escapeXml(profileTokenArg)}</trt:ProfileToken></trt:GetStreamUri>`);
|
||||||
|
const uri = this.firstText(xml, 'Uri');
|
||||||
|
if (!uri) {
|
||||||
|
throw new Error('ONVIF GetStreamUri response did not contain Uri.');
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchSnapshotUri(mediaServiceUrlArg: string, profileTokenArg: string): Promise<string> {
|
||||||
|
const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetSnapshotUri', `<trt:GetSnapshotUri><trt:ProfileToken>${this.escapeXml(profileTokenArg)}</trt:ProfileToken></trt:GetSnapshotUri>`);
|
||||||
|
const uri = this.firstText(xml, 'Uri');
|
||||||
|
if (!uri) {
|
||||||
|
throw new Error('ONVIF GetSnapshotUri response did not contain Uri.');
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async soapRequest(urlArg: string, namespaceArg: string, actionArg: string, bodyArg: string): Promise<string> {
|
||||||
|
const actionUri = `${namespaceArg}/${actionArg}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), this.config.soap?.timeoutMs || 8000);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': `application/soap+xml; charset=utf-8; action="${actionUri}"`,
|
||||||
|
SOAPAction: `"${actionUri}"`,
|
||||||
|
};
|
||||||
|
if (this.config.soap?.basicAuth && this.config.username) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${this.config.username}:${this.config.password || ''}`).toString('base64')}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(urlArg, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: this.soapEnvelope(bodyArg),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`ONVIF SOAP ${actionArg} failed with HTTP ${response.status}: ${this.compactXml(text)}`);
|
||||||
|
}
|
||||||
|
if (/<(?:\w+:)?Fault\b/i.test(text)) {
|
||||||
|
throw new Error(`ONVIF SOAP ${actionArg} returned fault: ${this.compactXml(text)}`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private soapEnvelope(bodyArg: string): string {
|
||||||
|
return `<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:tds="${namespaces.device}" xmlns:trt="${namespaces.media}" xmlns:tt="http://www.onvif.org/ver10/schema">${this.securityHeader()}<s:Body>${bodyArg}</s:Body></s:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private securityHeader(): string {
|
||||||
|
if (!this.config.username || this.config.soap?.wsSecurityUsernameToken === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const nonce = plugins.crypto.randomBytes(16);
|
||||||
|
const created = new Date().toISOString();
|
||||||
|
const password = this.config.password || '';
|
||||||
|
const digest = plugins.crypto.createHash('sha1').update(Buffer.concat([nonce, Buffer.from(created + password)])).digest('base64');
|
||||||
|
return `<s:Header><wsse:Security s:mustUnderstand="1" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"><wsse:UsernameToken><wsse:Username>${this.escapeXml(this.config.username)}</wsse:Username><wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${digest}</wsse:Password><wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${nonce.toString('base64')}</wsse:Nonce><wsu:Created>${created}</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private deviceServiceUrl(): string {
|
||||||
|
const transport = this.config.transport || 'http';
|
||||||
|
const host = this.config.host || 'localhost';
|
||||||
|
const port = this.config.port || onvifDefaultPort;
|
||||||
|
const path = this.config.deviceServicePath || onvifDefaultDeviceServicePath;
|
||||||
|
if (/^https?:\/\//i.test(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return `${transport}://${host}:${port}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDeviceInfo(xmlArg: string): IOnvifDeviceInfo {
|
||||||
|
return {
|
||||||
|
name: this.config.name,
|
||||||
|
manufacturer: this.firstText(xmlArg, 'Manufacturer'),
|
||||||
|
model: this.firstText(xmlArg, 'Model'),
|
||||||
|
firmwareVersion: this.firstText(xmlArg, 'FirmwareVersion'),
|
||||||
|
serialNumber: this.firstText(xmlArg, 'SerialNumber'),
|
||||||
|
hardwareId: this.firstText(xmlArg, 'HardwareId'),
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port || onvifDefaultPort,
|
||||||
|
configurationUrl: this.config.host ? `${this.config.transport || 'http'}://${this.config.host}:${this.config.port || onvifDefaultPort}` : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseServices(xmlArg: string): IOnvifServiceDescriptor[] {
|
||||||
|
const services: IOnvifServiceDescriptor[] = [];
|
||||||
|
for (const block of this.blocks(xmlArg, 'Service')) {
|
||||||
|
const namespace = this.firstText(block, 'Namespace');
|
||||||
|
const xaddr = this.firstText(block, 'XAddr');
|
||||||
|
if (!namespace || !xaddr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
services.push({
|
||||||
|
namespace,
|
||||||
|
xaddr,
|
||||||
|
version: this.compactXml(this.block(block, 'Version') || ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseProfiles(xmlArg: string): IOnvifProfile[] {
|
||||||
|
const profiles: IOnvifProfile[] = [];
|
||||||
|
let index = 0;
|
||||||
|
for (const block of this.blocks(xmlArg, 'Profiles')) {
|
||||||
|
const token = this.attribute(block, 'token') || this.firstText(block, 'token') || `profile_${index}`;
|
||||||
|
const videoEncoder = this.block(block, 'VideoEncoderConfiguration') || '';
|
||||||
|
const resolution = this.block(videoEncoder, 'Resolution') || '';
|
||||||
|
const ptzBlock = this.block(block, 'PTZConfiguration') || '';
|
||||||
|
const videoSource = this.block(block, 'VideoSourceConfiguration') || '';
|
||||||
|
profiles.push({
|
||||||
|
index,
|
||||||
|
token,
|
||||||
|
name: this.firstText(block, 'Name') || `Profile ${index}`,
|
||||||
|
video: {
|
||||||
|
encoding: this.firstText(videoEncoder, 'Encoding'),
|
||||||
|
resolution: {
|
||||||
|
width: this.numberValue(this.firstText(resolution, 'Width')) || 0,
|
||||||
|
height: this.numberValue(this.firstText(resolution, 'Height')) || 0,
|
||||||
|
},
|
||||||
|
frameRate: this.numberValue(this.firstText(videoEncoder, 'FrameRateLimit')),
|
||||||
|
bitrate: this.numberValue(this.firstText(videoEncoder, 'BitrateLimit')),
|
||||||
|
},
|
||||||
|
ptz: ptzBlock ? {
|
||||||
|
continuous: /DefaultContinuousPanTiltVelocitySpace/i.test(ptzBlock),
|
||||||
|
relative: /DefaultRelativePanTiltTranslationSpace/i.test(ptzBlock),
|
||||||
|
absolute: /DefaultAbsolutePanTiltPositionSpace|DefaultAbsolutePantTiltPositionSpace/i.test(ptzBlock),
|
||||||
|
} : undefined,
|
||||||
|
videoSourceToken: this.firstText(videoSource, 'SourceToken'),
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serviceUrl(servicesArg: IOnvifServiceDescriptor[], namespaceArg: string): string | undefined {
|
||||||
|
return servicesArg.find((serviceArg) => serviceArg.namespace === namespaceArg || serviceArg.namespace.includes(namespaceArg))?.xaddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstCapabilityXaddr(xmlArg: string, capabilityArg: string): string | undefined {
|
||||||
|
const block = this.block(xmlArg, capabilityArg);
|
||||||
|
return block ? this.firstText(block, 'XAddr') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findProfile(snapshotArg: IOnvifSnapshot, profileTokenArg?: string): { camera: IOnvifCameraSnapshot; profile: IOnvifProfile } | undefined {
|
||||||
|
for (const camera of snapshotArg.cameras) {
|
||||||
|
const profile = profileTokenArg
|
||||||
|
? camera.profiles.find((profileArg) => profileArg.token === profileTokenArg)
|
||||||
|
: camera.profiles[0];
|
||||||
|
if (profile) {
|
||||||
|
return { camera, profile };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private streamForProfile(cameraArg: IOnvifCameraSnapshot, profileArg: IOnvifProfile): IOnvifStream | undefined {
|
||||||
|
return cameraArg.streams?.find((streamArg) => streamArg.profileToken === profileArg.token)
|
||||||
|
|| (profileArg.streamUri ? { profileToken: profileArg.token, uri: profileArg.streamUri, protocol: this.protocolForUri(profileArg.streamUri), encoding: profileArg.video?.encoding, resolution: profileArg.video?.resolution } : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private protocolForUri(uriArg?: string): 'rtsp' | 'rtsps' | 'http' | 'https' | 'unknown' {
|
||||||
|
if (!uriArg) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
const protocol = uriArg.split(':', 1)[0]?.toLowerCase();
|
||||||
|
return protocol === 'rtsp' || protocol === 'rtsps' || protocol === 'http' || protocol === 'https' ? protocol : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private blocks(xmlArg: string, localNameArg: string): string[] {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>[\\s\\S]*?<\\/([\\w.-]+:)?${localNameArg}>`, 'gi');
|
||||||
|
for (const match of xmlArg.matchAll(pattern)) {
|
||||||
|
blocks.push(match[0]);
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private block(xmlArg: string, localNameArg: string): string | undefined {
|
||||||
|
return this.blocks(xmlArg, localNameArg)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstText(xmlArg: string, localNameArg: string): string | undefined {
|
||||||
|
const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>([\\s\\S]*?)<\\/([\\w.-]+:)?${localNameArg}>`, 'i');
|
||||||
|
const match = xmlArg.match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.decodeXml(match[2].replace(/<[^>]+>/g, '').trim()) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private attribute(xmlArg: string, attributeArg: string): string | undefined {
|
||||||
|
const match = xmlArg.match(new RegExp(`${attributeArg}=["']([^"']+)["']`, 'i'));
|
||||||
|
return match ? this.decodeXml(match[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private compactXml(xmlArg: string): string {
|
||||||
|
return xmlArg.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 escapeXml(valueArg: string): string {
|
||||||
|
return valueArg.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeXml(valueArg: string): string {
|
||||||
|
return valueArg
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IOnvifConfig } from './onvif.types.js';
|
||||||
|
import { onvifDefaultPort } from './onvif.types.js';
|
||||||
|
|
||||||
|
export class OnvifConfigFlow implements IConfigFlow<IOnvifConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IOnvifConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect ONVIF Camera',
|
||||||
|
description: 'Provide ONVIF device service connection details. Username and password may be empty for cameras that allow anonymous ONVIF access.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'Port', type: 'number', required: true },
|
||||||
|
{ name: 'username', label: 'Username', type: 'text', required: false },
|
||||||
|
{ name: 'password', label: 'Password', type: 'password', required: false },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const host = this.stringValue(valuesArg.host) || candidateArg.host;
|
||||||
|
const port = this.portValue(valuesArg.port) || candidateArg.port || onvifDefaultPort;
|
||||||
|
if (!host) {
|
||||||
|
return { kind: 'error', title: 'ONVIF host required', error: 'host_required' };
|
||||||
|
}
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
return { kind: 'error', title: 'Invalid ONVIF port', error: 'invalid_port' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'ONVIF camera configured',
|
||||||
|
config: {
|
||||||
|
id: candidateArg.id,
|
||||||
|
name: candidateArg.name || host,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username: this.stringValue(valuesArg.username) || '',
|
||||||
|
password: this.stringValue(valuesArg.password) || '',
|
||||||
|
transport: candidateArg.metadata?.transport === 'https' ? 'https' : 'http',
|
||||||
|
discovery: {
|
||||||
|
source: candidateArg.metadata?.discoveryProtocol === 'ws-discovery' ? 'ws-discovery' : candidateArg.source === 'mdns' ? 'mdns' : 'manual',
|
||||||
|
id: candidateArg.id,
|
||||||
|
name: candidateArg.name,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
manufacturer: candidateArg.manufacturer,
|
||||||
|
model: candidateArg.model,
|
||||||
|
serialNumber: candidateArg.serialNumber,
|
||||||
|
macAddress: candidateArg.macAddress,
|
||||||
|
metadata: candidateArg.metadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private portValue(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg === 'number' && Number.isInteger(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||||
|
const parsed = Number(valueArg);
|
||||||
|
return Number.isInteger(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,88 @@
|
|||||||
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 { OnvifClient } from './onvif.classes.client.js';
|
||||||
|
import { OnvifConfigFlow } from './onvif.classes.configflow.js';
|
||||||
|
import { createOnvifDiscoveryDescriptor } from './onvif.discovery.js';
|
||||||
|
import { OnvifMapper } from './onvif.mapper.js';
|
||||||
|
import type { IOnvifConfig } from './onvif.types.js';
|
||||||
|
|
||||||
export class HomeAssistantOnvifIntegration extends DescriptorOnlyIntegration {
|
export class OnvifIntegration extends BaseIntegration<IOnvifConfig> {
|
||||||
constructor() {
|
public readonly domain = 'onvif';
|
||||||
super({
|
public readonly displayName = 'ONVIF';
|
||||||
domain: "onvif",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "ONVIF",
|
public readonly discoveryDescriptor = createOnvifDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new OnvifConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/onvif",
|
upstreamPath: 'homeassistant/components/onvif',
|
||||||
"upstreamDomain": "onvif",
|
upstreamDomain: 'onvif',
|
||||||
"integrationType": "device",
|
integrationType: 'device',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
requirements: ['onvif-zeep-async==4.0.4', 'onvif_parsers==2.3.0', 'WSDiscovery==2.1.2'],
|
||||||
"onvif-zeep-async==4.0.4",
|
dependencies: ['ffmpeg'],
|
||||||
"onvif_parsers==2.3.0",
|
afterDependencies: [],
|
||||||
"WSDiscovery==2.1.2"
|
codeowners: ['@jterrace'],
|
||||||
],
|
documentation: 'https://www.home-assistant.io/integrations/onvif',
|
||||||
"dependencies": [
|
discovery: {
|
||||||
"ffmpeg"
|
wsDiscovery: {
|
||||||
],
|
type: 'dn:NetworkVideoTransmitter',
|
||||||
"afterDependencies": [],
|
scope: 'onvif://www.onvif.org/Profile/Streaming',
|
||||||
"codeowners": [
|
|
||||||
"@jterrace"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
});
|
mdns: ['_onvif._tcp.local.'],
|
||||||
|
},
|
||||||
|
nativePort: {
|
||||||
|
snapshotMapping: true,
|
||||||
|
basicSoapProbe: true,
|
||||||
|
livePtz: false,
|
||||||
|
liveEvents: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public async setup(configArg: IOnvifConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
|
void contextArg;
|
||||||
|
return new OnvifIntegrationRuntime(new OnvifClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantOnvifIntegration extends OnvifIntegration {}
|
||||||
|
|
||||||
|
class OnvifIntegrationRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'onvif';
|
||||||
|
|
||||||
|
constructor(private readonly client: OnvifClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return OnvifMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return OnvifMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(OnvifMapper.toIntegrationEvent(eventArg)));
|
||||||
|
await this.client.getSnapshot();
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const command = OnvifMapper.commandForService(snapshot, requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `ONVIF service ${requestArg.domain}.${requestArg.service} has no native mapping.` };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await this.client.execute(command);
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type {
|
||||||
|
IDiscoveryCandidate,
|
||||||
|
IDiscoveryContext,
|
||||||
|
IDiscoveryMatch,
|
||||||
|
IDiscoveryMatcher,
|
||||||
|
IDiscoveryValidator,
|
||||||
|
} from '../../core/types.js';
|
||||||
|
import type { IOnvifManualDiscoveryRecord, IOnvifMdnsRecord, IOnvifWsDiscoveryRecord } from './onvif.types.js';
|
||||||
|
import { onvifDefaultPort } from './onvif.types.js';
|
||||||
|
|
||||||
|
export class OnvifWsDiscoveryMatcher implements IDiscoveryMatcher<IOnvifWsDiscoveryRecord> {
|
||||||
|
public id = 'onvif-ws-discovery-match';
|
||||||
|
public source = 'custom' as const;
|
||||||
|
public description = 'Recognize ONVIF WS-Discovery ProbeMatch records for NetworkVideoTransmitter devices.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IOnvifWsDiscoveryRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const xaddrs = this.list(recordArg.xaddrs ?? recordArg.xAddrs ?? recordArg.XAddrs);
|
||||||
|
const scopes = this.scopeValues(recordArg.scopes);
|
||||||
|
const types = this.list(recordArg.types);
|
||||||
|
const epr = recordArg.epr || recordArg.endpointReference || recordArg.endpoint_reference;
|
||||||
|
const firstUrl = this.firstUrl(xaddrs);
|
||||||
|
const scopeInfo = this.infoFromScopes(scopes);
|
||||||
|
const hasOnvifType = types.some((typeArg) => /NetworkVideoTransmitter|onvif/i.test(typeArg));
|
||||||
|
const hasOnvifScope = scopes.some((scopeArg) => /onvif:\/\/www\.onvif\.org\/Profile\/Streaming/i.test(scopeArg));
|
||||||
|
const hasOnvifXaddr = xaddrs.some((xaddrArg) => /\/onvif\//i.test(xaddrArg));
|
||||||
|
|
||||||
|
if (!hasOnvifType && !hasOnvifScope && !hasOnvifXaddr) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'WS-Discovery record does not contain ONVIF type, streaming scope, or ONVIF XAddr.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDeviceId = normalizeMac(scopeInfo.macAddress) || this.normalizedEpr(epr) || firstUrl?.host;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: hasOnvifScope || hasOnvifType ? 'certain' : 'high',
|
||||||
|
reason: 'WS-Discovery record advertises an ONVIF camera service.',
|
||||||
|
normalizedDeviceId,
|
||||||
|
candidate: {
|
||||||
|
source: 'custom',
|
||||||
|
integrationDomain: 'onvif',
|
||||||
|
id: normalizedDeviceId,
|
||||||
|
host: firstUrl?.hostname,
|
||||||
|
port: firstUrl?.port,
|
||||||
|
name: scopeInfo.name || recordArg.name || epr,
|
||||||
|
manufacturer: scopeInfo.manufacturer,
|
||||||
|
model: scopeInfo.hardware,
|
||||||
|
macAddress: normalizeMac(scopeInfo.macAddress),
|
||||||
|
metadata: {
|
||||||
|
discoveryProtocol: 'ws-discovery',
|
||||||
|
endpointReference: epr,
|
||||||
|
xaddrs,
|
||||||
|
scopes,
|
||||||
|
serviceTypes: types,
|
||||||
|
transport: firstUrl?.protocol,
|
||||||
|
raw: recordArg.metadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
discoveryProtocol: 'ws-discovery',
|
||||||
|
xaddrs,
|
||||||
|
scopes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private list(valueArg: string[] | string | undefined): string[] {
|
||||||
|
if (!valueArg) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return valueArg.map((value) => String(value)).filter(Boolean);
|
||||||
|
}
|
||||||
|
return String(valueArg).split(/\s+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scopeValues(valueArg: IOnvifWsDiscoveryRecord['scopes']): string[] {
|
||||||
|
if (!valueArg) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
return this.list(valueArg);
|
||||||
|
}
|
||||||
|
return valueArg.map((scopeArg) => {
|
||||||
|
if (typeof scopeArg === 'string') {
|
||||||
|
return scopeArg;
|
||||||
|
}
|
||||||
|
return scopeArg.value || scopeArg.Value || '';
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstUrl(xaddrsArg: string[]): { hostname: string; host: string; port: number; protocol: string } | undefined {
|
||||||
|
for (const xaddr of xaddrsArg) {
|
||||||
|
try {
|
||||||
|
const url = new URL(xaddr);
|
||||||
|
return {
|
||||||
|
hostname: url.hostname,
|
||||||
|
host: url.host,
|
||||||
|
port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : onvifDefaultPort,
|
||||||
|
protocol: url.protocol.replace(':', ''),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private infoFromScopes(scopesArg: string[]): { name?: string; hardware?: string; macAddress?: string; manufacturer?: string } {
|
||||||
|
const info: { name?: string; hardware?: string; macAddress?: string; manufacturer?: string } = {};
|
||||||
|
for (const scope of scopesArg) {
|
||||||
|
const lower = scope.toLowerCase();
|
||||||
|
const value = decodeURIComponent(scope.split('/').pop() || '');
|
||||||
|
if (lower.startsWith('onvif://www.onvif.org/name')) {
|
||||||
|
info.name = value;
|
||||||
|
} else if (lower.startsWith('onvif://www.onvif.org/hardware')) {
|
||||||
|
info.hardware = value;
|
||||||
|
} else if (lower.startsWith('onvif://www.onvif.org/mac')) {
|
||||||
|
info.macAddress = value;
|
||||||
|
} else if (lower.startsWith('onvif://www.onvif.org/manufacturer')) {
|
||||||
|
info.manufacturer = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizedEpr(valueArg?: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return valueArg.replace(/^urn:uuid:/i, '').trim() || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnvifMdnsMatcher implements IDiscoveryMatcher<IOnvifMdnsRecord> {
|
||||||
|
public id = 'onvif-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize ONVIF-capable camera mDNS records such as _onvif._tcp.local.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IOnvifMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const txt = { ...(recordArg.properties || {}), ...(recordArg.txt || {}) };
|
||||||
|
const type = this.stringValue(recordArg.type).toLowerCase();
|
||||||
|
const name = this.stringValue(recordArg.name);
|
||||||
|
const host = this.stringValue(recordArg.host || recordArg.hostname || recordArg.addresses?.[0]);
|
||||||
|
const txtValues = Object.values(txt).map((valueArg) => String(valueArg).toLowerCase());
|
||||||
|
const txtKeys = Object.keys(txt).map((keyArg) => keyArg.toLowerCase());
|
||||||
|
const hasOnvifHint = type.includes('_onvif')
|
||||||
|
|| name.toLowerCase().includes('onvif')
|
||||||
|
|| txtKeys.some((keyArg) => keyArg.includes('onvif'))
|
||||||
|
|| txtValues.some((valueArg) => valueArg.includes('onvif'));
|
||||||
|
|
||||||
|
if (!hasOnvifHint) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record does not advertise ONVIF metadata.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const macAddress = normalizeMac(this.stringValue(txt.mac || txt.macAddress || txt.mac_address || txt.hwaddr));
|
||||||
|
const normalizedDeviceId = macAddress || this.stringValue(txt.serial || txt.serialNumber || txt.serial_number) || host;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: type.includes('_onvif') ? 'high' : 'medium',
|
||||||
|
reason: 'mDNS record contains ONVIF camera metadata.',
|
||||||
|
normalizedDeviceId,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'onvif',
|
||||||
|
id: normalizedDeviceId,
|
||||||
|
host,
|
||||||
|
port: recordArg.port || onvifDefaultPort,
|
||||||
|
name,
|
||||||
|
manufacturer: this.stringValue(txt.manufacturer || txt.vendor),
|
||||||
|
model: this.stringValue(txt.model || txt.hardware),
|
||||||
|
serialNumber: this.stringValue(txt.serial || txt.serialNumber || txt.serial_number),
|
||||||
|
macAddress,
|
||||||
|
metadata: {
|
||||||
|
discoveryProtocol: 'mdns',
|
||||||
|
type: recordArg.type,
|
||||||
|
txt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string {
|
||||||
|
return typeof valueArg === 'string' ? valueArg.trim() : valueArg === undefined || valueArg === null ? '' : String(valueArg).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnvifManualMatcher implements IDiscoveryMatcher<IOnvifManualDiscoveryRecord> {
|
||||||
|
public id = 'onvif-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manually configured ONVIF camera entries and cached snapshots.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IOnvifManualDiscoveryRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const snapshot = recordArg.snapshot || recordArg.discovery?.snapshot;
|
||||||
|
const host = recordArg.host || snapshot?.host || snapshot?.cameras[0]?.host;
|
||||||
|
const port = recordArg.port || snapshot?.port || snapshot?.cameras[0]?.port || onvifDefaultPort;
|
||||||
|
if (!host && !snapshot) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual ONVIF entries require a host or cached snapshot.' };
|
||||||
|
}
|
||||||
|
const deviceInfo = recordArg.deviceInfo || snapshot?.deviceInfo || snapshot?.cameras[0]?.deviceInfo;
|
||||||
|
const normalizedDeviceId = normalizeMac(deviceInfo?.macAddress) || deviceInfo?.serialNumber || recordArg.id || recordArg.discovery?.id || host;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: snapshot ? 'certain' : 'medium',
|
||||||
|
reason: snapshot ? 'Manual entry contains an ONVIF snapshot.' : 'Manual entry contains ONVIF host configuration.',
|
||||||
|
normalizedDeviceId,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'onvif',
|
||||||
|
id: normalizedDeviceId,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
name: recordArg.name || snapshot?.name || deviceInfo?.name,
|
||||||
|
manufacturer: deviceInfo?.manufacturer,
|
||||||
|
model: deviceInfo?.model,
|
||||||
|
serialNumber: deviceInfo?.serialNumber,
|
||||||
|
macAddress: normalizeMac(deviceInfo?.macAddress),
|
||||||
|
metadata: {
|
||||||
|
discoveryProtocol: 'manual',
|
||||||
|
snapshot,
|
||||||
|
profiles: recordArg.profiles,
|
||||||
|
streams: recordArg.streams,
|
||||||
|
transport: recordArg.transport || snapshot?.transport || 'http',
|
||||||
|
...recordArg.metadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnvifCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'onvif-candidate-validator';
|
||||||
|
public description = 'Confirm an ONVIF discovery candidate has a usable host/port or cached snapshot.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||||
|
void contextArg;
|
||||||
|
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== 'onvif') {
|
||||||
|
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not ONVIF.` };
|
||||||
|
}
|
||||||
|
const snapshot = candidateArg.metadata?.snapshot;
|
||||||
|
const port = candidateArg.port || onvifDefaultPort;
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'ONVIF candidate has an invalid port.' };
|
||||||
|
}
|
||||||
|
if (!candidateArg.host && !snapshot) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'ONVIF candidate requires host information or a cached snapshot.' };
|
||||||
|
}
|
||||||
|
const id = normalizeMac(candidateArg.macAddress) || candidateArg.serialNumber || candidateArg.id || candidateArg.host;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: candidateArg.id || candidateArg.macAddress || snapshot ? 'high' : 'medium',
|
||||||
|
reason: 'Candidate has enough ONVIF metadata to start configuration.',
|
||||||
|
candidate: candidateArg,
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
metadata: {
|
||||||
|
discoveryProtocol: candidateArg.metadata?.discoveryProtocol || candidateArg.source,
|
||||||
|
wsDiscoverySupported: candidateArg.metadata?.discoveryProtocol === 'ws-discovery',
|
||||||
|
mdnsSupported: candidateArg.source === 'mdns',
|
||||||
|
manualSupported: candidateArg.source === 'manual',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createOnvifDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({
|
||||||
|
integrationDomain: 'onvif',
|
||||||
|
displayName: 'ONVIF',
|
||||||
|
})
|
||||||
|
.addMatcher(new OnvifWsDiscoveryMatcher())
|
||||||
|
.addMatcher(new OnvifMdnsMatcher())
|
||||||
|
.addMatcher(new OnvifManualMatcher())
|
||||||
|
.addValidator(new OnvifCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMac = (valueArg?: string): string | undefined => {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
if (compact.length !== 12) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return compact.match(/.{1,2}/g)?.join(':');
|
||||||
|
};
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest } from '../../core/types.js';
|
||||||
|
import type {
|
||||||
|
IOnvifCameraSnapshot,
|
||||||
|
IOnvifClientCommand,
|
||||||
|
IOnvifConfig,
|
||||||
|
IOnvifEvent,
|
||||||
|
IOnvifProfile,
|
||||||
|
IOnvifSnapshot,
|
||||||
|
IOnvifStream,
|
||||||
|
TOnvifPtzMoveMode,
|
||||||
|
TOnvifStreamProtocol,
|
||||||
|
} from './onvif.types.js';
|
||||||
|
import { onvifDefaultPort } from './onvif.types.js';
|
||||||
|
|
||||||
|
const cameraServiceNames = new Set(['stream', 'stream_source', 'get_stream', 'stream_metadata']);
|
||||||
|
const snapshotServiceNames = new Set(['snapshot', 'camera_image', 'camera_snapshot', 'snapshot_metadata']);
|
||||||
|
const ptzMoveModes = new Set<TOnvifPtzMoveMode>(['ContinuousMove', 'RelativeMove', 'AbsoluteMove', 'GotoPreset', 'Stop']);
|
||||||
|
|
||||||
|
export class OnvifMapper {
|
||||||
|
public static toSnapshot(configArg: IOnvifConfig, connectedArg?: boolean, eventsArg: IOnvifEvent[] = []): IOnvifSnapshot {
|
||||||
|
const source = configArg.snapshot;
|
||||||
|
const cameras = this.uniqueCameras([
|
||||||
|
...(source?.cameras || []),
|
||||||
|
...(configArg.cameras || []),
|
||||||
|
...this.camerasFromManualEntries(configArg.manualEntries || []),
|
||||||
|
this.cameraFromTopLevel(configArg, source, eventsArg),
|
||||||
|
].filter((cameraArg): cameraArg is IOnvifCameraSnapshot => Boolean(cameraArg)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: configArg.id || source?.id || configArg.discovery?.id,
|
||||||
|
name: configArg.name || source?.name || configArg.discovery?.name,
|
||||||
|
host: configArg.host || source?.host || configArg.discovery?.host,
|
||||||
|
port: configArg.port || source?.port || configArg.discovery?.port || onvifDefaultPort,
|
||||||
|
transport: configArg.transport || source?.transport || configArg.discovery?.transport || 'http',
|
||||||
|
connected: connectedArg ?? source?.connected ?? cameras.some((cameraArg) => cameraArg.online === true),
|
||||||
|
configured: Boolean(configArg.host || source?.configured || cameras.length),
|
||||||
|
deviceInfo: configArg.deviceInfo || source?.deviceInfo,
|
||||||
|
capabilities: configArg.capabilities || source?.capabilities,
|
||||||
|
profiles: [...(source?.profiles || []), ...(configArg.profiles || [])],
|
||||||
|
streams: [...(source?.streams || []), ...(configArg.streams || [])],
|
||||||
|
events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg],
|
||||||
|
services: source?.services,
|
||||||
|
cameras,
|
||||||
|
discovery: configArg.discovery || source?.discovery,
|
||||||
|
metadata: {
|
||||||
|
...source?.metadata,
|
||||||
|
...configArg.metadata,
|
||||||
|
livePtzImplemented: false,
|
||||||
|
liveEventsImplemented: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toDevices(snapshotArg: IOnvifSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
return this.cameras(snapshotArg).map((cameraArg) => {
|
||||||
|
const deviceId = this.cameraDeviceId(cameraArg, snapshotArg);
|
||||||
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||||
|
{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false },
|
||||||
|
];
|
||||||
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||||
|
{ featureId: 'availability', value: cameraArg.online === false ? 'offline' : snapshotArg.connected || cameraArg.online ? 'online' : 'configured', updatedAt },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const profile of cameraArg.profiles) {
|
||||||
|
const stream = this.streamForProfile(cameraArg, profile);
|
||||||
|
const streamFeatureId = `profile_${this.slug(profile.token)}_stream`;
|
||||||
|
features.push({ id: streamFeatureId, capability: 'camera', name: `${this.profileName(profile)} Stream`, readable: true, writable: false });
|
||||||
|
state.push({ featureId: streamFeatureId, value: this.streamState(profile, stream), updatedAt });
|
||||||
|
|
||||||
|
if (cameraArg.capabilities?.snapshot || profile.snapshotUri) {
|
||||||
|
const snapshotFeatureId = `profile_${this.slug(profile.token)}_snapshot`;
|
||||||
|
features.push({ id: snapshotFeatureId, capability: 'camera', name: `${this.profileName(profile)} Snapshot`, readable: true, writable: false });
|
||||||
|
state.push({ featureId: snapshotFeatureId, value: { uri: profile.snapshotUri || null, available: Boolean(profile.snapshotUri || cameraArg.capabilities?.snapshot) }, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraArg.capabilities?.ptz || profile.ptz) {
|
||||||
|
const ptzFeatureId = `profile_${this.slug(profile.token)}_ptz`;
|
||||||
|
features.push({ id: ptzFeatureId, capability: 'camera', name: `${this.profileName(profile)} PTZ`, readable: true, writable: true });
|
||||||
|
state.push({ featureId: ptzFeatureId, value: { modes: this.ptzModes(profile), presets: profile.ptz?.presets || [] }, updatedAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of cameraArg.events || []) {
|
||||||
|
const featureId = `event_${this.slug(event.uid)}`;
|
||||||
|
features.push({ id: featureId, capability: 'sensor', name: event.name, readable: true, writable: false, unit: event.unit });
|
||||||
|
state.push({ featureId, value: this.deviceStateValue(event.value), updatedAt: new Date(event.timestamp || Date.now()).toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: deviceId,
|
||||||
|
integrationDomain: 'onvif',
|
||||||
|
name: this.cameraName(cameraArg, snapshotArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: cameraArg.deviceInfo?.manufacturer || snapshotArg.deviceInfo?.manufacturer || 'ONVIF',
|
||||||
|
model: cameraArg.deviceInfo?.model || snapshotArg.deviceInfo?.model,
|
||||||
|
online: cameraArg.online ?? snapshotArg.connected,
|
||||||
|
features: this.uniqueFeatures(features),
|
||||||
|
state,
|
||||||
|
metadata: {
|
||||||
|
host: cameraArg.host || snapshotArg.host,
|
||||||
|
port: cameraArg.port || snapshotArg.port || onvifDefaultPort,
|
||||||
|
transport: cameraArg.transport || snapshotArg.transport || 'http',
|
||||||
|
macAddress: this.normalizeMac(cameraArg.deviceInfo?.macAddress || snapshotArg.deviceInfo?.macAddress),
|
||||||
|
serialNumber: cameraArg.deviceInfo?.serialNumber || snapshotArg.deviceInfo?.serialNumber,
|
||||||
|
firmwareVersion: cameraArg.deviceInfo?.firmwareVersion || snapshotArg.deviceInfo?.firmwareVersion,
|
||||||
|
capabilities: cameraArg.capabilities || snapshotArg.capabilities,
|
||||||
|
services: cameraArg.services || snapshotArg.services,
|
||||||
|
cameraEntityPlatformConstraint: 'sensor',
|
||||||
|
livePtzImplemented: false,
|
||||||
|
liveEventsImplemented: false,
|
||||||
|
...cameraArg.metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IOnvifSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
const usedIds = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const camera of this.cameras(snapshotArg)) {
|
||||||
|
const deviceId = this.cameraDeviceId(camera, snapshotArg);
|
||||||
|
const cameraName = this.cameraName(camera, snapshotArg);
|
||||||
|
for (const profile of camera.profiles) {
|
||||||
|
const stream = this.streamForProfile(camera, profile);
|
||||||
|
const name = `${cameraName} ${this.profileName(profile)}`;
|
||||||
|
entities.push(this.entity('sensor', `${name} Camera`, deviceId, `onvif_${this.slug(`${deviceId}_${profile.token}`)}`, stream?.uri || profile.streamUri ? 'available' : 'configured', usedIds, {
|
||||||
|
capability: 'camera',
|
||||||
|
platformConstraint: 'sensor',
|
||||||
|
profileToken: profile.token,
|
||||||
|
profileName: profile.name,
|
||||||
|
streamUri: stream?.uri || profile.streamUri,
|
||||||
|
streamProtocol: stream?.protocol || this.streamProtocol(stream?.uri || profile.streamUri),
|
||||||
|
snapshotUri: profile.snapshotUri,
|
||||||
|
encoding: profile.video?.encoding || stream?.encoding,
|
||||||
|
resolution: profile.video?.resolution || stream?.resolution,
|
||||||
|
ptz: profile.ptz,
|
||||||
|
serviceMappings: {
|
||||||
|
streamMetadata: 'camera.stream_metadata',
|
||||||
|
snapshotMetadata: 'camera.snapshot_metadata',
|
||||||
|
ptz: 'camera.ptz',
|
||||||
|
},
|
||||||
|
livePtzImplemented: false,
|
||||||
|
liveEventsImplemented: false,
|
||||||
|
}, camera.online !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of camera.events || []) {
|
||||||
|
entities.push(this.entity(event.platform, `${cameraName} ${event.name}`, deviceId, event.uid, this.eventState(event), usedIds, {
|
||||||
|
deviceClass: event.deviceClass,
|
||||||
|
unit: event.unit,
|
||||||
|
entityCategory: event.entityCategory,
|
||||||
|
topic: event.topic,
|
||||||
|
metadata: event.metadata,
|
||||||
|
}, camera.online !== false, undefined, event.entityEnabled !== false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toIntegrationEvent(eventArg: IOnvifEvent): IIntegrationEvent {
|
||||||
|
return {
|
||||||
|
type: 'state_changed',
|
||||||
|
integrationDomain: 'onvif',
|
||||||
|
entityId: eventArg.uid,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static commandForService(snapshotArg: IOnvifSnapshot, requestArg: IServiceCallRequest): IOnvifClientCommand | undefined {
|
||||||
|
if (requestArg.domain === 'onvif') {
|
||||||
|
if (requestArg.service === 'snapshot' || requestArg.service === 'refresh') {
|
||||||
|
return { type: requestArg.service, service: requestArg.service, target: requestArg.target, data: requestArg.data } as IOnvifClientCommand;
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'subscribe_events') {
|
||||||
|
return { type: 'subscribe_events', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this.findTargetProfile(snapshotArg, requestArg);
|
||||||
|
const profileToken = target?.profile.token || this.stringValue(requestArg.data?.profileToken || requestArg.data?.profile_token);
|
||||||
|
|
||||||
|
if (requestArg.domain === 'camera' && cameraServiceNames.has(requestArg.service)) {
|
||||||
|
return {
|
||||||
|
type: 'stream_metadata',
|
||||||
|
service: requestArg.service,
|
||||||
|
entityId: requestArg.target.entityId,
|
||||||
|
deviceId: requestArg.target.deviceId,
|
||||||
|
profileToken,
|
||||||
|
stream: { profileToken },
|
||||||
|
target: requestArg.target,
|
||||||
|
data: requestArg.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === 'camera' && snapshotServiceNames.has(requestArg.service)) {
|
||||||
|
return {
|
||||||
|
type: 'snapshot_metadata',
|
||||||
|
service: requestArg.service,
|
||||||
|
entityId: requestArg.target.entityId,
|
||||||
|
deviceId: requestArg.target.deviceId,
|
||||||
|
profileToken,
|
||||||
|
snapshot: {
|
||||||
|
profileToken,
|
||||||
|
fetchImage: requestArg.service === 'camera_image' || requestArg.data?.fetchImage === true,
|
||||||
|
width: this.numberValue(requestArg.data?.width),
|
||||||
|
height: this.numberValue(requestArg.data?.height),
|
||||||
|
},
|
||||||
|
target: requestArg.target,
|
||||||
|
data: requestArg.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === 'camera' && requestArg.service === 'ptz') {
|
||||||
|
const moveMode = this.ptzMoveMode(requestArg.data?.move_mode || requestArg.data?.moveMode) || 'RelativeMove';
|
||||||
|
return {
|
||||||
|
type: 'ptz',
|
||||||
|
service: requestArg.service,
|
||||||
|
entityId: requestArg.target.entityId,
|
||||||
|
deviceId: requestArg.target.deviceId,
|
||||||
|
profileToken,
|
||||||
|
ptz: {
|
||||||
|
profileToken,
|
||||||
|
moveMode,
|
||||||
|
pan: this.stringValue(requestArg.data?.pan) as 'LEFT' | 'RIGHT' | undefined,
|
||||||
|
tilt: this.stringValue(requestArg.data?.tilt) as 'UP' | 'DOWN' | undefined,
|
||||||
|
zoom: this.stringValue(requestArg.data?.zoom) as 'ZOOM_IN' | 'ZOOM_OUT' | undefined,
|
||||||
|
distance: this.numberValue(requestArg.data?.distance),
|
||||||
|
speed: this.numberValue(requestArg.data?.speed),
|
||||||
|
continuousDuration: this.numberValue(requestArg.data?.continuous_duration ?? requestArg.data?.continuousDuration),
|
||||||
|
preset: this.stringValue(requestArg.data?.preset),
|
||||||
|
},
|
||||||
|
target: requestArg.target,
|
||||||
|
data: requestArg.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static cameraDeviceId(cameraArg: IOnvifCameraSnapshot, snapshotArg?: IOnvifSnapshot): string {
|
||||||
|
const info = cameraArg.deviceInfo || snapshotArg?.deviceInfo;
|
||||||
|
const identifier = this.normalizeMac(info?.macAddress)
|
||||||
|
|| info?.serialNumber
|
||||||
|
|| cameraArg.id
|
||||||
|
|| snapshotArg?.id
|
||||||
|
|| cameraArg.host
|
||||||
|
|| snapshotArg?.host
|
||||||
|
|| 'configured';
|
||||||
|
return `onvif.camera.${this.slug(identifier)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static normalizeMac(valueArg?: string): string | undefined {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
if (compact.length !== 12) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return compact.match(/.{1,2}/g)?.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static cameras(snapshotArg: IOnvifSnapshot): IOnvifCameraSnapshot[] {
|
||||||
|
if (snapshotArg.cameras.length) {
|
||||||
|
return snapshotArg.cameras;
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
id: snapshotArg.id,
|
||||||
|
name: snapshotArg.name,
|
||||||
|
host: snapshotArg.host,
|
||||||
|
port: snapshotArg.port,
|
||||||
|
transport: snapshotArg.transport,
|
||||||
|
online: snapshotArg.connected,
|
||||||
|
deviceInfo: snapshotArg.deviceInfo,
|
||||||
|
capabilities: snapshotArg.capabilities,
|
||||||
|
profiles: snapshotArg.profiles || [],
|
||||||
|
streams: snapshotArg.streams,
|
||||||
|
events: snapshotArg.events,
|
||||||
|
services: snapshotArg.services,
|
||||||
|
metadata: snapshotArg.metadata,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static cameraFromTopLevel(configArg: IOnvifConfig, sourceArg: IOnvifSnapshot | undefined, eventsArg: IOnvifEvent[]): IOnvifCameraSnapshot | undefined {
|
||||||
|
const profiles = [...(sourceArg?.profiles || []), ...(configArg.profiles || [])];
|
||||||
|
const streams = [...(sourceArg?.streams || []), ...(configArg.streams || [])];
|
||||||
|
const events = [...(sourceArg?.events || []), ...(configArg.events || []), ...eventsArg];
|
||||||
|
const hasTopLevel = Boolean(configArg.host || configArg.deviceInfo || configArg.capabilities || profiles.length || streams.length || events.length || sourceArg?.host || sourceArg?.deviceInfo);
|
||||||
|
if (!hasTopLevel) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: configArg.id || sourceArg?.id || configArg.discovery?.id,
|
||||||
|
name: configArg.name || sourceArg?.name || configArg.discovery?.name,
|
||||||
|
host: configArg.host || sourceArg?.host || configArg.discovery?.host,
|
||||||
|
port: configArg.port || sourceArg?.port || configArg.discovery?.port || onvifDefaultPort,
|
||||||
|
transport: configArg.transport || sourceArg?.transport || configArg.discovery?.transport || 'http',
|
||||||
|
online: sourceArg?.connected,
|
||||||
|
deviceInfo: configArg.deviceInfo || sourceArg?.deviceInfo,
|
||||||
|
capabilities: configArg.capabilities || sourceArg?.capabilities,
|
||||||
|
profiles,
|
||||||
|
streams,
|
||||||
|
events,
|
||||||
|
services: sourceArg?.services,
|
||||||
|
metadata: { ...sourceArg?.metadata, ...configArg.metadata },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static camerasFromManualEntries(entriesArg: NonNullable<IOnvifConfig['manualEntries']>): IOnvifCameraSnapshot[] {
|
||||||
|
const cameras: IOnvifCameraSnapshot[] = [];
|
||||||
|
for (const entry of entriesArg) {
|
||||||
|
if (entry.snapshot) {
|
||||||
|
cameras.push(...entry.snapshot.cameras);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.host && !entry.profiles?.length && !entry.deviceInfo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cameras.push({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
host: entry.host,
|
||||||
|
port: entry.port || onvifDefaultPort,
|
||||||
|
transport: entry.transport || 'http',
|
||||||
|
deviceInfo: entry.deviceInfo,
|
||||||
|
capabilities: entry.capabilities,
|
||||||
|
profiles: entry.profiles || [],
|
||||||
|
streams: entry.streams,
|
||||||
|
events: entry.events,
|
||||||
|
metadata: entry.metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueCameras(camerasArg: IOnvifCameraSnapshot[]): IOnvifCameraSnapshot[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const cameras: IOnvifCameraSnapshot[] = [];
|
||||||
|
for (const camera of camerasArg) {
|
||||||
|
const key = this.slug(this.normalizeMac(camera.deviceInfo?.macAddress) || camera.deviceInfo?.serialNumber || camera.id || camera.host || camera.name || String(cameras.length));
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
cameras.push(camera);
|
||||||
|
}
|
||||||
|
return cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueFeatures(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[]): plugins.shxInterfaces.data.IDeviceFeature[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return featuresArg.filter((featureArg) => {
|
||||||
|
if (seen.has(featureArg.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(featureArg.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static cameraName(cameraArg: IOnvifCameraSnapshot, snapshotArg?: IOnvifSnapshot): string {
|
||||||
|
return cameraArg.name || cameraArg.deviceInfo?.name || snapshotArg?.name || snapshotArg?.deviceInfo?.name || cameraArg.host || snapshotArg?.host || 'ONVIF Camera';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static profileName(profileArg: IOnvifProfile): string {
|
||||||
|
return profileArg.name || `Profile ${profileArg.index ?? profileArg.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static streamForProfile(cameraArg: IOnvifCameraSnapshot, profileArg: IOnvifProfile): IOnvifStream | undefined {
|
||||||
|
return cameraArg.streams?.find((streamArg) => streamArg.profileToken === profileArg.token)
|
||||||
|
|| (profileArg.streamUri ? {
|
||||||
|
profileToken: profileArg.token,
|
||||||
|
uri: profileArg.streamUri,
|
||||||
|
protocol: this.streamProtocol(profileArg.streamUri),
|
||||||
|
encoding: profileArg.video?.encoding,
|
||||||
|
resolution: profileArg.video?.resolution,
|
||||||
|
} : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static streamState(profileArg: IOnvifProfile, streamArg?: IOnvifStream): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
profileToken: profileArg.token,
|
||||||
|
uri: streamArg?.uri || profileArg.streamUri || null,
|
||||||
|
protocol: streamArg?.protocol || this.streamProtocol(streamArg?.uri || profileArg.streamUri),
|
||||||
|
encoding: streamArg?.encoding || profileArg.video?.encoding || null,
|
||||||
|
resolution: streamArg?.resolution || profileArg.video?.resolution || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static streamProtocol(uriArg?: string): TOnvifStreamProtocol {
|
||||||
|
if (!uriArg) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
const protocol = uriArg.split(':', 1)[0]?.toLowerCase();
|
||||||
|
return protocol === 'rtsp' || protocol === 'rtsps' || protocol === 'http' || protocol === 'https' ? protocol : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ptzModes(profileArg: IOnvifProfile): TOnvifPtzMoveMode[] {
|
||||||
|
const modes: TOnvifPtzMoveMode[] = [];
|
||||||
|
if (profileArg.ptz?.continuous) {
|
||||||
|
modes.push('ContinuousMove');
|
||||||
|
}
|
||||||
|
if (profileArg.ptz?.relative) {
|
||||||
|
modes.push('RelativeMove');
|
||||||
|
}
|
||||||
|
if (profileArg.ptz?.absolute) {
|
||||||
|
modes.push('AbsoluteMove');
|
||||||
|
}
|
||||||
|
if (profileArg.ptz?.presets?.length) {
|
||||||
|
modes.push('GotoPreset');
|
||||||
|
}
|
||||||
|
if (modes.length) {
|
||||||
|
modes.push('Stop');
|
||||||
|
}
|
||||||
|
return modes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findTargetProfile(snapshotArg: IOnvifSnapshot, requestArg: IServiceCallRequest): { camera: IOnvifCameraSnapshot; profile: IOnvifProfile } | undefined {
|
||||||
|
const requestedToken = this.stringValue(requestArg.data?.profileToken || requestArg.data?.profile_token);
|
||||||
|
for (const camera of this.cameras(snapshotArg)) {
|
||||||
|
for (const profile of camera.profiles) {
|
||||||
|
if (requestedToken && profile.token === requestedToken) {
|
||||||
|
return { camera, profile };
|
||||||
|
}
|
||||||
|
if (requestArg.target.deviceId && this.cameraDeviceId(camera, snapshotArg) !== requestArg.target.deviceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (requestArg.target.entityId) {
|
||||||
|
const entityId = `sensor.${this.slug(`${this.cameraName(camera, snapshotArg)} ${this.profileName(profile)} Camera`)}`;
|
||||||
|
if (requestArg.target.entityId !== entityId && requestArg.target.entityId !== `onvif_${this.slug(`${this.cameraDeviceId(camera, snapshotArg)}_${profile.token}`)}`) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { camera, profile };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entity(platformArg: 'sensor' | 'binary_sensor', nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean, explicitIdArg?: string, enabledArg = true): IIntegrationEntity {
|
||||||
|
const baseId = explicitIdArg || `${platformArg}.${this.slug(nameArg)}`;
|
||||||
|
const id = this.uniqueEntityId(baseId, usedIdsArg);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
uniqueId: uniqueIdArg,
|
||||||
|
integrationDomain: 'onvif',
|
||||||
|
deviceId: deviceIdArg,
|
||||||
|
platform: platformArg,
|
||||||
|
name: nameArg,
|
||||||
|
state: stateArg,
|
||||||
|
attributes: {
|
||||||
|
...attributesArg,
|
||||||
|
entityEnabled: enabledArg,
|
||||||
|
},
|
||||||
|
available: availableArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueEntityId(baseIdArg: string, usedIdsArg: Map<string, number>): string {
|
||||||
|
const count = usedIdsArg.get(baseIdArg) || 0;
|
||||||
|
usedIdsArg.set(baseIdArg, count + 1);
|
||||||
|
return count ? `${baseIdArg}_${count + 1}` : baseIdArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static eventState(eventArg: IOnvifEvent): unknown {
|
||||||
|
if (eventArg.platform === 'binary_sensor') {
|
||||||
|
return eventArg.value ? 'on' : 'off';
|
||||||
|
}
|
||||||
|
return eventArg.value ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||||
|
if (valueArg === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'object') {
|
||||||
|
return valueArg as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return String(valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ptzMoveMode(valueArg: unknown): TOnvifPtzMoveMode | undefined {
|
||||||
|
const value = this.stringValue(valueArg);
|
||||||
|
return value && ptzMoveModes.has(value as TOnvifPtzMoveMode) ? value as TOnvifPtzMoveMode : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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 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, '') || 'onvif';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,286 @@
|
|||||||
export interface IHomeAssistantOnvifConfig {
|
import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js';
|
||||||
// TODO: replace with the TypeScript-native config for onvif.
|
|
||||||
|
export const onvifDefaultPort = 80;
|
||||||
|
export const onvifDefaultDeviceServicePath = '/onvif/device_service';
|
||||||
|
|
||||||
|
export type TOnvifTransport = 'http' | 'https';
|
||||||
|
export type TOnvifStreamProtocol = 'rtsp' | 'rtsps' | 'http' | 'https' | 'unknown';
|
||||||
|
export type TOnvifEventPlatform = Extract<TEntityPlatform, 'sensor' | 'binary_sensor'>;
|
||||||
|
export type TOnvifPtzMoveMode = 'ContinuousMove' | 'RelativeMove' | 'AbsoluteMove' | 'GotoPreset' | 'Stop';
|
||||||
|
export type TOnvifPanDirection = 'LEFT' | 'RIGHT';
|
||||||
|
export type TOnvifTiltDirection = 'UP' | 'DOWN';
|
||||||
|
export type TOnvifZoomDirection = 'ZOOM_IN' | 'ZOOM_OUT';
|
||||||
|
|
||||||
|
export interface IOnvifConfig {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
transport?: TOnvifTransport;
|
||||||
|
deviceServicePath?: string;
|
||||||
|
deviceInfo?: IOnvifDeviceInfo;
|
||||||
|
capabilities?: IOnvifCapabilities;
|
||||||
|
profiles?: IOnvifProfile[];
|
||||||
|
streams?: IOnvifStream[];
|
||||||
|
events?: IOnvifEvent[];
|
||||||
|
cameras?: IOnvifCameraSnapshot[];
|
||||||
|
snapshot?: IOnvifSnapshot;
|
||||||
|
manualEntries?: IOnvifManualEntry[];
|
||||||
|
discovery?: IOnvifDiscoveryRecord;
|
||||||
|
soap?: IOnvifSoapOptions;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantOnvifConfig extends IOnvifConfig {}
|
||||||
|
|
||||||
|
export interface IOnvifSoapOptions {
|
||||||
|
timeoutMs?: number;
|
||||||
|
basicAuth?: boolean;
|
||||||
|
wsSecurityUsernameToken?: boolean;
|
||||||
|
fetchStreamUris?: boolean;
|
||||||
|
fetchSnapshotUris?: boolean;
|
||||||
|
liveProbe?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifDeviceInfo {
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
hardwareId?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
configurationUrl?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IOnvifCapabilities {
|
||||||
|
snapshot?: boolean;
|
||||||
|
events?: boolean;
|
||||||
|
pullPointEvents?: boolean;
|
||||||
|
webhookEvents?: boolean;
|
||||||
|
ptz?: boolean;
|
||||||
|
imaging?: boolean;
|
||||||
|
media?: boolean;
|
||||||
|
stream?: boolean;
|
||||||
|
supportedProfiles?: string[];
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifResolution {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifVideoProfile {
|
||||||
|
encoding?: string;
|
||||||
|
resolution?: IOnvifResolution;
|
||||||
|
frameRate?: number;
|
||||||
|
bitrate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifPtzCapabilities {
|
||||||
|
continuous?: boolean;
|
||||||
|
relative?: boolean;
|
||||||
|
absolute?: boolean;
|
||||||
|
presets?: string[];
|
||||||
|
auxiliaryCommands?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifProfile {
|
||||||
|
index?: number;
|
||||||
|
token: string;
|
||||||
|
name?: string;
|
||||||
|
video?: IOnvifVideoProfile;
|
||||||
|
ptz?: IOnvifPtzCapabilities;
|
||||||
|
videoSourceToken?: string;
|
||||||
|
streamUri?: string;
|
||||||
|
snapshotUri?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifStream {
|
||||||
|
id?: string;
|
||||||
|
profileToken?: string;
|
||||||
|
name?: string;
|
||||||
|
uri?: string;
|
||||||
|
protocol?: TOnvifStreamProtocol;
|
||||||
|
encoding?: string;
|
||||||
|
resolution?: IOnvifResolution;
|
||||||
|
transport?: 'RTP-Unicast' | 'RTP-Multicast' | string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifPtzCommand {
|
||||||
|
profileToken?: string;
|
||||||
|
moveMode: TOnvifPtzMoveMode;
|
||||||
|
pan?: TOnvifPanDirection;
|
||||||
|
tilt?: TOnvifTiltDirection;
|
||||||
|
zoom?: TOnvifZoomDirection;
|
||||||
|
distance?: number;
|
||||||
|
speed?: number;
|
||||||
|
continuousDuration?: number;
|
||||||
|
preset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifSnapshotCommand {
|
||||||
|
profileToken?: string;
|
||||||
|
fetchImage?: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifStreamCommand {
|
||||||
|
profileToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TOnvifCommandType =
|
||||||
|
| 'snapshot'
|
||||||
|
| 'stream_metadata'
|
||||||
|
| 'snapshot_metadata'
|
||||||
|
| 'ptz'
|
||||||
|
| 'subscribe_events'
|
||||||
|
| 'refresh';
|
||||||
|
|
||||||
|
export interface IOnvifClientCommand {
|
||||||
|
type: TOnvifCommandType;
|
||||||
|
service: string;
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
profileToken?: string;
|
||||||
|
ptz?: IOnvifPtzCommand;
|
||||||
|
snapshot?: IOnvifSnapshotCommand;
|
||||||
|
stream?: IOnvifStreamCommand;
|
||||||
|
target?: {
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
};
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifServiceCallResult extends IServiceCallResult {}
|
||||||
|
|
||||||
|
export interface IOnvifEvent {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
platform: TOnvifEventPlatform;
|
||||||
|
deviceClass?: string;
|
||||||
|
unit?: string;
|
||||||
|
value?: unknown;
|
||||||
|
entityCategory?: string;
|
||||||
|
entityEnabled?: boolean;
|
||||||
|
timestamp?: number;
|
||||||
|
topic?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifServiceDescriptor {
|
||||||
|
namespace: string;
|
||||||
|
xaddr: string;
|
||||||
|
version?: string;
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifCameraSnapshot {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
transport?: TOnvifTransport;
|
||||||
|
online?: boolean;
|
||||||
|
deviceInfo?: IOnvifDeviceInfo;
|
||||||
|
capabilities?: IOnvifCapabilities;
|
||||||
|
profiles: IOnvifProfile[];
|
||||||
|
streams?: IOnvifStream[];
|
||||||
|
events?: IOnvifEvent[];
|
||||||
|
services?: IOnvifServiceDescriptor[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifSnapshot {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
transport?: TOnvifTransport;
|
||||||
|
connected: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
deviceInfo?: IOnvifDeviceInfo;
|
||||||
|
capabilities?: IOnvifCapabilities;
|
||||||
|
profiles?: IOnvifProfile[];
|
||||||
|
streams?: IOnvifStream[];
|
||||||
|
events?: IOnvifEvent[];
|
||||||
|
services?: IOnvifServiceDescriptor[];
|
||||||
|
cameras: IOnvifCameraSnapshot[];
|
||||||
|
discovery?: IOnvifDiscoveryRecord;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifManualEntry {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
transport?: TOnvifTransport;
|
||||||
|
deviceInfo?: IOnvifDeviceInfo;
|
||||||
|
capabilities?: IOnvifCapabilities;
|
||||||
|
profiles?: IOnvifProfile[];
|
||||||
|
streams?: IOnvifStream[];
|
||||||
|
events?: IOnvifEvent[];
|
||||||
|
snapshot?: IOnvifSnapshot;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifDiscoveryRecord {
|
||||||
|
source?: 'ws-discovery' | 'mdns' | 'manual' | 'snapshot';
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
transport?: TOnvifTransport;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
xaddrs?: string[];
|
||||||
|
scopes?: string[];
|
||||||
|
endpointReference?: string;
|
||||||
|
serviceTypes?: string[];
|
||||||
|
snapshot?: IOnvifSnapshot;
|
||||||
|
txt?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifWsDiscoveryRecord {
|
||||||
|
epr?: string;
|
||||||
|
endpointReference?: string;
|
||||||
|
endpoint_reference?: string;
|
||||||
|
xaddrs?: string[] | string;
|
||||||
|
xAddrs?: string[] | string;
|
||||||
|
XAddrs?: string[] | string;
|
||||||
|
scopes?: Array<string | { value?: string; Value?: string }> | string;
|
||||||
|
types?: string[] | string;
|
||||||
|
name?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
hostname?: string;
|
||||||
|
addresses?: string[];
|
||||||
|
port?: number;
|
||||||
|
txt?: Record<string, unknown>;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOnvifManualDiscoveryRecord extends IOnvifManualEntry {
|
||||||
|
discovery?: IOnvifDiscoveryRecord;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 './plex.classes.integration.js';
|
export * from './plex.classes.integration.js';
|
||||||
|
export * from './plex.classes.client.js';
|
||||||
|
export * from './plex.classes.configflow.js';
|
||||||
|
export * from './plex.discovery.js';
|
||||||
|
export * from './plex.mapper.js';
|
||||||
export * from './plex.types.js';
|
export * from './plex.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
import type {
|
||||||
|
IPlexClientInfo,
|
||||||
|
IPlexCommandResult,
|
||||||
|
IPlexConfig,
|
||||||
|
IPlexEvent,
|
||||||
|
IPlexLibrarySection,
|
||||||
|
IPlexMediaContainer,
|
||||||
|
IPlexPlayMediaCommand,
|
||||||
|
IPlexPlaybackCommand,
|
||||||
|
IPlexRefreshLibraryCommand,
|
||||||
|
IPlexServerInfo,
|
||||||
|
IPlexSession,
|
||||||
|
IPlexSnapshot,
|
||||||
|
TPlexMediaContentType,
|
||||||
|
} from './plex.types.js';
|
||||||
|
import { plexDefaultPort } from './plex.types.js';
|
||||||
|
|
||||||
|
type TPlexEventHandler = (eventArg: IPlexEvent) => void;
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 7000;
|
||||||
|
const defaultClientIdentifier = 'smarthome-exchange-plex';
|
||||||
|
const plexProduct = 'smarthome.exchange';
|
||||||
|
const plexPlatform = 'Node.js';
|
||||||
|
const plexDeviceName = 'smarthome.exchange';
|
||||||
|
|
||||||
|
export class PlexClient {
|
||||||
|
private commandId = 0;
|
||||||
|
private currentSnapshot?: IPlexSnapshot;
|
||||||
|
private readonly eventHandlers = new Set<TPlexEventHandler>();
|
||||||
|
|
||||||
|
constructor(private readonly config: IPlexConfig) {
|
||||||
|
this.currentSnapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IPlexSnapshot> {
|
||||||
|
if (!this.hasLiveServer()) {
|
||||||
|
const snapshot = this.normalizeSnapshot(this.currentSnapshot || this.manualSnapshot(), 'manual');
|
||||||
|
this.currentSnapshot = this.cloneSnapshot(snapshot);
|
||||||
|
return this.cloneSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [server, identity, clients, sessions, libraries] = await Promise.all([
|
||||||
|
this.getServerInfo().catch(() => undefined),
|
||||||
|
this.getIdentity().catch(() => undefined),
|
||||||
|
this.getClients().catch(() => []),
|
||||||
|
this.getSessions().catch(() => []),
|
||||||
|
this.getLibraries().catch(() => []),
|
||||||
|
]);
|
||||||
|
if (!server && !identity) {
|
||||||
|
throw new Error('Plex server did not respond to / or /identity.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const librariesWithCounts = await Promise.all(libraries.map(async (libraryArg) => ({
|
||||||
|
...libraryArg,
|
||||||
|
itemCount: libraryArg.itemCount ?? await this.getLibraryItemCount(libraryArg).catch(() => undefined),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const snapshot = this.normalizeSnapshot({
|
||||||
|
server: {
|
||||||
|
...this.serverFromConfig(),
|
||||||
|
...server,
|
||||||
|
...identity,
|
||||||
|
online: true,
|
||||||
|
},
|
||||||
|
clients: this.mergeClients(clients, sessions),
|
||||||
|
sessions,
|
||||||
|
libraries: librariesWithCounts,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
source: 'http',
|
||||||
|
online: true,
|
||||||
|
}, 'http');
|
||||||
|
this.currentSnapshot = this.cloneSnapshot(snapshot);
|
||||||
|
this.emit({ type: 'snapshot', timestamp: Date.now(), serverId: snapshot.server.machineIdentifier, data: snapshot });
|
||||||
|
return this.cloneSnapshot(snapshot);
|
||||||
|
} catch (errorArg) {
|
||||||
|
const fallback = this.normalizeSnapshot(this.currentSnapshot || this.manualSnapshot(), this.currentSnapshot ? this.currentSnapshot.source || 'manual' : 'manual');
|
||||||
|
fallback.online = false;
|
||||||
|
fallback.server.online = false;
|
||||||
|
this.emit({ type: 'error', timestamp: Date.now(), serverId: fallback.server.machineIdentifier, data: { message: this.errorMessage(errorArg) } });
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getServerInfo(): Promise<IPlexServerInfo> {
|
||||||
|
const container = await this.requestJson<IPlexMediaContainer>('/', { method: 'GET' });
|
||||||
|
return this.normalizeServer(container.MediaContainer || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIdentity(): Promise<IPlexServerInfo> {
|
||||||
|
const container = await this.requestJson<IPlexMediaContainer>('/identity', { method: 'GET', allowWithoutToken: true });
|
||||||
|
return this.normalizeServer(container.MediaContainer || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getClients(): Promise<IPlexClientInfo[]> {
|
||||||
|
const container = await this.requestJson<IPlexMediaContainer<unknown, unknown>>('/clients', { method: 'GET' });
|
||||||
|
const mediaContainer = container.MediaContainer || {};
|
||||||
|
const rawClients = this.arrayValue<IPlexClientInfo>(mediaContainer.Server) || this.arrayValue<IPlexClientInfo>(mediaContainer.Directory) || [];
|
||||||
|
return rawClients.map((clientArg) => this.normalizeClient({ ...clientArg, source: clientArg.source || 'PMS' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSessions(): Promise<IPlexSession[]> {
|
||||||
|
const container = await this.requestJson<IPlexMediaContainer<unknown, IPlexSession>>('/status/sessions', { method: 'GET' });
|
||||||
|
return (container.MediaContainer?.Metadata || []).map((sessionArg) => this.normalizeSession(sessionArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLibraries(): Promise<IPlexLibrarySection[]> {
|
||||||
|
const container = await this.requestJson<IPlexMediaContainer<IPlexLibrarySection>>('/library/sections/all', { method: 'GET' });
|
||||||
|
return (container.MediaContainer?.Directory || []).map((libraryArg) => this.normalizeLibrary(libraryArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshLibrary(commandArg: IPlexRefreshLibraryCommand): Promise<IPlexCommandResult> {
|
||||||
|
try {
|
||||||
|
const library = await this.resolveLibrary(commandArg);
|
||||||
|
if (!library) {
|
||||||
|
return { success: false, error: 'Plex refresh_library requires data.library_name or data.library_id.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasLiveServer()) {
|
||||||
|
this.emit({ type: 'library_refresh', timestamp: Date.now(), serverId: this.currentSnapshot?.server.machineIdentifier, command: commandArg, data: { library } });
|
||||||
|
return { success: true, data: { library, mode: 'snapshot' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.requestText(`/library/sections/${encodeURIComponent(String(library.key))}/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
searchParams: {
|
||||||
|
force: commandArg.force === undefined ? undefined : commandArg.force ? 1 : 0,
|
||||||
|
path: commandArg.path,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.emit({ type: 'library_refresh', timestamp: Date.now(), serverId: this.currentSnapshot?.server.machineIdentifier, command: commandArg, data: { library } });
|
||||||
|
return { success: true, data: { library } };
|
||||||
|
} catch (errorArg) {
|
||||||
|
const error = this.errorMessage(errorArg);
|
||||||
|
this.emit({ type: 'error', timestamp: Date.now(), command: commandArg, data: { message: error } });
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendPlaybackCommand(commandArg: IPlexPlaybackCommand | IPlexPlayMediaCommand): Promise<IPlexCommandResult> {
|
||||||
|
try {
|
||||||
|
if (!commandArg.clientIdentifier) {
|
||||||
|
return { success: false, error: 'Plex playback command requires a client identifier.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasLiveServer()) {
|
||||||
|
const snapshot = this.currentSnapshot || this.manualSnapshot();
|
||||||
|
this.applySnapshotCommand(snapshot, commandArg);
|
||||||
|
this.currentSnapshot = this.normalizeSnapshot(snapshot, snapshot.source || 'manual');
|
||||||
|
this.emit({ type: 'command', timestamp: Date.now(), serverId: this.currentSnapshot.server.machineIdentifier, clientIdentifier: commandArg.clientIdentifier, command: commandArg });
|
||||||
|
return { success: true, data: { mode: 'snapshot', command: commandArg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `/player/playback/${commandArg.command}`;
|
||||||
|
await this.requestText(path, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Plex-Target-Client-Identifier': commandArg.clientIdentifier,
|
||||||
|
},
|
||||||
|
searchParams: this.commandSearchParams(commandArg),
|
||||||
|
});
|
||||||
|
this.emit({ type: 'command', timestamp: Date.now(), serverId: this.currentSnapshot?.server.machineIdentifier, clientIdentifier: commandArg.clientIdentifier, command: commandArg });
|
||||||
|
return { success: true };
|
||||||
|
} catch (errorArg) {
|
||||||
|
const error = this.errorMessage(errorArg);
|
||||||
|
this.emit({ type: 'error', timestamp: Date.now(), clientIdentifier: commandArg.clientIdentifier, command: commandArg, data: { message: error } });
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TPlexEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLibraryItemCount(libraryArg: IPlexLibrarySection): Promise<number | undefined> {
|
||||||
|
const container = await this.requestJson<IPlexMediaContainer>(`/library/sections/${encodeURIComponent(String(libraryArg.key))}/all`, {
|
||||||
|
method: 'GET',
|
||||||
|
searchParams: {
|
||||||
|
'X-Plex-Container-Start': 0,
|
||||||
|
'X-Plex-Container-Size': 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.numberValue(container.MediaContainer?.totalSize) ?? this.numberValue(container.MediaContainer?.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveLibrary(commandArg: IPlexRefreshLibraryCommand): Promise<IPlexLibrarySection | undefined> {
|
||||||
|
const snapshot = this.currentSnapshot || await this.getSnapshot();
|
||||||
|
if (commandArg.libraryKey !== undefined) {
|
||||||
|
const key = String(commandArg.libraryKey);
|
||||||
|
return snapshot.libraries.find((libraryArg) => String(libraryArg.key) === key);
|
||||||
|
}
|
||||||
|
if (commandArg.libraryName) {
|
||||||
|
const name = commandArg.libraryName.toLowerCase();
|
||||||
|
return snapshot.libraries.find((libraryArg) => libraryArg.title?.toLowerCase() === name);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandSearchParams(commandArg: IPlexPlaybackCommand | IPlexPlayMediaCommand): Record<string, string | number | boolean | undefined> {
|
||||||
|
const params: Record<string, string | number | boolean | undefined> = {
|
||||||
|
...commandArg.params,
|
||||||
|
commandID: ++this.commandId,
|
||||||
|
type: commandArg.mediaType || 'video',
|
||||||
|
};
|
||||||
|
if (commandArg.command === 'seekTo' && typeof commandArg.offsetMs === 'number') {
|
||||||
|
params.offset = Math.max(0, Math.round(commandArg.offsetMs));
|
||||||
|
}
|
||||||
|
if (commandArg.command === 'setParameters' && typeof commandArg.volume === 'number') {
|
||||||
|
params.volume = Math.max(0, Math.min(100, Math.round(commandArg.volume)));
|
||||||
|
}
|
||||||
|
if (commandArg.command === 'playMedia') {
|
||||||
|
const playMedia = commandArg as IPlexPlayMediaCommand;
|
||||||
|
const server = this.currentSnapshot?.server || this.serverFromConfig();
|
||||||
|
params.key = playMedia.key;
|
||||||
|
params.containerKey = playMedia.containerKey || playMedia.key;
|
||||||
|
params.machineIdentifier = playMedia.machineIdentifier || server.machineIdentifier;
|
||||||
|
params.address = playMedia.address || server.host || this.config.host;
|
||||||
|
params.port = playMedia.port || server.port || this.config.port || plexDefaultPort;
|
||||||
|
params.protocol = playMedia.protocol || (server.ssl || this.config.ssl ? 'https' : 'http');
|
||||||
|
params.token = playMedia.token || this.config.token;
|
||||||
|
params.providerIdentifier = 'com.plexapp.plugins.library';
|
||||||
|
params.offset = playMedia.offsetMs || commandArg.offsetMs || 0;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySnapshotCommand(snapshotArg: IPlexSnapshot, commandArg: IPlexPlaybackCommand | IPlexPlayMediaCommand): void {
|
||||||
|
const session = snapshotArg.sessions.find((sessionArg) => this.clientIdentifier(sessionArg.Player) === commandArg.clientIdentifier);
|
||||||
|
const client = snapshotArg.clients.find((clientArg) => this.clientIdentifier(clientArg) === commandArg.clientIdentifier);
|
||||||
|
if (commandArg.command === 'play') {
|
||||||
|
if (session) {
|
||||||
|
session.state = 'playing';
|
||||||
|
}
|
||||||
|
if (client) {
|
||||||
|
client.state = 'playing';
|
||||||
|
}
|
||||||
|
} else if (commandArg.command === 'pause') {
|
||||||
|
if (session) {
|
||||||
|
session.state = 'paused';
|
||||||
|
}
|
||||||
|
if (client) {
|
||||||
|
client.state = 'paused';
|
||||||
|
}
|
||||||
|
} else if (commandArg.command === 'stop') {
|
||||||
|
if (session) {
|
||||||
|
session.state = 'stopped';
|
||||||
|
}
|
||||||
|
if (client) {
|
||||||
|
client.state = 'idle';
|
||||||
|
}
|
||||||
|
} else if (commandArg.command === 'seekTo' && session && typeof commandArg.offsetMs === 'number') {
|
||||||
|
session.viewOffset = Math.max(0, Math.round(commandArg.offsetMs));
|
||||||
|
session.mediaPositionUpdatedAt = new Date().toISOString();
|
||||||
|
} else if (commandArg.command === 'setParameters' && client && typeof commandArg.volume === 'number') {
|
||||||
|
client.volumeLevel = Math.max(0, Math.min(1, commandArg.volume > 1 ? commandArg.volume / 100 : commandArg.volume));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSnapshot(snapshotArg: IPlexSnapshot, sourceArg: IPlexSnapshot['source']): IPlexSnapshot {
|
||||||
|
const server = {
|
||||||
|
...this.serverFromConfig(),
|
||||||
|
...snapshotArg.server,
|
||||||
|
};
|
||||||
|
if (!server.machineIdentifier) {
|
||||||
|
server.machineIdentifier = this.config.serverIdentifier || this.slug(server.friendlyName || server.name || server.host || 'plex');
|
||||||
|
}
|
||||||
|
if (!server.friendlyName) {
|
||||||
|
server.friendlyName = server.name || this.config.name || server.host || 'Plex Media Server';
|
||||||
|
}
|
||||||
|
if (server.online === undefined) {
|
||||||
|
server.online = snapshotArg.online ?? Boolean(this.hasLiveServer());
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...snapshotArg,
|
||||||
|
server,
|
||||||
|
clients: this.mergeClients((snapshotArg.clients || []).map((clientArg) => this.normalizeClient(clientArg)), snapshotArg.sessions || []),
|
||||||
|
sessions: (snapshotArg.sessions || []).map((sessionArg) => this.normalizeSession(sessionArg)),
|
||||||
|
libraries: (snapshotArg.libraries || []).map((libraryArg) => this.normalizeLibrary(libraryArg)),
|
||||||
|
capturedAt: snapshotArg.capturedAt || new Date().toISOString(),
|
||||||
|
source: sourceArg,
|
||||||
|
online: snapshotArg.online ?? server.online,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private manualSnapshot(): IPlexSnapshot {
|
||||||
|
const hasManualData = Boolean(this.config.clients?.length || this.config.sessions?.length || this.config.libraries?.length);
|
||||||
|
return {
|
||||||
|
server: this.serverFromConfig(),
|
||||||
|
clients: this.config.clients || [],
|
||||||
|
sessions: this.config.sessions || [],
|
||||||
|
libraries: this.config.libraries || [],
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
source: 'manual',
|
||||||
|
online: Boolean(this.config.snapshot?.online ?? this.config.server?.online ?? hasManualData),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serverFromConfig(): IPlexServerInfo {
|
||||||
|
const url = this.baseUrl();
|
||||||
|
return {
|
||||||
|
...this.config.server,
|
||||||
|
machineIdentifier: this.config.server?.machineIdentifier || this.config.serverIdentifier,
|
||||||
|
friendlyName: this.config.server?.friendlyName || this.config.name || this.config.server?.name || this.config.host || 'Plex Media Server',
|
||||||
|
url,
|
||||||
|
host: this.config.server?.host || this.config.host,
|
||||||
|
port: this.config.server?.port || this.config.port || (this.config.host || this.config.url ? plexDefaultPort : undefined),
|
||||||
|
ssl: this.config.server?.ssl ?? this.config.ssl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeServer(valueArg: Record<string, unknown>): IPlexServerInfo {
|
||||||
|
return {
|
||||||
|
...valueArg,
|
||||||
|
machineIdentifier: this.stringValue(valueArg.machineIdentifier) || this.stringValue(valueArg.machine_identifier),
|
||||||
|
friendlyName: this.stringValue(valueArg.friendlyName) || this.stringValue(valueArg.name) || this.stringValue(valueArg.title),
|
||||||
|
version: this.stringValue(valueArg.version),
|
||||||
|
platform: this.stringValue(valueArg.platform),
|
||||||
|
platformVersion: this.stringValue(valueArg.platformVersion),
|
||||||
|
claimed: this.booleanValue(valueArg.claimed),
|
||||||
|
myPlex: this.booleanValue(valueArg.myPlex),
|
||||||
|
myPlexUsername: this.stringValue(valueArg.myPlexUsername),
|
||||||
|
allowSync: this.booleanValue(valueArg.allowSync),
|
||||||
|
allowSharing: this.booleanValue(valueArg.allowSharing),
|
||||||
|
allowMediaDeletion: this.booleanValue(valueArg.allowMediaDeletion),
|
||||||
|
transcoderActiveVideoSessions: this.numberValue(valueArg.transcoderActiveVideoSessions),
|
||||||
|
updatedAt: this.numberValue(valueArg.updatedAt) ?? this.stringValue(valueArg.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeClient(clientArg: IPlexClientInfo): IPlexClientInfo {
|
||||||
|
const capabilities = Array.isArray(clientArg.protocolCapabilities)
|
||||||
|
? clientArg.protocolCapabilities
|
||||||
|
: typeof clientArg.protocolCapabilities === 'string'
|
||||||
|
? String(clientArg.protocolCapabilities).split(',').map((itemArg) => itemArg.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const port = this.numberValue(clientArg.port);
|
||||||
|
const host = this.stringValue(clientArg.host) || this.stringValue(clientArg.address);
|
||||||
|
return {
|
||||||
|
...clientArg,
|
||||||
|
machineIdentifier: this.clientIdentifier(clientArg),
|
||||||
|
host,
|
||||||
|
address: this.stringValue(clientArg.address) || host,
|
||||||
|
port,
|
||||||
|
baseUrl: clientArg.baseUrl || (host && port ? `http://${host}:${port}` : undefined),
|
||||||
|
name: clientArg.name || clientArg.title,
|
||||||
|
title: clientArg.title || clientArg.name,
|
||||||
|
product: clientArg.product,
|
||||||
|
platform: clientArg.platform,
|
||||||
|
protocolCapabilities: capabilities,
|
||||||
|
state: clientArg.state || 'idle',
|
||||||
|
volumeLevel: typeof clientArg.volumeLevel === 'number' ? clientArg.volumeLevel : undefined,
|
||||||
|
muted: typeof clientArg.muted === 'boolean' ? clientArg.muted : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSession(sessionArg: IPlexSession): IPlexSession {
|
||||||
|
const player = this.normalizeClient({ ...(sessionArg.Player || sessionArg.players?.[0] || {}), source: 'session' });
|
||||||
|
const state = sessionArg.state || player.state || 'idle';
|
||||||
|
return {
|
||||||
|
...sessionArg,
|
||||||
|
state,
|
||||||
|
Player: player,
|
||||||
|
username: sessionArg.username || sessionArg.User?.title || sessionArg.User?.username,
|
||||||
|
viewOffset: this.numberValue(sessionArg.viewOffset),
|
||||||
|
duration: this.numberValue(sessionArg.duration),
|
||||||
|
mediaPositionUpdatedAt: sessionArg.mediaPositionUpdatedAt || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLibrary(libraryArg: IPlexLibrarySection): IPlexLibrarySection {
|
||||||
|
const key = libraryArg.key ?? libraryArg.uuid ?? libraryArg.title ?? 'library';
|
||||||
|
return {
|
||||||
|
...libraryArg,
|
||||||
|
key,
|
||||||
|
title: libraryArg.title || String(key),
|
||||||
|
type: libraryArg.type,
|
||||||
|
totalSize: this.numberValue(libraryArg.totalSize),
|
||||||
|
itemCount: this.numberValue(libraryArg.itemCount),
|
||||||
|
leafCount: this.numberValue(libraryArg.leafCount),
|
||||||
|
refreshing: this.booleanValue(libraryArg.refreshing),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeClients(clientsArg: IPlexClientInfo[], sessionsArg: IPlexSession[]): IPlexClientInfo[] {
|
||||||
|
const clientsById = new Map<string, IPlexClientInfo>();
|
||||||
|
for (const client of clientsArg) {
|
||||||
|
const normalized = this.normalizeClient(client);
|
||||||
|
const id = this.clientIdentifier(normalized);
|
||||||
|
if (id) {
|
||||||
|
clientsById.set(id, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const session of sessionsArg) {
|
||||||
|
const player = this.normalizeClient({ ...(session.Player || session.players?.[0] || {}), state: session.state || session.Player?.state || 'idle', source: 'session' });
|
||||||
|
const id = this.clientIdentifier(player);
|
||||||
|
if (!id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clientsById.set(id, {
|
||||||
|
...clientsById.get(id),
|
||||||
|
...player,
|
||||||
|
state: session.state || player.state,
|
||||||
|
source: clientsById.get(id)?.source || player.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...clientsById.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestJson<T>(pathArg: string, optionsArg: { method: string; headers?: Record<string, string>; searchParams?: Record<string, string | number | boolean | undefined>; allowWithoutToken?: boolean }): Promise<T> {
|
||||||
|
const text = await this.requestText(pathArg, optionsArg);
|
||||||
|
return text ? JSON.parse(text) as T : {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestText(pathArg: string, optionsArg: { method: string; headers?: Record<string, string>; searchParams?: Record<string, string | number | boolean | undefined>; allowWithoutToken?: boolean }): Promise<string> {
|
||||||
|
const url = this.requestUrl(pathArg, optionsArg.searchParams);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = globalThis.setTimeout(() => controller.abort(), this.config.timeoutMs || defaultTimeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await globalThis.fetch(url, {
|
||||||
|
method: optionsArg.method,
|
||||||
|
headers: this.headers(optionsArg.headers, optionsArg.allowWithoutToken),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Plex request ${optionsArg.method} ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestUrl(pathArg: string, searchParamsArg?: Record<string, string | number | boolean | undefined>): string {
|
||||||
|
const base = this.baseUrl();
|
||||||
|
if (!base) {
|
||||||
|
throw new Error('Plex host or url is required for live HTTP requests.');
|
||||||
|
}
|
||||||
|
const url = new URL(pathArg.startsWith('/') ? pathArg : `/${pathArg}`, `${base}/`);
|
||||||
|
for (const [key, value] of Object.entries(searchParamsArg || {})) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(headersArg: Record<string, string> | undefined, allowWithoutTokenArg?: boolean): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/json',
|
||||||
|
'X-Plex-Client-Identifier': this.config.clientIdentifier || defaultClientIdentifier,
|
||||||
|
'X-Plex-Product': plexProduct,
|
||||||
|
'X-Plex-Version': '0.1.0',
|
||||||
|
'X-Plex-Platform': plexPlatform,
|
||||||
|
'X-Plex-Device-Name': plexDeviceName,
|
||||||
|
...headersArg,
|
||||||
|
};
|
||||||
|
if (this.config.token && !allowWithoutTokenArg) {
|
||||||
|
headers['X-Plex-Token'] = this.config.token;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseUrl(): string | undefined {
|
||||||
|
if (this.config.url) {
|
||||||
|
return this.config.url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
if (!this.config.host) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const protocol = this.config.ssl ? 'https' : 'http';
|
||||||
|
return `${protocol}://${this.config.host}:${this.config.port || plexDefaultPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasLiveServer(): boolean {
|
||||||
|
return Boolean(this.config.url || this.config.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(eventArg: IPlexEvent): void {
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clientIdentifier(clientArg: IPlexClientInfo | undefined): string | undefined {
|
||||||
|
return clientArg?.machineIdentifier || clientArg?.id || (clientArg?.title ? this.slug(clientArg.title) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private arrayValue<T>(valueArg: unknown): T[] | undefined {
|
||||||
|
if (Array.isArray(valueArg)) {
|
||||||
|
return valueArg as T[];
|
||||||
|
}
|
||||||
|
if (valueArg && typeof valueArg === 'object') {
|
||||||
|
return [valueArg as T];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() && Number.isFinite(Number(valueArg))) {
|
||||||
|
return Number(valueArg);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanValue(valueArg: unknown): boolean | undefined {
|
||||||
|
if (typeof valueArg === 'boolean') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'number') {
|
||||||
|
return valueArg !== 0;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
if (['1', 'true', 'yes'].includes(valueArg.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['0', 'false', 'no'].includes(valueArg.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'plex';
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneSnapshot(snapshotArg: IPlexSnapshot): IPlexSnapshot {
|
||||||
|
return JSON.parse(JSON.stringify(snapshotArg)) as IPlexSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorMessage(errorArg: unknown): string {
|
||||||
|
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const plexMediaTypeForSession = (sessionArg: IPlexSession | undefined): 'video' | 'music' | 'photo' => {
|
||||||
|
const type = (sessionArg?.type || '').toLowerCase() as TPlexMediaContentType;
|
||||||
|
if (type === 'track' || type === 'album' || type === 'artist' || type === 'music') {
|
||||||
|
return 'music';
|
||||||
|
}
|
||||||
|
if (type === 'photo') {
|
||||||
|
return 'photo';
|
||||||
|
}
|
||||||
|
return 'video';
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IPlexConfig } from './plex.types.js';
|
||||||
|
import { plexDefaultPort } from './plex.types.js';
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 7000;
|
||||||
|
|
||||||
|
export class PlexConfigFlow implements IConfigFlow<IPlexConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IPlexConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Plex Media Server',
|
||||||
|
description: 'Configure a local Plex Media Server HTTP API endpoint. A token is required for sessions, libraries, and controls on claimed servers.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'port', label: 'Port', type: 'number' },
|
||||||
|
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
|
||||||
|
{ name: 'verifySsl', label: 'Verify SSL certificates', type: 'boolean' },
|
||||||
|
{ name: 'token', label: 'Plex token', type: 'password' },
|
||||||
|
{ name: 'name', label: 'Name', type: 'text' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const host = this.stringValue(valuesArg.host) || candidateArg.host || this.stringMetadata(candidateArg, 'host') || '';
|
||||||
|
const port = this.numberValue(valuesArg.port) || candidateArg.port || plexDefaultPort;
|
||||||
|
const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanMetadata(candidateArg, 'ssl') ?? false;
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Plex configured',
|
||||||
|
config: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
ssl,
|
||||||
|
verifySsl: this.booleanValue(valuesArg.verifySsl) ?? this.booleanMetadata(candidateArg, 'verifySsl') ?? true,
|
||||||
|
token: this.stringValue(valuesArg.token),
|
||||||
|
name: this.stringValue(valuesArg.name) || candidateArg.name,
|
||||||
|
serverIdentifier: candidateArg.id,
|
||||||
|
url: host ? `${ssl ? 'https' : 'http'}://${host}:${port}` : undefined,
|
||||||
|
timeoutMs: defaultTimeoutMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanValue(valueArg: unknown): boolean | undefined {
|
||||||
|
return typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined {
|
||||||
|
const value = candidateArg.metadata?.[keyArg];
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): boolean | undefined {
|
||||||
|
const value = candidateArg.metadata?.[keyArg];
|
||||||
|
return typeof value === 'boolean' ? value : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,258 @@
|
|||||||
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 { PlexClient, plexMediaTypeForSession } from './plex.classes.client.js';
|
||||||
|
import { PlexConfigFlow } from './plex.classes.configflow.js';
|
||||||
|
import { createPlexDiscoveryDescriptor } from './plex.discovery.js';
|
||||||
|
import { PlexMapper } from './plex.mapper.js';
|
||||||
|
import type { IPlexConfig, IPlexPlayMediaCommand, IPlexSnapshot } from './plex.types.js';
|
||||||
|
|
||||||
export class HomeAssistantPlexIntegration extends DescriptorOnlyIntegration {
|
export class PlexIntegration extends BaseIntegration<IPlexConfig> {
|
||||||
constructor() {
|
public readonly domain = 'plex';
|
||||||
super({
|
public readonly displayName = 'Plex Media Server';
|
||||||
domain: "plex",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Plex Media Server",
|
public readonly discoveryDescriptor = createPlexDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new PlexConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/plex",
|
upstreamPath: 'homeassistant/components/plex',
|
||||||
"upstreamDomain": "plex",
|
upstreamDomain: 'plex',
|
||||||
"integrationType": "service",
|
integrationType: 'service',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
requirements: ['PlexAPI==4.15.16', 'plexauth==0.0.6', 'plexwebsocket==0.0.14'],
|
||||||
"PlexAPI==4.15.16",
|
dependencies: ['http'],
|
||||||
"plexauth==0.0.6",
|
afterDependencies: [],
|
||||||
"plexwebsocket==0.0.14"
|
codeowners: ['@jjlawren'],
|
||||||
|
configFlow: true,
|
||||||
|
documentation: 'https://www.home-assistant.io/integrations/plex',
|
||||||
|
zeroconf: ['_plexmediasvr._tcp.local.'],
|
||||||
|
localApi: [
|
||||||
|
'/',
|
||||||
|
'/identity',
|
||||||
|
'/clients',
|
||||||
|
'/status/sessions',
|
||||||
|
'/library/sections/all',
|
||||||
|
'/library/sections/{sectionId}/refresh',
|
||||||
|
'/player/playback/{command}',
|
||||||
],
|
],
|
||||||
"dependencies": [
|
};
|
||||||
"http"
|
|
||||||
],
|
public async setup(configArg: IPlexConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
"afterDependencies": [],
|
void contextArg;
|
||||||
"codeowners": [
|
return new PlexRuntime(new PlexClient(configArg));
|
||||||
"@jjlawren"
|
}
|
||||||
]
|
|
||||||
},
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantPlexIntegration extends PlexIntegration {}
|
||||||
|
|
||||||
|
class PlexRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'plex';
|
||||||
|
|
||||||
|
constructor(private readonly client: PlexClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return PlexMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return PlexMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => handlerArg({
|
||||||
|
type: eventArg.type === 'error' ? 'error' : 'state_changed',
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
deviceId: eventArg.clientIdentifier ? `plex.client.${eventArg.clientIdentifier}` : eventArg.serverId ? `plex.server.${eventArg.serverId}` : undefined,
|
||||||
|
entityId: eventArg.entityId,
|
||||||
|
data: eventArg.data || eventArg.command,
|
||||||
|
timestamp: eventArg.timestamp,
|
||||||
|
}));
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
try {
|
||||||
|
if (requestArg.domain === 'media_player') {
|
||||||
|
return await this.callMediaPlayerService(requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'plex') {
|
||||||
|
return await this.callPlexService(requestArg);
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Plex 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 snapshot = await this.client.getSnapshot();
|
||||||
|
const target = this.resolveClientTarget(snapshot, requestArg);
|
||||||
|
const session = snapshot.sessions.find((sessionArg) => this.clientIdentifier(sessionArg.Player) === target.clientIdentifier);
|
||||||
|
const mediaType = plexMediaTypeForSession(session);
|
||||||
|
|
||||||
|
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
|
||||||
|
return this.client.sendPlaybackCommand({ clientIdentifier: target.clientIdentifier, command: 'play', mediaType });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
|
||||||
|
return this.client.sendPlaybackCommand({ clientIdentifier: target.clientIdentifier, command: 'pause', mediaType });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
|
||||||
|
return this.client.sendPlaybackCommand({ clientIdentifier: target.clientIdentifier, command: 'stop', mediaType });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
|
||||||
|
return this.client.sendPlaybackCommand({ clientIdentifier: target.clientIdentifier, command: 'skipNext', mediaType });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
|
||||||
|
return this.client.sendPlaybackCommand({ clientIdentifier: target.clientIdentifier, command: 'skipPrevious', mediaType });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'seek' || requestArg.service === 'media_seek') {
|
||||||
|
const position = this.numberValue(requestArg.data?.seek_position ?? requestArg.data?.position, 'Plex media_seek requires data.seek_position or data.position.');
|
||||||
|
return this.client.sendPlaybackCommand({ clientIdentifier: target.clientIdentifier, command: 'seekTo', mediaType, offsetMs: Math.round(position * 1000) });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_set') {
|
||||||
|
const level = this.numberValue(requestArg.data?.volume_level ?? requestArg.data?.volume, 'Plex volume_set requires data.volume_level or data.volume.');
|
||||||
|
const volume = Math.max(0, Math.min(100, Math.round(level <= 1 ? level * 100 : level)));
|
||||||
|
return this.client.sendPlaybackCommand({ clientIdentifier: target.clientIdentifier, command: 'setParameters', mediaType, volume });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_mute') {
|
||||||
|
const muted = this.booleanValue(requestArg.data?.is_volume_muted ?? requestArg.data?.muted, 'Plex volume_mute requires data.is_volume_muted or data.muted.');
|
||||||
|
return this.client.sendPlaybackCommand({ clientIdentifier: target.clientIdentifier, command: 'setParameters', mediaType, volume: muted ? 0 : 100, muted });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play_media') {
|
||||||
|
return this.playMedia(snapshot, target.clientIdentifier, requestArg, mediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: `Unsupported Plex media_player service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callPlexService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (requestArg.service === 'snapshot') {
|
||||||
|
return { success: true, data: await this.client.getSnapshot() };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'refresh_library') {
|
||||||
|
return this.client.refreshLibrary({
|
||||||
|
libraryName: this.optionalString(requestArg.data?.library_name),
|
||||||
|
libraryKey: this.optionalString(requestArg.data?.library_id ?? requestArg.data?.section_id),
|
||||||
|
force: this.optionalBoolean(requestArg.data?.force),
|
||||||
|
path: this.optionalString(requestArg.data?.path),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (requestArg.service === 'scan_clients' || requestArg.service === 'refresh_clients') {
|
||||||
|
return { success: true, data: await this.client.getSnapshot() };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'stop') {
|
||||||
|
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player', service: `media_${requestArg.service}` });
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Plex service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async playMedia(snapshotArg: IPlexSnapshot, clientIdentifierArg: string, requestArg: IServiceCallRequest, fallbackMediaTypeArg: string): Promise<IServiceCallResult> {
|
||||||
|
const key = this.optionalString(requestArg.data?.key ?? requestArg.data?.media_content_id);
|
||||||
|
if (!key) {
|
||||||
|
return { success: false, error: 'Plex play_media requires data.media_content_id or data.key.' };
|
||||||
|
}
|
||||||
|
const command: IPlexPlayMediaCommand = {
|
||||||
|
clientIdentifier: clientIdentifierArg,
|
||||||
|
command: 'playMedia',
|
||||||
|
mediaType: this.optionalString(requestArg.data?.media_content_type ?? requestArg.data?.type) || fallbackMediaTypeArg,
|
||||||
|
key,
|
||||||
|
containerKey: this.optionalString(requestArg.data?.container_key ?? requestArg.data?.containerKey),
|
||||||
|
machineIdentifier: snapshotArg.server.machineIdentifier,
|
||||||
|
address: snapshotArg.server.host,
|
||||||
|
port: snapshotArg.server.port,
|
||||||
|
protocol: snapshotArg.server.ssl ? 'https' : 'http',
|
||||||
|
offsetMs: Math.round((this.optionalNumber(requestArg.data?.offset) || 0) * 1000),
|
||||||
|
};
|
||||||
|
return this.client.sendPlaybackCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveClientTarget(snapshotArg: IPlexSnapshot, requestArg: IServiceCallRequest): { clientIdentifier: string } {
|
||||||
|
const directClientId = this.optionalString(requestArg.data?.client_id ?? requestArg.data?.clientIdentifier);
|
||||||
|
if (directClientId) {
|
||||||
|
return { clientIdentifier: directClientId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = requestArg.target.entityId || requestArg.target.deviceId;
|
||||||
|
if (!targetId) {
|
||||||
|
const activeSession = snapshotArg.sessions.find((sessionArg) => sessionArg.state === 'playing' || sessionArg.state === 'paused');
|
||||||
|
const activeClientId = this.clientIdentifier(activeSession?.Player);
|
||||||
|
if (activeClientId) {
|
||||||
|
return { clientIdentifier: activeClientId };
|
||||||
|
}
|
||||||
|
throw new Error('Plex media service calls require target.entityId, target.deviceId, or data.client_id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = PlexMapper.toEntities(snapshotArg).find((entityArg) => entityArg.id === targetId || entityArg.deviceId === targetId);
|
||||||
|
const clientId = entity?.attributes?.plexClientId;
|
||||||
|
if (typeof clientId === 'string' && clientId) {
|
||||||
|
return { clientIdentifier: clientId };
|
||||||
|
}
|
||||||
|
const client = snapshotArg.clients.find((clientArg) => PlexMapper.clientDeviceId(snapshotArg, clientArg) === targetId || this.clientIdentifier(clientArg) === targetId);
|
||||||
|
const fallbackClientId = this.clientIdentifier(client);
|
||||||
|
if (fallbackClientId) {
|
||||||
|
return { clientIdentifier: fallbackClientId };
|
||||||
|
}
|
||||||
|
throw new Error(`Plex target was not found: ${targetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clientIdentifier(clientArg: { machineIdentifier?: string; id?: string } | undefined): string | undefined {
|
||||||
|
return clientArg?.machineIdentifier || clientArg?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown, errorArg: string): number {
|
||||||
|
const value = this.optionalNumber(valueArg);
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(errorArg);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalNumber(valueArg: unknown): number | undefined {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
|
||||||
|
return Number(valueArg);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanValue(valueArg: unknown, errorArg: string): boolean {
|
||||||
|
const value = this.optionalBoolean(valueArg);
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(errorArg);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalBoolean(valueArg: unknown): boolean | undefined {
|
||||||
|
if (typeof valueArg === 'boolean') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'number') {
|
||||||
|
return valueArg !== 0;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string') {
|
||||||
|
if (['1', 'true', 'yes'].includes(valueArg.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['0', 'false', 'no'].includes(valueArg.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalString(valueArg: unknown): string | undefined {
|
||||||
|
if (typeof valueArg === 'number') {
|
||||||
|
return String(valueArg);
|
||||||
|
}
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type {
|
||||||
|
IDiscoveryCandidate,
|
||||||
|
IDiscoveryContext,
|
||||||
|
IDiscoveryMatch,
|
||||||
|
IDiscoveryMatcher,
|
||||||
|
IDiscoveryProbe,
|
||||||
|
IDiscoveryProbeResult,
|
||||||
|
IDiscoveryValidator,
|
||||||
|
} from '../../core/types.js';
|
||||||
|
import type { IPlexGdmRecord, IPlexManualEntry, IPlexMdnsRecord, IPlexSsdpRecord } from './plex.types.js';
|
||||||
|
import { plexDefaultPort } from './plex.types.js';
|
||||||
|
|
||||||
|
const plexMdnsType = '_plexmediasvr._tcp';
|
||||||
|
const plexServerContentType = 'plex/media-server';
|
||||||
|
const plexPlayerContentType = 'plex/media-player';
|
||||||
|
|
||||||
|
export class PlexGdmDiscoveryProbe implements IDiscoveryProbe {
|
||||||
|
public id = 'plex-gdm-discovery-probe';
|
||||||
|
public source = 'custom' as const;
|
||||||
|
public description = 'Discover Plex Media Servers using the local GDM multicast protocol.';
|
||||||
|
|
||||||
|
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
|
||||||
|
if (contextArg.abortSignal?.aborted) {
|
||||||
|
return { candidates: [] };
|
||||||
|
}
|
||||||
|
return { candidates: await this.discover(1200) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async discover(timeoutMsArg: number): Promise<IDiscoveryCandidate[]> {
|
||||||
|
const { createSocket } = await import('node:dgram');
|
||||||
|
const message = Buffer.from('M-SEARCH * HTTP/1.0', 'ascii');
|
||||||
|
const matcher = new PlexGdmMatcher();
|
||||||
|
const candidates: IDiscoveryCandidate[] = [];
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = createSocket({ type: 'udp4', reuseAddr: true });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
closeSocket();
|
||||||
|
resolve(candidates);
|
||||||
|
}, timeoutMsArg);
|
||||||
|
|
||||||
|
const closeSocket = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// The discovery socket may already be closed after timeout or error.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('message', async (dataArg, remoteArg) => {
|
||||||
|
const record = parseGdmResponse(dataArg.toString('utf8'), remoteArg.address, remoteArg.port);
|
||||||
|
if (!record) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = await matcher.matches(record);
|
||||||
|
if (match.matched && match.candidate && !candidates.some((candidateArg) => candidateArg.id === match.candidate?.id || candidateArg.host === match.candidate?.host)) {
|
||||||
|
candidates.push(match.candidate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', () => {
|
||||||
|
closeSocket();
|
||||||
|
resolve(candidates);
|
||||||
|
});
|
||||||
|
socket.bind(() => {
|
||||||
|
socket.setMulticastTTL(1);
|
||||||
|
socket.send(message, 32414, '239.0.0.250');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlexGdmMatcher implements IDiscoveryMatcher<IPlexGdmRecord> {
|
||||||
|
public id = 'plex-gdm-match';
|
||||||
|
public source = 'custom' as const;
|
||||||
|
public description = 'Recognize Plex GDM server and client responses.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IPlexGdmRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const data = normalizeKeys(recordArg.data || {});
|
||||||
|
const contentType = lowerString(data['content-type']);
|
||||||
|
const isServer = contentType.includes(plexServerContentType);
|
||||||
|
const isPlayer = contentType.includes(plexPlayerContentType);
|
||||||
|
if (!isServer && !isPlayer) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'GDM response is not a Plex server or player.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromHost = Array.isArray(recordArg.from) ? recordArg.from[0] : recordArg.from?.address;
|
||||||
|
const host = recordArg.host || fromHost || data.host;
|
||||||
|
const port = numberValue(data.port) || recordArg.port || plexDefaultPort;
|
||||||
|
const id = data['resource-identifier'];
|
||||||
|
const name = data.name || (isServer ? 'Plex Media Server' : 'Plex Client');
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id && host ? 'certain' : host ? 'high' : 'medium',
|
||||||
|
reason: isServer ? 'GDM response advertises a Plex Media Server.' : 'GDM response advertises a Plex media player.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'custom',
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
name,
|
||||||
|
manufacturer: 'Plex',
|
||||||
|
model: isServer ? 'Plex Media Server' : data.product || 'Plex Client',
|
||||||
|
metadata: {
|
||||||
|
discoveryProtocol: 'gdm',
|
||||||
|
contentType,
|
||||||
|
version: data.version,
|
||||||
|
product: data.product,
|
||||||
|
protocolCapabilities: splitList(data['protocol-capabilities']),
|
||||||
|
raw: recordArg.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlexMdnsMatcher implements IDiscoveryMatcher<IPlexMdnsRecord> {
|
||||||
|
public id = 'plex-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Plex Media Server zeroconf advertisements.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IPlexMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const txt = normalizeKeys({ ...recordArg.txt, ...recordArg.properties });
|
||||||
|
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
|
||||||
|
const name = cleanServiceName(recordArg.name) || txt.name || 'Plex Media Server';
|
||||||
|
const text = [type, name, recordArg.host, recordArg.hostname, txt.product, txt.model].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
const matched = type === plexMdnsType || text.includes('plexmediasvr') || text.includes('plex media server');
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Plex Media Server advertisement.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||||
|
const id = txt['machineidentifier'] || txt['resource-identifier'] || txt.identifier || recordArg.name;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: id && host ? 'certain' : host ? 'high' : 'medium',
|
||||||
|
reason: 'mDNS service matches _plexmediasvr._tcp.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port: recordArg.port || plexDefaultPort,
|
||||||
|
name,
|
||||||
|
manufacturer: 'Plex',
|
||||||
|
model: 'Plex Media Server',
|
||||||
|
metadata: {
|
||||||
|
discoveryProtocol: 'mdns',
|
||||||
|
mdnsName: recordArg.name,
|
||||||
|
mdnsType: type,
|
||||||
|
txt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlexSsdpMatcher implements IDiscoveryMatcher<IPlexSsdpRecord> {
|
||||||
|
public id = 'plex-ssdp-match';
|
||||||
|
public source = 'ssdp' as const;
|
||||||
|
public description = 'Recognize Plex SSDP/DLNA advertisements when present.';
|
||||||
|
|
||||||
|
public async matches(recordArg: IPlexSsdpRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const headers = normalizeKeys(recordArg.headers || {});
|
||||||
|
const location = recordArg.location || headers.location;
|
||||||
|
const parsedLocation = parseLocation(location);
|
||||||
|
const text = [
|
||||||
|
recordArg.st,
|
||||||
|
headers.st,
|
||||||
|
recordArg.usn,
|
||||||
|
headers.usn,
|
||||||
|
recordArg.server,
|
||||||
|
headers.server,
|
||||||
|
recordArg.friendlyName,
|
||||||
|
recordArg.manufacturer,
|
||||||
|
recordArg.modelName,
|
||||||
|
location,
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
const matched = text.includes('plex') || recordArg.metadata?.plex === true;
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'SSDP record is not Plex-related.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuidFromUsn(recordArg.usn || headers.usn) || recordArg.host || parsedLocation?.host;
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: location ? 'high' : 'medium',
|
||||||
|
reason: 'SSDP metadata contains Plex identifiers.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'ssdp',
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
id,
|
||||||
|
host: recordArg.host || parsedLocation?.hostname,
|
||||||
|
port: recordArg.port || parsedLocation?.port || plexDefaultPort,
|
||||||
|
name: recordArg.friendlyName || 'Plex Media Server',
|
||||||
|
manufacturer: recordArg.manufacturer || 'Plex',
|
||||||
|
model: recordArg.modelName || 'Plex Media Server',
|
||||||
|
metadata: {
|
||||||
|
...recordArg.metadata,
|
||||||
|
discoveryProtocol: 'ssdp',
|
||||||
|
location,
|
||||||
|
st: recordArg.st || headers.st,
|
||||||
|
usn: recordArg.usn || headers.usn,
|
||||||
|
server: recordArg.server || headers.server,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlexManualMatcher implements IDiscoveryMatcher<IPlexManualEntry> {
|
||||||
|
public id = 'plex-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Plex setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IPlexManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const text = [inputArg.name, inputArg.manufacturer, inputArg.model].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.url || inputArg.token || inputArg.snapshot || inputArg.metadata?.plex || text.includes('plex'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Plex setup hints.' };
|
||||||
|
}
|
||||||
|
const host = inputArg.host || parseLocation(inputArg.url)?.hostname;
|
||||||
|
const port = inputArg.port || parseLocation(inputArg.url)?.port || plexDefaultPort;
|
||||||
|
const id = inputArg.serverIdentifier || inputArg.id || inputArg.snapshot?.server.machineIdentifier || (host ? `${host}:${port}` : undefined);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.snapshot?.server.machineIdentifier || host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Plex setup.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
name: inputArg.name || inputArg.snapshot?.server.friendlyName,
|
||||||
|
manufacturer: 'Plex',
|
||||||
|
model: inputArg.model || 'Plex Media Server',
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
discoveryProtocol: 'manual',
|
||||||
|
ssl: inputArg.ssl,
|
||||||
|
verifySsl: inputArg.verifySsl,
|
||||||
|
url: inputArg.url,
|
||||||
|
hasToken: Boolean(inputArg.token),
|
||||||
|
snapshot: inputArg.snapshot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlexCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'plex-candidate-validator';
|
||||||
|
public description = 'Validate Plex candidates from GDM, zeroconf, SSDP, and manual setup.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const metadata = candidateArg.metadata || {};
|
||||||
|
const protocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined;
|
||||||
|
const name = (candidateArg.name || '').toLowerCase();
|
||||||
|
const manufacturer = (candidateArg.manufacturer || '').toLowerCase();
|
||||||
|
const model = (candidateArg.model || '').toLowerCase();
|
||||||
|
const matched = candidateArg.integrationDomain === 'plex'
|
||||||
|
|| protocol === 'gdm'
|
||||||
|
|| protocol === 'mdns'
|
||||||
|
|| protocol === 'zeroconf'
|
||||||
|
|| protocol === 'ssdp'
|
||||||
|
|| manufacturer.includes('plex')
|
||||||
|
|| model.includes('plex')
|
||||||
|
|| name.includes('plex')
|
||||||
|
|| candidateArg.port === plexDefaultPort
|
||||||
|
|| metadata.plex === true;
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Candidate is not Plex.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: candidateArg.id && candidateArg.host ? 'certain' : candidateArg.host ? 'high' : 'medium',
|
||||||
|
reason: candidateArg.host ? 'Candidate has Plex metadata and host information.' : 'Candidate has Plex metadata but no host information.',
|
||||||
|
candidate: {
|
||||||
|
...candidateArg,
|
||||||
|
port: candidateArg.port || plexDefaultPort,
|
||||||
|
},
|
||||||
|
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || plexDefaultPort}` : undefined),
|
||||||
|
metadata: { discoveryProtocol: protocol },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPlexDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'plex', displayName: 'Plex Media Server' })
|
||||||
|
.addProbe(new PlexGdmDiscoveryProbe())
|
||||||
|
.addMatcher(new PlexGdmMatcher())
|
||||||
|
.addMatcher(new PlexMdnsMatcher())
|
||||||
|
.addMatcher(new PlexSsdpMatcher())
|
||||||
|
.addMatcher(new PlexManualMatcher())
|
||||||
|
.addValidator(new PlexCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseGdmResponse = (valueArg: string, hostArg: string, portArg: number): IPlexGdmRecord | undefined => {
|
||||||
|
const lines = valueArg.split(/\r?\n/).filter(Boolean);
|
||||||
|
if (!lines[0]?.includes('200 OK')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const delimiter = line.indexOf(':');
|
||||||
|
if (delimiter === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
data[line.slice(0, delimiter).trim()] = line.slice(delimiter + 1).trim();
|
||||||
|
}
|
||||||
|
return { data, from: [hostArg, portArg] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeKeys = (recordArg: Record<string, string | undefined>): Record<string, string | undefined> => {
|
||||||
|
const normalized: Record<string, string | undefined> = {};
|
||||||
|
for (const [key, value] of Object.entries(recordArg)) {
|
||||||
|
normalized[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
|
||||||
|
|
||||||
|
const cleanServiceName = (valueArg?: string): string | undefined => {
|
||||||
|
return valueArg?.replace(/\._plexmediasvr\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lowerString = (valueArg?: string): string => (valueArg || '').toLowerCase();
|
||||||
|
|
||||||
|
const splitList = (valueArg?: string): string[] => valueArg ? valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean) : [];
|
||||||
|
|
||||||
|
const numberValue = (valueArg: unknown): number | undefined => {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
|
||||||
|
return Number(valueArg);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseLocation = (valueArg?: string): { host: string; hostname: string; port?: number } | undefined => {
|
||||||
|
if (!valueArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(valueArg);
|
||||||
|
return { host: url.host, hostname: url.hostname, port: url.port ? Number(url.port) : undefined };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uuidFromUsn = (valueArg?: string): string | undefined => {
|
||||||
|
const match = valueArg?.match(/uuid:([^:\s]+)/i);
|
||||||
|
return match?.[1];
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity } from '../../core/types.js';
|
||||||
|
import type { IPlexClientInfo, IPlexLibrarySection, IPlexSession, IPlexSnapshot, TPlexMediaContentType } from './plex.types.js';
|
||||||
|
|
||||||
|
const plexLibraryPrimaryTypes: Record<string, string> = {
|
||||||
|
show: 'episode',
|
||||||
|
artist: 'track',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PlexMapper {
|
||||||
|
public static toDevices(snapshotArg: IPlexSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = snapshotArg.capturedAt || new Date().toISOString();
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.serverDevice(snapshotArg, updatedAt)];
|
||||||
|
const sessionsByClient = this.sessionsByClient(snapshotArg.sessions);
|
||||||
|
|
||||||
|
for (const client of snapshotArg.clients) {
|
||||||
|
devices.push(this.clientDevice(snapshotArg, client, sessionsByClient.get(this.clientIdentifier(client) || ''), updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IPlexSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
const sessionsByClient = this.sessionsByClient(snapshotArg.sessions);
|
||||||
|
const serverId = this.serverDeviceId(snapshotArg);
|
||||||
|
const serverName = this.serverName(snapshotArg);
|
||||||
|
|
||||||
|
entities.push({
|
||||||
|
id: `sensor.${this.slug(serverName)}_plex`,
|
||||||
|
uniqueId: `plex_server_${this.uniqueServerBase(snapshotArg)}_activity`,
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
deviceId: serverId,
|
||||||
|
platform: 'sensor',
|
||||||
|
name: `${serverName} Plex Activity`,
|
||||||
|
state: snapshotArg.sessions.filter((sessionArg) => sessionArg.state !== 'stopped').length,
|
||||||
|
attributes: {
|
||||||
|
serverId: snapshotArg.server.machineIdentifier,
|
||||||
|
version: snapshotArg.server.version,
|
||||||
|
url: snapshotArg.server.url,
|
||||||
|
watching: this.sensorAttributes(snapshotArg.sessions),
|
||||||
|
sessionKeys: snapshotArg.sessions.map((sessionArg) => sessionArg.sessionKey).filter((valueArg) => valueArg !== undefined),
|
||||||
|
},
|
||||||
|
available: this.serverOnline(snapshotArg),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const client of snapshotArg.clients) {
|
||||||
|
const session = sessionsByClient.get(this.clientIdentifier(client) || '');
|
||||||
|
entities.push(this.clientEntity(snapshotArg, client, session));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const library of snapshotArg.libraries) {
|
||||||
|
entities.push(this.libraryEntity(snapshotArg, library));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static clientDeviceId(snapshotArg: IPlexSnapshot, clientArg: IPlexClientInfo): string {
|
||||||
|
return `plex.client.${this.uniqueServerBase(snapshotArg)}.${this.slug(this.clientIdentifier(clientArg) || this.clientName(clientArg))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static serverDeviceId(snapshotArg: IPlexSnapshot): string {
|
||||||
|
return `plex.server.${this.uniqueServerBase(snapshotArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'plex';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serverDevice(snapshotArg: IPlexSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
return {
|
||||||
|
id: this.serverDeviceId(snapshotArg),
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
name: this.serverName(snapshotArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Plex',
|
||||||
|
model: 'Plex Media Server',
|
||||||
|
online: this.serverOnline(snapshotArg),
|
||||||
|
features: [
|
||||||
|
{ id: 'active_sessions', capability: 'sensor', name: 'Active sessions', readable: true, writable: false },
|
||||||
|
{ id: 'library_count', capability: 'sensor', name: 'Library count', readable: true, writable: false },
|
||||||
|
{ id: 'client_count', capability: 'sensor', name: 'Client count', readable: true, writable: false },
|
||||||
|
{ id: 'version', capability: 'sensor', name: 'Version', readable: true, writable: false },
|
||||||
|
{ id: 'refresh_library', capability: 'media', name: 'Refresh library', readable: false, writable: true },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'active_sessions', value: snapshotArg.sessions.filter((sessionArg) => sessionArg.state !== 'stopped').length, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'library_count', value: snapshotArg.libraries.length, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'client_count', value: snapshotArg.clients.length, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'version', value: snapshotArg.server.version || null, updatedAt: updatedAtArg },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
serverId: snapshotArg.server.machineIdentifier,
|
||||||
|
url: snapshotArg.server.url,
|
||||||
|
platform: snapshotArg.server.platform,
|
||||||
|
platformVersion: snapshotArg.server.platformVersion,
|
||||||
|
myPlexUsername: snapshotArg.server.myPlexUsername,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clientDevice(snapshotArg: IPlexSnapshot, clientArg: IPlexClientInfo, sessionArg: IPlexSession | undefined, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
return {
|
||||||
|
id: this.clientDeviceId(snapshotArg, clientArg),
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
name: this.clientName(clientArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: clientArg.platform || clientArg.vendor || 'Plex',
|
||||||
|
model: clientArg.product || clientArg.model || clientArg.device,
|
||||||
|
online: this.clientOnline(clientArg, sessionArg),
|
||||||
|
features: [
|
||||||
|
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: this.hasPlayback(clientArg) },
|
||||||
|
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: this.hasPlayback(clientArg), unit: '%' },
|
||||||
|
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: this.hasPlayback(clientArg) },
|
||||||
|
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||||
|
{ id: 'user', capability: 'sensor', name: 'User', readable: true, writable: false },
|
||||||
|
{ id: 'source', capability: 'sensor', name: 'Source', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'playback', value: this.mediaState(clientArg, sessionArg), updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'volume', value: this.volumePercent(clientArg), updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'muted', value: clientArg.muted ?? null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'current_title', value: this.mediaTitle(sessionArg) || null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'user', value: this.username(sessionArg) || null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'source', value: clientArg.source || null, updatedAt: updatedAtArg },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
serverId: snapshotArg.server.machineIdentifier,
|
||||||
|
clientIdentifier: this.clientIdentifier(clientArg),
|
||||||
|
host: clientArg.host || clientArg.address,
|
||||||
|
port: clientArg.port,
|
||||||
|
protocolCapabilities: clientArg.protocolCapabilities,
|
||||||
|
sessionKey: sessionArg?.sessionKey,
|
||||||
|
viaDevice: this.serverDeviceId(snapshotArg),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clientEntity(snapshotArg: IPlexSnapshot, clientArg: IPlexClientInfo, sessionArg: IPlexSession | undefined): IIntegrationEntity {
|
||||||
|
const clientName = this.clientName(clientArg);
|
||||||
|
return {
|
||||||
|
id: `media_player.${this.slug(clientName)}_plex`,
|
||||||
|
uniqueId: `plex_client_${this.uniqueServerBase(snapshotArg)}_${this.slug(this.clientIdentifier(clientArg) || clientName)}`,
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
deviceId: this.clientDeviceId(snapshotArg, clientArg),
|
||||||
|
platform: 'media_player',
|
||||||
|
name: `Plex (${clientName})`,
|
||||||
|
state: this.mediaState(clientArg, sessionArg),
|
||||||
|
attributes: {
|
||||||
|
plexServerId: snapshotArg.server.machineIdentifier,
|
||||||
|
plexClientId: this.clientIdentifier(clientArg),
|
||||||
|
playerSource: clientArg.source,
|
||||||
|
sessionKey: sessionArg?.sessionKey,
|
||||||
|
supportedFeatures: this.hasPlayback(clientArg) ? ['play', 'pause', 'stop', 'previous_track', 'next_track', 'seek', 'volume_set', 'volume_mute', 'play_media'] : ['play_media'],
|
||||||
|
volumeLevel: clientArg.volumeLevel,
|
||||||
|
volumePercent: this.volumePercent(clientArg),
|
||||||
|
isVolumeMuted: clientArg.muted,
|
||||||
|
mediaContentId: sessionArg?.ratingKey,
|
||||||
|
mediaContentType: this.mediaContentType(sessionArg),
|
||||||
|
mediaDuration: this.millisecondsToSeconds(sessionArg?.duration),
|
||||||
|
mediaPosition: this.millisecondsToSeconds(sessionArg?.viewOffset),
|
||||||
|
mediaPositionUpdatedAt: sessionArg?.mediaPositionUpdatedAt,
|
||||||
|
mediaTitle: this.mediaTitle(sessionArg),
|
||||||
|
mediaSeriesTitle: sessionArg?.grandparentTitle,
|
||||||
|
mediaSeason: sessionArg?.parentIndex,
|
||||||
|
mediaEpisode: sessionArg?.index,
|
||||||
|
mediaAlbumName: sessionArg?.parentTitle,
|
||||||
|
mediaArtist: sessionArg?.originalTitle || sessionArg?.grandparentTitle,
|
||||||
|
mediaAlbumArtist: sessionArg?.grandparentTitle,
|
||||||
|
mediaImageUrl: this.mediaImageUrl(sessionArg),
|
||||||
|
mediaSummary: sessionArg?.summary,
|
||||||
|
username: this.username(sessionArg),
|
||||||
|
mediaLibraryTitle: sessionArg?.librarySectionTitle,
|
||||||
|
platform: clientArg.platform,
|
||||||
|
product: clientArg.product,
|
||||||
|
},
|
||||||
|
available: this.clientOnline(clientArg, sessionArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static libraryEntity(snapshotArg: IPlexSnapshot, libraryArg: IPlexLibrarySection): IIntegrationEntity {
|
||||||
|
const serverName = this.serverName(snapshotArg);
|
||||||
|
const libraryTitle = libraryArg.title || String(libraryArg.key);
|
||||||
|
return {
|
||||||
|
id: `sensor.${this.slug(serverName)}_${this.slug(libraryTitle)}_plex_library`,
|
||||||
|
uniqueId: `plex_library_${this.uniqueServerBase(snapshotArg)}_${this.slug(libraryArg.uuid || String(libraryArg.key))}`,
|
||||||
|
integrationDomain: 'plex',
|
||||||
|
deviceId: this.serverDeviceId(snapshotArg),
|
||||||
|
platform: 'sensor',
|
||||||
|
name: `${serverName} Library - ${libraryTitle}`,
|
||||||
|
state: this.libraryCount(libraryArg),
|
||||||
|
attributes: {
|
||||||
|
plexServerId: snapshotArg.server.machineIdentifier,
|
||||||
|
libraryKey: libraryArg.key,
|
||||||
|
libraryUuid: libraryArg.uuid,
|
||||||
|
libraryType: libraryArg.type,
|
||||||
|
primaryType: this.libraryPrimaryType(libraryArg),
|
||||||
|
counts: libraryArg.counts,
|
||||||
|
refreshing: libraryArg.refreshing,
|
||||||
|
locations: libraryArg.Location?.map((locationArg) => locationArg.path).filter((valueArg) => valueArg !== undefined),
|
||||||
|
lastAddedItem: libraryArg.lastAddedItem,
|
||||||
|
lastAddedTimestamp: libraryArg.lastAddedTimestamp,
|
||||||
|
scannedAt: libraryArg.scannedAt,
|
||||||
|
updatedAt: libraryArg.updatedAt,
|
||||||
|
},
|
||||||
|
available: this.serverOnline(snapshotArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sessionsByClient(sessionsArg: IPlexSession[]): Map<string, IPlexSession> {
|
||||||
|
const sessions = new Map<string, IPlexSession>();
|
||||||
|
for (const session of sessionsArg) {
|
||||||
|
const clientId = this.clientIdentifier(session.Player || session.players?.[0]);
|
||||||
|
if (clientId) {
|
||||||
|
sessions.set(clientId, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sensorAttributes(sessionsArg: IPlexSession[]): Record<string, string> {
|
||||||
|
const attributes: Record<string, string> = {};
|
||||||
|
for (const session of sessionsArg) {
|
||||||
|
const user = this.username(session) || session.Player?.product || 'Unknown';
|
||||||
|
const product = session.Player?.product && session.Player.product !== user ? ` - ${session.Player.product}` : '';
|
||||||
|
attributes[`${user}${product}`] = this.mediaSensorTitle(session);
|
||||||
|
}
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaState(clientArg: IPlexClientInfo, sessionArg?: IPlexSession): string {
|
||||||
|
const state = sessionArg?.state || clientArg.state;
|
||||||
|
if (!this.clientOnline(clientArg, sessionArg)) {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
if (state === 'playing') {
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
|
if (state === 'paused') {
|
||||||
|
return 'paused';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaContentType(sessionArg: IPlexSession | undefined): string | undefined {
|
||||||
|
const type = sessionArg?.type?.toLowerCase() as TPlexMediaContentType | undefined;
|
||||||
|
if (type === 'episode') {
|
||||||
|
return 'tvshow';
|
||||||
|
}
|
||||||
|
if (type === 'track' || type === 'album' || type === 'artist') {
|
||||||
|
return 'music';
|
||||||
|
}
|
||||||
|
if (type === 'clip') {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaTitle(sessionArg: IPlexSession | undefined): string | undefined {
|
||||||
|
if (!sessionArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (sessionArg.type === 'movie' && sessionArg.year && sessionArg.title) {
|
||||||
|
return `${sessionArg.title} (${sessionArg.year})`;
|
||||||
|
}
|
||||||
|
return sessionArg.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaSensorTitle(sessionArg: IPlexSession): string {
|
||||||
|
if (sessionArg.type === 'episode') {
|
||||||
|
return [sessionArg.grandparentTitle, sessionArg.parentIndex && sessionArg.index ? `S${sessionArg.parentIndex}:E${sessionArg.index}` : undefined, sessionArg.title]
|
||||||
|
.filter((valueArg) => valueArg !== undefined && valueArg !== '')
|
||||||
|
.join(' - ') || 'Unknown';
|
||||||
|
}
|
||||||
|
if (sessionArg.type === 'track') {
|
||||||
|
return [sessionArg.originalTitle || sessionArg.grandparentTitle, sessionArg.parentTitle, sessionArg.title]
|
||||||
|
.filter((valueArg) => valueArg !== undefined && valueArg !== '')
|
||||||
|
.join(' - ') || 'Unknown';
|
||||||
|
}
|
||||||
|
return this.mediaTitle(sessionArg) || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mediaImageUrl(sessionArg: IPlexSession | undefined): string | undefined {
|
||||||
|
if (!sessionArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (sessionArg.type === 'episode') {
|
||||||
|
return sessionArg.grandparentThumb || sessionArg.thumb || sessionArg.art;
|
||||||
|
}
|
||||||
|
return sessionArg.thumb || sessionArg.art || sessionArg.parentThumb || sessionArg.grandparentThumb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static libraryCount(libraryArg: IPlexLibrarySection): number | null {
|
||||||
|
const primaryType = this.libraryPrimaryType(libraryArg);
|
||||||
|
return libraryArg.itemCount
|
||||||
|
?? libraryArg.totalSize
|
||||||
|
?? libraryArg.leafCount
|
||||||
|
?? libraryArg.counts?.[primaryType]
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static libraryPrimaryType(libraryArg: IPlexLibrarySection): string {
|
||||||
|
const type = libraryArg.type || 'item';
|
||||||
|
return plexLibraryPrimaryTypes[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static username(sessionArg: IPlexSession | undefined): string | undefined {
|
||||||
|
return sessionArg?.username || sessionArg?.User?.title || sessionArg?.User?.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volumePercent(clientArg: IPlexClientInfo): number | null {
|
||||||
|
if (typeof clientArg.volumeLevel !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.round((clientArg.volumeLevel <= 1 ? clientArg.volumeLevel * 100 : clientArg.volumeLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static millisecondsToSeconds(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' ? Math.round(valueArg / 1000) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hasPlayback(clientArg: IPlexClientInfo): boolean {
|
||||||
|
return Boolean(clientArg.protocolCapabilities?.includes('playback'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clientOnline(clientArg: IPlexClientInfo, sessionArg?: IPlexSession): boolean {
|
||||||
|
return Boolean(sessionArg && sessionArg.state !== 'stopped') || Boolean(clientArg.host || clientArg.address || clientArg.source === 'PMS' || clientArg.source === 'GDM');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clientIdentifier(clientArg: IPlexClientInfo | undefined): string | undefined {
|
||||||
|
return clientArg?.machineIdentifier || clientArg?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clientName(clientArg: IPlexClientInfo): string {
|
||||||
|
return clientArg.title || clientArg.name || clientArg.product || clientArg.machineIdentifier || clientArg.id || 'Plex Client';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serverName(snapshotArg: IPlexSnapshot): string {
|
||||||
|
return snapshotArg.server.friendlyName || snapshotArg.server.name || 'Plex Media Server';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serverOnline(snapshotArg: IPlexSnapshot): boolean {
|
||||||
|
return snapshotArg.online ?? snapshotArg.server.online ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueServerBase(snapshotArg: IPlexSnapshot): string {
|
||||||
|
return this.slug(snapshotArg.server.machineIdentifier || snapshotArg.server.host || this.serverName(snapshotArg));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,330 @@
|
|||||||
export interface IHomeAssistantPlexConfig {
|
export const plexDefaultPort = 32400;
|
||||||
// TODO: replace with the TypeScript-native config for plex.
|
|
||||||
|
export type TPlexSnapshotSource = 'manual' | 'http' | 'runtime';
|
||||||
|
export type TPlexDiscoveryProtocol = 'manual' | 'gdm' | 'mdns' | 'ssdp' | 'zeroconf';
|
||||||
|
export type TPlexPlayerState = 'playing' | 'paused' | 'stopped' | 'buffering' | 'idle' | 'offline' | (string & {});
|
||||||
|
export type TPlexMediaContentType = 'movie' | 'tvshow' | 'episode' | 'music' | 'track' | 'album' | 'artist' | 'photo' | 'video' | 'clip' | (string & {});
|
||||||
|
export type TPlexPlaybackCommand =
|
||||||
|
| 'play'
|
||||||
|
| 'pause'
|
||||||
|
| 'stop'
|
||||||
|
| 'skipNext'
|
||||||
|
| 'skipPrevious'
|
||||||
|
| 'seekTo'
|
||||||
|
| 'setParameters'
|
||||||
|
| 'playMedia'
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
|
export interface IPlexConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
ssl?: boolean;
|
||||||
|
verifySsl?: boolean;
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
name?: string;
|
||||||
|
serverIdentifier?: string;
|
||||||
|
clientIdentifier?: string;
|
||||||
|
ignorePlexWebClients?: boolean;
|
||||||
|
snapshot?: IPlexSnapshot;
|
||||||
|
server?: IPlexServerInfo;
|
||||||
|
clients?: IPlexClientInfo[];
|
||||||
|
sessions?: IPlexSession[];
|
||||||
|
libraries?: IPlexLibrarySection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantPlexConfig extends IPlexConfig {}
|
||||||
|
|
||||||
|
export interface IPlexServerInfo {
|
||||||
|
machineIdentifier?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
platform?: string;
|
||||||
|
platformVersion?: string;
|
||||||
|
url?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
ssl?: boolean;
|
||||||
|
claimed?: boolean;
|
||||||
|
myPlex?: boolean;
|
||||||
|
myPlexUsername?: string;
|
||||||
|
allowSync?: boolean;
|
||||||
|
allowSharing?: boolean;
|
||||||
|
allowMediaDeletion?: boolean;
|
||||||
|
transcoderActiveVideoSessions?: number;
|
||||||
|
updatedAt?: number | string;
|
||||||
|
online?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPlexClientInfo {
|
||||||
|
machineIdentifier?: string;
|
||||||
|
id?: string;
|
||||||
|
host?: string;
|
||||||
|
address?: string;
|
||||||
|
port?: number;
|
||||||
|
baseUrl?: string;
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
product?: string;
|
||||||
|
platform?: string;
|
||||||
|
platformVersion?: string;
|
||||||
|
device?: string;
|
||||||
|
deviceClass?: string;
|
||||||
|
model?: string;
|
||||||
|
vendor?: string;
|
||||||
|
version?: string;
|
||||||
|
protocol?: string;
|
||||||
|
protocolCapabilities?: string[];
|
||||||
|
state?: TPlexPlayerState;
|
||||||
|
local?: boolean;
|
||||||
|
relayed?: boolean;
|
||||||
|
secure?: boolean;
|
||||||
|
transient?: boolean;
|
||||||
|
source?: 'PMS' | 'GDM' | 'plex.tv' | 'session' | 'manual' | (string & {});
|
||||||
|
volumeLevel?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexSessionUser {
|
||||||
|
id?: string | number;
|
||||||
|
title?: string;
|
||||||
|
username?: string;
|
||||||
|
thumb?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexSessionTransport {
|
||||||
|
bandwidth?: number;
|
||||||
|
id?: string;
|
||||||
|
location?: 'lan' | 'wan' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexMediaStream {
|
||||||
|
id?: string | number;
|
||||||
|
streamType?: number;
|
||||||
|
codec?: string;
|
||||||
|
displayTitle?: string;
|
||||||
|
language?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexMediaPart {
|
||||||
|
id?: string | number;
|
||||||
|
key?: string;
|
||||||
|
file?: string;
|
||||||
|
duration?: number;
|
||||||
|
size?: number;
|
||||||
|
container?: string;
|
||||||
|
Stream?: IPlexMediaStream[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexMediaItem {
|
||||||
|
ratingKey?: string | number;
|
||||||
|
key?: string;
|
||||||
|
guid?: string;
|
||||||
|
title?: string;
|
||||||
|
type?: TPlexMediaContentType;
|
||||||
|
summary?: string;
|
||||||
|
contentRating?: string;
|
||||||
|
librarySectionID?: string | number;
|
||||||
|
librarySectionTitle?: string;
|
||||||
|
grandparentTitle?: string;
|
||||||
|
parentTitle?: string;
|
||||||
|
grandparentThumb?: string;
|
||||||
|
parentThumb?: string;
|
||||||
|
thumb?: string;
|
||||||
|
art?: string;
|
||||||
|
duration?: number;
|
||||||
|
viewOffset?: number;
|
||||||
|
year?: number;
|
||||||
|
index?: number;
|
||||||
|
parentIndex?: number;
|
||||||
|
originalTitle?: string;
|
||||||
|
Media?: Array<{
|
||||||
|
id?: string | number;
|
||||||
|
duration?: number;
|
||||||
|
videoResolution?: string;
|
||||||
|
audioCodec?: string;
|
||||||
|
videoCodec?: string;
|
||||||
|
Part?: IPlexMediaPart[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexSession extends IPlexMediaItem {
|
||||||
|
sessionKey?: string | number;
|
||||||
|
state?: TPlexPlayerState;
|
||||||
|
Player?: IPlexClientInfo;
|
||||||
|
Session?: IPlexSessionTransport;
|
||||||
|
User?: IPlexSessionUser;
|
||||||
|
username?: string;
|
||||||
|
players?: IPlexClientInfo[];
|
||||||
|
mediaPositionUpdatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexLibrarySection {
|
||||||
|
key: string | number;
|
||||||
|
uuid?: string;
|
||||||
|
title?: string;
|
||||||
|
type?: TPlexMediaContentType;
|
||||||
|
agent?: string;
|
||||||
|
scanner?: string;
|
||||||
|
language?: string;
|
||||||
|
allowSync?: boolean;
|
||||||
|
refreshing?: boolean;
|
||||||
|
thumb?: string;
|
||||||
|
art?: string;
|
||||||
|
composite?: string;
|
||||||
|
contentChangedAt?: number;
|
||||||
|
createdAt?: number;
|
||||||
|
scannedAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
totalSize?: number;
|
||||||
|
itemCount?: number;
|
||||||
|
leafCount?: number;
|
||||||
|
counts?: Record<string, number | undefined>;
|
||||||
|
lastAddedItem?: string;
|
||||||
|
lastAddedTimestamp?: string | number;
|
||||||
|
Location?: Array<{ id?: number | string; path?: string }>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexSnapshot {
|
||||||
|
server: IPlexServerInfo;
|
||||||
|
clients: IPlexClientInfo[];
|
||||||
|
sessions: IPlexSession[];
|
||||||
|
libraries: IPlexLibrarySection[];
|
||||||
|
capturedAt?: string;
|
||||||
|
source?: TPlexSnapshotSource;
|
||||||
|
online?: boolean;
|
||||||
|
events?: IPlexEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexPlaybackCommand {
|
||||||
|
clientIdentifier: string;
|
||||||
|
command: TPlexPlaybackCommand;
|
||||||
|
mediaType?: 'video' | 'music' | 'photo' | string;
|
||||||
|
offsetMs?: number;
|
||||||
|
volume?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
params?: Record<string, string | number | boolean | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexPlayMediaCommand extends IPlexPlaybackCommand {
|
||||||
|
command: 'playMedia';
|
||||||
|
key: string;
|
||||||
|
containerKey?: string;
|
||||||
|
machineIdentifier?: string;
|
||||||
|
address?: string;
|
||||||
|
port?: number;
|
||||||
|
protocol?: 'http' | 'https' | string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexRefreshLibraryCommand {
|
||||||
|
libraryName?: string;
|
||||||
|
libraryKey?: string | number;
|
||||||
|
force?: boolean;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexCommandResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TPlexEventType = 'snapshot' | 'command' | 'library_refresh' | 'error';
|
||||||
|
|
||||||
|
export interface IPlexEvent {
|
||||||
|
type: TPlexEventType;
|
||||||
|
timestamp: number;
|
||||||
|
serverId?: string;
|
||||||
|
clientIdentifier?: string;
|
||||||
|
entityId?: string;
|
||||||
|
command?: IPlexPlaybackCommand | IPlexRefreshLibraryCommand;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexMediaContainer<TDirectory = unknown, TMetadata = unknown> {
|
||||||
|
MediaContainer?: {
|
||||||
|
Directory?: TDirectory[];
|
||||||
|
Metadata?: TMetadata[];
|
||||||
|
Server?: IPlexClientInfo[];
|
||||||
|
size?: number;
|
||||||
|
totalSize?: number;
|
||||||
|
machineIdentifier?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
version?: string;
|
||||||
|
claimed?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexGdmRecord {
|
||||||
|
data?: Record<string, string | undefined>;
|
||||||
|
from?: [string, number] | { address?: string; port?: number };
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexMdnsRecord {
|
||||||
|
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 IPlexSsdpRecord {
|
||||||
|
headers?: Record<string, string | undefined>;
|
||||||
|
st?: string;
|
||||||
|
usn?: string;
|
||||||
|
location?: string;
|
||||||
|
server?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
modelName?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
ssl?: boolean;
|
||||||
|
verifySsl?: boolean;
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
id?: string;
|
||||||
|
serverIdentifier?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
snapshot?: IPlexSnapshot;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlexGdmDiscoveryEntry {
|
||||||
|
source: 'gdm';
|
||||||
|
contentType?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
name?: string;
|
||||||
|
resourceIdentifier?: string;
|
||||||
|
version?: string;
|
||||||
|
product?: string;
|
||||||
|
protocolCapabilities?: string[];
|
||||||
|
raw?: IPlexGdmRecord;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 './rainbird.classes.client.js';
|
||||||
|
export * from './rainbird.classes.configflow.js';
|
||||||
export * from './rainbird.classes.integration.js';
|
export * from './rainbird.classes.integration.js';
|
||||||
|
export * from './rainbird.discovery.js';
|
||||||
|
export * from './rainbird.mapper.js';
|
||||||
export * from './rainbird.types.js';
|
export * from './rainbird.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,687 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type {
|
||||||
|
IRainbirdCommand,
|
||||||
|
IRainbirdCommandResult,
|
||||||
|
IRainbirdConfig,
|
||||||
|
IRainbirdController,
|
||||||
|
IRainbirdEvent,
|
||||||
|
IRainbirdHttpLocalCommandShape,
|
||||||
|
IRainbirdLocalJsonRpcResponse,
|
||||||
|
IRainbirdModelAndVersion,
|
||||||
|
IRainbirdSnapshot,
|
||||||
|
IRainbirdTunnelSipResponse,
|
||||||
|
IRainbirdWifiParams,
|
||||||
|
IRainbirdZone,
|
||||||
|
TRainbirdProtocol,
|
||||||
|
} from './rainbird.types.js';
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 20000;
|
||||||
|
const defaultIrrigationDurationMinutes = 6;
|
||||||
|
const rainbirdManufacturer = 'Rain Bird';
|
||||||
|
|
||||||
|
const rainbirdHeaders = {
|
||||||
|
'accept-language': 'en',
|
||||||
|
'accept-encoding': 'gzip, deflate',
|
||||||
|
'user-agent': 'RainBird/2.0 CFNetwork/811.5.4 Darwin/16.7.0',
|
||||||
|
accept: '*/*',
|
||||||
|
connection: 'keep-alive',
|
||||||
|
'content-type': 'application/octet-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelInfoById: Record<string, Omit<IRainbirdModelAndVersion, 'protocolRevisionMajor' | 'protocolRevisionMinor'>> = {
|
||||||
|
'0003': { modelId: '0003', modelCode: 'ESP_RZXe', modelName: 'ESP-RZXe', supportsWaterBudget: false, maxPrograms: 0, maxRunTimes: 6, maxStations: 8 },
|
||||||
|
'0007': { modelId: '0007', modelCode: 'ESP_ME', modelName: 'ESP-Me', supportsWaterBudget: true, maxPrograms: 4, maxRunTimes: 6, maxStations: 22 },
|
||||||
|
'0006': { modelId: '0006', modelCode: 'ST8X_WF', modelName: 'ST8x-WiFi', supportsWaterBudget: false, maxPrograms: 0, maxRunTimes: 6, maxStations: 8 },
|
||||||
|
'0005': { modelId: '0005', modelCode: 'ESP_TM2', modelName: 'ESP-TM2', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 4, maxStations: 12 },
|
||||||
|
'0008': { modelId: '0008', modelCode: 'ST8X_WF2', modelName: 'ST8x-WiFi2', supportsWaterBudget: false, maxPrograms: 8, maxRunTimes: 6, maxStations: 8 },
|
||||||
|
'0009': { modelId: '0009', modelCode: 'ESP_ME3', modelName: 'ESP-ME3', supportsWaterBudget: true, maxPrograms: 4, maxRunTimes: 6, maxStations: 22 },
|
||||||
|
'0010': { modelId: '0010', modelCode: 'MOCK_ESP_ME2', modelName: 'ESP=Me2', supportsWaterBudget: true, maxPrograms: 4, maxRunTimes: 6, maxStations: 22 },
|
||||||
|
'000a': { modelId: '000a', modelCode: 'ESP_TM2v2', modelName: 'ESP-TM2', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 4, maxStations: 12 },
|
||||||
|
'010a': { modelId: '010a', modelCode: 'ESP_TM2v3', modelName: 'ESP-TM2', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 4, maxStations: 12 },
|
||||||
|
'0099': { modelId: '0099', modelCode: 'TBOS_BT', modelName: 'TBOS-BT', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 8, maxStations: 8 },
|
||||||
|
'0100': { modelId: '0100', modelCode: 'TBOS_BT', modelName: 'TBOS-BT', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 8, maxStations: 8 },
|
||||||
|
'0107': { modelId: '0107', modelCode: 'ESP_MEv2', modelName: 'ESP-Me', supportsWaterBudget: true, maxPrograms: 4, maxRunTimes: 6, maxStations: 22 },
|
||||||
|
'0103': { modelId: '0103', modelCode: 'ESP_RZXe2', modelName: 'ESP-RZXe2', supportsWaterBudget: false, maxPrograms: 8, maxRunTimes: 6, maxStations: 8 },
|
||||||
|
'0812': { modelId: '0812', modelCode: 'RC2', modelName: 'RC2', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 4, maxStations: 12 },
|
||||||
|
'0813': { modelId: '0813', modelCode: 'ARC8', modelName: 'ARC8', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 4, maxStations: 12 },
|
||||||
|
'0014': { modelId: '0014', modelCode: 'TM2R', modelName: 'TM2R', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 4, maxStations: 12 },
|
||||||
|
'0015': { modelId: '0015', modelCode: 'TRU', modelName: 'TRU', supportsWaterBudget: true, maxPrograms: 3, maxRunTimes: 4, maxStations: 12 },
|
||||||
|
'0011': { modelId: '0011', modelCode: 'ESP_2WIRE', modelName: 'ESP-2WIRE', supportsWaterBudget: true, maxPrograms: 4, maxRunTimes: 6, maxStations: 50 },
|
||||||
|
'000c': { modelId: '000c', modelCode: 'LXME2', modelName: 'LXME2', supportsWaterBudget: true, maxPrograms: 40, maxRunTimes: 10, maxStations: 22 },
|
||||||
|
'000d': { modelId: '000d', modelCode: 'LX_IVM', modelName: 'LX-IVM', supportsWaterBudget: true, maxPrograms: 10, maxRunTimes: 8, maxStations: 22 },
|
||||||
|
'000e': { modelId: '000e', modelCode: 'LX_IVM_PRO', modelName: 'LX-IVM Pro', supportsWaterBudget: true, maxPrograms: 40, maxRunTimes: 8, maxStations: 22 },
|
||||||
|
};
|
||||||
|
|
||||||
|
type TRainbirdEventHandler = (eventArg: IRainbirdEvent) => void;
|
||||||
|
|
||||||
|
export class RainbirdApiError extends Error {
|
||||||
|
constructor(messageArg: string, public readonly status?: number) {
|
||||||
|
super(messageArg);
|
||||||
|
this.name = 'RainbirdApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RainbirdUnsupportedError extends Error {
|
||||||
|
constructor(messageArg: string) {
|
||||||
|
super(messageArg);
|
||||||
|
this.name = 'RainbirdUnsupportedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RainbirdClient {
|
||||||
|
private snapshot?: IRainbirdSnapshot;
|
||||||
|
private readonly events: IRainbirdEvent[] = [];
|
||||||
|
private readonly eventHandlers = new Set<TRainbirdEventHandler>();
|
||||||
|
private readonly localClient: RainbirdLocalApiClient;
|
||||||
|
|
||||||
|
constructor(private readonly config: IRainbirdConfig) {
|
||||||
|
this.localClient = new RainbirdLocalApiClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IRainbirdSnapshot> {
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.host && this.config.password) {
|
||||||
|
this.snapshot = this.normalizeSnapshot(await this.fetchSnapshot());
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasManualSnapshotData()) {
|
||||||
|
this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.config.connected ?? true));
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig(false));
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent(handlerArg: TRainbirdEventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handlerArg);
|
||||||
|
return () => this.eventHandlers.delete(handlerArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(commandArg: IRainbirdCommand): Promise<IRainbirdCommandResult> {
|
||||||
|
this.emit({ type: 'command_mapped', command: commandArg, zoneId: this.zoneId(commandArg), timestamp: Date.now() });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.executeCommand(commandArg);
|
||||||
|
this.emit({
|
||||||
|
type: result.success ? 'command_executed' : 'command_failed',
|
||||||
|
command: commandArg,
|
||||||
|
zoneId: this.zoneId(commandArg),
|
||||||
|
data: result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (errorArg) {
|
||||||
|
const result = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } };
|
||||||
|
this.emit({ type: 'command_failed', command: commandArg, zoneId: this.zoneId(commandArg), data: result, timestamp: Date.now() });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refresh(): Promise<IRainbirdSnapshot> {
|
||||||
|
this.snapshot = undefined;
|
||||||
|
const snapshot = await this.getSnapshot();
|
||||||
|
this.emit({ type: 'snapshot_refreshed', data: snapshot, timestamp: Date.now() });
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public localCommandShape(methodArg: string, paramsArg: Record<string, unknown> = {}): IRainbirdHttpLocalCommandShape {
|
||||||
|
return {
|
||||||
|
endpoint: '/stick',
|
||||||
|
method: 'POST',
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
jsonRpcMethod: methodArg,
|
||||||
|
encrypted: Boolean(this.config.password),
|
||||||
|
params: paramsArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeCommand(commandArg: IRainbirdCommand): Promise<IRainbirdCommandResult> {
|
||||||
|
if (this.config.commandExecutor) {
|
||||||
|
return this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandArg.type === 'refresh') {
|
||||||
|
return { success: true, data: await this.refresh() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandArg.type === 'raw_local_rpc') {
|
||||||
|
return { success: true, data: await this.localClient.request(commandArg.method, commandArg.params) };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertLiveLocalControl();
|
||||||
|
if (commandArg.type === 'start_zone') {
|
||||||
|
await this.localClient.irrigateZone(commandArg.zoneId, commandArg.durationMinutes);
|
||||||
|
this.patchZone(commandArg.zoneId, true);
|
||||||
|
this.emit({ type: 'zone_started', command: commandArg, zoneId: commandArg.zoneId, timestamp: Date.now() });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (commandArg.type === 'stop_zone') {
|
||||||
|
await this.localClient.stopIrrigation();
|
||||||
|
this.patchZone(commandArg.zoneId, false);
|
||||||
|
this.emit({ type: 'zone_stopped', command: commandArg, zoneId: commandArg.zoneId, timestamp: Date.now() });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (commandArg.type === 'set_rain_delay') {
|
||||||
|
await this.localClient.setRainDelay(commandArg.days);
|
||||||
|
this.patchRainDelay(commandArg.days);
|
||||||
|
this.emit({ type: 'rain_delay_set', command: commandArg, data: { days: commandArg.days }, timestamp: Date.now() });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (commandArg.type === 'start_program') {
|
||||||
|
await this.localClient.startProgram(commandArg.programId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Rain Bird command: ${(commandArg as { type: string }).type}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchSnapshot(): Promise<IRainbirdSnapshot> {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const model = await this.localClient.getModelAndVersion();
|
||||||
|
const serialNumber = await this.optional(() => this.localClient.getSerialNumber());
|
||||||
|
const wifiParams = await this.optional(() => this.localClient.getWifiParams());
|
||||||
|
const availableZoneIds = await this.localClient.getAvailableStations(model.maxStations);
|
||||||
|
const activeZoneIds = await this.localClient.getZoneStates(model.maxStations);
|
||||||
|
const rainSensorActive = await this.localClient.getRainSensorState();
|
||||||
|
const rainDelayDays = await this.localClient.getRainDelay();
|
||||||
|
const currentIrrigation = await this.optional(() => this.localClient.getCurrentIrrigation());
|
||||||
|
const firmwareVersion = await this.optional(() => this.localClient.getControllerFirmwareVersion());
|
||||||
|
const id = this.config.uniqueId || this.normalizeMac(wifiParams?.macAddress) || serialNumber || this.config.host || 'rainbird-controller';
|
||||||
|
const controller: IRainbirdController = {
|
||||||
|
id,
|
||||||
|
name: this.config.name || `${rainbirdManufacturer} Controller`,
|
||||||
|
manufacturer: rainbirdManufacturer,
|
||||||
|
modelId: model.modelId,
|
||||||
|
modelCode: model.modelCode,
|
||||||
|
modelName: this.config.model || model.modelName,
|
||||||
|
serialNumber: serialNumber || this.config.serialNumber,
|
||||||
|
macAddress: this.normalizeMac(wifiParams?.macAddress || this.config.macAddress) || undefined,
|
||||||
|
firmwareVersion,
|
||||||
|
protocolRevision: `${model.protocolRevisionMajor}.${model.protocolRevisionMinor}`,
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port,
|
||||||
|
protocol: this.config.protocol || 'auto',
|
||||||
|
online: true,
|
||||||
|
maxPrograms: model.maxPrograms,
|
||||||
|
maxRunTimes: model.maxRunTimes,
|
||||||
|
maxStations: model.maxStations,
|
||||||
|
supportsWaterBudget: model.supportsWaterBudget,
|
||||||
|
rainSensorActive,
|
||||||
|
rainDelayDays,
|
||||||
|
currentIrrigation,
|
||||||
|
rssi: wifiParams?.rssi,
|
||||||
|
wifiSsid: wifiParams?.wifiSsid,
|
||||||
|
localIpAddress: wifiParams?.localIpAddress,
|
||||||
|
localGateway: wifiParams?.localGateway,
|
||||||
|
availableZoneIds,
|
||||||
|
activeZoneIds,
|
||||||
|
};
|
||||||
|
const zones = availableZoneIds.map((zoneIdArg) => ({
|
||||||
|
id: zoneIdArg,
|
||||||
|
name: this.config.zones?.find((zoneArg) => zoneArg.id === zoneIdArg)?.name || `Sprinkler ${zoneIdArg}`,
|
||||||
|
available: true,
|
||||||
|
active: activeZoneIds.includes(zoneIdArg),
|
||||||
|
defaultDurationMinutes: this.config.defaultIrrigationDurationMinutes || defaultIrrigationDurationMinutes,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
zones,
|
||||||
|
programs: this.config.programs || this.config.schedule?.programs || [],
|
||||||
|
schedule: this.config.schedule,
|
||||||
|
events: [...(this.config.events || []), ...this.events],
|
||||||
|
connected: true,
|
||||||
|
updatedAt,
|
||||||
|
raw: { model, wifiParams },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotFromConfig(connectedArg: boolean): IRainbirdSnapshot {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const controller: IRainbirdController = {
|
||||||
|
id: this.config.controller?.id || this.config.uniqueId || this.normalizeMac(this.config.macAddress) || this.config.serialNumber || this.config.host || 'rainbird-controller',
|
||||||
|
name: this.config.controller?.name || this.config.name || `${rainbirdManufacturer} Controller`,
|
||||||
|
manufacturer: rainbirdManufacturer,
|
||||||
|
modelName: this.config.controller?.modelName || this.config.model,
|
||||||
|
serialNumber: this.config.controller?.serialNumber || this.config.serialNumber,
|
||||||
|
macAddress: this.normalizeMac(this.config.controller?.macAddress || this.config.macAddress) || undefined,
|
||||||
|
host: this.config.controller?.host || this.config.host,
|
||||||
|
port: this.config.controller?.port || this.config.port,
|
||||||
|
protocol: this.config.controller?.protocol || this.config.protocol || 'auto',
|
||||||
|
online: connectedArg,
|
||||||
|
rainSensorActive: this.config.controller?.rainSensorActive,
|
||||||
|
rainDelayDays: this.config.controller?.rainDelayDays,
|
||||||
|
availableZoneIds: this.config.controller?.availableZoneIds,
|
||||||
|
activeZoneIds: this.config.controller?.activeZoneIds,
|
||||||
|
...this.config.controller,
|
||||||
|
};
|
||||||
|
const zones = this.zonesFromConfig(controller);
|
||||||
|
const programs = this.config.programs || this.config.schedule?.programs || [];
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
zones,
|
||||||
|
programs,
|
||||||
|
schedule: this.config.schedule,
|
||||||
|
events: [...(this.config.events || []), ...this.events],
|
||||||
|
connected: connectedArg,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private zonesFromConfig(controllerArg: IRainbirdController): IRainbirdZone[] {
|
||||||
|
if (this.config.zones?.length) {
|
||||||
|
return this.config.zones.map((zoneArg) => ({
|
||||||
|
defaultDurationMinutes: this.config.defaultIrrigationDurationMinutes || defaultIrrigationDurationMinutes,
|
||||||
|
available: true,
|
||||||
|
...zoneArg,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const zoneIds = controllerArg.availableZoneIds || (this.config.zoneCount ? Array.from({ length: this.config.zoneCount }, (_value, indexArg) => indexArg + 1) : []);
|
||||||
|
return zoneIds.map((zoneIdArg) => ({
|
||||||
|
id: zoneIdArg,
|
||||||
|
name: `Sprinkler ${zoneIdArg}`,
|
||||||
|
available: true,
|
||||||
|
active: controllerArg.activeZoneIds?.includes(zoneIdArg) || false,
|
||||||
|
defaultDurationMinutes: this.config.defaultIrrigationDurationMinutes || defaultIrrigationDurationMinutes,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSnapshot(snapshotArg: IRainbirdSnapshot): IRainbirdSnapshot {
|
||||||
|
const controller = {
|
||||||
|
manufacturer: rainbirdManufacturer,
|
||||||
|
name: `${rainbirdManufacturer} Controller`,
|
||||||
|
...snapshotArg.controller,
|
||||||
|
};
|
||||||
|
controller.id = controller.id || this.config.uniqueId || this.normalizeMac(controller.macAddress) || controller.serialNumber || controller.host || 'rainbird-controller';
|
||||||
|
controller.macAddress = this.normalizeMac(controller.macAddress) || controller.macAddress;
|
||||||
|
controller.online = snapshotArg.connected && controller.online !== false;
|
||||||
|
const activeZoneIds = controller.activeZoneIds || snapshotArg.zones.filter((zoneArg) => zoneArg.active).map((zoneArg) => zoneArg.id);
|
||||||
|
const zones = this.uniqueZones(snapshotArg.zones).map((zoneArg) => ({
|
||||||
|
name: `Sprinkler ${zoneArg.id}`,
|
||||||
|
available: true,
|
||||||
|
defaultDurationMinutes: this.config.defaultIrrigationDurationMinutes || defaultIrrigationDurationMinutes,
|
||||||
|
...zoneArg,
|
||||||
|
active: zoneArg.active ?? activeZoneIds.includes(zoneArg.id),
|
||||||
|
}));
|
||||||
|
controller.availableZoneIds = controller.availableZoneIds || zones.map((zoneArg) => zoneArg.id);
|
||||||
|
controller.activeZoneIds = zones.filter((zoneArg) => zoneArg.active).map((zoneArg) => zoneArg.id);
|
||||||
|
return {
|
||||||
|
...snapshotArg,
|
||||||
|
controller,
|
||||||
|
zones,
|
||||||
|
programs: snapshotArg.programs || snapshotArg.schedule?.programs || [],
|
||||||
|
events: snapshotArg.events || [],
|
||||||
|
connected: snapshotArg.connected && controller.online !== false,
|
||||||
|
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private uniqueZones(zonesArg: IRainbirdZone[]): IRainbirdZone[] {
|
||||||
|
const map = new Map<number, IRainbirdZone>();
|
||||||
|
for (const zone of zonesArg) {
|
||||||
|
map.set(zone.id, { ...map.get(zone.id), ...zone });
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((leftArg, rightArg) => leftArg.id - rightArg.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneSnapshot(snapshotArg: IRainbirdSnapshot): IRainbirdSnapshot {
|
||||||
|
return JSON.parse(JSON.stringify(snapshotArg)) as IRainbirdSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasManualSnapshotData(): boolean {
|
||||||
|
return Boolean(this.config.controller || this.config.zones?.length || this.config.programs?.length || this.config.schedule || this.config.events?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchZone(zoneIdArg: number | undefined, activeArg: boolean): void {
|
||||||
|
if (!this.snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const zone of this.snapshot.zones) {
|
||||||
|
if (zoneIdArg === undefined || zone.id === zoneIdArg) {
|
||||||
|
zone.active = zoneIdArg === undefined ? false : activeArg;
|
||||||
|
if (activeArg) {
|
||||||
|
zone.lastStartedAt = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
zone.lastStoppedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.snapshot.controller.activeZoneIds = this.snapshot.zones.filter((zoneArg) => zoneArg.active).map((zoneArg) => zoneArg.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchRainDelay(daysArg: number): void {
|
||||||
|
if (!this.snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.snapshot.controller.rainDelayDays = daysArg;
|
||||||
|
if (this.snapshot.schedule) {
|
||||||
|
this.snapshot.schedule.rainDelayDays = daysArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertLiveLocalControl(): void {
|
||||||
|
if (!this.config.host || !this.config.password) {
|
||||||
|
throw new RainbirdUnsupportedError('Rain Bird live local control requires config.host and config.password, or provide commandExecutor for manual snapshots.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private commandResult(resultArg: unknown, commandArg: IRainbirdCommand): IRainbirdCommandResult {
|
||||||
|
if (this.isCommandResult(resultArg)) {
|
||||||
|
return resultArg;
|
||||||
|
}
|
||||||
|
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandResult(valueArg: unknown): valueArg is IRainbirdCommandResult {
|
||||||
|
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(eventArg: IRainbirdEvent): void {
|
||||||
|
this.events.push(eventArg);
|
||||||
|
for (const handler of this.eventHandlers) {
|
||||||
|
handler(eventArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneId(commandArg: IRainbirdCommand): number | undefined {
|
||||||
|
return commandArg.type === 'start_zone' || commandArg.type === 'stop_zone' ? commandArg.zoneId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async optional<TValue>(getterArg: () => Promise<TValue>): Promise<TValue | undefined> {
|
||||||
|
try {
|
||||||
|
return await getterArg();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMac(valueArg?: string): string {
|
||||||
|
const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return cleaned.length === 12 ? cleaned : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RainbirdLocalApiClient {
|
||||||
|
constructor(private readonly config: IRainbirdConfig) {}
|
||||||
|
|
||||||
|
public async request<TResult = unknown>(methodArg: string, paramsArg: Record<string, unknown> = {}): Promise<TResult> {
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new RainbirdUnsupportedError('Rain Bird local API requests require config.host.');
|
||||||
|
}
|
||||||
|
if (!this.config.password) {
|
||||||
|
throw new RainbirdUnsupportedError('Rain Bird local API requests require config.password for AES payload encryption.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.encodeJsonRpc(methodArg, paramsArg);
|
||||||
|
const urls = this.candidateUrls();
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithTimeout(url, payload);
|
||||||
|
return this.decodeJsonRpc<TResult>(Buffer.from(await response.arrayBuffer()));
|
||||||
|
} catch (errorArg) {
|
||||||
|
lastError = errorArg;
|
||||||
|
if ((this.config.protocol && this.config.protocol !== 'auto') || errorArg instanceof RainbirdApiError) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new RainbirdApiError(String(lastError));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getModelAndVersion(): Promise<IRainbirdModelAndVersion> {
|
||||||
|
const response = await this.processSipCommand('ModelAndVersionRequest');
|
||||||
|
const modelId = response.slice(2, 6).toLowerCase();
|
||||||
|
const modelInfo = modelInfoById[modelId] || { modelId, modelCode: 'UNKNOWN', modelName: 'Unknown', supportsWaterBudget: false, maxPrograms: 0, maxRunTimes: 0, maxStations: 0 };
|
||||||
|
return {
|
||||||
|
...modelInfo,
|
||||||
|
modelId,
|
||||||
|
protocolRevisionMajor: parseHex(response.slice(6, 8)),
|
||||||
|
protocolRevisionMinor: parseHex(response.slice(8, 10)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSerialNumber(): Promise<string> {
|
||||||
|
const response = await this.processSipCommand('SerialNumberRequest');
|
||||||
|
return response.slice(2, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getControllerFirmwareVersion(): Promise<string> {
|
||||||
|
const response = await this.processSipCommand('ControllerFirmwareVersionRequest');
|
||||||
|
return `${parseHex(response.slice(2, 4))}.${parseHex(response.slice(4, 6))}.${parseHex(response.slice(6, 10))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWifiParams(): Promise<IRainbirdWifiParams> {
|
||||||
|
const result = await this.request<Record<string, unknown>>('getWifiParams');
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
macAddress: typeof result.macAddress === 'string' ? result.macAddress : undefined,
|
||||||
|
localIpAddress: typeof result.localIpAddress === 'string' ? result.localIpAddress : undefined,
|
||||||
|
localGateway: typeof result.localGateway === 'string' ? result.localGateway : undefined,
|
||||||
|
localNetmask: typeof result.localNetmask === 'string' ? result.localNetmask : undefined,
|
||||||
|
rssi: typeof result.rssi === 'number' ? result.rssi : undefined,
|
||||||
|
wifiSsid: typeof result.wifiSsid === 'string' ? result.wifiSsid : undefined,
|
||||||
|
stickVersion: typeof result.stickVersion === 'string' ? result.stickVersion : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAvailableStations(maxStationsArg = 32): Promise<number[]> {
|
||||||
|
const pages = Math.max(1, Math.ceil(maxStationsArg / 32));
|
||||||
|
let mask = '';
|
||||||
|
for (let page = 0; page < pages; page++) {
|
||||||
|
const response = await this.processSipCommand('AvailableStationsRequest', page);
|
||||||
|
mask += response.slice(4, 12);
|
||||||
|
}
|
||||||
|
return activeSet(mask, maxStationsArg || mask.length * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getZoneStates(maxStationsArg = 32): Promise<number[]> {
|
||||||
|
const pages = Math.max(1, Math.ceil(maxStationsArg / 32));
|
||||||
|
let mask = '';
|
||||||
|
for (let page = 0; page < pages; page++) {
|
||||||
|
const response = await this.processSipCommand('CurrentStationsActiveRequest', page);
|
||||||
|
mask += response.slice(4, 12);
|
||||||
|
}
|
||||||
|
return activeSet(mask, maxStationsArg || mask.length * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRainSensorState(): Promise<boolean> {
|
||||||
|
const response = await this.processSipCommand('CurrentRainSensorStateRequest');
|
||||||
|
return parseHex(response.slice(2, 4)) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRainDelay(): Promise<number> {
|
||||||
|
const response = await this.processSipCommand('RainDelayGetRequest');
|
||||||
|
return parseHex(response.slice(2, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentIrrigation(): Promise<boolean> {
|
||||||
|
const response = await this.processSipCommand('CurrentIrrigationStateRequest');
|
||||||
|
return parseHex(response.slice(2, 4)) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async irrigateZone(zoneIdArg: number, minutesArg: number): Promise<void> {
|
||||||
|
await this.processSipCommand('ManuallyRunStationRequest', zoneIdArg, minutesArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stopIrrigation(): Promise<void> {
|
||||||
|
await this.processSipCommand('StopIrrigationRequest');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setRainDelay(daysArg: number): Promise<void> {
|
||||||
|
await this.processSipCommand('RainDelaySetRequest', daysArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async startProgram(programIdArg: number): Promise<void> {
|
||||||
|
await this.processSipCommand('ManuallyRunProgramRequest', programIdArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async retrieveScheduleRaw(commandCodeArg: string | number): Promise<string> {
|
||||||
|
return await this.processSipCommand('RetrieveScheduleRequest', typeof commandCodeArg === 'string' ? parseInt(commandCodeArg, 16) : commandCodeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processSipCommand(commandArg: keyof typeof sipCommands, ...argsArg: number[]): Promise<string> {
|
||||||
|
const command = sipCommands[commandArg];
|
||||||
|
const data = encodeSipCommand(commandArg, ...argsArg);
|
||||||
|
const result = await this.request<IRainbirdTunnelSipResponse>('tunnelSip', { data, length: command.length });
|
||||||
|
if (!result.data || typeof result.data !== 'string') {
|
||||||
|
throw new RainbirdApiError("Rain Bird tunnelSip response is missing required 'data' field.");
|
||||||
|
}
|
||||||
|
const responseCode = result.data.slice(0, 2).toUpperCase();
|
||||||
|
if (responseCode === '00') {
|
||||||
|
throw new RainbirdUnsupportedError(`Rain Bird controller returned NACK for ${commandArg}.`);
|
||||||
|
}
|
||||||
|
if (responseCode !== command.response) {
|
||||||
|
throw new RainbirdApiError(`Unexpected Rain Bird response for ${commandArg}: expected ${command.response}, got ${responseCode}.`);
|
||||||
|
}
|
||||||
|
return result.data.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private candidateUrls(): string[] {
|
||||||
|
const host = this.hostWithoutScheme();
|
||||||
|
const protocol = this.config.protocol || 'auto';
|
||||||
|
if (protocol === 'http') {
|
||||||
|
return [this.url('http', host)];
|
||||||
|
}
|
||||||
|
if (protocol === 'https') {
|
||||||
|
return [this.url('https', host)];
|
||||||
|
}
|
||||||
|
return [this.url('https', host), this.url('http', host)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private url(protocolArg: Exclude<TRainbirdProtocol, 'auto'>, hostArg: string): string {
|
||||||
|
const hasExplicitPort = /^\[[^\]]+\]:\d+$/.test(hostArg) || (/^[^:]+:\d+$/.test(hostArg));
|
||||||
|
const port = this.config.port && !hasExplicitPort ? `:${this.config.port}` : '';
|
||||||
|
return `${protocolArg}://${hostArg}${port}/stick`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hostWithoutScheme(): string {
|
||||||
|
const value = (this.config.host || '').trim().replace(/\/$/, '');
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.host;
|
||||||
|
} catch {
|
||||||
|
return value.replace(/^https?:\/\//i, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWithTimeout(urlArg: string, payloadArg: Buffer): Promise<Response> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await globalThis.fetch(urlArg, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: rainbirdHeaders,
|
||||||
|
body: payloadArg as BodyInit,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
if (response.status === 503) {
|
||||||
|
throw new RainbirdApiError('Rain Bird device is busy; wait and try again.', response.status);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new RainbirdApiError('Rain Bird controller denied authentication; check the local password.', response.status);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new RainbirdApiError(`Rain Bird controller responded with HTTP ${response.status}.`, response.status);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeJsonRpc(methodArg: string, paramsArg: Record<string, unknown>): Buffer {
|
||||||
|
const request = JSON.stringify({ id: Date.now() / 1000, jsonrpc: '2.0', method: methodArg, params: paramsArg });
|
||||||
|
const password = this.config.password;
|
||||||
|
if (!password) {
|
||||||
|
return Buffer.from(request, 'utf8');
|
||||||
|
}
|
||||||
|
const iv = plugins.crypto.randomBytes(16);
|
||||||
|
const key = plugins.crypto.createHash('sha256').update(Buffer.from(password, 'utf8')).digest();
|
||||||
|
const plaintext = Buffer.from(addPadding(`${request}\x00\x10`), 'utf8');
|
||||||
|
const checksum = plugins.crypto.createHash('sha256').update(Buffer.from(request, 'utf8')).digest();
|
||||||
|
const cipher = plugins.crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||||
|
cipher.setAutoPadding(false);
|
||||||
|
return Buffer.concat([checksum, iv, cipher.update(plaintext), cipher.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeJsonRpc<TResult>(contentArg: Buffer): TResult {
|
||||||
|
const password = this.config.password;
|
||||||
|
let content = contentArg.toString('utf8');
|
||||||
|
if (password) {
|
||||||
|
const iv = contentArg.subarray(32, 48);
|
||||||
|
const encrypted = contentArg.subarray(48);
|
||||||
|
const key = plugins.crypto.createHash('sha256').update(Buffer.from(password, 'utf8')).digest();
|
||||||
|
const decipher = plugins.crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||||
|
decipher.setAutoPadding(false);
|
||||||
|
content = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8').replace(/[\x10\x0a\x00\s]+$/g, '');
|
||||||
|
}
|
||||||
|
const response = JSON.parse(content) as IRainbirdLocalJsonRpcResponse<TResult>;
|
||||||
|
if (response.error) {
|
||||||
|
throw new RainbirdApiError(`Rain Bird responded with an error: ${response.error.message || response.error.code || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return response.result as TResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sipCommands = {
|
||||||
|
ModelAndVersionRequest: { command: '02', response: '82', length: 1 },
|
||||||
|
AvailableStationsRequest: { command: '03', response: '83', length: 2 },
|
||||||
|
SerialNumberRequest: { command: '05', response: '85', length: 1 },
|
||||||
|
ControllerFirmwareVersionRequest: { command: '0B', response: '8B', length: 1 },
|
||||||
|
RetrieveScheduleRequest: { command: '20', response: 'A0', length: 3 },
|
||||||
|
RainDelayGetRequest: { command: '36', response: 'B6', length: 1 },
|
||||||
|
RainDelaySetRequest: { command: '37', response: '01', length: 3 },
|
||||||
|
ManuallyRunProgramRequest: { command: '38', response: '01', length: 2 },
|
||||||
|
ManuallyRunStationRequest: { command: '39', response: '01', length: 4 },
|
||||||
|
CurrentRainSensorStateRequest: { command: '3E', response: 'BE', length: 1 },
|
||||||
|
CurrentStationsActiveRequest: { command: '3F', response: 'BF', length: 2 },
|
||||||
|
StopIrrigationRequest: { command: '40', response: '01', length: 1 },
|
||||||
|
CurrentIrrigationStateRequest: { command: '48', response: 'C8', length: 1 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const encodeSipCommand = (commandArg: keyof typeof sipCommands, ...argsArg: number[]): string => {
|
||||||
|
const command = sipCommands[commandArg];
|
||||||
|
if (!argsArg.length) {
|
||||||
|
return command.command;
|
||||||
|
}
|
||||||
|
if (argsArg.length > command.length) {
|
||||||
|
throw new RainbirdApiError(`Too many SIP parameters for ${commandArg}.`);
|
||||||
|
}
|
||||||
|
const firstWidth = Math.max(0, (command.length - argsArg.length) * 2);
|
||||||
|
const encodedArgs = argsArg.map((arg, index) => toHex(arg, index === 0 ? firstWidth || 2 : 2)).join('');
|
||||||
|
return `${command.command}${encodedArgs}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeSet = (maskArg: string, maxStationsArg: number): number[] => {
|
||||||
|
const active: number[] = [];
|
||||||
|
let zone = 1;
|
||||||
|
for (let i = 0; i < maskArg.length; i += 2) {
|
||||||
|
const byte = parseHex(maskArg.slice(i, i + 2));
|
||||||
|
for (let bit = 0; bit < 8 && zone <= maxStationsArg; bit++) {
|
||||||
|
if ((byte & (1 << bit)) !== 0) {
|
||||||
|
active.push(zone);
|
||||||
|
}
|
||||||
|
zone++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseHex = (valueArg: string): number => Number.parseInt(valueArg || '0', 16);
|
||||||
|
|
||||||
|
const toHex = (valueArg: number, widthArg: number): string => Math.trunc(valueArg).toString(16).toUpperCase().padStart(widthArg, '0');
|
||||||
|
|
||||||
|
const addPadding = (dataArg: string): string => {
|
||||||
|
const paddingLength = (16 - (Buffer.byteLength(dataArg, 'utf8') % 16)) % 16;
|
||||||
|
return `${dataArg}${'\x10'.repeat(paddingLength)}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IRainbirdConfig, TRainbirdProtocol } from './rainbird.types.js';
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 20000;
|
||||||
|
const defaultDurationMinutes = 6;
|
||||||
|
|
||||||
|
export class RainbirdConfigFlow implements IConfigFlow<IRainbirdConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IRainbirdConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Rain Bird controller',
|
||||||
|
description: 'Configure the local Rain Bird LNK WiFi controller endpoint.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'password', label: 'Password', type: 'password', required: true },
|
||||||
|
{ name: 'protocol', label: 'Protocol', type: 'select', required: false, options: [{ label: 'Auto', value: 'auto' }, { label: 'HTTP', value: 'http' }, { label: 'HTTPS', value: 'https' }] },
|
||||||
|
{ name: 'port', label: 'Port', type: 'number', required: false },
|
||||||
|
{ name: 'defaultIrrigationDurationMinutes', label: 'Default irrigation duration', type: 'number', required: false },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const protocol = this.protocolValue(valuesArg.protocol) || this.protocolMetadata(candidateArg) || 'auto';
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Rain Bird controller configured',
|
||||||
|
config: {
|
||||||
|
host: this.stringValue(valuesArg.host) || candidateArg.host || '',
|
||||||
|
password: this.stringValue(valuesArg.password) || '',
|
||||||
|
protocol,
|
||||||
|
port: this.numberValue(valuesArg.port) || candidateArg.port,
|
||||||
|
name: candidateArg.name,
|
||||||
|
uniqueId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber,
|
||||||
|
macAddress: candidateArg.macAddress,
|
||||||
|
serialNumber: candidateArg.serialNumber,
|
||||||
|
model: candidateArg.model,
|
||||||
|
timeoutMs: defaultTimeoutMs,
|
||||||
|
defaultIrrigationDurationMinutes: this.numberValue(valuesArg.defaultIrrigationDurationMinutes) || defaultDurationMinutes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown): number | undefined {
|
||||||
|
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private protocolValue(valueArg: unknown): TRainbirdProtocol | undefined {
|
||||||
|
return valueArg === 'auto' || valueArg === 'http' || valueArg === 'https' ? valueArg : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private protocolMetadata(candidateArg: IDiscoveryCandidate): TRainbirdProtocol | undefined {
|
||||||
|
const protocol = candidateArg.metadata?.protocol;
|
||||||
|
return this.protocolValue(protocol);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,100 @@
|
|||||||
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 { RainbirdClient } from './rainbird.classes.client.js';
|
||||||
|
import { RainbirdConfigFlow } from './rainbird.classes.configflow.js';
|
||||||
|
import { createRainbirdDiscoveryDescriptor } from './rainbird.discovery.js';
|
||||||
|
import { RainbirdMapper } from './rainbird.mapper.js';
|
||||||
|
import type { IRainbirdConfig } from './rainbird.types.js';
|
||||||
|
|
||||||
export class HomeAssistantRainbirdIntegration extends DescriptorOnlyIntegration {
|
export class RainbirdIntegration extends BaseIntegration<IRainbirdConfig> {
|
||||||
constructor() {
|
public readonly domain = 'rainbird';
|
||||||
super({
|
public readonly displayName = 'Rain Bird';
|
||||||
domain: "rainbird",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Rain Bird",
|
public readonly discoveryDescriptor = createRainbirdDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new RainbirdConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/rainbird",
|
upstreamPath: 'homeassistant/components/rainbird',
|
||||||
"upstreamDomain": "rainbird",
|
upstreamDomain: 'rainbird',
|
||||||
"integrationType": "hub",
|
integrationType: 'hub',
|
||||||
"iotClass": "local_polling",
|
iotClass: 'local_polling',
|
||||||
"requirements": [
|
requirements: ['pyrainbird==6.3.0'],
|
||||||
"pyrainbird==6.3.0"
|
dependencies: [],
|
||||||
],
|
afterDependencies: [],
|
||||||
"dependencies": [],
|
codeowners: ['@konikvranik', '@allenporter'],
|
||||||
"afterDependencies": [],
|
configFlow: true,
|
||||||
"codeowners": [
|
documentation: 'https://www.home-assistant.io/integrations/rainbird',
|
||||||
"@konikvranik",
|
discovery: {
|
||||||
"@allenporter"
|
manual: true,
|
||||||
]
|
dhcp: false,
|
||||||
|
mdns: false,
|
||||||
|
note: 'Home Assistant Rain Bird manifest has no dhcp or zeroconf discovery entries; manual local host/password setup is implemented.',
|
||||||
},
|
},
|
||||||
});
|
runtime: {
|
||||||
|
type: 'control-runtime',
|
||||||
|
polling: 'local snapshot',
|
||||||
|
services: ['start_zone', 'stop_zone', 'set_rain_delay', 'refresh', 'start_irrigation'],
|
||||||
|
},
|
||||||
|
localApi: {
|
||||||
|
implemented: [
|
||||||
|
'AES-256-CBC encrypted JSON-RPC POST /stick payloads from pyrainbird PayloadCoder',
|
||||||
|
'tunnelSip ModelAndVersionRequest',
|
||||||
|
'tunnelSip SerialNumberRequest',
|
||||||
|
'getWifiParams JSON-RPC',
|
||||||
|
'tunnelSip AvailableStationsRequest',
|
||||||
|
'tunnelSip CurrentStationsActiveRequest',
|
||||||
|
'tunnelSip CurrentRainSensorStateRequest',
|
||||||
|
'tunnelSip RainDelayGetRequest/RainDelaySetRequest',
|
||||||
|
'tunnelSip ManuallyRunStationRequest/StopIrrigationRequest/ManuallyRunProgramRequest',
|
||||||
|
],
|
||||||
|
explicitUnsupported: [
|
||||||
|
'cloud relay API',
|
||||||
|
'full schedule timeline decoding in the live client',
|
||||||
|
'HTTPS controllers with self-signed certificates when runtime fetch rejects the certificate',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public async setup(configArg: IRainbirdConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
|
void contextArg;
|
||||||
|
return new RainbirdRuntime(new RainbirdClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantRainbirdIntegration extends RainbirdIntegration {}
|
||||||
|
|
||||||
|
class RainbirdRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'rainbird';
|
||||||
|
|
||||||
|
constructor(private readonly client: RainbirdClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return RainbirdMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return RainbirdMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||||
|
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(RainbirdMapper.toIntegrationEvent(eventArg)));
|
||||||
|
return async () => unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
const snapshot = await this.client.getSnapshot();
|
||||||
|
const command = RainbirdMapper.commandForService(snapshot, requestArg);
|
||||||
|
if (!command) {
|
||||||
|
return { success: false, error: `Unsupported Rain Bird service: ${requestArg.domain}.${requestArg.service}` };
|
||||||
|
}
|
||||||
|
const result = await this.client.sendCommand(command);
|
||||||
|
return { success: result.success, error: result.error, data: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { IRainbirdManualEntry, TRainbirdProtocol } from './rainbird.types.js';
|
||||||
|
|
||||||
|
export class RainbirdManualMatcher implements IDiscoveryMatcher<IRainbirdManualEntry> {
|
||||||
|
public id = 'rainbird-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Rain Bird LNK WiFi controller setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: IRainbirdManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const mac = normalizeMac(inputArg.macAddress || inputArg.id);
|
||||||
|
const haystack = `${inputArg.name || ''} ${inputArg.model || ''} ${inputArg.manufacturer || ''}`.toLowerCase();
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.metadata?.rainbird || inputArg.metadata?.rainBird || haystack.includes('rain bird') || haystack.includes('rainbird'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Rain Bird setup hints.' };
|
||||||
|
}
|
||||||
|
const protocol = protocolValue(inputArg.protocol) || 'auto';
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Rain Bird setup.',
|
||||||
|
normalizedDeviceId: mac || inputArg.serialNumber || inputArg.id,
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'rainbird',
|
||||||
|
id: mac || inputArg.serialNumber || inputArg.id,
|
||||||
|
host: inputArg.host,
|
||||||
|
port: inputArg.port || (protocol === 'http' ? 80 : protocol === 'https' ? 443 : undefined),
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: inputArg.manufacturer || 'Rain Bird',
|
||||||
|
model: inputArg.model,
|
||||||
|
serialNumber: inputArg.serialNumber,
|
||||||
|
macAddress: mac || undefined,
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
protocol,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RainbirdCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'rainbird-candidate-validator';
|
||||||
|
public description = 'Validate that a discovery candidate can be configured as a Rain Bird controller.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const haystack = `${candidateArg.name || ''} ${candidateArg.model || ''} ${candidateArg.manufacturer || ''}`.toLowerCase();
|
||||||
|
const matched = candidateArg.integrationDomain === 'rainbird' || haystack.includes('rain bird') || haystack.includes('rainbird') || Boolean(candidateArg.metadata?.rainbird || candidateArg.metadata?.rainBird);
|
||||||
|
const mac = normalizeMac(candidateArg.macAddress || candidateArg.id);
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Candidate has Rain Bird metadata.' : 'Candidate is not Rain Bird.',
|
||||||
|
normalizedDeviceId: mac || candidateArg.serialNumber || candidateArg.id,
|
||||||
|
candidate: matched ? {
|
||||||
|
...candidateArg,
|
||||||
|
integrationDomain: 'rainbird',
|
||||||
|
manufacturer: candidateArg.manufacturer || 'Rain Bird',
|
||||||
|
id: candidateArg.id || mac || candidateArg.serialNumber,
|
||||||
|
macAddress: candidateArg.macAddress || mac || undefined,
|
||||||
|
} : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRainbirdDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'rainbird', displayName: 'Rain Bird' })
|
||||||
|
.addMatcher(new RainbirdManualMatcher())
|
||||||
|
.addValidator(new RainbirdCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMac = (valueArg: string | undefined): string => {
|
||||||
|
const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
return cleaned.length === 12 ? cleaned : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocolValue = (valueArg: unknown): TRainbirdProtocol | undefined => valueArg === 'auto' || valueArg === 'http' || valueArg === 'https' ? valueArg : undefined;
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest } from '../../core/types.js';
|
||||||
|
import type {
|
||||||
|
IRainbirdCommand,
|
||||||
|
IRainbirdEvent,
|
||||||
|
IRainbirdProgram,
|
||||||
|
IRainbirdSnapshot,
|
||||||
|
IRainbirdZone,
|
||||||
|
} from './rainbird.types.js';
|
||||||
|
|
||||||
|
const rainbirdDomain = 'rainbird';
|
||||||
|
const defaultDurationMinutes = 6;
|
||||||
|
|
||||||
|
export class RainbirdMapper {
|
||||||
|
public static toDevices(snapshotArg: IRainbirdSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||||
|
const controllerDeviceId = this.controllerDeviceId(snapshotArg);
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
|
||||||
|
id: controllerDeviceId,
|
||||||
|
integrationDomain: rainbirdDomain,
|
||||||
|
name: snapshotArg.controller.name || 'Rain Bird Controller',
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: snapshotArg.controller.manufacturer || 'Rain Bird',
|
||||||
|
model: snapshotArg.controller.modelName || snapshotArg.controller.modelCode || snapshotArg.controller.modelId,
|
||||||
|
online: snapshotArg.connected,
|
||||||
|
features: [
|
||||||
|
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||||
|
{ id: 'rain_sensor', capability: 'sensor', name: 'Rain sensor', readable: true, writable: false },
|
||||||
|
{ id: 'rain_delay', capability: 'sensor', name: 'Rain delay', readable: true, writable: true, unit: 'd' },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt },
|
||||||
|
{ featureId: 'rain_sensor', value: snapshotArg.controller.rainSensorActive ?? null, updatedAt },
|
||||||
|
{ featureId: 'rain_delay', value: snapshotArg.controller.rainDelayDays ?? null, updatedAt },
|
||||||
|
],
|
||||||
|
metadata: this.cleanAttributes({
|
||||||
|
host: snapshotArg.controller.host,
|
||||||
|
port: snapshotArg.controller.port,
|
||||||
|
protocol: snapshotArg.controller.protocol,
|
||||||
|
serialNumber: snapshotArg.controller.serialNumber,
|
||||||
|
macAddress: snapshotArg.controller.macAddress,
|
||||||
|
modelId: snapshotArg.controller.modelId,
|
||||||
|
modelCode: snapshotArg.controller.modelCode,
|
||||||
|
firmwareVersion: snapshotArg.controller.firmwareVersion,
|
||||||
|
protocolRevision: snapshotArg.controller.protocolRevision,
|
||||||
|
maxPrograms: snapshotArg.controller.maxPrograms,
|
||||||
|
maxStations: snapshotArg.controller.maxStations,
|
||||||
|
supportsWaterBudget: snapshotArg.controller.supportsWaterBudget,
|
||||||
|
}),
|
||||||
|
}];
|
||||||
|
|
||||||
|
for (const zone of snapshotArg.zones) {
|
||||||
|
devices.push({
|
||||||
|
id: this.zoneDeviceId(snapshotArg, zone),
|
||||||
|
integrationDomain: rainbirdDomain,
|
||||||
|
name: zone.name || `Sprinkler ${zone.id}`,
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Rain Bird',
|
||||||
|
model: 'Irrigation zone',
|
||||||
|
online: snapshotArg.connected && zone.available !== false,
|
||||||
|
features: [
|
||||||
|
{ id: 'irrigation', capability: 'switch', name: 'Irrigation', readable: true, writable: true },
|
||||||
|
{ id: 'default_duration', capability: 'sensor', name: 'Default duration', readable: true, writable: true, unit: 'min' },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'irrigation', value: zone.active ?? false, updatedAt },
|
||||||
|
{ featureId: 'default_duration', value: zone.defaultDurationMinutes ?? defaultDurationMinutes, updatedAt },
|
||||||
|
],
|
||||||
|
metadata: this.cleanAttributes({
|
||||||
|
zoneId: zone.id,
|
||||||
|
viaDevice: controllerDeviceId,
|
||||||
|
remainingRuntimeSeconds: zone.remainingRuntimeSeconds,
|
||||||
|
lastStartedAt: zone.lastStartedAt,
|
||||||
|
lastStoppedAt: zone.lastStoppedAt,
|
||||||
|
...zone.attributes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const program of snapshotArg.programs) {
|
||||||
|
devices.push({
|
||||||
|
id: this.programDeviceId(snapshotArg, program),
|
||||||
|
integrationDomain: rainbirdDomain,
|
||||||
|
name: program.name || `Program ${program.id}`,
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Rain Bird',
|
||||||
|
model: 'Irrigation program',
|
||||||
|
online: snapshotArg.connected && program.enabled !== false,
|
||||||
|
features: [
|
||||||
|
{ id: 'program_state', capability: 'sensor', name: 'Program state', readable: true, writable: false },
|
||||||
|
{ id: 'duration', capability: 'sensor', name: 'Duration', readable: true, writable: false, unit: 'min' },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'program_state', value: program.enabled === false ? 'disabled' : 'scheduled', updatedAt },
|
||||||
|
{ featureId: 'duration', value: this.programDuration(program), updatedAt },
|
||||||
|
],
|
||||||
|
metadata: this.cleanAttributes({
|
||||||
|
programId: program.id,
|
||||||
|
viaDevice: controllerDeviceId,
|
||||||
|
frequency: program.frequency,
|
||||||
|
starts: program.starts,
|
||||||
|
daysOfWeek: program.daysOfWeek,
|
||||||
|
nextStartAt: program.nextStartAt,
|
||||||
|
zoneDurations: program.zoneDurations,
|
||||||
|
...program.attributes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: IRainbirdSnapshot): IIntegrationEntity[] {
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
const usedIds = new Map<string, number>();
|
||||||
|
const controllerDeviceId = this.controllerDeviceId(snapshotArg);
|
||||||
|
const uniqueBase = this.uniqueBase(snapshotArg);
|
||||||
|
|
||||||
|
entities.push(this.entity('binary_sensor', 'Rainsensor', controllerDeviceId, `rainbird_${uniqueBase}_rainsensor`, snapshotArg.controller.rainSensorActive ? 'on' : 'off', usedIds, {
|
||||||
|
deviceClass: 'moisture',
|
||||||
|
}, snapshotArg.connected));
|
||||||
|
|
||||||
|
entities.push(this.entity('sensor', 'Raindelay', controllerDeviceId, `rainbird_${uniqueBase}_raindelay`, snapshotArg.controller.rainDelayDays ?? null, usedIds, {
|
||||||
|
unit: 'd',
|
||||||
|
deviceClass: 'duration',
|
||||||
|
}, snapshotArg.connected));
|
||||||
|
|
||||||
|
entities.push(this.entity('number', 'Rain delay', controllerDeviceId, `rainbird_${uniqueBase}_rain_delay`, snapshotArg.controller.rainDelayDays ?? null, usedIds, {
|
||||||
|
unit: 'd',
|
||||||
|
min: 0,
|
||||||
|
max: 14,
|
||||||
|
step: 1,
|
||||||
|
}, snapshotArg.connected));
|
||||||
|
|
||||||
|
for (const zone of snapshotArg.zones) {
|
||||||
|
entities.push(this.entity('switch', zone.name || `Sprinkler ${zone.id}`, this.zoneDeviceId(snapshotArg, zone), `rainbird_${uniqueBase}_zone_${zone.id}`, zone.active ? 'on' : 'off', usedIds, {
|
||||||
|
zoneId: zone.id,
|
||||||
|
defaultDurationMinutes: zone.defaultDurationMinutes ?? defaultDurationMinutes,
|
||||||
|
remainingRuntimeSeconds: zone.remainingRuntimeSeconds,
|
||||||
|
...zone.attributes,
|
||||||
|
}, snapshotArg.connected && zone.available !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const program of snapshotArg.programs) {
|
||||||
|
entities.push(this.entity('sensor', program.name || `Program ${program.id}`, this.programDeviceId(snapshotArg, program), `rainbird_${uniqueBase}_program_${this.slug(String(program.id))}`, program.enabled === false ? 'disabled' : 'scheduled', usedIds, {
|
||||||
|
programId: program.id,
|
||||||
|
frequency: program.frequency,
|
||||||
|
starts: program.starts,
|
||||||
|
daysOfWeek: program.daysOfWeek,
|
||||||
|
periodDays: program.periodDays,
|
||||||
|
synchroDays: program.synchroDays,
|
||||||
|
durationMinutes: this.programDuration(program),
|
||||||
|
zoneDurations: program.zoneDurations,
|
||||||
|
nextStartAt: program.nextStartAt,
|
||||||
|
...program.attributes,
|
||||||
|
}, snapshotArg.connected && program.enabled !== false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static commandForService(snapshotArg: IRainbirdSnapshot, requestArg: IServiceCallRequest): IRainbirdCommand | undefined {
|
||||||
|
if (requestArg.domain === rainbirdDomain && ['refresh', 'reload'].includes(requestArg.service)) {
|
||||||
|
return { type: 'refresh' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === rainbirdDomain && ['start_zone', 'start_irrigation'].includes(requestArg.service)) {
|
||||||
|
const zone = this.findZone(snapshotArg, requestArg);
|
||||||
|
const duration = this.durationValue(requestArg, zone) ?? defaultDurationMinutes;
|
||||||
|
return zone && duration > 0 ? { type: 'start_zone', zoneId: zone.id, durationMinutes: duration, entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === 'switch' && requestArg.service === 'turn_on') {
|
||||||
|
const zone = this.findZone(snapshotArg, requestArg);
|
||||||
|
const duration = this.durationValue(requestArg, zone) ?? defaultDurationMinutes;
|
||||||
|
return zone ? { type: 'start_zone', zoneId: zone.id, durationMinutes: duration, entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((requestArg.domain === rainbirdDomain && ['stop_zone', 'stop_irrigation'].includes(requestArg.service)) || (requestArg.domain === 'switch' && requestArg.service === 'turn_off')) {
|
||||||
|
const zone = this.findZone(snapshotArg, requestArg);
|
||||||
|
return { type: 'stop_zone', zoneId: zone?.id, entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === rainbirdDomain && requestArg.service === 'set_rain_delay') {
|
||||||
|
const days = this.numberData(requestArg, 'days') ?? this.numberData(requestArg, 'delayDays') ?? this.numberData(requestArg, 'duration');
|
||||||
|
return days !== undefined && days >= 0 && days <= 14 ? { type: 'set_rain_delay', days, entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === 'number' && requestArg.service === 'set_value') {
|
||||||
|
const days = this.numberData(requestArg, 'value');
|
||||||
|
const target = requestArg.target.entityId || '';
|
||||||
|
const isRainDelayTarget = this.toEntities(snapshotArg).some((entityArg) => entityArg.id === target && entityArg.uniqueId.endsWith('_rain_delay'));
|
||||||
|
return isRainDelayTarget && days !== undefined && days >= 0 && days <= 14 ? { type: 'set_rain_delay', days, entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === rainbirdDomain && requestArg.service === 'start_program') {
|
||||||
|
const programId = this.numberData(requestArg, 'programId') ?? this.numberData(requestArg, 'program');
|
||||||
|
return programId !== undefined ? { type: 'start_program', programId } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.domain === rainbirdDomain && requestArg.service === 'raw_local_rpc') {
|
||||||
|
const method = this.stringData(requestArg, 'method');
|
||||||
|
return method ? { type: 'raw_local_rpc', method, params: this.recordData(requestArg, 'params') || {} } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toIntegrationEvent(eventArg: IRainbirdEvent): IIntegrationEvent {
|
||||||
|
return {
|
||||||
|
type: eventArg.type === 'command_failed' ? 'error' : 'state_changed',
|
||||||
|
integrationDomain: rainbirdDomain,
|
||||||
|
deviceId: eventArg.deviceId,
|
||||||
|
entityId: eventArg.entityId,
|
||||||
|
data: eventArg,
|
||||||
|
timestamp: eventArg.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static controllerDeviceId(snapshotArg: IRainbirdSnapshot): string {
|
||||||
|
return `rainbird.controller.${this.uniqueBase(snapshotArg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static zoneDeviceId(snapshotArg: IRainbirdSnapshot, zoneArg: IRainbirdZone): string {
|
||||||
|
return `rainbird.zone.${this.uniqueBase(snapshotArg)}.${zoneArg.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static programDeviceId(snapshotArg: IRainbirdSnapshot, programArg: IRainbirdProgram): string {
|
||||||
|
return `rainbird.program.${this.uniqueBase(snapshotArg)}.${this.slug(String(programArg.id))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static entity(platformArg: IIntegrationEntity['platform'], 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: rainbirdDomain,
|
||||||
|
deviceId: deviceIdArg,
|
||||||
|
platform: platformArg,
|
||||||
|
name: nameArg,
|
||||||
|
state: stateArg,
|
||||||
|
attributes: this.cleanAttributes(attributesArg),
|
||||||
|
available: availableArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findZone(snapshotArg: IRainbirdSnapshot, requestArg: IServiceCallRequest): IRainbirdZone | undefined {
|
||||||
|
const explicit = this.numberData(requestArg, 'zoneId') ?? this.numberData(requestArg, 'zone') ?? this.numberData(requestArg, 'station');
|
||||||
|
if (explicit !== undefined) {
|
||||||
|
return snapshotArg.zones.find((zoneArg) => zoneArg.id === explicit);
|
||||||
|
}
|
||||||
|
const target = requestArg.target.entityId || requestArg.target.deviceId;
|
||||||
|
if (!target) {
|
||||||
|
return snapshotArg.zones[0];
|
||||||
|
}
|
||||||
|
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.deviceId === target);
|
||||||
|
const zoneId = typeof entity?.attributes?.zoneId === 'number' ? entity.attributes.zoneId : undefined;
|
||||||
|
return snapshotArg.zones.find((zoneArg) => zoneArg.id === zoneId) || snapshotArg.zones.find((zoneArg) => this.zoneDeviceId(snapshotArg, zoneArg) === target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static durationValue(requestArg: IServiceCallRequest, zoneArg?: IRainbirdZone): number | undefined {
|
||||||
|
return this.numberData(requestArg, 'durationMinutes') ?? this.numberData(requestArg, 'duration') ?? this.numberData(requestArg, 'minutes') ?? zoneArg?.defaultDurationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static programDuration(programArg: IRainbirdProgram): number | null {
|
||||||
|
if (typeof programArg.durationMinutes === 'number') {
|
||||||
|
return programArg.durationMinutes;
|
||||||
|
}
|
||||||
|
if (programArg.zoneDurations?.length) {
|
||||||
|
return programArg.zoneDurations.reduce((sumArg, zoneArg) => sumArg + zoneArg.durationMinutes, 0);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uniqueBase(snapshotArg: IRainbirdSnapshot): string {
|
||||||
|
return this.slug(snapshotArg.controller.macAddress || snapshotArg.controller.serialNumber || snapshotArg.controller.id || snapshotArg.controller.host || snapshotArg.controller.name || 'rainbird');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
||||||
|
const value = requestArg.data?.[keyArg];
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && value.trim() && Number.isFinite(Number(value)) ? Number(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||||
|
const value = requestArg.data?.[keyArg];
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static recordData(requestArg: IServiceCallRequest, keyArg: string): Record<string, unknown> | undefined {
|
||||||
|
const value = requestArg.data?.[keyArg];
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'rainbird';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,289 @@
|
|||||||
export interface IHomeAssistantRainbirdConfig {
|
export type TRainbirdProtocol = 'auto' | 'http' | 'https';
|
||||||
// TODO: replace with the TypeScript-native config for rainbird.
|
|
||||||
|
export type TRainbirdEventType =
|
||||||
|
| 'snapshot_refreshed'
|
||||||
|
| 'command_mapped'
|
||||||
|
| 'command_executed'
|
||||||
|
| 'command_failed'
|
||||||
|
| 'zone_started'
|
||||||
|
| 'zone_stopped'
|
||||||
|
| 'rain_delay_set';
|
||||||
|
|
||||||
|
export type TRainbirdProgramFrequency = 'custom' | 'cyclic' | 'odd' | 'even' | 'unknown';
|
||||||
|
|
||||||
|
export interface IRainbirdConfig {
|
||||||
|
host?: string;
|
||||||
|
password?: string;
|
||||||
|
protocol?: TRainbirdProtocol;
|
||||||
|
port?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
name?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
model?: string;
|
||||||
|
defaultIrrigationDurationMinutes?: number;
|
||||||
|
connected?: boolean;
|
||||||
|
zoneCount?: number;
|
||||||
|
controller?: IRainbirdController;
|
||||||
|
zones?: IRainbirdZone[];
|
||||||
|
programs?: IRainbirdProgram[];
|
||||||
|
schedule?: IRainbirdSchedule;
|
||||||
|
events?: IRainbirdEvent[];
|
||||||
|
snapshot?: IRainbirdSnapshot;
|
||||||
|
commandExecutor?: (commandArg: IRainbirdCommand) => Promise<IRainbirdCommandResult | unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantRainbirdConfig extends IRainbirdConfig {}
|
||||||
|
|
||||||
|
export interface IRainbirdController {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
modelId?: string;
|
||||||
|
modelCode?: string;
|
||||||
|
modelName?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
protocolRevision?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
protocol?: TRainbirdProtocol;
|
||||||
|
online?: boolean;
|
||||||
|
maxPrograms?: number;
|
||||||
|
maxRunTimes?: number;
|
||||||
|
maxStations?: number;
|
||||||
|
supportsWaterBudget?: boolean;
|
||||||
|
rainSensorActive?: boolean;
|
||||||
|
rainDelayDays?: number;
|
||||||
|
currentIrrigation?: boolean;
|
||||||
|
activeStation?: number;
|
||||||
|
remainingRuntimeSeconds?: number;
|
||||||
|
rssi?: number;
|
||||||
|
wifiSsid?: string;
|
||||||
|
localIpAddress?: string;
|
||||||
|
localGateway?: string;
|
||||||
|
availableZoneIds?: number[];
|
||||||
|
activeZoneIds?: number[];
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdZone {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
available?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
defaultDurationMinutes?: number;
|
||||||
|
remainingRuntimeSeconds?: number;
|
||||||
|
lastStartedAt?: string;
|
||||||
|
lastStoppedAt?: string;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdProgram {
|
||||||
|
id: number | string;
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
frequency?: TRainbirdProgramFrequency;
|
||||||
|
starts?: string[];
|
||||||
|
daysOfWeek?: string[];
|
||||||
|
periodDays?: number;
|
||||||
|
synchroDays?: number;
|
||||||
|
durationMinutes?: number;
|
||||||
|
zoneDurations?: IRainbirdZoneDuration[];
|
||||||
|
nextStartAt?: string;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdZoneDuration {
|
||||||
|
zoneId: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdSchedule {
|
||||||
|
programs: IRainbirdProgram[];
|
||||||
|
zoneSchedules?: IRainbirdZoneSchedule[];
|
||||||
|
rainDelayDays?: number;
|
||||||
|
rainSensorActive?: boolean;
|
||||||
|
updatedAt?: string;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdZoneSchedule {
|
||||||
|
zoneId: number;
|
||||||
|
starts: string[];
|
||||||
|
frequency?: TRainbirdProgramFrequency;
|
||||||
|
durationMinutes?: number;
|
||||||
|
daysOfWeek?: string[];
|
||||||
|
periodDays?: number;
|
||||||
|
synchroDays?: number;
|
||||||
|
nextStartAt?: string;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdSnapshot {
|
||||||
|
controller: IRainbirdController;
|
||||||
|
zones: IRainbirdZone[];
|
||||||
|
programs: IRainbirdProgram[];
|
||||||
|
schedule?: IRainbirdSchedule;
|
||||||
|
events: IRainbirdEvent[];
|
||||||
|
connected: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdEvent {
|
||||||
|
type: TRainbirdEventType;
|
||||||
|
command?: IRainbirdCommand;
|
||||||
|
zoneId?: number;
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
data?: unknown;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IRainbirdCommand =
|
||||||
|
| IRainbirdStartZoneCommand
|
||||||
|
| IRainbirdStopZoneCommand
|
||||||
|
| IRainbirdSetRainDelayCommand
|
||||||
|
| IRainbirdRefreshCommand
|
||||||
|
| IRainbirdStartProgramCommand
|
||||||
|
| IRainbirdRawLocalRpcCommand;
|
||||||
|
|
||||||
|
export interface IRainbirdStartZoneCommand {
|
||||||
|
type: 'start_zone';
|
||||||
|
zoneId: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdStopZoneCommand {
|
||||||
|
type: 'stop_zone';
|
||||||
|
zoneId?: number;
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdSetRainDelayCommand {
|
||||||
|
type: 'set_rain_delay';
|
||||||
|
days: number;
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdRefreshCommand {
|
||||||
|
type: 'refresh';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdStartProgramCommand {
|
||||||
|
type: 'start_program';
|
||||||
|
programId: number;
|
||||||
|
entityId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
uniqueId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdRawLocalRpcCommand {
|
||||||
|
type: 'raw_local_rpc';
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdCommandResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdModelAndVersion {
|
||||||
|
modelId: string;
|
||||||
|
modelCode: string;
|
||||||
|
modelName: string;
|
||||||
|
protocolRevisionMajor: number;
|
||||||
|
protocolRevisionMinor: number;
|
||||||
|
maxPrograms: number;
|
||||||
|
maxRunTimes: number;
|
||||||
|
maxStations: number;
|
||||||
|
supportsWaterBudget: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdWifiParams {
|
||||||
|
macAddress?: string;
|
||||||
|
localIpAddress?: string;
|
||||||
|
localNetmask?: string;
|
||||||
|
localGateway?: string;
|
||||||
|
rssi?: number;
|
||||||
|
wifiSsid?: string;
|
||||||
|
stickVersion?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdLocalJsonRpcRequest {
|
||||||
|
id: number;
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
method: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdLocalJsonRpcResponse<TResult = unknown> {
|
||||||
|
id?: number;
|
||||||
|
jsonrpc?: '2.0';
|
||||||
|
result?: TResult;
|
||||||
|
error?: {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdHttpLocalCommandShape {
|
||||||
|
endpoint: '/stick';
|
||||||
|
method: 'POST';
|
||||||
|
contentType: 'application/octet-stream';
|
||||||
|
jsonRpcMethod: string;
|
||||||
|
encrypted: boolean;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdTunnelSipResponse {
|
||||||
|
data?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdManualEntry {
|
||||||
|
host?: string;
|
||||||
|
password?: string;
|
||||||
|
port?: number;
|
||||||
|
protocol?: TRainbirdProtocol;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdMdnsRecord {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
addresses?: string[];
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRainbirdDhcpLease {
|
||||||
|
hostname?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
macAddress?: string;
|
||||||
|
vendorClassIdentifier?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 './snapcast.classes.integration.js';
|
export * from './snapcast.classes.integration.js';
|
||||||
|
export * from './snapcast.classes.client.js';
|
||||||
|
export * from './snapcast.classes.configflow.js';
|
||||||
|
export * from './snapcast.discovery.js';
|
||||||
|
export * from './snapcast.mapper.js';
|
||||||
export * from './snapcast.types.js';
|
export * from './snapcast.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type {
|
||||||
|
ISnapcastClient,
|
||||||
|
ISnapcastConfig,
|
||||||
|
ISnapcastGroup,
|
||||||
|
ISnapcastRpcRequest,
|
||||||
|
ISnapcastRpcResponse,
|
||||||
|
ISnapcastServerStatus,
|
||||||
|
ISnapcastSnapshot,
|
||||||
|
ISnapcastVolume,
|
||||||
|
TSnapcastRpcMethod,
|
||||||
|
} from './snapcast.types.js';
|
||||||
|
import { snapcastHttpControlPort, snapcastTcpControlPort } from './snapcast.types.js';
|
||||||
|
|
||||||
|
export class SnapcastClient {
|
||||||
|
private nextId = 1;
|
||||||
|
private currentSnapshot?: ISnapcastSnapshot;
|
||||||
|
private restorePoint?: ISnapcastSnapshot;
|
||||||
|
|
||||||
|
constructor(private readonly config: ISnapcastConfig) {
|
||||||
|
this.currentSnapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRpcVersion(): Promise<{ major: number; minor: number; patch: number }> {
|
||||||
|
return this.rpc('Server.GetRPCVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStatus(): Promise<ISnapcastServerStatus> {
|
||||||
|
const result = await this.rpc<{ server: ISnapcastServerStatus }>('Server.GetStatus');
|
||||||
|
return result.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<ISnapcastSnapshot> {
|
||||||
|
return {
|
||||||
|
server: await this.getStatus(),
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
source: this.isSnapshotMode() ? 'manual' : 'jsonrpc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setClientVolume(clientIdArg: string, percentArg: number): Promise<ISnapcastVolume> {
|
||||||
|
const volume = await this.currentClientVolume(clientIdArg);
|
||||||
|
return this.setClientVolumeState(clientIdArg, {
|
||||||
|
muted: volume.muted,
|
||||||
|
percent: this.clampPercent(percentArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setClientMuted(clientIdArg: string, mutedArg: boolean): Promise<ISnapcastVolume> {
|
||||||
|
const volume = await this.currentClientVolume(clientIdArg);
|
||||||
|
return this.setClientVolumeState(clientIdArg, {
|
||||||
|
muted: mutedArg,
|
||||||
|
percent: volume.percent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setClientLatency(clientIdArg: string, latencyArg: number): Promise<number> {
|
||||||
|
const result = await this.rpc<{ latency: number }>('Client.SetLatency', {
|
||||||
|
id: clientIdArg,
|
||||||
|
latency: Math.round(latencyArg),
|
||||||
|
});
|
||||||
|
return result.latency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setClientName(clientIdArg: string, nameArg: string): Promise<string> {
|
||||||
|
const result = await this.rpc<{ name: string }>('Client.SetName', {
|
||||||
|
id: clientIdArg,
|
||||||
|
name: nameArg,
|
||||||
|
});
|
||||||
|
return result.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setGroupMuted(groupIdArg: string, mutedArg: boolean): Promise<boolean> {
|
||||||
|
const result = await this.rpc<{ mute: boolean }>('Group.SetMute', {
|
||||||
|
id: groupIdArg,
|
||||||
|
mute: mutedArg,
|
||||||
|
});
|
||||||
|
return result.mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setGroupStream(groupIdArg: string, streamIdArg: string): Promise<string> {
|
||||||
|
const result = await this.rpc<{ stream_id: string }>('Group.SetStream', {
|
||||||
|
id: groupIdArg,
|
||||||
|
stream_id: streamIdArg,
|
||||||
|
});
|
||||||
|
return result.stream_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setGroupClients(groupIdArg: string, clientIdsArg: string[]): Promise<ISnapcastServerStatus> {
|
||||||
|
const result = await this.rpc<{ server: ISnapcastServerStatus }>('Group.SetClients', {
|
||||||
|
id: groupIdArg,
|
||||||
|
clients: clientIdsArg,
|
||||||
|
});
|
||||||
|
return result.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setGroupName(groupIdArg: string, nameArg: string): Promise<string> {
|
||||||
|
const result = await this.rpc<{ name: string }>('Group.SetName', {
|
||||||
|
id: groupIdArg,
|
||||||
|
name: nameArg,
|
||||||
|
});
|
||||||
|
return result.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async streamControl(streamIdArg: string, commandArg: string, paramsArg?: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return this.rpc('Stream.Control', {
|
||||||
|
id: streamIdArg,
|
||||||
|
command: commandArg,
|
||||||
|
params: paramsArg || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async snapshot(): Promise<ISnapcastSnapshot> {
|
||||||
|
this.restorePoint = await this.getSnapshot();
|
||||||
|
return this.cloneSnapshot(this.restorePoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restore(snapshotArg = this.restorePoint): Promise<void> {
|
||||||
|
if (!snapshotArg) {
|
||||||
|
throw new Error('Snapcast restore requires a prior snapshot.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
this.currentSnapshot = this.cloneSnapshot(snapshotArg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.applySnapshot(snapshotArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async watchEvents(): Promise<never> {
|
||||||
|
throw new Error('Snapcast live event subscription is not implemented in this TypeScript port; use devices()/entities() polling or JSON-RPC service calls.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private async setClientVolumeState(clientIdArg: string, volumeArg: ISnapcastVolume): Promise<ISnapcastVolume> {
|
||||||
|
const result = await this.rpc<{ volume: ISnapcastVolume }>('Client.SetVolume', {
|
||||||
|
id: clientIdArg,
|
||||||
|
volume: {
|
||||||
|
muted: volumeArg.muted,
|
||||||
|
percent: this.clampPercent(volumeArg.percent),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async currentClientVolume(clientIdArg: string): Promise<ISnapcastVolume> {
|
||||||
|
const client = this.findClient((await this.getStatus()).groups, clientIdArg);
|
||||||
|
return {
|
||||||
|
muted: client?.config.volume?.muted ?? false,
|
||||||
|
percent: client?.config.volume?.percent ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rpc<TResult>(methodArg: TSnapcastRpcMethod, paramsArg?: Record<string, unknown>): Promise<TResult> {
|
||||||
|
if (this.isSnapshotMode()) {
|
||||||
|
return this.snapshotRpc<TResult>(methodArg, paramsArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ISnapcastRpcRequest = {
|
||||||
|
id: this.nextId++,
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: methodArg,
|
||||||
|
params: paramsArg,
|
||||||
|
};
|
||||||
|
const transport = this.config.transport || 'tcp';
|
||||||
|
if (transport === 'http' || transport === 'https') {
|
||||||
|
return this.requestHttp<TResult>(request, transport);
|
||||||
|
}
|
||||||
|
if (transport !== 'tcp') {
|
||||||
|
throw new Error(`Unsupported Snapcast JSON-RPC transport: ${transport}`);
|
||||||
|
}
|
||||||
|
return this.requestTcp<TResult>(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestTcp<TResult>(requestArg: ISnapcastRpcRequest): Promise<TResult> {
|
||||||
|
const host = this.config.host;
|
||||||
|
if (!host) {
|
||||||
|
throw new Error('Snapcast TCP JSON-RPC requires config.host.');
|
||||||
|
}
|
||||||
|
const port = this.config.port || snapcastTcpControlPort;
|
||||||
|
const timeoutMs = this.config.timeoutMs || 5000;
|
||||||
|
|
||||||
|
return new Promise<TResult>((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
let settled = false;
|
||||||
|
const socket = plugins.net.createConnection({ host, port });
|
||||||
|
|
||||||
|
const finish = (errorArg: Error | undefined, valueArg?: TResult) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
if (errorArg) {
|
||||||
|
reject(errorArg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(valueArg as TResult);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setEncoding('utf8');
|
||||||
|
socket.setTimeout(timeoutMs, () => finish(new Error(`Snapcast TCP JSON-RPC timed out after ${timeoutMs}ms.`)));
|
||||||
|
socket.on('connect', () => socket.write(`${JSON.stringify(requestArg)}\n`));
|
||||||
|
socket.on('error', (errorArg) => finish(errorArg));
|
||||||
|
socket.on('close', () => finish(new Error('Snapcast TCP JSON-RPC connection closed before a response was received.')));
|
||||||
|
socket.on('data', (chunkArg) => {
|
||||||
|
buffer += chunkArg;
|
||||||
|
const lines = buffer.split(/\r?\n/);
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line) as ISnapcastRpcResponse<TResult> | Array<ISnapcastRpcResponse<TResult>>;
|
||||||
|
const value = this.unwrapRpcResponse<TResult>(parsed, requestArg.id, requestArg.method);
|
||||||
|
if (value !== undefined) {
|
||||||
|
finish(undefined, value);
|
||||||
|
}
|
||||||
|
} catch (errorArg) {
|
||||||
|
finish(errorArg instanceof Error ? errorArg : new Error(String(errorArg)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestHttp<TResult>(requestArg: ISnapcastRpcRequest, transportArg: 'http' | 'https'): Promise<TResult> {
|
||||||
|
const host = this.config.host;
|
||||||
|
if (!host) {
|
||||||
|
throw new Error('Snapcast HTTP JSON-RPC requires config.host.');
|
||||||
|
}
|
||||||
|
const port = this.config.port || snapcastHttpControlPort;
|
||||||
|
const timeoutMs = this.config.timeoutMs || 5000;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await globalThis.fetch(`${transportArg}://${host}:${port}/jsonrpc`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestArg),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Snapcast HTTP JSON-RPC failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return this.unwrapRpcResponse<TResult>(JSON.parse(text), requestArg.id, requestArg.method) as TResult;
|
||||||
|
} finally {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwrapRpcResponse<TResult>(responseArg: ISnapcastRpcResponse<TResult> | Array<ISnapcastRpcResponse<TResult>>, idArg: string | number, methodArg: string): TResult | undefined {
|
||||||
|
const responses = Array.isArray(responseArg) ? responseArg : [responseArg];
|
||||||
|
const response = responses.find((itemArg) => itemArg.id === idArg);
|
||||||
|
if (!response) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(`Snapcast ${methodArg} failed: ${response.error.message} (${response.error.code})`);
|
||||||
|
}
|
||||||
|
return response.result as TResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotRpc<TResult>(methodArg: TSnapcastRpcMethod, paramsArg?: Record<string, unknown>): TResult {
|
||||||
|
const snapshot = this.requireSnapshot();
|
||||||
|
const server = snapshot.server;
|
||||||
|
if (methodArg === 'Server.GetRPCVersion') {
|
||||||
|
return { major: 2, minor: 0, patch: 0 } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Server.GetStatus') {
|
||||||
|
return { server: this.cloneServer(server) } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Client.GetStatus') {
|
||||||
|
const client = this.requireClient(server, this.stringParam(paramsArg, 'id'));
|
||||||
|
return { client: this.cloneClient(client) } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Client.SetVolume') {
|
||||||
|
const client = this.requireClient(server, this.stringParam(paramsArg, 'id'));
|
||||||
|
const volume = this.volumeParam(paramsArg);
|
||||||
|
client.config.volume = { muted: volume.muted, percent: this.clampPercent(volume.percent) };
|
||||||
|
return { volume: { ...client.config.volume } } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Client.SetLatency') {
|
||||||
|
const client = this.requireClient(server, this.stringParam(paramsArg, 'id'));
|
||||||
|
client.config.latency = Math.round(this.numberParam(paramsArg, 'latency'));
|
||||||
|
return { latency: client.config.latency } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Client.SetName') {
|
||||||
|
const client = this.requireClient(server, this.stringParam(paramsArg, 'id'));
|
||||||
|
client.config.name = this.stringParam(paramsArg, 'name');
|
||||||
|
return { name: client.config.name } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Group.GetStatus') {
|
||||||
|
const group = this.requireGroup(server, this.stringParam(paramsArg, 'id'));
|
||||||
|
return { group: this.cloneGroup(group) } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Group.SetMute') {
|
||||||
|
const group = this.requireGroup(server, this.stringParam(paramsArg, 'id'));
|
||||||
|
group.muted = this.booleanParam(paramsArg, 'mute');
|
||||||
|
return { mute: group.muted } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Group.SetStream') {
|
||||||
|
const group = this.requireGroup(server, this.stringParam(paramsArg, 'id'));
|
||||||
|
const streamId = this.stringParam(paramsArg, 'stream_id');
|
||||||
|
if (server.streams.length && !server.streams.some((streamArg) => streamArg.id === streamId)) {
|
||||||
|
throw new Error(`Snapcast stream not found: ${streamId}`);
|
||||||
|
}
|
||||||
|
group.stream_id = streamId;
|
||||||
|
return { stream_id: streamId } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Group.SetClients') {
|
||||||
|
this.setSnapshotGroupClients(server, this.stringParam(paramsArg, 'id'), this.stringArrayParam(paramsArg, 'clients'));
|
||||||
|
return { server: this.cloneServer(server) } as TResult;
|
||||||
|
}
|
||||||
|
if (methodArg === 'Group.SetName') {
|
||||||
|
const group = this.requireGroup(server, this.stringParam(paramsArg, 'id'));
|
||||||
|
group.name = this.stringParam(paramsArg, 'name');
|
||||||
|
return { name: group.name } as TResult;
|
||||||
|
}
|
||||||
|
throw new Error(`Snapcast snapshot transport does not support ${methodArg}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applySnapshot(snapshotArg: ISnapcastSnapshot): Promise<void> {
|
||||||
|
for (const group of snapshotArg.server.groups) {
|
||||||
|
await this.setGroupClients(group.id, group.clients.map((clientArg) => clientArg.id));
|
||||||
|
if (this.groupStreamId(group)) {
|
||||||
|
await this.setGroupStream(group.id, this.groupStreamId(group));
|
||||||
|
}
|
||||||
|
await this.setGroupMuted(group.id, Boolean(group.muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of this.allClients(snapshotArg.server)) {
|
||||||
|
if (client.config.volume) {
|
||||||
|
await this.setClientVolumeState(client.id, client.config.volume);
|
||||||
|
}
|
||||||
|
if (typeof client.config.latency === 'number') {
|
||||||
|
await this.setClientLatency(client.id, client.config.latency);
|
||||||
|
}
|
||||||
|
if (client.config.name) {
|
||||||
|
await this.setClientName(client.id, client.config.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSnapshotGroupClients(serverArg: ISnapcastServerStatus, groupIdArg: string, clientIdsArg: string[]): void {
|
||||||
|
const targetGroup = this.requireGroup(serverArg, groupIdArg);
|
||||||
|
const clientMap = new Map(this.allClients(serverArg).map((clientArg) => [clientArg.id, clientArg]));
|
||||||
|
const desiredClients = clientIdsArg.map((clientIdArg) => clientMap.get(clientIdArg)).filter((clientArg): clientArg is ISnapcastClient => Boolean(clientArg));
|
||||||
|
const desiredSet = new Set(desiredClients.map((clientArg) => clientArg.id));
|
||||||
|
const removedClients = targetGroup.clients.filter((clientArg) => !desiredSet.has(clientArg.id));
|
||||||
|
|
||||||
|
for (const group of serverArg.groups) {
|
||||||
|
group.clients = group.clients.filter((clientArg) => !desiredSet.has(clientArg.id));
|
||||||
|
}
|
||||||
|
targetGroup.clients = desiredClients;
|
||||||
|
|
||||||
|
for (const client of removedClients) {
|
||||||
|
if (!serverArg.groups.some((groupArg) => groupArg.clients.some((clientArg) => clientArg.id === client.id))) {
|
||||||
|
serverArg.groups.push({
|
||||||
|
id: `manual_${this.slug(client.id)}`,
|
||||||
|
muted: false,
|
||||||
|
stream_id: this.groupStreamId(targetGroup),
|
||||||
|
clients: [client],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serverArg.groups = serverArg.groups.filter((groupArg) => groupArg.id === targetGroup.id || groupArg.clients.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSnapshotMode(): boolean {
|
||||||
|
return Boolean(this.currentSnapshot) || this.config.transport === 'snapshot';
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireSnapshot(): ISnapcastSnapshot {
|
||||||
|
if (!this.currentSnapshot) {
|
||||||
|
throw new Error('Snapcast snapshot transport requires config.snapshot.');
|
||||||
|
}
|
||||||
|
return this.currentSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireClient(serverArg: ISnapcastServerStatus, clientIdArg: string): ISnapcastClient {
|
||||||
|
const client = this.findClient(serverArg.groups, clientIdArg);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Snapcast client not found: ${clientIdArg}`);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireGroup(serverArg: ISnapcastServerStatus, groupIdArg: string): ISnapcastGroup {
|
||||||
|
const group = serverArg.groups.find((itemArg) => itemArg.id === groupIdArg);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Snapcast group not found: ${groupIdArg}`);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findClient(groupsArg: ISnapcastGroup[], clientIdArg: string): ISnapcastClient | undefined {
|
||||||
|
for (const group of groupsArg) {
|
||||||
|
const client = group.clients.find((itemArg) => itemArg.id === clientIdArg);
|
||||||
|
if (client) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private allClients(serverArg: ISnapcastServerStatus): ISnapcastClient[] {
|
||||||
|
return serverArg.groups.flatMap((groupArg) => groupArg.clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupStreamId(groupArg: ISnapcastGroup): string {
|
||||||
|
return groupArg.stream_id || groupArg.streamId || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringParam(paramsArg: Record<string, unknown> | undefined, keyArg: string): string {
|
||||||
|
const value = paramsArg?.[keyArg];
|
||||||
|
if (typeof value !== 'string' || !value) {
|
||||||
|
throw new Error(`Snapcast ${keyArg} parameter is required.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberParam(paramsArg: Record<string, unknown> | undefined, keyArg: string): number {
|
||||||
|
const value = paramsArg?.[keyArg];
|
||||||
|
if (typeof value !== 'number') {
|
||||||
|
throw new Error(`Snapcast ${keyArg} number parameter is required.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanParam(paramsArg: Record<string, unknown> | undefined, keyArg: string): boolean {
|
||||||
|
const value = paramsArg?.[keyArg];
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
throw new Error(`Snapcast ${keyArg} boolean parameter is required.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringArrayParam(paramsArg: Record<string, unknown> | undefined, keyArg: string): string[] {
|
||||||
|
const value = paramsArg?.[keyArg];
|
||||||
|
if (!Array.isArray(value) || value.some((itemArg) => typeof itemArg !== 'string')) {
|
||||||
|
throw new Error(`Snapcast ${keyArg} string array parameter is required.`);
|
||||||
|
}
|
||||||
|
return value as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private volumeParam(paramsArg: Record<string, unknown> | undefined): ISnapcastVolume {
|
||||||
|
const value = paramsArg?.volume;
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
throw new Error('Snapcast volume parameter is required.');
|
||||||
|
}
|
||||||
|
const volume = value as Partial<ISnapcastVolume>;
|
||||||
|
if (typeof volume.muted !== 'boolean' || typeof volume.percent !== 'number') {
|
||||||
|
throw new Error('Snapcast volume requires muted and percent.');
|
||||||
|
}
|
||||||
|
return { muted: volume.muted, percent: volume.percent };
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampPercent(valueArg: number): number {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(valueArg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneSnapshot(snapshotArg: ISnapcastSnapshot): ISnapcastSnapshot {
|
||||||
|
return {
|
||||||
|
...snapshotArg,
|
||||||
|
server: this.cloneServer(snapshotArg.server),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneServer(serverArg: ISnapcastServerStatus): ISnapcastServerStatus {
|
||||||
|
return JSON.parse(JSON.stringify(serverArg)) as ISnapcastServerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneGroup(groupArg: ISnapcastGroup): ISnapcastGroup {
|
||||||
|
return JSON.parse(JSON.stringify(groupArg)) as ISnapcastGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneClient(clientArg: ISnapcastClient): ISnapcastClient {
|
||||||
|
return JSON.parse(JSON.stringify(clientArg)) as ISnapcastClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'snapcast';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { ISnapcastConfig, TSnapcastRpcTransport } from './snapcast.types.js';
|
||||||
|
import { snapcastHttpControlPort, snapcastTcpControlPort } from './snapcast.types.js';
|
||||||
|
|
||||||
|
export class SnapcastConfigFlow implements IConfigFlow<ISnapcastConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISnapcastConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Snapcast Server',
|
||||||
|
description: 'Provide the Snapserver host and JSON-RPC control port. TCP defaults to 1705; HTTP defaults to 1780.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||||
|
{ name: 'transport', label: 'JSON-RPC transport', type: 'select', required: true, options: [
|
||||||
|
{ label: 'TCP control port (1705)', value: 'tcp' },
|
||||||
|
{ label: 'HTTP /jsonrpc (1780)', value: 'http' },
|
||||||
|
{ label: 'HTTPS /jsonrpc', value: 'https' },
|
||||||
|
] },
|
||||||
|
{ name: 'port', label: 'Control port', type: 'number' },
|
||||||
|
],
|
||||||
|
submit: async (valuesArg) => {
|
||||||
|
const transport = this.transportValue(valuesArg.transport, candidateArg);
|
||||||
|
const host = String(valuesArg.host || candidateArg.host || '').trim();
|
||||||
|
const port = this.portValue(valuesArg.port, candidateArg.port, transport);
|
||||||
|
if (!host) {
|
||||||
|
return { kind: 'error', title: 'Snapcast setup failed', error: 'Snapcast host is required.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Snapcast configured',
|
||||||
|
config: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
transport,
|
||||||
|
serverId: candidateArg.id || `${host}:${port}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private transportValue(valueArg: unknown, candidateArg: IDiscoveryCandidate): TSnapcastRpcTransport {
|
||||||
|
if (valueArg === 'http' || valueArg === 'https' || valueArg === 'tcp') {
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
const candidateTransport = candidateArg.metadata?.transport;
|
||||||
|
if (candidateTransport === 'http' || candidateTransport === 'https' || candidateTransport === 'tcp') {
|
||||||
|
return candidateTransport;
|
||||||
|
}
|
||||||
|
return candidateArg.port === snapcastHttpControlPort ? 'http' : 'tcp';
|
||||||
|
}
|
||||||
|
|
||||||
|
private portValue(valueArg: unknown, candidatePortArg: number | undefined, transportArg: TSnapcastRpcTransport): number {
|
||||||
|
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
|
||||||
|
return Math.round(valueArg);
|
||||||
|
}
|
||||||
|
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||||
|
const parsed = Number(valueArg);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return Math.round(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidatePortArg || (transportArg === 'http' || transportArg === 'https' ? snapcastHttpControlPort : snapcastTcpControlPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,304 @@
|
|||||||
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 { SnapcastClient } from './snapcast.classes.client.js';
|
||||||
|
import { SnapcastConfigFlow } from './snapcast.classes.configflow.js';
|
||||||
|
import { createSnapcastDiscoveryDescriptor } from './snapcast.discovery.js';
|
||||||
|
import { SnapcastMapper } from './snapcast.mapper.js';
|
||||||
|
import type { ISnapcastClient, ISnapcastConfig, ISnapcastGroup, ISnapcastServerStatus } from './snapcast.types.js';
|
||||||
|
|
||||||
export class HomeAssistantSnapcastIntegration extends DescriptorOnlyIntegration {
|
export class SnapcastIntegration extends BaseIntegration<ISnapcastConfig> {
|
||||||
constructor() {
|
public readonly domain = 'snapcast';
|
||||||
super({
|
public readonly displayName = 'Snapcast';
|
||||||
domain: "snapcast",
|
public readonly status = 'control-runtime' as const;
|
||||||
displayName: "Snapcast",
|
public readonly discoveryDescriptor = createSnapcastDiscoveryDescriptor();
|
||||||
status: 'descriptor-only',
|
public readonly configFlow = new SnapcastConfigFlow();
|
||||||
metadata: {
|
public readonly metadata = {
|
||||||
"source": "home-assistant/core",
|
source: 'home-assistant/core',
|
||||||
"upstreamPath": "homeassistant/components/snapcast",
|
upstreamPath: 'homeassistant/components/snapcast',
|
||||||
"upstreamDomain": "snapcast",
|
upstreamDomain: 'snapcast',
|
||||||
"integrationType": "hub",
|
integrationType: 'hub',
|
||||||
"iotClass": "local_push",
|
iotClass: 'local_push',
|
||||||
"requirements": [
|
requirements: ['snapcast==2.3.7'],
|
||||||
"snapcast==2.3.7"
|
dependencies: [],
|
||||||
],
|
afterDependencies: [],
|
||||||
"dependencies": [],
|
codeowners: ['@luar123'],
|
||||||
"afterDependencies": [],
|
configFlow: true,
|
||||||
"codeowners": [
|
documentation: 'https://www.home-assistant.io/integrations/snapcast',
|
||||||
"@luar123"
|
};
|
||||||
]
|
|
||||||
},
|
public async setup(configArg: ISnapcastConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||||
|
void contextArg;
|
||||||
|
return new SnapcastRuntime(new SnapcastClient(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantSnapcastIntegration extends SnapcastIntegration {}
|
||||||
|
|
||||||
|
class SnapcastRuntime implements IIntegrationRuntime {
|
||||||
|
public domain = 'snapcast';
|
||||||
|
|
||||||
|
constructor(private readonly client: SnapcastClient) {}
|
||||||
|
|
||||||
|
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||||
|
return SnapcastMapper.toDevices(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async entities(): Promise<IIntegrationEntity[]> {
|
||||||
|
return SnapcastMapper.toEntities(await this.client.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(): Promise<() => Promise<void>> {
|
||||||
|
throw new Error('Snapcast live event subscription is not implemented in this TypeScript port; poll devices()/entities() or use JSON-RPC service calls.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
try {
|
||||||
|
if (requestArg.domain === 'media_player') {
|
||||||
|
return await this.callMediaPlayerService(requestArg);
|
||||||
|
}
|
||||||
|
if (requestArg.domain === 'snapcast') {
|
||||||
|
return await this.callSnapcastService(requestArg);
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Snapcast 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 === 'volume_set') {
|
||||||
|
const volume = this.volumePercent(requestArg.data?.volume_level ?? requestArg.data?.volume);
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
if (target.kind === 'group') {
|
||||||
|
await this.setVolumeForGroup(target.id, volume);
|
||||||
|
} else {
|
||||||
|
await this.client.setClientVolume(this.requireClientTarget(target), volume);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'volume_mute') {
|
||||||
|
const muted = this.booleanValue(requestArg.data?.is_volume_muted ?? requestArg.data?.muted ?? requestArg.data?.mute, 'Snapcast volume_mute requires data.is_volume_muted or data.muted.');
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
if (target.kind === 'group') {
|
||||||
|
await this.client.setGroupMuted(target.id, muted);
|
||||||
|
} else {
|
||||||
|
await this.client.setClientMuted(this.requireClientTarget(target), muted);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'select_source' || requestArg.service === 'set_stream') {
|
||||||
|
await this.setStreamFromService(requestArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'join') {
|
||||||
|
await this.joinPlayers(requestArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.service === 'unjoin') {
|
||||||
|
await this.unjoinPlayer(requestArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: `Unsupported Snapcast media_player service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callSnapcastService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||||
|
if (requestArg.service === 'snapshot') {
|
||||||
|
return { success: true, data: await this.client.snapshot() };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'restore') {
|
||||||
|
await this.client.restore();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_latency') {
|
||||||
|
const latency = this.numberValue(requestArg.data?.latency, 'Snapcast set_latency requires data.latency.');
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
await this.client.setClientLatency(this.requireClientTarget(target), latency);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_stream') {
|
||||||
|
await this.setStreamFromService(requestArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_group_mute' || requestArg.service === 'group_mute') {
|
||||||
|
const muted = this.booleanValue(requestArg.data?.mute ?? requestArg.data?.muted, 'Snapcast set_group_mute requires data.mute or data.muted.');
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
await this.client.setGroupMuted(await this.groupIdFromTarget(target), muted);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'set_clients' || requestArg.service === 'group_set_clients') {
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
const groupId = await this.groupIdFromTarget(target);
|
||||||
|
const clients = await this.clientIdsFromData(requestArg.data?.clients ?? requestArg.data?.client_ids);
|
||||||
|
await this.client.setGroupClients(groupId, clients);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'join') {
|
||||||
|
await this.joinPlayers(requestArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'unjoin') {
|
||||||
|
await this.unjoinPlayer(requestArg);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') {
|
||||||
|
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player', service: 'volume_set' });
|
||||||
|
}
|
||||||
|
if (requestArg.service === 'volume_mute' || requestArg.service === 'set_mute') {
|
||||||
|
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player', service: 'volume_mute' });
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unsupported Snapcast service: ${requestArg.service}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setStreamFromService(requestArg: IServiceCallRequest): Promise<void> {
|
||||||
|
const streamId = this.stringValue(requestArg.data?.source ?? requestArg.data?.stream_id ?? requestArg.data?.stream, 'Snapcast set_stream/select_source requires data.source or data.stream_id.');
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
await this.client.setGroupStream(await this.groupIdFromTarget(target), streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async joinPlayers(requestArg: IServiceCallRequest): Promise<void> {
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
const groupId = await this.groupIdFromTarget(target);
|
||||||
|
const status = await this.client.getStatus();
|
||||||
|
const group = this.requireGroup(status, groupId);
|
||||||
|
const memberIds = await this.clientIdsFromData(requestArg.data?.group_members ?? requestArg.data?.clients ?? requestArg.data?.client_ids);
|
||||||
|
await this.client.setGroupClients(groupId, [...new Set([...group.clients.map((clientArg) => clientArg.id), ...memberIds])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unjoinPlayer(requestArg: IServiceCallRequest): Promise<void> {
|
||||||
|
const target = await this.resolveTarget(requestArg);
|
||||||
|
const clientId = this.requireClientTarget(target);
|
||||||
|
const status = await this.client.getStatus();
|
||||||
|
const group = this.groupForClient(status, clientId);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Snapcast client has no group: ${clientId}`);
|
||||||
|
}
|
||||||
|
await this.client.setGroupClients(group.id, group.clients.filter((clientArg) => clientArg.id !== clientId).map((clientArg) => clientArg.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setVolumeForGroup(groupIdArg: string, volumeArg: number): Promise<void> {
|
||||||
|
const status = await this.client.getStatus();
|
||||||
|
const group = this.requireGroup(status, groupIdArg);
|
||||||
|
for (const client of group.clients) {
|
||||||
|
await this.client.setClientVolume(client.id, volumeArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveTarget(requestArg: IServiceCallRequest): Promise<{ kind: 'client' | 'group' | 'stream'; id: string }> {
|
||||||
|
const status = await this.client.getStatus();
|
||||||
|
const directClientId = this.optionalString(requestArg.data?.client_id);
|
||||||
|
if (directClientId) {
|
||||||
|
return { kind: 'client', id: directClientId };
|
||||||
|
}
|
||||||
|
const directGroupId = this.optionalString(requestArg.data?.group_id);
|
||||||
|
if (directGroupId) {
|
||||||
|
return { kind: 'group', id: directGroupId };
|
||||||
|
}
|
||||||
|
const targetId = requestArg.target.entityId || requestArg.target.deviceId;
|
||||||
|
if (!targetId) {
|
||||||
|
throw new Error('Snapcast service calls require target.entityId, target.deviceId, data.client_id, or data.group_id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = SnapcastMapper.toEntities(status).find((entityArg) => entityArg.id === targetId || entityArg.deviceId === targetId);
|
||||||
|
if (entity?.attributes?.snapcastClientId && typeof entity.attributes.snapcastClientId === 'string') {
|
||||||
|
return { kind: 'client', id: entity.attributes.snapcastClientId };
|
||||||
|
}
|
||||||
|
if (entity?.attributes?.snapcastGroupId && typeof entity.attributes.snapcastGroupId === 'string') {
|
||||||
|
return { kind: 'group', id: entity.attributes.snapcastGroupId };
|
||||||
|
}
|
||||||
|
if (entity?.attributes?.snapcastStreamId && typeof entity.attributes.snapcastStreamId === 'string') {
|
||||||
|
return { kind: 'stream', id: entity.attributes.snapcastStreamId };
|
||||||
|
}
|
||||||
|
throw new Error(`Snapcast target was not found: ${targetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireClientTarget(targetArg: { kind: 'client' | 'group' | 'stream'; id: string }): string {
|
||||||
|
if (targetArg.kind !== 'client') {
|
||||||
|
throw new Error(`Snapcast service requires a client target, got ${targetArg.kind}.`);
|
||||||
|
}
|
||||||
|
return targetArg.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async groupIdFromTarget(targetArg: { kind: 'client' | 'group' | 'stream'; id: string }): Promise<string> {
|
||||||
|
if (targetArg.kind === 'group') {
|
||||||
|
return targetArg.id;
|
||||||
|
}
|
||||||
|
if (targetArg.kind !== 'client') {
|
||||||
|
throw new Error(`Snapcast group control requires a client or group target, got ${targetArg.kind}.`);
|
||||||
|
}
|
||||||
|
const group = this.groupForClient(await this.client.getStatus(), targetArg.id);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Snapcast client has no group: ${targetArg.id}`);
|
||||||
|
}
|
||||||
|
return group.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clientIdsFromData(valueArg: unknown): Promise<string[]> {
|
||||||
|
const values = Array.isArray(valueArg) ? valueArg : typeof valueArg === 'string' ? [valueArg] : [];
|
||||||
|
if (!values.length || values.some((itemArg) => typeof itemArg !== 'string')) {
|
||||||
|
throw new Error('Snapcast group control requires a clients/client_ids/group_members string array.');
|
||||||
|
}
|
||||||
|
const status = await this.client.getStatus();
|
||||||
|
const entities = SnapcastMapper.toEntities(status);
|
||||||
|
return values.map((value) => {
|
||||||
|
const entity = entities.find((entityArg) => entityArg.id === value || entityArg.deviceId === value);
|
||||||
|
if (entity?.attributes?.snapcastClientId && typeof entity.attributes.snapcastClientId === 'string') {
|
||||||
|
return entity.attributes.snapcastClientId;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private groupForClient(statusArg: ISnapcastServerStatus, clientIdArg: string): ISnapcastGroup | undefined {
|
||||||
|
return statusArg.groups.find((groupArg) => groupArg.clients.some((clientArg) => clientArg.id === clientIdArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireGroup(statusArg: ISnapcastServerStatus, groupIdArg: string): ISnapcastGroup {
|
||||||
|
const group = statusArg.groups.find((itemArg) => itemArg.id === groupIdArg);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Snapcast group not found: ${groupIdArg}`);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private volumePercent(valueArg: unknown): number {
|
||||||
|
const value = this.numberValue(valueArg, 'Snapcast volume_set requires data.volume_level or data.volume.');
|
||||||
|
return Math.max(0, Math.min(100, Math.round(value <= 1 ? value * 100 : value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private numberValue(valueArg: unknown, errorArg: string): number {
|
||||||
|
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
|
||||||
|
throw new Error(errorArg);
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private booleanValue(valueArg: unknown, errorArg: string): boolean {
|
||||||
|
if (typeof valueArg !== 'boolean') {
|
||||||
|
throw new Error(errorArg);
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringValue(valueArg: unknown, errorArg: string): string {
|
||||||
|
if (typeof valueArg !== 'string' || !valueArg) {
|
||||||
|
throw new Error(errorArg);
|
||||||
|
}
|
||||||
|
return valueArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalString(valueArg: unknown): string | undefined {
|
||||||
|
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||||
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||||
|
import type { ISnapcastManualEntry, ISnapcastMdnsRecord, TSnapcastRpcTransport } from './snapcast.types.js';
|
||||||
|
import { snapcastHttpControlPort, snapcastTcpControlPort } from './snapcast.types.js';
|
||||||
|
|
||||||
|
const snapcastStreamTypes = new Set(['_snapcast._tcp', '_snapcast-stream._tcp']);
|
||||||
|
const snapcastTcpTypes = new Set(['_snapcast-ctrl._tcp', '_snapcast-jsonrpc._tcp']);
|
||||||
|
const snapcastHttpTypes = new Set(['_snapcast-http._tcp']);
|
||||||
|
const snapcastHttpsTypes = new Set(['_snapcast-https._tcp']);
|
||||||
|
|
||||||
|
export class SnapcastMdnsMatcher implements IDiscoveryMatcher<ISnapcastMdnsRecord> {
|
||||||
|
public id = 'snapcast-mdns-match';
|
||||||
|
public source = 'mdns' as const;
|
||||||
|
public description = 'Recognize Snapcast mDNS records for stream, TCP control, and HTTP JSON-RPC services.';
|
||||||
|
|
||||||
|
public async matches(recordArg: ISnapcastMdnsRecord): Promise<IDiscoveryMatch> {
|
||||||
|
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
|
||||||
|
const name = recordArg.name || 'Snapcast';
|
||||||
|
const serviceMatch = snapcastStreamTypes.has(type) || snapcastTcpTypes.has(type) || snapcastHttpTypes.has(type) || snapcastHttpsTypes.has(type);
|
||||||
|
const nameMatch = name.toLowerCase().includes('snapcast');
|
||||||
|
if (!serviceMatch && !nameMatch) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Snapcast service.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = transportForMdnsType(type);
|
||||||
|
const host = recordArg.host || recordArg.addresses?.[0];
|
||||||
|
const port = controlPortForMdnsType(type, recordArg.port);
|
||||||
|
const id = recordArg.txt?.id || recordArg.txt?.server || (host ? `${host}:${port}` : name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: serviceMatch && host ? 'certain' : serviceMatch ? 'high' : 'medium',
|
||||||
|
reason: serviceMatch ? `mDNS service ${type} is a Snapcast service.` : 'mDNS name contains Snapcast.',
|
||||||
|
normalizedDeviceId: id,
|
||||||
|
candidate: {
|
||||||
|
source: 'mdns',
|
||||||
|
integrationDomain: 'snapcast',
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
name,
|
||||||
|
manufacturer: 'Snapcast',
|
||||||
|
model: 'Snapserver',
|
||||||
|
metadata: {
|
||||||
|
transport,
|
||||||
|
mdnsType: type,
|
||||||
|
txt: recordArg.txt,
|
||||||
|
streamPort: snapcastStreamTypes.has(type) ? recordArg.port : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
transport,
|
||||||
|
mdnsType: type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SnapcastManualMatcher implements IDiscoveryMatcher<ISnapcastManualEntry> {
|
||||||
|
public id = 'snapcast-manual-match';
|
||||||
|
public source = 'manual' as const;
|
||||||
|
public description = 'Recognize manual Snapcast setup entries.';
|
||||||
|
|
||||||
|
public async matches(inputArg: ISnapcastManualEntry): Promise<IDiscoveryMatch> {
|
||||||
|
const manufacturer = inputArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const model = inputArg.model?.toLowerCase() || '';
|
||||||
|
const matched = Boolean(inputArg.host || inputArg.metadata?.snapcast || manufacturer.includes('snapcast') || model.includes('snapserver'));
|
||||||
|
if (!matched) {
|
||||||
|
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Snapcast setup hints.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = inputArg.transport || transportFromPort(inputArg.port);
|
||||||
|
const port = inputArg.port || defaultPortForTransport(transport);
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: inputArg.host ? 'high' : 'medium',
|
||||||
|
reason: 'Manual entry can start Snapcast setup.',
|
||||||
|
normalizedDeviceId: inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined),
|
||||||
|
candidate: {
|
||||||
|
source: 'manual',
|
||||||
|
integrationDomain: 'snapcast',
|
||||||
|
id: inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined),
|
||||||
|
host: inputArg.host,
|
||||||
|
port,
|
||||||
|
name: inputArg.name,
|
||||||
|
manufacturer: 'Snapcast',
|
||||||
|
model: inputArg.model || 'Snapserver',
|
||||||
|
metadata: {
|
||||||
|
...inputArg.metadata,
|
||||||
|
transport,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SnapcastCandidateValidator implements IDiscoveryValidator {
|
||||||
|
public id = 'snapcast-candidate-validator';
|
||||||
|
public description = 'Validate Snapcast candidates have a host and Snapcast service metadata.';
|
||||||
|
|
||||||
|
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||||
|
const manufacturer = candidateArg.manufacturer?.toLowerCase() || '';
|
||||||
|
const model = candidateArg.model?.toLowerCase() || '';
|
||||||
|
const mdnsType = typeof candidateArg.metadata?.mdnsType === 'string' ? normalizeMdnsType(candidateArg.metadata.mdnsType) : '';
|
||||||
|
const matched = candidateArg.integrationDomain === 'snapcast'
|
||||||
|
|| manufacturer.includes('snapcast')
|
||||||
|
|| model.includes('snapserver')
|
||||||
|
|| model.includes('snapcast')
|
||||||
|
|| snapcastStreamTypes.has(mdnsType)
|
||||||
|
|| snapcastTcpTypes.has(mdnsType)
|
||||||
|
|| snapcastHttpTypes.has(mdnsType)
|
||||||
|
|| snapcastHttpsTypes.has(mdnsType);
|
||||||
|
|
||||||
|
if (!matched || !candidateArg.host) {
|
||||||
|
return {
|
||||||
|
matched: false,
|
||||||
|
confidence: matched ? 'medium' : 'low',
|
||||||
|
reason: matched ? 'Snapcast candidate lacks host information.' : 'Candidate is not Snapcast.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
confidence: candidateArg.id ? 'certain' : 'high',
|
||||||
|
reason: 'Candidate has Snapcast metadata and host information.',
|
||||||
|
candidate: {
|
||||||
|
...candidateArg,
|
||||||
|
port: candidateArg.port || defaultPortForTransport(transportFromCandidate(candidateArg)),
|
||||||
|
},
|
||||||
|
normalizedDeviceId: candidateArg.id || `${candidateArg.host}:${candidateArg.port || snapcastTcpControlPort}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSnapcastDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||||
|
return new DiscoveryDescriptor({ integrationDomain: 'snapcast', displayName: 'Snapcast' })
|
||||||
|
.addMatcher(new SnapcastMdnsMatcher())
|
||||||
|
.addMatcher(new SnapcastManualMatcher())
|
||||||
|
.addValidator(new SnapcastCandidateValidator());
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
|
||||||
|
|
||||||
|
const transportForMdnsType = (typeArg: string): TSnapcastRpcTransport => {
|
||||||
|
if (snapcastHttpTypes.has(typeArg)) {
|
||||||
|
return 'http';
|
||||||
|
}
|
||||||
|
if (snapcastHttpsTypes.has(typeArg)) {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
return 'tcp';
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlPortForMdnsType = (typeArg: string, portArg?: number): number => {
|
||||||
|
if (snapcastHttpTypes.has(typeArg) || snapcastHttpsTypes.has(typeArg)) {
|
||||||
|
return portArg || snapcastHttpControlPort;
|
||||||
|
}
|
||||||
|
if (snapcastTcpTypes.has(typeArg)) {
|
||||||
|
return portArg || snapcastTcpControlPort;
|
||||||
|
}
|
||||||
|
return snapcastTcpControlPort;
|
||||||
|
};
|
||||||
|
|
||||||
|
const transportFromPort = (portArg?: number): TSnapcastRpcTransport => {
|
||||||
|
if (portArg === snapcastHttpControlPort) {
|
||||||
|
return 'http';
|
||||||
|
}
|
||||||
|
return 'tcp';
|
||||||
|
};
|
||||||
|
|
||||||
|
const transportFromCandidate = (candidateArg: IDiscoveryCandidate): TSnapcastRpcTransport => {
|
||||||
|
const transport = candidateArg.metadata?.transport;
|
||||||
|
if (transport === 'http' || transport === 'https' || transport === 'snapshot' || transport === 'tcp') {
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
|
return transportFromPort(candidateArg.port);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPortForTransport = (transportArg: TSnapcastRpcTransport): number => {
|
||||||
|
if (transportArg === 'http' || transportArg === 'https') {
|
||||||
|
return snapcastHttpControlPort;
|
||||||
|
}
|
||||||
|
return snapcastTcpControlPort;
|
||||||
|
};
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IIntegrationEntity } from '../../core/types.js';
|
||||||
|
import type { ISnapcastClient, ISnapcastGroup, ISnapcastServerStatus, ISnapcastSnapshot, ISnapcastStream } from './snapcast.types.js';
|
||||||
|
|
||||||
|
export class SnapcastMapper {
|
||||||
|
public static toDevices(snapshotArg: ISnapcastSnapshot | ISnapcastServerStatus): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||||
|
const server = this.serverStatus(snapshotArg);
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [];
|
||||||
|
|
||||||
|
for (const group of server.groups) {
|
||||||
|
const stream = this.streamForGroup(server, group);
|
||||||
|
devices.push(this.groupDevice(group, stream, updatedAt));
|
||||||
|
for (const client of group.clients) {
|
||||||
|
devices.push(this.clientDevice(client, group, stream, updatedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stream of server.streams) {
|
||||||
|
devices.push(this.streamDevice(stream, updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toEntities(snapshotArg: ISnapcastSnapshot | ISnapcastServerStatus): IIntegrationEntity[] {
|
||||||
|
const server = this.serverStatus(snapshotArg);
|
||||||
|
const entities: IIntegrationEntity[] = [];
|
||||||
|
|
||||||
|
for (const group of server.groups) {
|
||||||
|
const stream = this.streamForGroup(server, group);
|
||||||
|
entities.push({
|
||||||
|
id: `media_player.${this.slug(this.groupName(group))}_snapcast_group`,
|
||||||
|
uniqueId: `snapcast_group_${this.slug(group.id)}`,
|
||||||
|
integrationDomain: 'snapcast',
|
||||||
|
deviceId: this.groupDeviceId(group),
|
||||||
|
platform: 'media_player',
|
||||||
|
name: `${this.groupName(group)} Snapcast Group`,
|
||||||
|
state: this.playbackState(group, stream),
|
||||||
|
attributes: {
|
||||||
|
snapcastGroupId: group.id,
|
||||||
|
muted: Boolean(group.muted),
|
||||||
|
source: this.groupStreamId(group),
|
||||||
|
sourceList: server.streams.map((streamArg) => streamArg.id),
|
||||||
|
groupMembers: group.clients.map((clientArg) => `media_player.${this.slug(this.clientName(clientArg))}_snapcast_client`),
|
||||||
|
clientIds: group.clients.map((clientArg) => clientArg.id),
|
||||||
|
mediaTitle: stream?.metadata?.title,
|
||||||
|
mediaArtist: this.joinArtist(stream?.metadata?.artist),
|
||||||
|
mediaAlbum: stream?.metadata?.album,
|
||||||
|
mediaImageUrl: stream?.metadata?.artUrl,
|
||||||
|
},
|
||||||
|
available: group.clients.some((clientArg) => clientArg.connected),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const client of group.clients) {
|
||||||
|
entities.push({
|
||||||
|
id: `media_player.${this.slug(this.clientName(client))}_snapcast_client`,
|
||||||
|
uniqueId: `snapcast_client_${this.slug(client.id)}`,
|
||||||
|
integrationDomain: 'snapcast',
|
||||||
|
deviceId: this.clientDeviceId(client),
|
||||||
|
platform: 'media_player',
|
||||||
|
name: `${this.clientName(client)} Snapcast Client`,
|
||||||
|
state: this.playbackState(group, stream, client),
|
||||||
|
attributes: {
|
||||||
|
snapcastClientId: client.id,
|
||||||
|
snapcastGroupId: group.id,
|
||||||
|
latency: client.config.latency,
|
||||||
|
volumeLevel: typeof client.config.volume?.percent === 'number' ? client.config.volume.percent / 100 : undefined,
|
||||||
|
volumePercent: client.config.volume?.percent,
|
||||||
|
muted: client.config.volume?.muted,
|
||||||
|
source: this.groupStreamId(group),
|
||||||
|
sourceList: server.streams.map((streamArg) => streamArg.id),
|
||||||
|
groupMembers: group.clients.map((clientArg) => `media_player.${this.slug(this.clientName(clientArg))}_snapcast_client`),
|
||||||
|
hostName: client.host?.name,
|
||||||
|
hostIp: client.host?.ip,
|
||||||
|
hostMac: client.host?.mac,
|
||||||
|
mediaTitle: stream?.metadata?.title,
|
||||||
|
mediaArtist: this.joinArtist(stream?.metadata?.artist),
|
||||||
|
mediaAlbum: stream?.metadata?.album,
|
||||||
|
mediaImageUrl: stream?.metadata?.artUrl,
|
||||||
|
mediaDuration: stream?.metadata?.duration,
|
||||||
|
mediaPosition: typeof stream?.properties?.position === 'number' ? stream.properties.position : undefined,
|
||||||
|
},
|
||||||
|
available: client.connected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stream of server.streams) {
|
||||||
|
entities.push({
|
||||||
|
id: `sensor.${this.slug(this.streamName(stream))}_snapcast_stream`,
|
||||||
|
uniqueId: `snapcast_stream_${this.slug(stream.id)}`,
|
||||||
|
integrationDomain: 'snapcast',
|
||||||
|
deviceId: this.streamDeviceId(stream),
|
||||||
|
platform: 'sensor',
|
||||||
|
name: `${this.streamName(stream)} Snapcast Stream`,
|
||||||
|
state: stream.status || 'unknown',
|
||||||
|
attributes: {
|
||||||
|
snapcastStreamId: stream.id,
|
||||||
|
uri: stream.uri?.raw,
|
||||||
|
scheme: stream.uri?.scheme,
|
||||||
|
metadata: stream.metadata,
|
||||||
|
properties: stream.properties,
|
||||||
|
},
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static clientDeviceId(clientArg: ISnapcastClient): string {
|
||||||
|
return `snapcast.client.${this.slug(clientArg.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static groupDeviceId(groupArg: ISnapcastGroup): string {
|
||||||
|
return `snapcast.group.${this.slug(groupArg.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static streamDeviceId(streamArg: ISnapcastStream): string {
|
||||||
|
return `snapcast.stream.${this.slug(streamArg.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static slug(valueArg: string): string {
|
||||||
|
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'snapcast';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clientDevice(clientArg: ISnapcastClient, groupArg: ISnapcastGroup, streamArg: ISnapcastStream | undefined, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
return {
|
||||||
|
id: this.clientDeviceId(clientArg),
|
||||||
|
integrationDomain: 'snapcast',
|
||||||
|
name: this.clientName(clientArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Snapcast',
|
||||||
|
model: clientArg.snapclient?.name || 'Snapclient',
|
||||||
|
online: clientArg.connected,
|
||||||
|
features: [
|
||||||
|
{ id: 'playback', capability: 'media', name: 'Playback', 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: 'latency', capability: 'media', name: 'Latency', readable: true, writable: true, unit: 'ms' },
|
||||||
|
{ id: 'stream', capability: 'media', name: 'Stream', readable: true, writable: true },
|
||||||
|
{ id: 'group', capability: 'media', name: 'Group', readable: true, writable: true },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'playback', value: this.playbackState(groupArg, streamArg, clientArg), updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'volume', value: clientArg.config.volume?.percent ?? null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'muted', value: clientArg.config.volume?.muted ?? null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'latency', value: clientArg.config.latency ?? null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'stream', value: this.groupStreamId(groupArg) || null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'group', value: groupArg.id, updatedAt: updatedAtArg },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
snapcastClientId: clientArg.id,
|
||||||
|
snapcastGroupId: groupArg.id,
|
||||||
|
host: clientArg.host,
|
||||||
|
snapclient: clientArg.snapclient,
|
||||||
|
lastSeen: clientArg.lastSeen,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupDevice(groupArg: ISnapcastGroup, streamArg: ISnapcastStream | undefined, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
return {
|
||||||
|
id: this.groupDeviceId(groupArg),
|
||||||
|
integrationDomain: 'snapcast',
|
||||||
|
name: this.groupName(groupArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Snapcast',
|
||||||
|
model: 'Snapcast Group',
|
||||||
|
online: groupArg.clients.some((clientArg) => clientArg.connected),
|
||||||
|
features: [
|
||||||
|
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: false },
|
||||||
|
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
|
||||||
|
{ id: 'stream', capability: 'media', name: 'Stream', readable: true, writable: true },
|
||||||
|
{ id: 'client_count', capability: 'sensor', name: 'Client count', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'playback', value: this.playbackState(groupArg, streamArg), updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'muted', value: Boolean(groupArg.muted), updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'stream', value: this.groupStreamId(groupArg) || null, updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'client_count', value: groupArg.clients.length, updatedAt: updatedAtArg },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
snapcastGroupId: groupArg.id,
|
||||||
|
clientIds: groupArg.clients.map((clientArg) => clientArg.id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static streamDevice(streamArg: ISnapcastStream, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||||
|
return {
|
||||||
|
id: this.streamDeviceId(streamArg),
|
||||||
|
integrationDomain: 'snapcast',
|
||||||
|
name: this.streamName(streamArg),
|
||||||
|
protocol: 'http',
|
||||||
|
manufacturer: 'Snapcast',
|
||||||
|
model: `${streamArg.uri?.scheme || 'audio'} stream`,
|
||||||
|
online: true,
|
||||||
|
features: [
|
||||||
|
{ id: 'status', capability: 'media', name: 'Status', readable: true, writable: false },
|
||||||
|
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||||
|
],
|
||||||
|
state: [
|
||||||
|
{ featureId: 'status', value: streamArg.status || 'unknown', updatedAt: updatedAtArg },
|
||||||
|
{ featureId: 'current_title', value: streamArg.metadata?.title || null, updatedAt: updatedAtArg },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
snapcastStreamId: streamArg.id,
|
||||||
|
uri: streamArg.uri,
|
||||||
|
metadata: streamArg.metadata,
|
||||||
|
properties: streamArg.properties,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static serverStatus(snapshotArg: ISnapcastSnapshot | ISnapcastServerStatus): ISnapcastServerStatus {
|
||||||
|
return Array.isArray((snapshotArg as ISnapcastServerStatus).groups) ? snapshotArg as ISnapcastServerStatus : (snapshotArg as ISnapcastSnapshot).server;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static streamForGroup(serverArg: ISnapcastServerStatus, groupArg: ISnapcastGroup): ISnapcastStream | undefined {
|
||||||
|
const streamId = this.groupStreamId(groupArg);
|
||||||
|
return serverArg.streams.find((streamArg) => streamArg.id === streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static playbackState(groupArg: ISnapcastGroup, streamArg?: ISnapcastStream, clientArg?: ISnapcastClient): string {
|
||||||
|
if (clientArg && !clientArg.connected) {
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
if (clientArg?.config.volume?.muted || groupArg.muted) {
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
if (streamArg?.status === 'playing') {
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
|
if (streamArg?.status === 'idle') {
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
return streamArg?.status || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static clientName(clientArg: ISnapcastClient): string {
|
||||||
|
return clientArg.config.name || clientArg.host?.name || clientArg.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupName(groupArg: ISnapcastGroup): string {
|
||||||
|
return groupArg.name || groupArg.clients.map((clientArg) => this.clientName(clientArg)).join(', ') || groupArg.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static streamName(streamArg: ISnapcastStream): string {
|
||||||
|
return streamArg.uri?.query?.name || streamArg.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupStreamId(groupArg: ISnapcastGroup): string {
|
||||||
|
return groupArg.stream_id || groupArg.streamId || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static joinArtist(valueArg: string[] | string | undefined): string | undefined {
|
||||||
|
return Array.isArray(valueArg) ? valueArg.join(', ') : valueArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,269 @@
|
|||||||
export interface IHomeAssistantSnapcastConfig {
|
export const snapcastTcpControlPort = 1705;
|
||||||
// TODO: replace with the TypeScript-native config for snapcast.
|
export const snapcastHttpControlPort = 1780;
|
||||||
|
|
||||||
|
export type TSnapcastRpcTransport = 'tcp' | 'http' | 'https' | 'snapshot';
|
||||||
|
export type TSnapcastStreamStatus = 'idle' | 'playing' | 'unknown' | (string & {});
|
||||||
|
|
||||||
|
export type TSnapcastRpcMethod =
|
||||||
|
| 'Client.GetStatus'
|
||||||
|
| 'Client.SetVolume'
|
||||||
|
| 'Client.SetLatency'
|
||||||
|
| 'Client.SetName'
|
||||||
|
| 'Group.GetStatus'
|
||||||
|
| 'Group.SetMute'
|
||||||
|
| 'Group.SetStream'
|
||||||
|
| 'Group.SetClients'
|
||||||
|
| 'Group.SetName'
|
||||||
|
| 'Server.GetRPCVersion'
|
||||||
|
| 'Server.GetStatus'
|
||||||
|
| 'Server.DeleteClient'
|
||||||
|
| 'Stream.Control'
|
||||||
|
| 'Stream.SetProperty'
|
||||||
|
| 'Stream.AddStream'
|
||||||
|
| 'Stream.RemoveStream';
|
||||||
|
|
||||||
|
export type TSnapcastEventMethod =
|
||||||
|
| 'Client.OnConnect'
|
||||||
|
| 'Client.OnDisconnect'
|
||||||
|
| 'Client.OnVolumeChanged'
|
||||||
|
| 'Client.OnLatencyChanged'
|
||||||
|
| 'Client.OnNameChanged'
|
||||||
|
| 'Group.OnMute'
|
||||||
|
| 'Group.OnStreamChanged'
|
||||||
|
| 'Group.OnNameChanged'
|
||||||
|
| 'Stream.OnProperties'
|
||||||
|
| 'Stream.OnUpdate'
|
||||||
|
| 'Server.OnUpdate';
|
||||||
|
|
||||||
|
export type TSnapcastServiceCommand =
|
||||||
|
| 'volume_set'
|
||||||
|
| 'volume_mute'
|
||||||
|
| 'set_latency'
|
||||||
|
| 'set_stream'
|
||||||
|
| 'set_group_mute'
|
||||||
|
| 'set_clients'
|
||||||
|
| 'join'
|
||||||
|
| 'unjoin'
|
||||||
|
| 'snapshot'
|
||||||
|
| 'restore';
|
||||||
|
|
||||||
|
export interface ISnapcastConfig {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
transport?: TSnapcastRpcTransport;
|
||||||
|
timeoutMs?: number;
|
||||||
|
serverId?: string;
|
||||||
|
snapshot?: ISnapcastSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHomeAssistantSnapcastConfig extends ISnapcastConfig {}
|
||||||
|
|
||||||
|
export interface ISnapcastSnapshot {
|
||||||
|
server: ISnapcastServerStatus;
|
||||||
|
capturedAt?: string;
|
||||||
|
source?: 'manual' | 'jsonrpc' | 'runtime';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastServerStatus {
|
||||||
|
groups: ISnapcastGroup[];
|
||||||
|
streams: ISnapcastStream[];
|
||||||
|
server?: ISnapcastServerInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastServerInfo {
|
||||||
|
host?: ISnapcastHostInfo;
|
||||||
|
snapserver?: {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
protocolVersion?: number;
|
||||||
|
controlProtocolVersion?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastHostInfo {
|
||||||
|
arch?: string;
|
||||||
|
ip?: string;
|
||||||
|
mac?: string;
|
||||||
|
name?: string;
|
||||||
|
os?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastVolume {
|
||||||
|
muted: boolean;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastClientConfig {
|
||||||
|
instance?: number;
|
||||||
|
latency?: number;
|
||||||
|
name?: string;
|
||||||
|
volume?: ISnapcastVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastClient {
|
||||||
|
id: string;
|
||||||
|
connected: boolean;
|
||||||
|
host?: ISnapcastHostInfo;
|
||||||
|
config: ISnapcastClientConfig;
|
||||||
|
lastSeen?: {
|
||||||
|
sec?: number;
|
||||||
|
usec?: number;
|
||||||
|
};
|
||||||
|
snapclient?: {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
protocolVersion?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastGroup {
|
||||||
|
id: string;
|
||||||
|
clients: ISnapcastClient[];
|
||||||
|
muted?: boolean;
|
||||||
|
name?: string;
|
||||||
|
stream_id?: string;
|
||||||
|
streamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastStreamUri {
|
||||||
|
raw?: string;
|
||||||
|
scheme?: string;
|
||||||
|
host?: string;
|
||||||
|
path?: string;
|
||||||
|
fragment?: string;
|
||||||
|
query?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastMediaMetadata {
|
||||||
|
title?: string;
|
||||||
|
artist?: string[] | string;
|
||||||
|
album?: string;
|
||||||
|
albumArtist?: string[] | string;
|
||||||
|
artUrl?: string;
|
||||||
|
trackNumber?: number | string;
|
||||||
|
duration?: number | string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastStream {
|
||||||
|
id: string;
|
||||||
|
status?: TSnapcastStreamStatus;
|
||||||
|
uri?: ISnapcastStreamUri;
|
||||||
|
metadata?: ISnapcastMediaMetadata;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastRpcRequest<TParams = Record<string, unknown>> {
|
||||||
|
id: number | string;
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
method: TSnapcastRpcMethod | string;
|
||||||
|
params?: TParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastRpcError {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastRpcResponse<TResult = unknown> {
|
||||||
|
id?: number | string;
|
||||||
|
jsonrpc?: '2.0';
|
||||||
|
result?: TResult;
|
||||||
|
error?: ISnapcastRpcError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastRpcNotification<TParams = Record<string, unknown>> {
|
||||||
|
jsonrpc?: '2.0';
|
||||||
|
method: TSnapcastEventMethod | string;
|
||||||
|
params?: TParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastClientVolumeCommand {
|
||||||
|
clientId: string;
|
||||||
|
percent?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastClientLatencyCommand {
|
||||||
|
clientId: string;
|
||||||
|
latency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastSetStreamCommand {
|
||||||
|
groupId: string;
|
||||||
|
streamId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastSetGroupClientsCommand {
|
||||||
|
groupId: string;
|
||||||
|
clientIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastStreamControlCommand {
|
||||||
|
streamId: string;
|
||||||
|
command: 'next' | 'previous' | 'pause' | 'playPause' | 'stop' | 'play' | 'seek' | 'setPosition' | (string & {});
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastClientEvent {
|
||||||
|
method: Extract<TSnapcastEventMethod, `Client.${string}`>;
|
||||||
|
clientId?: string;
|
||||||
|
client?: ISnapcastClient;
|
||||||
|
volume?: ISnapcastVolume;
|
||||||
|
latency?: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastGroupEvent {
|
||||||
|
method: Extract<TSnapcastEventMethod, `Group.${string}`>;
|
||||||
|
groupId?: string;
|
||||||
|
muted?: boolean;
|
||||||
|
streamId?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastStreamEvent {
|
||||||
|
method: Extract<TSnapcastEventMethod, `Stream.${string}`>;
|
||||||
|
streamId?: string;
|
||||||
|
stream?: ISnapcastStream;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastServerEvent {
|
||||||
|
method: 'Server.OnUpdate';
|
||||||
|
server?: ISnapcastServerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TSnapcastEvent = ISnapcastClientEvent | ISnapcastGroupEvent | ISnapcastStreamEvent | ISnapcastServerEvent;
|
||||||
|
|
||||||
|
export interface ISnapcastMdnsRecord {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
serviceType?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
addresses?: string[];
|
||||||
|
txt?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastManualEntry {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
transport?: TSnapcastRpcTransport;
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISnapcastDiscoveryRecord {
|
||||||
|
source: 'mdns' | 'manual';
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
transport?: TSnapcastRpcTransport;
|
||||||
|
mdnsType?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: 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 './volumio.classes.integration.js';
|
export * from './volumio.classes.integration.js';
|
||||||
|
export * from './volumio.classes.client.js';
|
||||||
|
export * from './volumio.classes.configflow.js';
|
||||||
|
export * from './volumio.discovery.js';
|
||||||
|
export * from './volumio.mapper.js';
|
||||||
export * from './volumio.types.js';
|
export * from './volumio.types.js';
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import type {
|
||||||
|
IVolumioConfig,
|
||||||
|
IVolumioDeviceInfo,
|
||||||
|
IVolumioPlaylistCollection,
|
||||||
|
IVolumioQueue,
|
||||||
|
IVolumioSnapshot,
|
||||||
|
IVolumioState,
|
||||||
|
IVolumioSystemInfo,
|
||||||
|
IVolumioSystemVersion,
|
||||||
|
} from './volumio.types.js';
|
||||||
|
import { volumioDefaultPort } from './volumio.types.js';
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 5000;
|
||||||
|
|
||||||
|
export class VolumioHttpError extends Error {
|
||||||
|
constructor(public readonly status: number, messageArg: string) {
|
||||||
|
super(messageArg);
|
||||||
|
this.name = 'VolumioHttpError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VolumioClient {
|
||||||
|
private currentSnapshot?: IVolumioSnapshot;
|
||||||
|
|
||||||
|
constructor(private readonly config: IVolumioConfig) {
|
||||||
|
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneSnapshot(config.snapshot)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot(): Promise<IVolumioSnapshot> {
|
||||||
|
if (!this.config.host && this.currentSnapshot) {
|
||||||
|
return this.cloneSnapshot(this.currentSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.snapshot) {
|
||||||
|
this.currentSnapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
|
||||||
|
return this.cloneSnapshot(this.currentSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasManualSnapshotData()) {
|
||||||
|
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.config.connected ?? true));
|
||||||
|
return this.cloneSnapshot(this.currentSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config.host) {
|
||||||
|
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(false));
|
||||||
|
return this.cloneSnapshot(this.currentSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSnapshot = this.normalizeSnapshot(await this.fetchSnapshot());
|
||||||
|
return this.cloneSnapshot(this.currentSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ping(): Promise<boolean> {
|
||||||
|
const response = await this.requestText('ping');
|
||||||
|
return response.trim().toLowerCase() === 'pong';
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSystemVersion(): Promise<IVolumioSystemVersion> {
|
||||||
|
return this.getJson<IVolumioSystemVersion>('getSystemVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSystemInfo(): Promise<IVolumioSystemInfo> {
|
||||||
|
return this.getJson<IVolumioSystemInfo>('getSystemInfo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getState(): Promise<IVolumioState> {
|
||||||
|
return this.getJson<IVolumioState>('getState');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPlaylists(): Promise<IVolumioPlaylistCollection> {
|
||||||
|
return this.getJson<IVolumioPlaylistCollection>('listplaylists');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQueue(): Promise<IVolumioQueue> {
|
||||||
|
return this.getJson<IVolumioQueue>('getQueue');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendCommand(paramsArg: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const command = paramsArg.cmd;
|
||||||
|
if (typeof command !== 'string' || !command) {
|
||||||
|
throw new Error('Volumio command requires params.cmd.');
|
||||||
|
}
|
||||||
|
if (!this.config.host) {
|
||||||
|
this.applyCommandToCachedSnapshot(paramsArg);
|
||||||
|
return { response: `${command} Success` };
|
||||||
|
}
|
||||||
|
return this.getJson('commands', this.params(paramsArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async play(): Promise<void> {
|
||||||
|
await this.command('play');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pause(): Promise<void> {
|
||||||
|
await this.command('pause');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toggle(): Promise<void> {
|
||||||
|
await this.command('toggle');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
await this.command('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async next(): Promise<void> {
|
||||||
|
await this.command('next');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async previous(): Promise<void> {
|
||||||
|
await this.command('prev');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setVolumeLevel(volumeLevelArg: number): Promise<void> {
|
||||||
|
await this.command('volume', { volume: this.volumePercent(volumeLevelArg) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async volumeUp(): Promise<void> {
|
||||||
|
await this.command('volume', { volume: 'plus' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async volumeDown(): Promise<void> {
|
||||||
|
await this.command('volume', { volume: 'minus' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setMuted(mutedArg: boolean): Promise<void> {
|
||||||
|
await this.command('volume', { volume: mutedArg ? 'mute' : 'unmute' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setShuffle(shuffleArg: boolean): Promise<void> {
|
||||||
|
await this.command('random', { value: String(shuffleArg) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async repeatAll(repeatArg: boolean): Promise<void> {
|
||||||
|
await this.command('repeat', { value: String(repeatArg) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async playPlaylist(playlistArg: string): Promise<void> {
|
||||||
|
await this.command('playplaylist', { name: playlistArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearPlaylist(): Promise<void> {
|
||||||
|
await this.command('clearQueue');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async seek(positionSecondsArg: number): Promise<void> {
|
||||||
|
await this.command('seek', { position: Math.max(0, Math.round(positionSecondsArg)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async replaceAndPlay(itemArg: unknown): Promise<void> {
|
||||||
|
if (!this.config.host) {
|
||||||
|
this.patchCachedState({ status: 'play' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.postJson('replaceAndPlay', itemArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public canonicUrl(urlArg: string | undefined): string | undefined {
|
||||||
|
if (!urlArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(urlArg)) {
|
||||||
|
return urlArg;
|
||||||
|
}
|
||||||
|
const host = this.config.host || this.currentSnapshot?.deviceInfo.host;
|
||||||
|
if (!host) {
|
||||||
|
return urlArg;
|
||||||
|
}
|
||||||
|
return new URL(urlArg, `${this.protocol()}://${host}:${this.config.port || this.currentSnapshot?.deviceInfo.port || volumioDefaultPort}`).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async watchSocketIoPush(): Promise<never> {
|
||||||
|
throw new Error('Volumio socket.io push is not implemented in this TypeScript port; use devices()/entities() polling and REST service calls.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
private async fetchSnapshot(): Promise<IVolumioSnapshot> {
|
||||||
|
const state = await this.getState();
|
||||||
|
const [systemInfo, systemVersion, playlists, queue] = await Promise.all([
|
||||||
|
this.getSystemInfo().catch(() => this.config.systemInfo),
|
||||||
|
this.getSystemVersion().catch(() => this.config.systemVersion),
|
||||||
|
this.getPlaylists().catch(() => this.config.playlists),
|
||||||
|
this.getQueue().catch(() => this.config.queue),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceInfo: this.deviceInfoFromPayload(systemInfo, systemVersion),
|
||||||
|
systemInfo,
|
||||||
|
systemVersion,
|
||||||
|
state,
|
||||||
|
playlists,
|
||||||
|
queue,
|
||||||
|
online: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotFromConfig(onlineArg: boolean): IVolumioSnapshot {
|
||||||
|
return {
|
||||||
|
deviceInfo: this.deviceInfoFromPayload(this.config.systemInfo, this.config.systemVersion),
|
||||||
|
systemInfo: this.config.systemInfo,
|
||||||
|
systemVersion: this.config.systemVersion,
|
||||||
|
state: this.config.state || { status: onlineArg ? 'stop' : 'offline' },
|
||||||
|
playlists: this.config.playlists,
|
||||||
|
queue: this.config.queue,
|
||||||
|
online: onlineArg,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSnapshot(snapshotArg: IVolumioSnapshot): IVolumioSnapshot {
|
||||||
|
const deviceInfo = {
|
||||||
|
...this.deviceInfoFromPayload(snapshotArg.systemInfo || this.config.systemInfo, snapshotArg.systemVersion || this.config.systemVersion),
|
||||||
|
...snapshotArg.deviceInfo,
|
||||||
|
};
|
||||||
|
const state = { ...(snapshotArg.state || {}) };
|
||||||
|
const albumart = typeof state.albumart === 'string' ? this.canonicUrl(state.albumart) : undefined;
|
||||||
|
if (albumart) {
|
||||||
|
state.albumart = albumart;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...snapshotArg,
|
||||||
|
deviceInfo,
|
||||||
|
state,
|
||||||
|
online: Boolean(snapshotArg.online),
|
||||||
|
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private deviceInfoFromPayload(systemInfoArg: IVolumioSystemInfo | undefined, systemVersionArg: IVolumioSystemVersion | undefined): IVolumioDeviceInfo {
|
||||||
|
const version = stringValue(systemVersionArg?.systemversion)
|
||||||
|
|| stringValue(systemVersionArg?.systemVersion)
|
||||||
|
|| stringValue(systemVersionArg?.volumioVersion)
|
||||||
|
|| stringValue(systemVersionArg?.version)
|
||||||
|
|| stringValue(systemInfoArg?.systemversion)
|
||||||
|
|| stringValue(systemInfoArg?.systemVersion)
|
||||||
|
|| stringValue(systemInfoArg?.volumioVersion)
|
||||||
|
|| stringValue(systemInfoArg?.version);
|
||||||
|
const hardware = stringValue(systemVersionArg?.hardware) || stringValue(systemInfoArg?.hardware);
|
||||||
|
return {
|
||||||
|
...this.config.deviceInfo,
|
||||||
|
id: this.config.deviceInfo?.id || this.config.uniqueId || stringValue(systemInfoArg?.id) || stringValue(systemInfoArg?.uuid),
|
||||||
|
uuid: this.config.deviceInfo?.uuid || this.config.uniqueId || stringValue(systemInfoArg?.id) || stringValue(systemInfoArg?.uuid),
|
||||||
|
name: this.config.deviceInfo?.name || this.config.name || stringValue(systemInfoArg?.name) || this.config.host || 'Volumio',
|
||||||
|
host: this.config.deviceInfo?.host || this.config.host,
|
||||||
|
port: this.config.deviceInfo?.port || this.config.port || volumioDefaultPort,
|
||||||
|
manufacturer: this.config.deviceInfo?.manufacturer || 'Volumio',
|
||||||
|
model: this.config.deviceInfo?.model || hardware,
|
||||||
|
hardware: this.config.deviceInfo?.hardware || hardware,
|
||||||
|
systemVersion: this.config.deviceInfo?.systemVersion || version,
|
||||||
|
softwareVersion: this.config.deviceInfo?.softwareVersion || version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async command(commandArg: string, paramsArg: Record<string, unknown> = {}): Promise<void> {
|
||||||
|
await this.sendCommand({ cmd: commandArg, ...paramsArg });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getJson<T = unknown>(pathArg: string, paramsArg?: URLSearchParams): Promise<T> {
|
||||||
|
return this.requestJson<T>('GET', pathArg, paramsArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postJson<T = unknown>(pathArg: string, bodyArg: unknown): Promise<T> {
|
||||||
|
return this.requestJson<T>('POST', pathArg, undefined, bodyArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestJson<T>(methodArg: 'GET' | 'POST', pathArg: string, paramsArg?: URLSearchParams, bodyArg?: unknown): Promise<T> {
|
||||||
|
const response = await this.request(pathArg, {
|
||||||
|
method: methodArg,
|
||||||
|
headers: methodArg === 'POST' ? { 'content-type': 'application/json' } : undefined,
|
||||||
|
body: methodArg === 'POST' ? JSON.stringify(bodyArg) : undefined,
|
||||||
|
}, paramsArg);
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new VolumioHttpError(response.status, `Volumio request ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
if (!text.trim()) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestText(pathArg: string, paramsArg?: URLSearchParams): Promise<string> {
|
||||||
|
const response = await this.request(pathArg, { method: 'GET' }, paramsArg);
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new VolumioHttpError(response.status, `Volumio request ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(pathArg: string, initArg: RequestInit, paramsArg?: URLSearchParams): Promise<Response> {
|
||||||
|
if (!this.config.host) {
|
||||||
|
throw new Error('Volumio host is required when snapshot or manual config data is not provided.');
|
||||||
|
}
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
|
||||||
|
try {
|
||||||
|
return await globalThis.fetch(this.url(pathArg, paramsArg), {
|
||||||
|
...initArg,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private url(pathArg: string, paramsArg?: URLSearchParams): string {
|
||||||
|
const normalizedPath = pathArg.replace(/^\/+/, '');
|
||||||
|
const query = paramsArg && Array.from(paramsArg.keys()).length ? `?${paramsArg.toString()}` : '';
|
||||||
|
return `${this.protocol()}://${this.config.host}:${this.config.port || volumioDefaultPort}/api/v1/${normalizedPath}${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private protocol(): 'http' | 'https' {
|
||||||
|
return this.config.ssl ? 'https' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
private params(recordArg: Record<string, unknown>): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(recordArg)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private volumePercent(valueArg: number): number {
|
||||||
|
const percent = valueArg <= 1 ? valueArg * 100 : valueArg;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(percent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasManualSnapshotData(): boolean {
|
||||||
|
return Boolean(this.config.state || this.config.systemInfo || this.config.systemVersion || this.config.playlists || this.config.queue || this.config.deviceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCommandToCachedSnapshot(paramsArg: Record<string, unknown>): void {
|
||||||
|
const command = paramsArg.cmd;
|
||||||
|
if (typeof command !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.currentSnapshot) {
|
||||||
|
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.config.connected ?? true));
|
||||||
|
}
|
||||||
|
if (command === 'play' || command === 'toggle') {
|
||||||
|
this.patchCachedState({ status: 'play' });
|
||||||
|
} else if (command === 'pause') {
|
||||||
|
this.patchCachedState({ status: 'pause' });
|
||||||
|
} else if (command === 'stop') {
|
||||||
|
this.patchCachedState({ status: 'stop', seek: 0 });
|
||||||
|
} else if (command === 'volume') {
|
||||||
|
this.applyVolumeCommand(paramsArg.volume);
|
||||||
|
} else if (command === 'random') {
|
||||||
|
this.patchCachedState({ random: paramsArg.value === true || paramsArg.value === 'true' });
|
||||||
|
} else if (command === 'repeat') {
|
||||||
|
this.patchCachedState({ repeat: paramsArg.value === true || paramsArg.value === 'true' });
|
||||||
|
} else if (command === 'seek') {
|
||||||
|
this.patchCachedState({ seek: Number(paramsArg.position || 0) * 1000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyVolumeCommand(valueArg: unknown): void {
|
||||||
|
const state = this.currentSnapshot?.state || {};
|
||||||
|
const current = numberValue(state.volume) || 0;
|
||||||
|
if (valueArg === 'mute') {
|
||||||
|
this.patchCachedState({ mute: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (valueArg === 'unmute') {
|
||||||
|
this.patchCachedState({ mute: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (valueArg === 'plus') {
|
||||||
|
this.patchCachedState({ volume: Math.min(100, current + 5), mute: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (valueArg === 'minus') {
|
||||||
|
this.patchCachedState({ volume: Math.max(0, current - 5), mute: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const volume = numberValue(valueArg);
|
||||||
|
if (typeof volume === 'number') {
|
||||||
|
this.patchCachedState({ volume: this.volumePercent(volume), mute: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchCachedState(stateArg: Partial<IVolumioState>): void {
|
||||||
|
if (!this.currentSnapshot) {
|
||||||
|
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.config.connected ?? true));
|
||||||
|
}
|
||||||
|
this.currentSnapshot.state = { ...this.currentSnapshot.state, ...stateArg };
|
||||||
|
this.currentSnapshot.updatedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneSnapshot(snapshotArg: IVolumioSnapshot): IVolumioSnapshot {
|
||||||
|
return JSON.parse(JSON.stringify(snapshotArg)) as IVolumioSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||||
|
import type { IVolumioConfig } from './volumio.types.js';
|
||||||
|
import { volumioDefaultPort } from './volumio.types.js';
|
||||||
|
|
||||||
|
const defaultTimeoutMs = 5000;
|
||||||
|
|
||||||
|
export class VolumioConfigFlow implements IConfigFlow<IVolumioConfig> {
|
||||||
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IVolumioConfig>> {
|
||||||
|
void contextArg;
|
||||||
|
return {
|
||||||
|
kind: 'form',
|
||||||
|
title: 'Connect Volumio',
|
||||||
|
description: 'Configure the local Volumio REST API endpoint.',
|
||||||
|
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 || '';
|
||||||
|
const port = this.numberValue(valuesArg.port) || candidateArg.port || volumioDefaultPort;
|
||||||
|
if (!host) {
|
||||||
|
return { kind: 'error', title: 'Volumio setup failed', error: 'Volumio host is required.' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'done',
|
||||||
|
title: 'Volumio configured',
|
||||||
|
config: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
name: this.stringValue(valuesArg.name) || candidateArg.name,
|
||||||
|
uniqueId: candidateArg.id,
|
||||||
|
timeoutMs: defaultTimeoutMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user