Add native camera and media service integrations

This commit is contained in:
2026-05-05 17:13:54 +00:00
parent 489d9d5243
commit e7441844c9
112 changed files with 18608 additions and 327 deletions
+50
View File
@@ -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();
+68
View File
@@ -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();
+32
View File
@@ -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();
+79
View File
@@ -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();
+67
View File
@@ -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();
+106
View File
@@ -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();
+70
View File
@@ -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();
+99
View File
@@ -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();
+67
View File
@@ -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
View File
@@ -4,26 +4,37 @@ export * from './integrations/index.js';
import { HueIntegration } from './integrations/hue/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 { DeconzIntegration } from './integrations/deconz/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 { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
import { JellyfinIntegration } from './integrations/jellyfin/index.js';
import { KodiIntegration } from './integrations/kodi/index.js';
import { MatterIntegration } from './integrations/matter/index.js';
import { MqttIntegration } from './integrations/mqtt/index.js';
import { MpdIntegration } from './integrations/mpd/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 { SamsungtvIntegration } from './integrations/samsungtv/index.js';
import { ShellyIntegration } from './integrations/shelly/index.js';
import { SnapcastIntegration } from './integrations/snapcast/index.js';
import { SonosIntegration } from './integrations/sonos/index.js';
import { TplinkIntegration } from './integrations/tplink/index.js';
import { TradfriIntegration } from './integrations/tradfri/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 { WizIntegration } from './integrations/wiz/index.js';
import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/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 { ZwaveJsIntegration } from './integrations/zwave_js/index.js';
import { generatedHomeAssistantPortIntegrations } from './integrations/generated/index.js';
@@ -31,27 +42,38 @@ import { IntegrationRegistry } from './core/index.js';
export const integrations = [
new AndroidtvIntegration(),
new AxisIntegration(),
new BraviatvIntegration(),
new CastIntegration(),
new DeconzIntegration(),
new DenonavrIntegration(),
new DlnaDmrIntegration(),
new EsphomeIntegration(),
new HomekitControllerIntegration(),
new HueIntegration(),
new JellyfinIntegration(),
new KodiIntegration(),
new MatterIntegration(),
new MqttIntegration(),
new MpdIntegration(),
new NanoleafIntegration(),
new OnvifIntegration(),
new PlexIntegration(),
new RainbirdIntegration(),
new RokuIntegration(),
new SamsungtvIntegration(),
new ShellyIntegration(),
new SnapcastIntegration(),
new SonosIntegration(),
new TplinkIntegration(),
new TradfriIntegration(),
new UnifiIntegration(),
new VolumioIntegration(),
new WolfSmartsetIntegration(),
new WizIntegration(),
new XiaomiMiioIntegration(),
new YeelightIntegration(),
new YamahaMusiccastIntegration(),
new ZhaIntegration(),
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.
+830
View File
@@ -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;
}
}
+130 -23
View File
@@ -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 {
constructor() {
super({
domain: "axis",
displayName: "Axis",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/axis",
"upstreamDomain": "axis",
"integrationType": "device",
"iotClass": "local_push",
"requirements": [
"axis==69"
export class AxisIntegration extends BaseIntegration<IAxisConfig> {
public readonly domain = 'axis';
public readonly displayName = 'Axis';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createAxisDiscoveryDescriptor();
public readonly configFlow = new AxisConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/axis',
upstreamDomain: 'axis',
integrationType: 'device',
iotClass: 'local_push',
requirements: ['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": [],
"afterDependencies": [
"mqtt"
explicitUnsupported: [
'RTSP/MJPEG proxying',
'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;
}
}
+206
View File
@@ -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;
}
};
+364
View File
@@ -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';
}
}
+316 -2
View File
@@ -1,4 +1,318 @@
export interface IHomeAssistantAxisConfig {
// TODO: replace with the TypeScript-native config for axis.
export type TAxisProtocol = 'http' | 'https';
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;
}
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>;
}
+4
View File
@@ -1,2 +1,6 @@
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';
@@ -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 {
constructor() {
super({
domain: "braviatv",
displayName: "Sony Bravia TV",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/braviatv",
"upstreamDomain": "braviatv",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"pybravia==0.4.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@bieniu",
"@Drafteed"
]
},
});
export class BraviatvIntegration extends BaseIntegration<IBraviatvConfig> {
public readonly domain = 'braviatv';
public readonly displayName = 'Sony Bravia TV';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createBraviatvDiscoveryDescriptor();
public readonly configFlow = new BraviatvConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/braviatv',
upstreamDomain: 'braviatv',
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['pybravia==0.4.1'],
dependencies: ['ssdp'],
afterDependencies: [],
codeowners: ['@bieniu', '@Drafteed'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/braviatv',
};
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 [];
};
+147
View File
@@ -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';
}
}
+192 -2
View File
@@ -1,4 +1,194 @@
export interface IHomeAssistantBraviatvConfig {
// TODO: replace with the TypeScript-native config for braviatv.
export type TBraviatvPowerStatus = 'active' | 'standby' | 'off' | 'unknown' | string;
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;
}
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;
+4
View File
@@ -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.discovery.js';
export * from './braviatv.mapper.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
private decodeXml(valueArg: string): string {
return valueArg.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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 {
constructor() {
super({
domain: "dlna_dmr",
displayName: "DLNA Digital Media Renderer",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/dlna_dmr",
"upstreamDomain": "dlna_dmr",
"integrationType": "device",
"iotClass": "local_push",
"requirements": [
"async-upnp-client==0.46.2",
"getmac==0.9.5"
],
"dependencies": [
"ssdp"
],
"afterDependencies": [
"media_source"
],
"codeowners": [
"@chishm"
]
},
});
export class DlnaDmrIntegration extends BaseIntegration<IDlnaDmrConfig> {
public readonly domain = 'dlna_dmr';
public readonly displayName = 'DLNA Digital Media Renderer';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createDlnaDmrDiscoveryDescriptor();
public readonly configFlow = new DlnaDmrConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/dlna_dmr',
upstreamDomain: 'dlna_dmr',
integrationType: 'device',
iotClass: 'local_push',
requirements: ['async-upnp-client==0.46.2', 'getmac==0.9.5'],
dependencies: ['ssdp'],
afterDependencies: ['media_source'],
documentation: 'https://www.home-assistant.io/integrations/dlna_dmr',
codeowners: ['@chishm'],
};
public async setup(configArg: IDlnaDmrConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new DlnaDmrRuntime(new DlnaDmrClient(configArg));
}
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());
};
+153
View File
@@ -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';
}
}
+211 -3
View File
@@ -1,4 +1,212 @@
export interface IHomeAssistantDlnaDmrConfig {
// TODO: replace with the TypeScript-native config for dlna_dmr.
[key: string]: unknown;
export type TDlnaDmrDeviceType =
| 'urn:schemas-upnp-org:device:MediaRenderer:1'
| '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[];
}
+4
View File
@@ -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.discovery.js';
export * from './dlna_dmr.mapper.js';
export * from './dlna_dmr.types.js';
+12 -23
View File
@@ -106,7 +106,6 @@ import { HomeAssistantAvionIntegration } from '../avion/index.js';
import { HomeAssistantAwairIntegration } from '../awair/index.js';
import { HomeAssistantAwsIntegration } from '../aws/index.js';
import { HomeAssistantAwsS3Integration } from '../aws_s3/index.js';
import { HomeAssistantAxisIntegration } from '../axis/index.js';
import { HomeAssistantAzureDataExplorerIntegration } from '../azure_data_explorer/index.js';
import { HomeAssistantAzureDevopsIntegration } from '../azure_devops/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 { HomeAssistantBrandsIntegration } from '../brands/index.js';
import { HomeAssistantBrandtIntegration } from '../brandt/index.js';
import { HomeAssistantBraviatvIntegration } from '../braviatv/index.js';
import { HomeAssistantBrelHomeIntegration } from '../brel_home/index.js';
import { HomeAssistantBringIntegration } from '../bring/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 { HomeAssistantDiscovergyIntegration } from '../discovergy/index.js';
import { HomeAssistantDlinkIntegration } from '../dlink/index.js';
import { HomeAssistantDlnaDmrIntegration } from '../dlna_dmr/index.js';
import { HomeAssistantDlnaDmsIntegration } from '../dlna_dms/index.js';
import { HomeAssistantDnsipIntegration } from '../dnsip/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 { HomeAssistantIturanIntegration } from '../ituran/index.js';
import { HomeAssistantIzoneIntegration } from '../izone/index.js';
import { HomeAssistantJellyfinIntegration } from '../jellyfin/index.js';
import { HomeAssistantJewishCalendarIntegration } from '../jewish_calendar/index.js';
import { HomeAssistantJoaoappsJoinIntegration } from '../joaoapps_join/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 { HomeAssistantMotioneyeIntegration } from '../motioneye/index.js';
import { HomeAssistantMotionmountIntegration } from '../motionmount/index.js';
import { HomeAssistantMpdIntegration } from '../mpd/index.js';
import { HomeAssistantMqttEventstreamIntegration } from '../mqtt_eventstream/index.js';
import { HomeAssistantMqttJsonIntegration } from '../mqtt_json/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 { HomeAssistantOnewireIntegration } from '../onewire/index.js';
import { HomeAssistantOnkyoIntegration } from '../onkyo/index.js';
import { HomeAssistantOnvifIntegration } from '../onvif/index.js';
import { HomeAssistantOpenMeteoIntegration } from '../open_meteo/index.js';
import { HomeAssistantOpenRouterIntegration } from '../open_router/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 { HomeAssistantPlantIntegration } from '../plant/index.js';
import { HomeAssistantPlaystationNetworkIntegration } from '../playstation_network/index.js';
import { HomeAssistantPlexIntegration } from '../plex/index.js';
import { HomeAssistantPlugwiseIntegration } from '../plugwise/index.js';
import { HomeAssistantPlumLightpadIntegration } from '../plum_lightpad/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 { HomeAssistantRadioFrequencyIntegration } from '../radio_frequency/index.js';
import { HomeAssistantRadiothermIntegration } from '../radiotherm/index.js';
import { HomeAssistantRainbirdIntegration } from '../rainbird/index.js';
import { HomeAssistantRaincloudIntegration } from '../raincloud/index.js';
import { HomeAssistantRainforestEagleIntegration } from '../rainforest_eagle/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 { HomeAssistantSmtpIntegration } from '../smtp/index.js';
import { HomeAssistantSmudIntegration } from '../smud/index.js';
import { HomeAssistantSnapcastIntegration } from '../snapcast/index.js';
import { HomeAssistantSnmpIntegration } from '../snmp/index.js';
import { HomeAssistantSnooIntegration } from '../snoo/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 { HomeAssistantVoipIntegration } from '../voip/index.js';
import { HomeAssistantVolkszaehlerIntegration } from '../volkszaehler/index.js';
import { HomeAssistantVolumioIntegration } from '../volumio/index.js';
import { HomeAssistantVolvoIntegration } from '../volvo/index.js';
import { HomeAssistantVolvooncallIntegration } from '../volvooncall/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 { HomeAssistantYalexsBleIntegration } from '../yalexs_ble/index.js';
import { HomeAssistantYamahaIntegration } from '../yamaha/index.js';
import { HomeAssistantYamahaMusiccastIntegration } from '../yamaha_musiccast/index.js';
import { HomeAssistantYandexTransportIntegration } from '../yandex_transport/index.js';
import { HomeAssistantYandexttsIntegration } from '../yandextts/index.js';
import { HomeAssistantYardianIntegration } from '../yardian/index.js';
@@ -1543,7 +1532,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAvionIntegration())
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwairIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAwsS3Integration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAxisIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureDataExplorerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureDevopsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAzureEventHubIntegration());
@@ -1585,7 +1573,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegrati
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschShcIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBraviatvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrelHomeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBringIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBroadlinkIntegration());
@@ -1695,7 +1682,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscogsIntegration(
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscordIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDiscovergyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlinkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlnaDmrIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDlnaDmsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDnsipIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDoodsIntegration());
@@ -2046,7 +2032,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantItachIntegration())
generatedHomeAssistantPortIntegrations.push(new HomeAssistantItunesIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIturanIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIzoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJellyfinIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJewishCalendarIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJoaoappsJoinIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantJuicenetIntegration());
@@ -2214,7 +2199,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionBlindsIntegra
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionblindsBleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotioneyeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionmountIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMpdIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttEventstreamIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttJsonIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttRoomIntegration());
@@ -2309,7 +2293,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnedriveIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnedriveForBusinessIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnewireIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnkyoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOnvifIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenMeteoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenRouterIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenaiConversationIntegration());
@@ -2375,7 +2358,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantPjlinkIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlaatoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlantIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlaystationNetworkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlexIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlugwiseIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPlumLightpadIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPocketcastsIntegration());
@@ -2435,7 +2417,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadarrIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadioBrowserIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadioFrequencyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRadiothermIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRainbirdIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRaincloudIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRainforestEagleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRainforestRavenIntegration());
@@ -2573,7 +2554,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmhiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmlightIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmtpIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSmudIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnapcastIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnmpIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnooIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSnoozIntegration());
@@ -2788,7 +2768,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantVodafoneStationInte
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVoicerssIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVoipIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolkszaehlerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolumioIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolvoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVolvooncallIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantW800rf32Integration());
@@ -2847,7 +2826,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantYaleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYaleSmartAlarmIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYalexsBleIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYamahaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYamahaMusiccastIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexTransportIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYandexttsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantYardianIntegration());
@@ -2874,28 +2852,39 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1435;
export const generatedHomeAssistantPortCount = 1424;
export const handwrittenHomeAssistantPortDomains = [
"androidtv",
"axis",
"braviatv",
"cast",
"deconz",
"denonavr",
"dlna_dmr",
"esphome",
"homekit_controller",
"hue",
"jellyfin",
"kodi",
"matter",
"mpd",
"mqtt",
"nanoleaf",
"onvif",
"plex",
"rainbird",
"roku",
"samsungtv",
"shelly",
"snapcast",
"sonos",
"tplink",
"tradfri",
"unifi",
"volumio",
"wiz",
"xiaomi_miio",
"yamaha_musiccast",
"yeelight",
"zha",
"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.
+4
View File
@@ -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.discovery.js';
export * from './jellyfin.mapper.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 {
constructor() {
super({
domain: "jellyfin",
displayName: "Jellyfin",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/jellyfin",
"upstreamDomain": "jellyfin",
"integrationType": "service",
"iotClass": "local_polling",
"requirements": [
"jellyfin-apiclient-python==1.11.0"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@RunC0deRun",
"@ctalkington"
]
},
});
export class JellyfinIntegration extends BaseIntegration<IJellyfinConfig> {
public readonly domain = 'jellyfin';
public readonly displayName = 'Jellyfin';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createJellyfinDiscoveryDescriptor();
public readonly configFlow = new JellyfinConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/jellyfin',
upstreamDomain: 'jellyfin',
integrationType: 'service',
iotClass: 'local_polling',
requirements: ['jellyfin-apiclient-python==1.11.0'],
dependencies: [],
afterDependencies: [],
codeowners: ['@RunC0deRun', '@ctalkington'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/jellyfin',
nativeRuntime: {
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;
};
+215
View File
@@ -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;
}
}
+210 -2
View File
@@ -1,4 +1,212 @@
export interface IHomeAssistantJellyfinConfig {
// TODO: replace with the TypeScript-native config for jellyfin.
export interface IJellyfinConfig {
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;
}
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -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.discovery.js';
export * from './mpd.mapper.js';
export * from './mpd.types.js';
+625
View File
@@ -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;
}
}
+209 -21
View File
@@ -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 {
constructor() {
super({
domain: "mpd",
displayName: "Music Player Daemon (MPD)",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/mpd",
"upstreamDomain": "mpd",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"python-mpd2==3.1.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": []
},
});
export class MpdIntegration extends BaseIntegration<IMpdConfig> {
public readonly domain = 'mpd';
public readonly displayName = 'Music Player Daemon (MPD)';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createMpdDiscoveryDescriptor();
public readonly configFlow = new MpdConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/mpd',
upstreamDomain: 'mpd',
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['python-mpd2==3.1.1'],
dependencies: [],
afterDependencies: [],
codeowners: [],
configFlow: true,
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`;
};
+150
View File
@@ -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;
};
+299
View File
@@ -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));
}
}
+209 -2
View File
@@ -1,4 +1,211 @@
export interface IHomeAssistantMpdConfig {
// TODO: replace with the TypeScript-native config for mpd.
export const mpdDefaultPort = 6600;
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;
}
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.
+4
View File
@@ -1,2 +1,6 @@
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';
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
private decodeXml(valueArg: string): string {
return valueArg
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/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 {
constructor() {
super({
domain: "onvif",
displayName: "ONVIF",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/onvif",
"upstreamDomain": "onvif",
"integrationType": "device",
"iotClass": "local_push",
"requirements": [
"onvif-zeep-async==4.0.4",
"onvif_parsers==2.3.0",
"WSDiscovery==2.1.2"
],
"dependencies": [
"ffmpeg"
],
"afterDependencies": [],
"codeowners": [
"@jterrace"
]
},
});
export class OnvifIntegration extends BaseIntegration<IOnvifConfig> {
public readonly domain = 'onvif';
public readonly displayName = 'ONVIF';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createOnvifDiscoveryDescriptor();
public readonly configFlow = new OnvifConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/onvif',
upstreamDomain: 'onvif',
integrationType: 'device',
iotClass: 'local_push',
requirements: ['onvif-zeep-async==4.0.4', 'onvif_parsers==2.3.0', 'WSDiscovery==2.1.2'],
dependencies: ['ffmpeg'],
afterDependencies: [],
codeowners: ['@jterrace'],
documentation: 'https://www.home-assistant.io/integrations/onvif',
discovery: {
wsDiscovery: {
type: 'dn:NetworkVideoTransmitter',
scope: 'onvif://www.onvif.org/Profile/Streaming',
},
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();
}
}
+284
View File
@@ -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(':');
};
+513
View File
@@ -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';
}
}
+284 -2
View File
@@ -1,4 +1,286 @@
export interface IHomeAssistantOnvifConfig {
// TODO: replace with the TypeScript-native config for onvif.
import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js';
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;
}
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.
+4
View File
@@ -1,2 +1,6 @@
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';
+558
View File
@@ -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;
}
}
+253 -25
View File
@@ -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 {
constructor() {
super({
domain: "plex",
displayName: "Plex Media Server",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/plex",
"upstreamDomain": "plex",
"integrationType": "service",
"iotClass": "local_push",
"requirements": [
"PlexAPI==4.15.16",
"plexauth==0.0.6",
"plexwebsocket==0.0.14"
export class PlexIntegration extends BaseIntegration<IPlexConfig> {
public readonly domain = 'plex';
public readonly displayName = 'Plex Media Server';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createPlexDiscoveryDescriptor();
public readonly configFlow = new PlexConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/plex',
upstreamDomain: 'plex',
integrationType: 'service',
iotClass: 'local_push',
requirements: ['PlexAPI==4.15.16', 'plexauth==0.0.6', 'plexwebsocket==0.0.14'],
dependencies: ['http'],
afterDependencies: [],
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"
],
"afterDependencies": [],
"codeowners": [
"@jjlawren"
]
},
};
public async setup(configArg: IPlexConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new PlexRuntime(new PlexClient(configArg));
}
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;
}
}
+366
View File
@@ -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];
};
+350
View File
@@ -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));
}
}
+328 -2
View File
@@ -1,4 +1,330 @@
export interface IHomeAssistantPlexConfig {
// TODO: replace with the TypeScript-native config for plex.
export const plexDefaultPort = 32400;
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;
}
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.
+4
View File
@@ -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.discovery.js';
export * from './rainbird.mapper.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 {
constructor() {
super({
domain: "rainbird",
displayName: "Rain Bird",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/rainbird",
"upstreamDomain": "rainbird",
"integrationType": "hub",
"iotClass": "local_polling",
"requirements": [
"pyrainbird==6.3.0"
export class RainbirdIntegration extends BaseIntegration<IRainbirdConfig> {
public readonly domain = 'rainbird';
public readonly displayName = 'Rain Bird';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createRainbirdDiscoveryDescriptor();
public readonly configFlow = new RainbirdConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/rainbird',
upstreamDomain: 'rainbird',
integrationType: 'hub',
iotClass: 'local_polling',
requirements: ['pyrainbird==6.3.0'],
dependencies: [],
afterDependencies: [],
codeowners: ['@konikvranik', '@allenporter'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/rainbird',
discovery: {
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',
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@konikvranik",
"@allenporter"
]
},
});
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;
+303
View File
@@ -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';
}
}
+287 -2
View File
@@ -1,4 +1,289 @@
export interface IHomeAssistantRainbirdConfig {
// TODO: replace with the TypeScript-native config for rainbird.
export type TRainbirdProtocol = 'auto' | 'http' | 'https';
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;
}
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.
+4
View File
@@ -1,2 +1,6 @@
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';
@@ -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 {
constructor() {
super({
domain: "snapcast",
displayName: "Snapcast",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/snapcast",
"upstreamDomain": "snapcast",
"integrationType": "hub",
"iotClass": "local_push",
"requirements": [
"snapcast==2.3.7"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@luar123"
]
},
export class SnapcastIntegration extends BaseIntegration<ISnapcastConfig> {
public readonly domain = 'snapcast';
public readonly displayName = 'Snapcast';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createSnapcastDiscoveryDescriptor();
public readonly configFlow = new SnapcastConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/snapcast',
upstreamDomain: 'snapcast',
integrationType: 'hub',
iotClass: 'local_push',
requirements: ['snapcast==2.3.7'],
dependencies: [],
afterDependencies: [],
codeowners: ['@luar123'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/snapcast',
};
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;
};
+261
View File
@@ -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;
}
}
+267 -2
View File
@@ -1,4 +1,269 @@
export interface IHomeAssistantSnapcastConfig {
// TODO: replace with the TypeScript-native config for snapcast.
export const snapcastTcpControlPort = 1705;
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;
}
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.
+4
View File
@@ -1,2 +1,6 @@
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';
@@ -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