From e7441844c9a2ebe8988f056f118ff30726ee0dbf Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 5 May 2026 17:13:54 +0000 Subject: [PATCH] Add native camera and media service integrations --- test/axis/test.axis.discovery.node.ts | 50 ++ test/axis/test.axis.mapper.node.ts | 68 ++ test/braviatv/test.braviatv.discovery.node.ts | 68 ++ test/braviatv/test.braviatv.mapper.node.ts | 48 + test/dlna_dmr/test.dlna_dmr.discovery.node.ts | 37 + test/dlna_dmr/test.dlna_dmr.mapper.node.ts | 56 ++ test/jellyfin/test.jellyfin.discovery.node.ts | 47 + test/jellyfin/test.jellyfin.mapper.node.ts | 64 ++ test/mpd/test.mpd.discovery.node.ts | 32 + test/mpd/test.mpd.mapper.node.ts | 79 ++ test/onvif/test.onvif.discovery.node.ts | 67 ++ test/onvif/test.onvif.mapper.node.ts | 106 +++ test/plex/test.plex.discovery.node.ts | 70 ++ test/plex/test.plex.mapper.node.ts | 99 +++ test/rainbird/test.rainbird.discovery.node.ts | 32 + test/rainbird/test.rainbird.mapper.node.ts | 68 ++ test/snapcast/test.snapcast.discovery.node.ts | 32 + test/snapcast/test.snapcast.mapper.node.ts | 55 ++ test/volumio/test.volumio.discovery.node.ts | 57 ++ test/volumio/test.volumio.mapper.node.ts | 67 ++ .../test.yamaha_musiccast.discovery.node.ts | 58 ++ .../test.yamaha_musiccast.mapper.node.ts | 106 +++ ts/index.ts | 22 + .../axis/.generated-by-smarthome-exchange | 1 - ts/integrations/axis/axis.classes.client.ts | 830 ++++++++++++++++++ .../axis/axis.classes.configflow.ts | 60 ++ .../axis/axis.classes.integration.ts | 157 +++- ts/integrations/axis/axis.discovery.ts | 206 +++++ ts/integrations/axis/axis.mapper.ts | 364 ++++++++ ts/integrations/axis/axis.types.ts | 318 ++++++- ts/integrations/axis/index.ts | 4 + .../braviatv/.generated-by-smarthome-exchange | 1 - .../braviatv/braviatv.classes.client.ts | 739 ++++++++++++++++ .../braviatv/braviatv.classes.configflow.ts | 84 ++ .../braviatv/braviatv.classes.integration.ts | 198 ++++- .../braviatv/braviatv.discovery.ts | 251 ++++++ ts/integrations/braviatv/braviatv.mapper.ts | 147 ++++ ts/integrations/braviatv/braviatv.types.ts | 194 +++- ts/integrations/braviatv/index.ts | 4 + .../dlna_dmr/.generated-by-smarthome-exchange | 1 - .../dlna_dmr/dlna_dmr.classes.client.ts | 448 ++++++++++ .../dlna_dmr/dlna_dmr.classes.configflow.ts | 52 ++ .../dlna_dmr/dlna_dmr.classes.integration.ts | 143 ++- .../dlna_dmr/dlna_dmr.discovery.ts | 137 +++ ts/integrations/dlna_dmr/dlna_dmr.mapper.ts | 153 ++++ ts/integrations/dlna_dmr/dlna_dmr.types.ts | 214 ++++- ts/integrations/dlna_dmr/index.ts | 4 + ts/integrations/generated/index.ts | 35 +- .../jellyfin/.generated-by-smarthome-exchange | 1 - ts/integrations/jellyfin/index.ts | 4 + .../jellyfin/jellyfin.classes.client.ts | 355 ++++++++ .../jellyfin/jellyfin.classes.configflow.ts | 59 ++ .../jellyfin/jellyfin.classes.integration.ts | 283 +++++- .../jellyfin/jellyfin.discovery.ts | 189 ++++ ts/integrations/jellyfin/jellyfin.mapper.ts | 215 +++++ ts/integrations/jellyfin/jellyfin.types.ts | 212 ++++- .../mpd/.generated-by-smarthome-exchange | 1 - ts/integrations/mpd/index.ts | 4 + ts/integrations/mpd/mpd.classes.client.ts | 625 +++++++++++++ ts/integrations/mpd/mpd.classes.configflow.ts | 57 ++ .../mpd/mpd.classes.integration.ts | 230 ++++- ts/integrations/mpd/mpd.discovery.ts | 150 ++++ ts/integrations/mpd/mpd.mapper.ts | 299 +++++++ ts/integrations/mpd/mpd.types.ts | 211 ++++- .../onvif/.generated-by-smarthome-exchange | 1 - ts/integrations/onvif/index.ts | 4 + ts/integrations/onvif/onvif.classes.client.ts | 479 ++++++++++ .../onvif/onvif.classes.configflow.ts | 70 ++ .../onvif/onvif.classes.integration.ts | 112 ++- ts/integrations/onvif/onvif.discovery.ts | 284 ++++++ ts/integrations/onvif/onvif.mapper.ts | 513 +++++++++++ ts/integrations/onvif/onvif.types.ts | 286 +++++- .../plex/.generated-by-smarthome-exchange | 1 - ts/integrations/plex/index.ts | 4 + ts/integrations/plex/plex.classes.client.ts | 558 ++++++++++++ .../plex/plex.classes.configflow.ts | 66 ++ .../plex/plex.classes.integration.ts | 282 +++++- ts/integrations/plex/plex.discovery.ts | 366 ++++++++ ts/integrations/plex/plex.mapper.ts | 350 ++++++++ ts/integrations/plex/plex.types.ts | 330 ++++++- .../rainbird/.generated-by-smarthome-exchange | 1 - ts/integrations/rainbird/index.ts | 4 + .../rainbird/rainbird.classes.client.ts | 687 +++++++++++++++ .../rainbird/rainbird.classes.configflow.ts | 60 ++ .../rainbird/rainbird.classes.integration.ts | 121 ++- .../rainbird/rainbird.discovery.ts | 78 ++ ts/integrations/rainbird/rainbird.mapper.ts | 303 +++++++ ts/integrations/rainbird/rainbird.types.ts | 289 +++++- .../snapcast/.generated-by-smarthome-exchange | 1 - ts/integrations/snapcast/index.ts | 4 + .../snapcast/snapcast.classes.client.ts | 494 +++++++++++ .../snapcast/snapcast.classes.configflow.ts | 65 ++ .../snapcast/snapcast.classes.integration.ts | 322 ++++++- .../snapcast/snapcast.discovery.ts | 184 ++++ ts/integrations/snapcast/snapcast.mapper.ts | 261 ++++++ ts/integrations/snapcast/snapcast.types.ts | 269 +++++- .../volumio/.generated-by-smarthome-exchange | 1 - ts/integrations/volumio/index.ts | 4 + .../volumio/volumio.classes.client.ts | 418 +++++++++ .../volumio/volumio.classes.configflow.ts | 54 ++ .../volumio/volumio.classes.integration.ts | 249 +++++- ts/integrations/volumio/volumio.discovery.ts | 144 +++ ts/integrations/volumio/volumio.mapper.ts | 203 +++++ ts/integrations/volumio/volumio.types.ts | 156 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/yamaha_musiccast/index.ts | 4 + .../yamaha_musiccast.classes.client.ts | 512 +++++++++++ .../yamaha_musiccast.classes.configflow.ts | 73 ++ .../yamaha_musiccast.classes.integration.ts | 265 +++++- .../yamaha_musiccast.discovery.ts | 217 +++++ .../yamaha_musiccast.mapper.ts | 342 ++++++++ .../yamaha_musiccast.types.ts | 415 ++++++++- 112 files changed, 18608 insertions(+), 327 deletions(-) create mode 100644 test/axis/test.axis.discovery.node.ts create mode 100644 test/axis/test.axis.mapper.node.ts create mode 100644 test/braviatv/test.braviatv.discovery.node.ts create mode 100644 test/braviatv/test.braviatv.mapper.node.ts create mode 100644 test/dlna_dmr/test.dlna_dmr.discovery.node.ts create mode 100644 test/dlna_dmr/test.dlna_dmr.mapper.node.ts create mode 100644 test/jellyfin/test.jellyfin.discovery.node.ts create mode 100644 test/jellyfin/test.jellyfin.mapper.node.ts create mode 100644 test/mpd/test.mpd.discovery.node.ts create mode 100644 test/mpd/test.mpd.mapper.node.ts create mode 100644 test/onvif/test.onvif.discovery.node.ts create mode 100644 test/onvif/test.onvif.mapper.node.ts create mode 100644 test/plex/test.plex.discovery.node.ts create mode 100644 test/plex/test.plex.mapper.node.ts create mode 100644 test/rainbird/test.rainbird.discovery.node.ts create mode 100644 test/rainbird/test.rainbird.mapper.node.ts create mode 100644 test/snapcast/test.snapcast.discovery.node.ts create mode 100644 test/snapcast/test.snapcast.mapper.node.ts create mode 100644 test/volumio/test.volumio.discovery.node.ts create mode 100644 test/volumio/test.volumio.mapper.node.ts create mode 100644 test/yamaha_musiccast/test.yamaha_musiccast.discovery.node.ts create mode 100644 test/yamaha_musiccast/test.yamaha_musiccast.mapper.node.ts delete mode 100644 ts/integrations/axis/.generated-by-smarthome-exchange create mode 100644 ts/integrations/axis/axis.classes.client.ts create mode 100644 ts/integrations/axis/axis.classes.configflow.ts create mode 100644 ts/integrations/axis/axis.discovery.ts create mode 100644 ts/integrations/axis/axis.mapper.ts delete mode 100644 ts/integrations/braviatv/.generated-by-smarthome-exchange create mode 100644 ts/integrations/braviatv/braviatv.classes.client.ts create mode 100644 ts/integrations/braviatv/braviatv.classes.configflow.ts create mode 100644 ts/integrations/braviatv/braviatv.discovery.ts create mode 100644 ts/integrations/braviatv/braviatv.mapper.ts delete mode 100644 ts/integrations/dlna_dmr/.generated-by-smarthome-exchange create mode 100644 ts/integrations/dlna_dmr/dlna_dmr.classes.client.ts create mode 100644 ts/integrations/dlna_dmr/dlna_dmr.classes.configflow.ts create mode 100644 ts/integrations/dlna_dmr/dlna_dmr.discovery.ts create mode 100644 ts/integrations/dlna_dmr/dlna_dmr.mapper.ts delete mode 100644 ts/integrations/jellyfin/.generated-by-smarthome-exchange create mode 100644 ts/integrations/jellyfin/jellyfin.classes.client.ts create mode 100644 ts/integrations/jellyfin/jellyfin.classes.configflow.ts create mode 100644 ts/integrations/jellyfin/jellyfin.discovery.ts create mode 100644 ts/integrations/jellyfin/jellyfin.mapper.ts delete mode 100644 ts/integrations/mpd/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mpd/mpd.classes.client.ts create mode 100644 ts/integrations/mpd/mpd.classes.configflow.ts create mode 100644 ts/integrations/mpd/mpd.discovery.ts create mode 100644 ts/integrations/mpd/mpd.mapper.ts delete mode 100644 ts/integrations/onvif/.generated-by-smarthome-exchange create mode 100644 ts/integrations/onvif/onvif.classes.client.ts create mode 100644 ts/integrations/onvif/onvif.classes.configflow.ts create mode 100644 ts/integrations/onvif/onvif.discovery.ts create mode 100644 ts/integrations/onvif/onvif.mapper.ts delete mode 100644 ts/integrations/plex/.generated-by-smarthome-exchange create mode 100644 ts/integrations/plex/plex.classes.client.ts create mode 100644 ts/integrations/plex/plex.classes.configflow.ts create mode 100644 ts/integrations/plex/plex.discovery.ts create mode 100644 ts/integrations/plex/plex.mapper.ts delete mode 100644 ts/integrations/rainbird/.generated-by-smarthome-exchange create mode 100644 ts/integrations/rainbird/rainbird.classes.client.ts create mode 100644 ts/integrations/rainbird/rainbird.classes.configflow.ts create mode 100644 ts/integrations/rainbird/rainbird.discovery.ts create mode 100644 ts/integrations/rainbird/rainbird.mapper.ts delete mode 100644 ts/integrations/snapcast/.generated-by-smarthome-exchange create mode 100644 ts/integrations/snapcast/snapcast.classes.client.ts create mode 100644 ts/integrations/snapcast/snapcast.classes.configflow.ts create mode 100644 ts/integrations/snapcast/snapcast.discovery.ts create mode 100644 ts/integrations/snapcast/snapcast.mapper.ts delete mode 100644 ts/integrations/volumio/.generated-by-smarthome-exchange create mode 100644 ts/integrations/volumio/volumio.classes.client.ts create mode 100644 ts/integrations/volumio/volumio.classes.configflow.ts create mode 100644 ts/integrations/volumio/volumio.discovery.ts create mode 100644 ts/integrations/volumio/volumio.mapper.ts delete mode 100644 ts/integrations/yamaha_musiccast/.generated-by-smarthome-exchange create mode 100644 ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.client.ts create mode 100644 ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.configflow.ts create mode 100644 ts/integrations/yamaha_musiccast/yamaha_musiccast.discovery.ts create mode 100644 ts/integrations/yamaha_musiccast/yamaha_musiccast.mapper.ts diff --git a/test/axis/test.axis.discovery.node.ts b/test/axis/test.axis.discovery.node.ts new file mode 100644 index 0000000..f6530d3 --- /dev/null +++ b/test/axis/test.axis.discovery.node.ts @@ -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(); diff --git a/test/axis/test.axis.mapper.node.ts b/test/axis/test.axis.mapper.node.ts new file mode 100644 index 0000000..39134b4 --- /dev/null +++ b/test/axis/test.axis.mapper.node.ts @@ -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(); diff --git a/test/braviatv/test.braviatv.discovery.node.ts b/test/braviatv/test.braviatv.discovery.node.ts new file mode 100644 index 0000000..bfcf197 --- /dev/null +++ b/test/braviatv/test.braviatv.discovery.node.ts @@ -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(); diff --git a/test/braviatv/test.braviatv.mapper.node.ts b/test/braviatv/test.braviatv.mapper.node.ts new file mode 100644 index 0000000..a701695 --- /dev/null +++ b/test/braviatv/test.braviatv.mapper.node.ts @@ -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(); diff --git a/test/dlna_dmr/test.dlna_dmr.discovery.node.ts b/test/dlna_dmr/test.dlna_dmr.discovery.node.ts new file mode 100644 index 0000000..e84fed2 --- /dev/null +++ b/test/dlna_dmr/test.dlna_dmr.discovery.node.ts @@ -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(); diff --git a/test/dlna_dmr/test.dlna_dmr.mapper.node.ts b/test/dlna_dmr/test.dlna_dmr.mapper.node.ts new file mode 100644 index 0000000..58a140f --- /dev/null +++ b/test/dlna_dmr/test.dlna_dmr.mapper.node.ts @@ -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(); diff --git a/test/jellyfin/test.jellyfin.discovery.node.ts b/test/jellyfin/test.jellyfin.discovery.node.ts new file mode 100644 index 0000000..0ff59fd --- /dev/null +++ b/test/jellyfin/test.jellyfin.discovery.node.ts @@ -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(); diff --git a/test/jellyfin/test.jellyfin.mapper.node.ts b/test/jellyfin/test.jellyfin.mapper.node.ts new file mode 100644 index 0000000..786bded --- /dev/null +++ b/test/jellyfin/test.jellyfin.mapper.node.ts @@ -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(); diff --git a/test/mpd/test.mpd.discovery.node.ts b/test/mpd/test.mpd.discovery.node.ts new file mode 100644 index 0000000..77993db --- /dev/null +++ b/test/mpd/test.mpd.discovery.node.ts @@ -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(); diff --git a/test/mpd/test.mpd.mapper.node.ts b/test/mpd/test.mpd.mapper.node.ts new file mode 100644 index 0000000..dfede41 --- /dev/null +++ b/test/mpd/test.mpd.mapper.node.ts @@ -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(); diff --git a/test/onvif/test.onvif.discovery.node.ts b/test/onvif/test.onvif.discovery.node.ts new file mode 100644 index 0000000..0245d02 --- /dev/null +++ b/test/onvif/test.onvif.discovery.node.ts @@ -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(); diff --git a/test/onvif/test.onvif.mapper.node.ts b/test/onvif/test.onvif.mapper.node.ts new file mode 100644 index 0000000..8a21e1b --- /dev/null +++ b/test/onvif/test.onvif.mapper.node.ts @@ -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(); diff --git a/test/plex/test.plex.discovery.node.ts b/test/plex/test.plex.discovery.node.ts new file mode 100644 index 0000000..aeb7b5c --- /dev/null +++ b/test/plex/test.plex.discovery.node.ts @@ -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(); diff --git a/test/plex/test.plex.mapper.node.ts b/test/plex/test.plex.mapper.node.ts new file mode 100644 index 0000000..2bac1f1 --- /dev/null +++ b/test/plex/test.plex.mapper.node.ts @@ -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(); diff --git a/test/rainbird/test.rainbird.discovery.node.ts b/test/rainbird/test.rainbird.discovery.node.ts new file mode 100644 index 0000000..1f47355 --- /dev/null +++ b/test/rainbird/test.rainbird.discovery.node.ts @@ -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(); diff --git a/test/rainbird/test.rainbird.mapper.node.ts b/test/rainbird/test.rainbird.mapper.node.ts new file mode 100644 index 0000000..5812625 --- /dev/null +++ b/test/rainbird/test.rainbird.mapper.node.ts @@ -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(); diff --git a/test/snapcast/test.snapcast.discovery.node.ts b/test/snapcast/test.snapcast.discovery.node.ts new file mode 100644 index 0000000..db54685 --- /dev/null +++ b/test/snapcast/test.snapcast.discovery.node.ts @@ -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(); diff --git a/test/snapcast/test.snapcast.mapper.node.ts b/test/snapcast/test.snapcast.mapper.node.ts new file mode 100644 index 0000000..3e027cc --- /dev/null +++ b/test/snapcast/test.snapcast.mapper.node.ts @@ -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(); diff --git a/test/volumio/test.volumio.discovery.node.ts b/test/volumio/test.volumio.discovery.node.ts new file mode 100644 index 0000000..7950657 --- /dev/null +++ b/test/volumio/test.volumio.discovery.node.ts @@ -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(); diff --git a/test/volumio/test.volumio.mapper.node.ts b/test/volumio/test.volumio.mapper.node.ts new file mode 100644 index 0000000..3069285 --- /dev/null +++ b/test/volumio/test.volumio.mapper.node.ts @@ -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(); diff --git a/test/yamaha_musiccast/test.yamaha_musiccast.discovery.node.ts b/test/yamaha_musiccast/test.yamaha_musiccast.discovery.node.ts new file mode 100644 index 0000000..ed31b6d --- /dev/null +++ b/test/yamaha_musiccast/test.yamaha_musiccast.discovery.node.ts @@ -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(); diff --git a/test/yamaha_musiccast/test.yamaha_musiccast.mapper.node.ts b/test/yamaha_musiccast/test.yamaha_musiccast.mapper.node.ts new file mode 100644 index 0000000..e384935 --- /dev/null +++ b/test/yamaha_musiccast/test.yamaha_musiccast.mapper.node.ts @@ -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(); diff --git a/ts/index.ts b/ts/index.ts index a8d34d6..96e9849 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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(), ]; diff --git a/ts/integrations/axis/.generated-by-smarthome-exchange b/ts/integrations/axis/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/axis/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/axis/axis.classes.client.ts b/ts/integrations/axis/axis.classes.client.ts new file mode 100644 index 0000000..afa5193 --- /dev/null +++ b/ts/integrations/axis/axis.classes.client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 {} + + private async fetchSnapshot(): Promise { + 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 { + const data = await this.postAxisJson('/axis-cgi/apidiscovery.cgi', { + apiVersion: '1.0', + context: axisContext, + method: 'getApiList', + }); + return data.data?.apiList || []; + } + + private async fetchBasicDeviceInfo(): Promise | undefined> { + const data = await this.postAxisJson('/axis-cgi/basicdeviceinfo.cgi', { + apiVersion: '1.1', + context: axisContext, + method: 'getAllProperties', + }); + return data.data?.propertyList; + } + + private async fetchParams(groupArg: 'Properties' | 'Image' | 'IOPort' | 'PTZ'): Promise { + 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 { + const supportsPortManagement = apiDiscoveryArg.some((apiArg) => apiArg.id === 'io-port-management'); + if (!supportsPortManagement) { + return this.portsFromParams(ioPortParamsArg); + } + const data = await this.postAxisJson('/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 { + 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('/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 | 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>(pathArg: string, payloadArg: Record): Promise { + 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 { + return (await this.request(pathArg, initArg)).text(); + } + + private async request(pathArg: string, initArg: RequestInit): Promise { + 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 { + 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 { + 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 = { + 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 { + 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 { + const data: Record = {}; + 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, 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 | undefined { + return record(record(paramsArg?.root)?.[groupArg]); + } + + private stringAt(recordArg: Record | 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 | 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 { + const root: Record = {}; + 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(); + 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 => { + const result: Record = {}; + 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 | undefined => { + return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : 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, 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); +}; diff --git a/ts/integrations/axis/axis.classes.configflow.ts b/ts/integrations/axis/axis.classes.configflow.ts new file mode 100644 index 0000000..9cfebd7 --- /dev/null +++ b/ts/integrations/axis/axis.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +} diff --git a/ts/integrations/axis/axis.classes.integration.ts b/ts/integrations/axis/axis.classes.integration.ts index 37e0c4f..35fd6ea 100644 --- a/ts/integrations/axis/axis.classes.integration.ts +++ b/ts/integrations/axis/axis.classes.integration.ts @@ -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" - ], - "dependencies": [], - "afterDependencies": [ - "mqtt" - ], - "codeowners": [ - "@Kane610" - ] -}, - }); +export class AxisIntegration extends BaseIntegration { + 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', + ], + explicitUnsupported: [ + 'RTSP/MJPEG proxying', + 'event stream subscription over RTSP/WebSocket/MQTT', + 'writing snapshots to files', + ], + }, + }; + + public async setup(configArg: IAxisConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new AxisRuntime(new AxisClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantAxisIntegration extends AxisIntegration {} + +class AxisRuntime implements IIntegrationRuntime { + public domain = 'axis'; + + constructor(private readonly client: AxisClient) {} + + public async devices(): Promise { + return AxisMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return AxisMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + 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 { + 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 { + await this.client.destroy(); + } + + private cameraIdFromTarget(snapshotArg: Awaited>, 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; } } diff --git a/ts/integrations/axis/axis.discovery.ts b/ts/integrations/axis/axis.discovery.ts new file mode 100644 index 0000000..e8dc9f8 --- /dev/null +++ b/ts/integrations/axis/axis.discovery.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + public id = 'axis-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Axis setup entries.'; + + public async matches(inputArg: IAxisManualEntry): Promise { + 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 { + 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 | 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; + } +}; diff --git a/ts/integrations/axis/axis.mapper.ts b/ts/integrations/axis/axis.mapper.ts new file mode 100644 index 0000000..e241d70 --- /dev/null +++ b/ts/integrations/axis/axis.mapper.ts @@ -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(); + 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, attributesArg: Record, 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; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static cleanAttributes(attributesArg: Record): Record { + 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'; + } +} diff --git a/ts/integrations/axis/axis.types.ts b/ts/integrations/axis/axis.types.ts index b15d0b0..357305a 100644 --- a/ts/integrations/axis/axis.types.ts +++ b/ts/integrations/axis/axis.types.ts @@ -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; +} + +export interface IAxisSensor { + id: string; + name: string; + value: TValue; + unit?: string; + deviceClass?: string; + category?: string; + available?: boolean; + attributes?: Record; +} + +export interface IAxisBinarySensor { + id: string; + name: string; + isOn: boolean; + deviceClass?: string; + eventTopic?: string; + source?: string; + available?: boolean; + attributes?: Record; +} + +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; +} + +export interface IAxisPort { + id: string; + name?: string; + usage?: string; + direction: TAxisPortDirection; + state?: TAxisPortState; + normalState?: TAxisPortState; + configurable?: boolean; + readonly?: boolean; + available?: boolean; + attributes?: Record; +} + +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; +} + +export interface IAxisApiDescription { + id: string; + name?: string; + version?: string; + status?: string; + docLink?: string; +} + +export interface IAxisParamTree { + root?: Record; [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; + }; + 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; + 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; + properties?: Record; +} + +export interface IAxisSsdpRecord { + manufacturer?: string; + server?: string; + st?: string; + usn?: string; + location?: string; + upnp?: Record; + headers?: Record; +} + +export interface IAxisManualEntry { + host?: string; + port?: number; + protocol?: TAxisProtocol; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + macAddress?: string; + metadata?: Record; +} diff --git a/ts/integrations/axis/index.ts b/ts/integrations/axis/index.ts index 12aaee4..0354488 100644 --- a/ts/integrations/axis/index.ts +++ b/ts/integrations/axis/index.ts @@ -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'; diff --git a/ts/integrations/braviatv/.generated-by-smarthome-exchange b/ts/integrations/braviatv/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/braviatv/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/braviatv/braviatv.classes.client.ts b/ts/integrations/braviatv/braviatv.classes.client.ts new file mode 100644 index 0000000..362653e --- /dev/null +++ b/ts/integrations/braviatv/braviatv.classes.client.ts @@ -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 = { + 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; + 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 { + 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 { + 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 { + await this.sendRestQuick('system', 'setPowerStatus', { status: false }); + this.updateLocalState({ powerStatus: 'standby', power: 'off', playback: 'off' }); + } + + public async setVolumeLevel(volumeLevelArg: number): Promise { + 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 { + await this.stepVolume(1); + } + + public async volumeDown(): Promise { + await this.stepVolume(-1); + } + + public async muteVolume(mutedArg: boolean): Promise { + await this.sendRestQuick('audio', 'setAudioMute', { status: mutedArg }); + this.updateLocalState({ muted: mutedArg }); + } + + public async mediaPlay(): Promise { + await this.sendCommand('Play'); + this.updateLocalState({ playback: 'playing' }); + } + + public async mediaPause(): Promise { + await this.sendCommand('Pause'); + this.updateLocalState({ playback: 'paused' }); + } + + public async mediaStop(): Promise { + await this.sendCommand('Stop'); + this.updateLocalState({ playback: 'idle' }); + } + + public async nextTrack(): Promise { + await this.sendCommand('Next'); + } + + public async previousTrack(): Promise { + await this.sendCommand('Prev'); + } + + public async selectSource(sourceArg: string): Promise { + 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 { + 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 { + 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 { + 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 { + await this.sendRestQuick('system', 'requestReboot'); + } + + public async terminateApps(): Promise { + await this.sendRestQuick('appControl', 'terminateApps'); + } + + public async destroy(): Promise {} + + private async getLiveSnapshot(): Promise { + 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 { + const response = await this.sendRest('system', 'getPowerStatus', undefined, defaultRestVersion, 5000); + return this.firstResult<{ status?: string }>(response)?.status || 'unknown'; + } + + private async getSystemInfo(): Promise { + const response = await this.sendRest('system', 'getSystemInformation'); + const info = this.firstResult(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 { + const response = await this.sendRest('audio', 'getVolumeInformation'); + const outputs = this.firstResult(response) || []; + return outputs.find((outputArg) => outputArg.target === targetArg) || outputs[0]; + } + + private async getPlayingInfo(): Promise { + const response = await this.sendRest('avContent', 'getPlayingContentInfo'); + return this.firstResult(response); + } + + private async getExternalInputsStatus(): Promise { + const response = await this.sendRest('avContent', 'getCurrentExternalInputsStatus'); + return (this.firstResult(response) || []).map((sourceArg) => ({ ...sourceArg, type: 'input' })); + } + + private async getAppList(): Promise { + const response = await this.sendRest('appControl', 'getApplicationList'); + return (this.firstResult(response) || []).map((appArg) => ({ ...appArg, type: 'app' })); + } + + private async getSourceList(schemeArg: string): Promise { + const response = await this.sendRest('avContent', 'getSourceList', { scheme: schemeArg }); + return (this.firstResult>(response) || []).map((itemArg) => itemArg.source).filter((itemArg): itemArg is string => Boolean(itemArg)); + } + + private async getContentCount(sourceArg: string): Promise { + 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 { + const response = await this.sendRest('avContent', 'getContentList', { source: sourceArg, stIdx: indexArg, cnt: countArg }, defaultRestVersion, 20000); + return (this.firstResult(response) || []).map((channelArg) => ({ ...channelArg, type: 'channel' })); + } + + private async getContentListAll(schemeArg: string): Promise { + 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> { + 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 { + const prefix = stepArg > 0 ? '+' : ''; + await this.sendRestQuick('audio', 'setAudioVolume', { target: defaultAudioTarget, volume: `${prefix}${stepArg}` }); + } + + private async findSource(queryArg: string, sourceTypeArg?: TBraviatvSourceType): Promise>> { + 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> | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + await this.request(urlArg, headersArg, bodyArg); + return true; + } + + private async request(urlArg: string, headersArg: Record, bodyArg: string, timeoutMsArg?: number): Promise { + 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 `${codeArg}`; + } + + private firstResult(responseArg: IBraviatvRestResponse): TResult | undefined { + return responseArg.result?.[0] as TResult | undefined; + } + + private async commandCode(commandArg: string): Promise { + 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 = { + 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): 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); + } +} diff --git a/ts/integrations/braviatv/braviatv.classes.configflow.ts b/ts/integrations/braviatv/braviatv.classes.configflow.ts new file mode 100644 index 0000000..302db3e --- /dev/null +++ b/ts/integrations/braviatv/braviatv.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +} diff --git a/ts/integrations/braviatv/braviatv.classes.integration.ts b/ts/integrations/braviatv/braviatv.classes.integration.ts index d5699a0..090513b 100644 --- a/ts/integrations/braviatv/braviatv.classes.integration.ts +++ b/ts/integrations/braviatv/braviatv.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new BraviatvRuntime(new BraviatvClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantBraviatvIntegration extends BraviatvIntegration {} + +class BraviatvRuntime implements IIntegrationRuntime { + public domain = 'braviatv'; + + constructor(private readonly client: BraviatvClient) {} + + public async devices(): Promise { + return BraviatvMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return BraviatvMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); + } + + private async callRemoteService(requestArg: IServiceCallRequest): Promise { + 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 { + 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 { + 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}` }; } } diff --git a/ts/integrations/braviatv/braviatv.discovery.ts b/ts/integrations/braviatv/braviatv.discovery.ts new file mode 100644 index 0000000..7785f2f --- /dev/null +++ b/ts/integrations/braviatv/braviatv.discovery.ts @@ -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 { + public id = 'braviatv-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Sony Bravia ScalarWebAPI SSDP advertisements.'; + + public async matches(recordArg: IBraviatvSsdpRecord): Promise { + 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 { + public id = 'braviatv-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Sony Bravia mDNS setup hints.'; + + public async matches(recordArg: IBraviatvMdnsRecord): Promise { + 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 { + 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 { + 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 { + 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): 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 | 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].filter(Boolean) as Array>; + 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 | 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 : undefined; +}; + +const scalarBaseUrl = (infoArg: Record | undefined): string | undefined => { + const value = infoArg?.X_ScalarWebAPI_BaseURL || infoArg?.['x_scalarwebapi_baseurl']; + return typeof value === 'string' ? value : undefined; +}; + +const scalarWebApiServices = (infoArg: Record | undefined): string[] => { + const serviceList = infoArg?.X_ScalarWebAPI_ServiceList || infoArg?.['x_scalarwebapi_servicelist']; + if (!serviceList || typeof serviceList !== 'object') { + return []; + } + const serviceTypes = (serviceList as Record).X_ScalarWebAPI_ServiceType || (serviceList as Record)['x_scalarwebapi_servicetype']; + if (Array.isArray(serviceTypes)) { + return serviceTypes.filter((itemArg): itemArg is string => typeof itemArg === 'string'); + } + if (typeof serviceTypes === 'string') { + return [serviceTypes]; + } + return []; +}; diff --git a/ts/integrations/braviatv/braviatv.mapper.ts b/ts/integrations/braviatv/braviatv.mapper.ts new file mode 100644 index 0000000..38ebd2b --- /dev/null +++ b/ts/integrations/braviatv/braviatv.mapper.ts @@ -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> { + 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'; + } +} diff --git a/ts/integrations/braviatv/braviatv.types.ts b/ts/integrations/braviatv/braviatv.types.ts index d900c67..3243fd6 100644 --- a/ts/integrations/braviatv/braviatv.types.ts +++ b/ts/integrations/braviatv/braviatv.types.ts @@ -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; + 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; + 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; + upnp?: Record; + 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; + properties?: Record; + metadata?: Record; +} + +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; +} + +export type TBraviatvDiscoveryRecord = IBraviatvSsdpRecord | IBraviatvMdnsRecord | IBraviatvManualEntry; diff --git a/ts/integrations/braviatv/index.ts b/ts/integrations/braviatv/index.ts index fbf9416..14f8403 100644 --- a/ts/integrations/braviatv/index.ts +++ b/ts/integrations/braviatv/index.ts @@ -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'; diff --git a/ts/integrations/dlna_dmr/.generated-by-smarthome-exchange b/ts/integrations/dlna_dmr/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/dlna_dmr/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/dlna_dmr/dlna_dmr.classes.client.ts b/ts/integrations/dlna_dmr/dlna_dmr.classes.client.ts new file mode 100644 index 0000000..5f37f50 --- /dev/null +++ b/ts/integrations/dlna_dmr/dlna_dmr.classes.client.ts @@ -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 = { + '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 { + 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 { + await this.sendCommand({ service: 'AVTransport', action: 'Play', args: { InstanceID: 0, Speed: '1' } }); + } + + public async pause(): Promise { + await this.sendCommand({ service: 'AVTransport', action: 'Pause', args: { InstanceID: 0 } }); + } + + public async stop(): Promise { + await this.sendCommand({ service: 'AVTransport', action: 'Stop', args: { InstanceID: 0 } }); + } + + public async setVolume(volumeArg: number): Promise { + 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 { + await this.sendCommand({ service: 'RenderingControl', action: 'SetMute', args: { InstanceID: 0, Channel: 'Master', DesiredMute: mutedArg ? 1 : 0 } }); + } + + public async selectSource(sourceArg: string): Promise { + await this.sendCommand({ service: 'RenderingControl', action: 'SelectPreset', args: { InstanceID: 0, PresetName: sourceArg } }); + } + + public async setUri(uriArg: string, metadataArg?: string, titleArg?: string, autoplayArg = false): Promise { + 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> { + 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 [ + '', + '', + `${this.escapeXml(titleArg)}`, + `${upnpClass}`, + `${this.escapeXml(uriArg)}`, + '', + '', + ].join(''); + } + + public async destroy(): Promise {} + + private async getRendererState(deviceArg: IDlnaDmrDeviceDescription): Promise { + 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 { + 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> = {}; + 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): Promise> { + if (!serviceArg) { + return {}; + } + try { + return await this.soap(serviceArg, actionArg, argsArg); + } catch { + return {}; + } + } + + private async soap(serviceArg: IDlnaDmrServiceDescription, actionArg: string, argsArg: Record): Promise> { + 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 { + const args = Object.entries(argsArg) + .filter((entryArg): entryArg is [string, string | number | boolean] => entryArg[1] !== undefined) + .map(([keyArg, valueArg]) => `<${keyArg}>${this.escapeXml(String(valueArg))}`) + .join(''); + return `${args}`; + } + + private parseSoapResponse(xmlArg: string): Record { + const body = this.firstTagByLocalName(xmlArg, 'Body') || xmlArg; + const responseBlock = this.extractFirstChildBlock(body) || body; + const responseInner = this.stripOuterTag(responseBlock) || responseBlock; + const result: Record = {}; + 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): 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('= 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(``, '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]*?)`, '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]*?)`, 'i')); + return match ? this.decodeXml(match[1].trim()) : undefined; + } + + private extractFirstChildBlock(xmlArg: string): string | undefined { + const match = xmlArg.match(/<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>[\s\S]*<\/\1>/); + return match?.[0]; + } + + private stripOuterTag(xmlArg: string): string | undefined { + const match = xmlArg.match(/^<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>([\s\S]*)<\/\1>$/); + return match?.[2]; + } + + private absoluteUrl(baseArg: string, valueArg: string): string | undefined { + if (!valueArg) { + return undefined; + } + return new URL(valueArg, baseArg).toString(); + } + + private splitList(valueArg: string | undefined): string[] { + if (!valueArg) { + return []; + } + return valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean); + } + + private numberValue(valueArg: string | undefined): number | undefined { + if (valueArg === undefined || valueArg === '') { + return undefined; + } + const value = Number(valueArg); + return Number.isFinite(value) ? value : undefined; + } + + private booleanValue(valueArg: string | undefined): boolean | undefined { + if (valueArg === undefined || valueArg === '') { + return undefined; + } + return valueArg === '1' || valueArg.toLowerCase() === 'true' || valueArg.toLowerCase() === 'yes'; + } + + private seconds(valueArg: string | undefined): number | undefined { + if (!valueArg || valueArg === 'NOT_IMPLEMENTED') { + return undefined; + } + const match = valueArg.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/); + if (!match) { + return undefined; + } + return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]); + } + + private mimeType(uriArg: string): string { + const path = uriArg.split('?')[0].toLowerCase(); + if (path.endsWith('.mp3')) return 'audio/mpeg'; + if (path.endsWith('.flac')) return 'audio/flac'; + if (path.endsWith('.wav')) return 'audio/wav'; + if (path.endsWith('.mp4') || path.endsWith('.m4v')) return 'video/mp4'; + if (path.endsWith('.mkv')) return 'video/x-matroska'; + if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; + if (path.endsWith('.png')) return 'image/png'; + return 'application/octet-stream'; + } + + private escapeXml(valueArg: string): string { + return valueArg.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + private decodeXml(valueArg: string): string { + return valueArg.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&'); + } +} diff --git a/ts/integrations/dlna_dmr/dlna_dmr.classes.configflow.ts b/ts/integrations/dlna_dmr/dlna_dmr.classes.configflow.ts new file mode 100644 index 0000000..674ef37 --- /dev/null +++ b/ts/integrations/dlna_dmr/dlna_dmr.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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, + }, + }; + }, + }; + } +} diff --git a/ts/integrations/dlna_dmr/dlna_dmr.classes.integration.ts b/ts/integrations/dlna_dmr/dlna_dmr.classes.integration.ts index c0e5227..b0b0738 100644 --- a/ts/integrations/dlna_dmr/dlna_dmr.classes.integration.ts +++ b/ts/integrations/dlna_dmr/dlna_dmr.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new DlnaDmrRuntime(new DlnaDmrClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantDlnaDmrIntegration extends DlnaDmrIntegration {} + +class DlnaDmrRuntime implements IIntegrationRuntime { + public domain = 'dlna_dmr'; + + constructor(private readonly client: DlnaDmrClient) {} + + public async devices(): Promise { + return DlnaDmrMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return DlnaDmrMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/dlna_dmr/dlna_dmr.discovery.ts b/ts/integrations/dlna_dmr/dlna_dmr.discovery.ts new file mode 100644 index 0000000..a01b08d --- /dev/null +++ b/ts/integrations/dlna_dmr/dlna_dmr.discovery.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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()); +}; diff --git a/ts/integrations/dlna_dmr/dlna_dmr.mapper.ts b/ts/integrations/dlna_dmr/dlna_dmr.mapper.ts new file mode 100644 index 0000000..d0c3f54 --- /dev/null +++ b/ts/integrations/dlna_dmr/dlna_dmr.mapper.ts @@ -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 = { + 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'; + } +} diff --git a/ts/integrations/dlna_dmr/dlna_dmr.types.ts b/ts/integrations/dlna_dmr/dlna_dmr.types.ts index b8e737c..bf39ebc 100644 --- a/ts/integrations/dlna_dmr/dlna_dmr.types.ts +++ b/ts/integrations/dlna_dmr/dlna_dmr.types.ts @@ -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>; + 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>; + 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; +} + +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; + snapshot?: IDlnaDmrSnapshot; +} + +export interface IDlnaDmrSsdpRecord { + st?: string; + nt?: string; + usn?: string; + location?: string; + udn?: string; + deviceType?: string; + headers?: Record; + 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; +} + +export interface IDlnaDmrDiscoveryMetadata { + st?: string; + nt?: string; + usn?: string; + location?: string; + deviceType?: string; + serviceIds?: string[]; } diff --git a/ts/integrations/dlna_dmr/index.ts b/ts/integrations/dlna_dmr/index.ts index 9c2b608..fdb5cd7 100644 --- a/ts/integrations/dlna_dmr/index.ts +++ b/ts/integrations/dlna_dmr/index.ts @@ -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'; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index 79811aa..cf55f2a 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -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" diff --git a/ts/integrations/jellyfin/.generated-by-smarthome-exchange b/ts/integrations/jellyfin/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/jellyfin/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/jellyfin/index.ts b/ts/integrations/jellyfin/index.ts index 59e778f..47bbdd8 100644 --- a/ts/integrations/jellyfin/index.ts +++ b/ts/integrations/jellyfin/index.ts @@ -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'; diff --git a/ts/integrations/jellyfin/jellyfin.classes.client.ts b/ts/integrations/jellyfin/jellyfin.classes.client.ts new file mode 100644 index 0000000..f3ac8a0 --- /dev/null +++ b/ts/integrations/jellyfin/jellyfin.classes.client.ts @@ -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 { + 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 { + if (this.config.server) { + return this.config.server; + } + if (!this.hasBaseUrl()) { + return this.serverInfoFromConfig(); + } + if (this.hasAuthConfig()) { + return this.fetchJson('/System/Info', { token: await this.ensureAccessToken() }); + } + return this.fetchJson('/System/Info/Public'); + } + + public async getSessions(): Promise { + if (!this.hasAuthConfig()) { + return this.config.sessions || []; + } + const query: Record = {}; + if (this.config.userId) { + query.controllableByUserId = this.config.userId; + } + if (typeof this.config.activeWithinSeconds === 'number') { + query.activeWithinSeconds = this.config.activeWithinSeconds; + } + return this.fetchJson('/Sessions', { query, token: await this.ensureAccessToken() }); + } + + public async authenticateByName(usernameArg: string, passwordArg: string): Promise { + const result = await this.fetchJson('/Users/AuthenticateByName', { + method: 'POST', + body: { + Username: usernameArg, + Pw: passwordArg, + }, + }); + if (result.AccessToken) { + this.authenticatedAccessToken = result.AccessToken; + } + return result; + } + + public async pause(sessionIdArg: string): Promise { + await this.sendPlaystateCommand(sessionIdArg, 'Pause'); + } + + public async play(sessionIdArg: string): Promise { + await this.sendPlaystateCommand(sessionIdArg, 'Unpause'); + } + + public async playPause(sessionIdArg: string): Promise { + await this.sendPlaystateCommand(sessionIdArg, 'PlayPause'); + } + + public async stop(sessionIdArg: string): Promise { + await this.sendPlaystateCommand(sessionIdArg, 'Stop'); + } + + public async nextTrack(sessionIdArg: string): Promise { + await this.sendPlaystateCommand(sessionIdArg, 'NextTrack'); + } + + public async previousTrack(sessionIdArg: string): Promise { + await this.sendPlaystateCommand(sessionIdArg, 'PreviousTrack'); + } + + public async seek(sessionIdArg: string, positionSecondsArg: number): Promise { + await this.sendPlaystateCommand(sessionIdArg, 'Seek', { + seekPositionTicks: Math.max(0, Math.round(positionSecondsArg * 10000000)), + }); + } + + public async setVolumeLevel(sessionIdArg: string, volumeLevelArg: number): Promise { + 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 { + await this.sendGeneralCommand(sessionIdArg, 'VolumeUp'); + } + + public async volumeDown(sessionIdArg: string): Promise { + await this.sendGeneralCommand(sessionIdArg, 'VolumeDown'); + } + + public async mute(sessionIdArg: string): Promise { + await this.sendGeneralCommand(sessionIdArg, 'Mute'); + } + + public async unmute(sessionIdArg: string): Promise { + await this.sendGeneralCommand(sessionIdArg, 'Unmute'); + } + + public async playMedia(sessionIdArg: string, itemIdsArg: string[], commandArg: TJellyfinPlayCommand = 'PlayNow'): Promise { + 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 { + await this.fetchNoContent(`/Sessions/${encodeURIComponent(sessionIdArg)}/Command/${encodeURIComponent(commandArg)}`, { + method: 'POST', + token: await this.ensureAccessToken(), + }); + } + + public async sendFullGeneralCommand(sessionIdArg: string, commandArg: TJellyfinGeneralCommand, argumentsArg?: Record): Promise { + 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 { + throw new JellyfinUnsupportedLivePushError(); + } + + public async destroy(): Promise {} + + private async sendPlaystateCommand(sessionIdArg: string, commandArg: TJellyfinPlaystateCommand, optionsArg?: { seekPositionTicks?: number }): Promise { + 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 { + 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(pathArg: string, optionsArg: IJellyfinRequestOptions = {}): Promise { + 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 { + 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 { + 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 { + 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 { + const headers: Record = { + 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 = { + 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; + token?: string; + body?: unknown; +} diff --git a/ts/integrations/jellyfin/jellyfin.classes.configflow.ts b/ts/integrations/jellyfin/jellyfin.classes.configflow.ts new file mode 100644 index 0000000..d2580c9 --- /dev/null +++ b/ts/integrations/jellyfin/jellyfin.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +} diff --git a/ts/integrations/jellyfin/jellyfin.classes.integration.ts b/ts/integrations/jellyfin/jellyfin.classes.integration.ts index 0fbbd72..83d1dca 100644 --- a/ts/integrations/jellyfin/jellyfin.classes.integration.ts +++ b/ts/integrations/jellyfin/jellyfin.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new JellyfinRuntime(new JellyfinClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantJellyfinIntegration extends JellyfinIntegration {} + +class JellyfinRuntime implements IIntegrationRuntime { + public domain = 'jellyfin'; + + constructor(private readonly client: JellyfinClient) {} + + public async devices(): Promise { + return JellyfinMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return JellyfinMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + void handlerArg; + throw new JellyfinUnsupportedLivePushError(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + 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 { + 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 { + 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 { + 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 { + 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; } } diff --git a/ts/integrations/jellyfin/jellyfin.discovery.ts b/ts/integrations/jellyfin/jellyfin.discovery.ts new file mode 100644 index 0000000..32affb0 --- /dev/null +++ b/ts/integrations/jellyfin/jellyfin.discovery.ts @@ -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 { + public id = 'jellyfin-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manually supplied Jellyfin server endpoints.'; + + public async matches(inputArg: IJellyfinManualEntry): Promise { + 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 { + 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 { + 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 { + public id = 'jellyfin-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Jellyfin DLNA/UPnP SSDP advertisements.'; + + public async matches(recordArg: IJellyfinSsdpRecord): Promise { + 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 { + 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, 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, 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; +}; diff --git a/ts/integrations/jellyfin/jellyfin.mapper.ts b/ts/integrations/jellyfin/jellyfin.mapper.ts new file mode 100644 index 0000000..7baa1a6 --- /dev/null +++ b/ts/integrations/jellyfin/jellyfin.mapper.ts @@ -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 = { + 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; + } +} diff --git a/ts/integrations/jellyfin/jellyfin.types.ts b/ts/integrations/jellyfin/jellyfin.types.ts index c3e3468..06932e6 100644 --- a/ts/integrations/jellyfin/jellyfin.types.ts +++ b/ts/integrations/jellyfin/jellyfin.types.ts @@ -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; + 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; + 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; +} + +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; +} + +export interface IJellyfinMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + addresses?: string[]; + hostname?: string; + txt?: Record; + properties?: Record; +} + +export interface IJellyfinSsdpRecord { + st?: string; + usn?: string; + location?: string; + server?: string; + headers?: Record; +} + +export interface IJellyfinDiscoveryMetadata { + url?: string; + ssl?: boolean; + ssdpSt?: string; + ssdpUsn?: string; + mdnsType?: string; [key: string]: unknown; } diff --git a/ts/integrations/mpd/.generated-by-smarthome-exchange b/ts/integrations/mpd/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/mpd/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/mpd/index.ts b/ts/integrations/mpd/index.ts index e9df80d..1305f89 100644 --- a/ts/integrations/mpd/index.ts +++ b/ts/integrations/mpd/index.ts @@ -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'; diff --git a/ts/integrations/mpd/mpd.classes.client.ts b/ts/integrations/mpd/mpd.classes.client.ts new file mode 100644 index 0000000..5426cee --- /dev/null +++ b/ts/integrations/mpd/mpd.classes.client.ts @@ -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 { + 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 { + await this.command('ping'); + return true; + } + + public async getStatus(): Promise { + if (this.isSnapshotMode()) { + return { ...this.requireSnapshot().status }; + } + return this.recordFromResponse(await this.command('status')) as IMpdStatus; + } + + public async getCurrentSong(): Promise { + 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 { + if (this.isSnapshotMode()) { + return this.cloneValue(this.requireSnapshot().outputs) || []; + } + return this.outputsFromResponse(await this.command('outputs')); + } + + public async getCommands(): Promise { + if (this.isSnapshotMode()) { + return [...this.requireSnapshot().commands]; + } + return this.commandValues(await this.command('commands')); + } + + public async getPlaylists(): Promise { + if (this.isSnapshotMode()) { + return this.cloneValue(this.requireSnapshot().playlists) || []; + } + return this.playlistsFromResponse(await this.command('listplaylists')); + } + + public async getStats(): Promise { + if (this.isSnapshotMode()) { + return { ...(this.requireSnapshot().stats || {}) }; + } + return this.recordFromResponse(await this.command('stats')) as IMpdStats; + } + + public async play(): Promise { + if (this.isSnapshotMode()) { + this.requireSnapshot().status.state = 'play'; + return; + } + await this.command('play'); + } + + public async pause(pausedArg = true): Promise { + if (this.isSnapshotMode()) { + this.requireSnapshot().status.state = pausedArg ? 'pause' : 'play'; + return; + } + await this.command('pause', [pausedArg ? 1 : 0]); + } + + public async stop(): Promise { + if (this.isSnapshotMode()) { + this.requireSnapshot().status.state = 'stop'; + return; + } + await this.command('stop'); + } + + public async next(): Promise { + if (this.isSnapshotMode()) { + return; + } + await this.command('next'); + } + + public async previous(): Promise { + if (this.isSnapshotMode()) { + return; + } + await this.command('previous'); + } + + public async setVolumeLevel(volumeArg: number): Promise { + 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 { + 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 { + 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 { + 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 { + if (this.isSnapshotMode()) { + const output = this.requireOutput(outputIdArg); + output.outputenabled = !this.outputEnabled(output); + return; + } + await this.command('toggleoutput', [outputIdArg]); + } + + public async snapshot(): Promise { + this.restorePoint = await this.getSnapshot(); + return this.cloneSnapshot(this.restorePoint); + } + + public async restore(snapshotArg = this.restorePoint): Promise { + 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 = []): Promise { + if (this.isSnapshotMode()) { + return this.snapshotCommand(commandArg, argsArg); + } + return this.requestTcp({ command: commandArg, args: argsArg }); + } + + public async destroy(): Promise {} + + private async requestTcp(requestArg: IMpdCommandRequest): Promise { + 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((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 = []) => { + 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): 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 { + const record: Record = {}; + 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 | 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 { + 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[] { + 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(valueArg: TValue): TValue { + return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue; + } +} diff --git a/ts/integrations/mpd/mpd.classes.configflow.ts b/ts/integrations/mpd/mpd.classes.configflow.ts new file mode 100644 index 0000000..d8db2dd --- /dev/null +++ b/ts/integrations/mpd/mpd.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +} diff --git a/ts/integrations/mpd/mpd.classes.integration.ts b/ts/integrations/mpd/mpd.classes.integration.ts index 739492c..138b6a1 100644 --- a/ts/integrations/mpd/mpd.classes.integration.ts +++ b/ts/integrations/mpd/mpd.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new MpdRuntime(new MpdClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantMpdIntegration extends MpdIntegration {} + +class MpdRuntime implements IIntegrationRuntime { + public domain = 'mpd'; + + constructor(private readonly client: MpdClient) {} + + public async devices(): Promise { + return MpdMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return MpdMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } + + 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`; +}; diff --git a/ts/integrations/mpd/mpd.discovery.ts b/ts/integrations/mpd/mpd.discovery.ts new file mode 100644 index 0000000..f74eac3 --- /dev/null +++ b/ts/integrations/mpd/mpd.discovery.ts @@ -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 { + public id = 'mpd-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Music Player Daemon mDNS advertisements.'; + + public async matches(recordArg: IMpdMdnsRecord): Promise { + 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 { + public id = 'mpd-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual MPD setup entries.'; + + public async matches(inputArg: IMpdManualEntry): Promise { + 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 { + 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, 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; +}; diff --git a/ts/integrations/mpd/mpd.mapper.ts b/ts/integrations/mpd/mpd.mapper.ts new file mode 100644 index 0000000..72b1fb9 --- /dev/null +++ b/ts/integrations/mpd/mpd.mapper.ts @@ -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)); + } +} diff --git a/ts/integrations/mpd/mpd.types.ts b/ts/integrations/mpd/mpd.types.ts index 849a21c..d7c9398 100644 --- a/ts/integrations/mpd/mpd.types.ts +++ b/ts/integrations/mpd/mpd.types.ts @@ -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; + [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; +} + +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; + properties?: Record; +} + +export interface IMpdManualEntry { + host?: string; + port?: number; + password?: string; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + metadata?: Record; +} + +export interface IMpdDiscoveryRecord { + source: 'mdns' | 'manual'; + host?: string; + port?: number; + name?: string; + id?: string; + mdnsType?: string; +} diff --git a/ts/integrations/onvif/.generated-by-smarthome-exchange b/ts/integrations/onvif/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/onvif/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/onvif/index.ts b/ts/integrations/onvif/index.ts index ed5590d..9f6d6d1 100644 --- a/ts/integrations/onvif/index.ts +++ b/ts/integrations/onvif/index.ts @@ -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'; diff --git a/ts/integrations/onvif/onvif.classes.client.ts b/ts/integrations/onvif/onvif.classes.client.ts new file mode 100644 index 0000000..7fc756a --- /dev/null +++ b/ts/integrations/onvif/onvif.classes.client.ts @@ -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(); + 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 { + 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 { + 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 { + this.eventHandlers.clear(); + } + + private async streamMetadata(commandArg: IOnvifClientCommand): Promise { + 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 { + 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 { + 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 { + const deviceServiceUrl = this.deviceServiceUrl(); + const servicesXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetServices', 'false'); + const services = this.parseServices(servicesXml); + const deviceInfoXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetDeviceInformation', ''); + const capabilitiesXml = await this.soapRequest(deviceServiceUrl, namespaces.device, 'GetCapabilities', 'All'); + 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 { + const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetProfiles', ''); + return this.parseProfiles(xml); + } + + private async fetchStreamUri(mediaServiceUrlArg: string, profileTokenArg: string): Promise { + const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetStreamUri', `RTP-UnicastRTSP${this.escapeXml(profileTokenArg)}`); + 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 { + const xml = await this.soapRequest(mediaServiceUrlArg, namespaces.media, 'GetSnapshotUri', `${this.escapeXml(profileTokenArg)}`); + 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 { + const actionUri = `${namespaceArg}/${actionArg}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.config.soap?.timeoutMs || 8000); + const headers: Record = { + '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 `${this.securityHeader()}${bodyArg}`; + } + + 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 `${this.escapeXml(this.config.username)}${digest}${nonce.toString('base64')}${created}`; + } + + private deviceServiceUrl(): string { + const transport = this.config.transport || 'http'; + const host = this.config.host || 'localhost'; + const port = this.config.port || onvifDefaultPort; + const path = this.config.deviceServicePath || onvifDefaultDeviceServicePath; + if (/^https?:\/\//i.test(path)) { + return path; + } + return `${transport}://${host}:${port}${path.startsWith('/') ? path : `/${path}`}`; + } + + private parseDeviceInfo(xmlArg: string): IOnvifDeviceInfo { + return { + name: this.config.name, + manufacturer: this.firstText(xmlArg, 'Manufacturer'), + model: this.firstText(xmlArg, 'Model'), + firmwareVersion: this.firstText(xmlArg, 'FirmwareVersion'), + serialNumber: this.firstText(xmlArg, 'SerialNumber'), + hardwareId: this.firstText(xmlArg, 'HardwareId'), + host: this.config.host, + port: this.config.port || onvifDefaultPort, + configurationUrl: this.config.host ? `${this.config.transport || 'http'}://${this.config.host}:${this.config.port || onvifDefaultPort}` : undefined, + }; + } + + private parseServices(xmlArg: string): IOnvifServiceDescriptor[] { + const services: IOnvifServiceDescriptor[] = []; + for (const block of this.blocks(xmlArg, 'Service')) { + const namespace = this.firstText(block, 'Namespace'); + const xaddr = this.firstText(block, 'XAddr'); + if (!namespace || !xaddr) { + continue; + } + services.push({ + namespace, + xaddr, + version: this.compactXml(this.block(block, 'Version') || ''), + }); + } + return services; + } + + private parseProfiles(xmlArg: string): IOnvifProfile[] { + const profiles: IOnvifProfile[] = []; + let index = 0; + for (const block of this.blocks(xmlArg, 'Profiles')) { + const token = this.attribute(block, 'token') || this.firstText(block, 'token') || `profile_${index}`; + const videoEncoder = this.block(block, 'VideoEncoderConfiguration') || ''; + const resolution = this.block(videoEncoder, 'Resolution') || ''; + const ptzBlock = this.block(block, 'PTZConfiguration') || ''; + const videoSource = this.block(block, 'VideoSourceConfiguration') || ''; + profiles.push({ + index, + token, + name: this.firstText(block, 'Name') || `Profile ${index}`, + video: { + encoding: this.firstText(videoEncoder, 'Encoding'), + resolution: { + width: this.numberValue(this.firstText(resolution, 'Width')) || 0, + height: this.numberValue(this.firstText(resolution, 'Height')) || 0, + }, + frameRate: this.numberValue(this.firstText(videoEncoder, 'FrameRateLimit')), + bitrate: this.numberValue(this.firstText(videoEncoder, 'BitrateLimit')), + }, + ptz: ptzBlock ? { + continuous: /DefaultContinuousPanTiltVelocitySpace/i.test(ptzBlock), + relative: /DefaultRelativePanTiltTranslationSpace/i.test(ptzBlock), + absolute: /DefaultAbsolutePanTiltPositionSpace|DefaultAbsolutePantTiltPositionSpace/i.test(ptzBlock), + } : undefined, + videoSourceToken: this.firstText(videoSource, 'SourceToken'), + }); + index += 1; + } + return profiles; + } + + private serviceUrl(servicesArg: IOnvifServiceDescriptor[], namespaceArg: string): string | undefined { + return servicesArg.find((serviceArg) => serviceArg.namespace === namespaceArg || serviceArg.namespace.includes(namespaceArg))?.xaddr; + } + + private firstCapabilityXaddr(xmlArg: string, capabilityArg: string): string | undefined { + const block = this.block(xmlArg, capabilityArg); + return block ? this.firstText(block, 'XAddr') : undefined; + } + + private findProfile(snapshotArg: IOnvifSnapshot, profileTokenArg?: string): { camera: IOnvifCameraSnapshot; profile: IOnvifProfile } | undefined { + for (const camera of snapshotArg.cameras) { + const profile = profileTokenArg + ? camera.profiles.find((profileArg) => profileArg.token === profileTokenArg) + : camera.profiles[0]; + if (profile) { + return { camera, profile }; + } + } + return undefined; + } + + private streamForProfile(cameraArg: IOnvifCameraSnapshot, profileArg: IOnvifProfile): IOnvifStream | undefined { + return cameraArg.streams?.find((streamArg) => streamArg.profileToken === profileArg.token) + || (profileArg.streamUri ? { profileToken: profileArg.token, uri: profileArg.streamUri, protocol: this.protocolForUri(profileArg.streamUri), encoding: profileArg.video?.encoding, resolution: profileArg.video?.resolution } : undefined); + } + + private protocolForUri(uriArg?: string): 'rtsp' | 'rtsps' | 'http' | 'https' | 'unknown' { + if (!uriArg) { + return 'unknown'; + } + const protocol = uriArg.split(':', 1)[0]?.toLowerCase(); + return protocol === 'rtsp' || protocol === 'rtsps' || protocol === 'http' || protocol === 'https' ? protocol : 'unknown'; + } + + private blocks(xmlArg: string, localNameArg: string): string[] { + const blocks: string[] = []; + const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>[\\s\\S]*?<\\/([\\w.-]+:)?${localNameArg}>`, 'gi'); + for (const match of xmlArg.matchAll(pattern)) { + blocks.push(match[0]); + } + return blocks; + } + + private block(xmlArg: string, localNameArg: string): string | undefined { + return this.blocks(xmlArg, localNameArg)[0]; + } + + private firstText(xmlArg: string, localNameArg: string): string | undefined { + const pattern = new RegExp(`<([\\w.-]+:)?${localNameArg}\\b[^>]*>([\\s\\S]*?)<\\/([\\w.-]+:)?${localNameArg}>`, 'i'); + const match = xmlArg.match(pattern); + if (!match) { + return undefined; + } + return this.decodeXml(match[2].replace(/<[^>]+>/g, '').trim()) || undefined; + } + + private attribute(xmlArg: string, attributeArg: string): string | undefined { + const match = xmlArg.match(new RegExp(`${attributeArg}=["']([^"']+)["']`, 'i')); + return match ? this.decodeXml(match[1]) : undefined; + } + + private compactXml(xmlArg: string): string { + return xmlArg.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500); + } + + private numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + + private escapeXml(valueArg: string): string { + return valueArg.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + private decodeXml(valueArg: string): string { + return valueArg + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); + } +} diff --git a/ts/integrations/onvif/onvif.classes.configflow.ts b/ts/integrations/onvif/onvif.classes.configflow.ts new file mode 100644 index 0000000..74c7f92 --- /dev/null +++ b/ts/integrations/onvif/onvif.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +} diff --git a/ts/integrations/onvif/onvif.classes.integration.ts b/ts/integrations/onvif/onvif.classes.integration.ts index 9da57a9..fd51228 100644 --- a/ts/integrations/onvif/onvif.classes.integration.ts +++ b/ts/integrations/onvif/onvif.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new OnvifIntegrationRuntime(new OnvifClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantOnvifIntegration extends OnvifIntegration {} + +class OnvifIntegrationRuntime implements IIntegrationRuntime { + public domain = 'onvif'; + + constructor(private readonly client: OnvifClient) {} + + public async devices(): Promise { + return OnvifMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return OnvifMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(OnvifMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/onvif/onvif.discovery.ts b/ts/integrations/onvif/onvif.discovery.ts new file mode 100644 index 0000000..bbe8ae7 --- /dev/null +++ b/ts/integrations/onvif/onvif.discovery.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(':'); +}; diff --git a/ts/integrations/onvif/onvif.mapper.ts b/ts/integrations/onvif/onvif.mapper.ts new file mode 100644 index 0000000..d4facf6 --- /dev/null +++ b/ts/integrations/onvif/onvif.mapper.ts @@ -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(['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(); + + 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): 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(); + 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(); + 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 { + 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, attributesArg: Record, 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 { + 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; + } + 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'; + } +} diff --git a/ts/integrations/onvif/onvif.types.ts b/ts/integrations/onvif/onvif.types.ts index bbb1506..ab653ea 100644 --- a/ts/integrations/onvif/onvif.types.ts +++ b/ts/integrations/onvif/onvif.types.ts @@ -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; +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; +} + +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; +} + +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; +} + +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; +} + +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; +} + +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; +} + +export interface IOnvifServiceDescriptor { + namespace: string; + xaddr: string; + version?: string; + capabilities?: Record; +} + +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; +} + +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; +} + +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; +} + +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; + metadata?: Record; +} + +export interface IOnvifWsDiscoveryRecord { + epr?: string; + endpointReference?: string; + endpoint_reference?: string; + xaddrs?: string[] | string; + xAddrs?: string[] | string; + XAddrs?: string[] | string; + scopes?: Array | string; + types?: string[] | string; + name?: string; + metadata?: Record; +} + +export interface IOnvifMdnsRecord { + type?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; +} + +export interface IOnvifManualDiscoveryRecord extends IOnvifManualEntry { + discovery?: IOnvifDiscoveryRecord; +} diff --git a/ts/integrations/plex/.generated-by-smarthome-exchange b/ts/integrations/plex/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/plex/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/plex/index.ts b/ts/integrations/plex/index.ts index 2413e2b..262c2a1 100644 --- a/ts/integrations/plex/index.ts +++ b/ts/integrations/plex/index.ts @@ -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'; diff --git a/ts/integrations/plex/plex.classes.client.ts b/ts/integrations/plex/plex.classes.client.ts new file mode 100644 index 0000000..6d4abfc --- /dev/null +++ b/ts/integrations/plex/plex.classes.client.ts @@ -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(); + + constructor(private readonly config: IPlexConfig) { + this.currentSnapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined; + } + + public async getSnapshot(): Promise { + 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 { + const container = await this.requestJson('/', { method: 'GET' }); + return this.normalizeServer(container.MediaContainer || {}); + } + + public async getIdentity(): Promise { + const container = await this.requestJson('/identity', { method: 'GET', allowWithoutToken: true }); + return this.normalizeServer(container.MediaContainer || {}); + } + + public async getClients(): Promise { + const container = await this.requestJson>('/clients', { method: 'GET' }); + const mediaContainer = container.MediaContainer || {}; + const rawClients = this.arrayValue(mediaContainer.Server) || this.arrayValue(mediaContainer.Directory) || []; + return rawClients.map((clientArg) => this.normalizeClient({ ...clientArg, source: clientArg.source || 'PMS' })); + } + + public async getSessions(): Promise { + const container = await this.requestJson>('/status/sessions', { method: 'GET' }); + return (container.MediaContainer?.Metadata || []).map((sessionArg) => this.normalizeSession(sessionArg)); + } + + public async getLibraries(): Promise { + const container = await this.requestJson>('/library/sections/all', { method: 'GET' }); + return (container.MediaContainer?.Directory || []).map((libraryArg) => this.normalizeLibrary(libraryArg)); + } + + public async refreshLibrary(commandArg: IPlexRefreshLibraryCommand): Promise { + 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 { + 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 { + this.eventHandlers.clear(); + } + + private async getLibraryItemCount(libraryArg: IPlexLibrarySection): Promise { + const container = await this.requestJson(`/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 { + 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 { + const params: Record = { + ...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): 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(); + 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(pathArg: string, optionsArg: { method: string; headers?: Record; searchParams?: Record; allowWithoutToken?: boolean }): Promise { + 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; searchParams?: Record; allowWithoutToken?: boolean }): Promise { + 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 { + 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 | undefined, allowWithoutTokenArg?: boolean): Record { + const headers: Record = { + 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(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'; +}; diff --git a/ts/integrations/plex/plex.classes.configflow.ts b/ts/integrations/plex/plex.classes.configflow.ts new file mode 100644 index 0000000..d6bbe4c --- /dev/null +++ b/ts/integrations/plex/plex.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +} diff --git a/ts/integrations/plex/plex.classes.integration.ts b/ts/integrations/plex/plex.classes.integration.ts index 63fe503..2a3a1df 100644 --- a/ts/integrations/plex/plex.classes.integration.ts +++ b/ts/integrations/plex/plex.classes.integration.ts @@ -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" - ], - "dependencies": [ - "http" - ], - "afterDependencies": [], - "codeowners": [ - "@jjlawren" - ] -}, - }); +export class PlexIntegration extends BaseIntegration { + 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}', + ], + }; + + public async setup(configArg: IPlexConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new PlexRuntime(new PlexClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantPlexIntegration extends PlexIntegration {} + +class PlexRuntime implements IIntegrationRuntime { + public domain = 'plex'; + + constructor(private readonly client: PlexClient) {} + + public async devices(): Promise { + return PlexMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return PlexMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + 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 { + 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 { + await this.client.destroy(); + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + 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 { + 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 { + 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; } } diff --git a/ts/integrations/plex/plex.discovery.ts b/ts/integrations/plex/plex.discovery.ts new file mode 100644 index 0000000..1b3bd12 --- /dev/null +++ b/ts/integrations/plex/plex.discovery.ts @@ -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 { + if (contextArg.abortSignal?.aborted) { + return { candidates: [] }; + } + return { candidates: await this.discover(1200) }; + } + + private async discover(timeoutMsArg: number): Promise { + 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 { + 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 { + 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 { + public id = 'plex-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Plex Media Server zeroconf advertisements.'; + + public async matches(recordArg: IPlexMdnsRecord): Promise { + 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 { + 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 { + 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 { + public id = 'plex-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Plex setup entries.'; + + public async matches(inputArg: IPlexManualEntry): Promise { + 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 { + 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 = {}; + 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): Record => { + const normalized: Record = {}; + 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]; +}; diff --git a/ts/integrations/plex/plex.mapper.ts b/ts/integrations/plex/plex.mapper.ts new file mode 100644 index 0000000..c9cc4c0 --- /dev/null +++ b/ts/integrations/plex/plex.mapper.ts @@ -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 = { + 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 { + const sessions = new Map(); + 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 { + const attributes: Record = {}; + 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)); + } +} diff --git a/ts/integrations/plex/plex.types.ts b/ts/integrations/plex/plex.types.ts index cfa160b..85017e1 100644 --- a/ts/integrations/plex/plex.types.ts +++ b/ts/integrations/plex/plex.types.ts @@ -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; + 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; +} + +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 { + 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; + 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; + properties?: Record; +} + +export interface IPlexSsdpRecord { + headers?: Record; + st?: string; + usn?: string; + location?: string; + server?: string; + friendlyName?: string; + manufacturer?: string; + modelName?: string; + host?: string; + port?: number; + metadata?: Record; +} + +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; +} + +export interface IPlexGdmDiscoveryEntry { + source: 'gdm'; + contentType?: string; + host?: string; + port?: number; + name?: string; + resourceIdentifier?: string; + version?: string; + product?: string; + protocolCapabilities?: string[]; + raw?: IPlexGdmRecord; +} diff --git a/ts/integrations/rainbird/.generated-by-smarthome-exchange b/ts/integrations/rainbird/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/rainbird/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/rainbird/index.ts b/ts/integrations/rainbird/index.ts index 9b088ee..319627a 100644 --- a/ts/integrations/rainbird/index.ts +++ b/ts/integrations/rainbird/index.ts @@ -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'; diff --git a/ts/integrations/rainbird/rainbird.classes.client.ts b/ts/integrations/rainbird/rainbird.classes.client.ts new file mode 100644 index 0000000..c64dcc2 --- /dev/null +++ b/ts/integrations/rainbird/rainbird.classes.client.ts @@ -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> = { + '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(); + private readonly localClient: RainbirdLocalApiClient; + + constructor(private readonly config: IRainbirdConfig) { + this.localClient = new RainbirdLocalApiClient(config); + } + + public async getSnapshot(): Promise { + 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 { + 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 { + 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 = {}): IRainbirdHttpLocalCommandShape { + return { + endpoint: '/stick', + method: 'POST', + contentType: 'application/octet-stream', + jsonRpcMethod: methodArg, + encrypted: Boolean(this.config.password), + params: paramsArg, + }; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private async executeCommand(commandArg: IRainbirdCommand): Promise { + 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 { + 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(); + 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(getterArg: () => Promise): Promise { + 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(methodArg: string, paramsArg: Record = {}): Promise { + 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(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 { + 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 { + const response = await this.processSipCommand('SerialNumberRequest'); + return response.slice(2, 18); + } + + public async getControllerFirmwareVersion(): Promise { + 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 { + const result = await this.request>('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 { + 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 { + 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 { + const response = await this.processSipCommand('CurrentRainSensorStateRequest'); + return parseHex(response.slice(2, 4)) !== 0; + } + + public async getRainDelay(): Promise { + const response = await this.processSipCommand('RainDelayGetRequest'); + return parseHex(response.slice(2, 6)); + } + + public async getCurrentIrrigation(): Promise { + const response = await this.processSipCommand('CurrentIrrigationStateRequest'); + return parseHex(response.slice(2, 4)) !== 0; + } + + public async irrigateZone(zoneIdArg: number, minutesArg: number): Promise { + await this.processSipCommand('ManuallyRunStationRequest', zoneIdArg, minutesArg); + } + + public async stopIrrigation(): Promise { + await this.processSipCommand('StopIrrigationRequest'); + } + + public async setRainDelay(daysArg: number): Promise { + await this.processSipCommand('RainDelaySetRequest', daysArg); + } + + public async startProgram(programIdArg: number): Promise { + await this.processSipCommand('ManuallyRunProgramRequest', programIdArg); + } + + public async retrieveScheduleRaw(commandCodeArg: string | number): Promise { + return await this.processSipCommand('RetrieveScheduleRequest', typeof commandCodeArg === 'string' ? parseInt(commandCodeArg, 16) : commandCodeArg); + } + + private async processSipCommand(commandArg: keyof typeof sipCommands, ...argsArg: number[]): Promise { + const command = sipCommands[commandArg]; + const data = encodeSipCommand(commandArg, ...argsArg); + const result = await this.request('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, 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 { + 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): 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(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; + 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)}`; +}; diff --git a/ts/integrations/rainbird/rainbird.classes.configflow.ts b/ts/integrations/rainbird/rainbird.classes.configflow.ts new file mode 100644 index 0000000..3591153 --- /dev/null +++ b/ts/integrations/rainbird/rainbird.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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); + } +} diff --git a/ts/integrations/rainbird/rainbird.classes.integration.ts b/ts/integrations/rainbird/rainbird.classes.integration.ts index add8500..e7bc914 100644 --- a/ts/integrations/rainbird/rainbird.classes.integration.ts +++ b/ts/integrations/rainbird/rainbird.classes.integration.ts @@ -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" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@konikvranik", - "@allenporter" - ] -}, - }); +export class RainbirdIntegration extends BaseIntegration { + 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', + ], + 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 { + void contextArg; + return new RainbirdRuntime(new RainbirdClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantRainbirdIntegration extends RainbirdIntegration {} + +class RainbirdRuntime implements IIntegrationRuntime { + public domain = 'rainbird'; + + constructor(private readonly client: RainbirdClient) {} + + public async devices(): Promise { + return RainbirdMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return RainbirdMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(RainbirdMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/rainbird/rainbird.discovery.ts b/ts/integrations/rainbird/rainbird.discovery.ts new file mode 100644 index 0000000..eaf78d3 --- /dev/null +++ b/ts/integrations/rainbird/rainbird.discovery.ts @@ -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 { + 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 { + 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 { + 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; diff --git a/ts/integrations/rainbird/rainbird.mapper.ts b/ts/integrations/rainbird/rainbird.mapper.ts new file mode 100644 index 0000000..ff9ebe6 --- /dev/null +++ b/ts/integrations/rainbird/rainbird.mapper.ts @@ -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(); + 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, attributesArg: Record, 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 | undefined { + const value = requestArg.data?.[keyArg]; + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; + } + + private static cleanAttributes(attributesArg: Record): Record { + 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'; + } +} diff --git a/ts/integrations/rainbird/rainbird.types.ts b/ts/integrations/rainbird/rainbird.types.ts index bc68799..c556e20 100644 --- a/ts/integrations/rainbird/rainbird.types.ts +++ b/ts/integrations/rainbird/rainbird.types.ts @@ -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; +} + +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; +} + +export interface IRainbirdZone { + id: number; + name?: string; + available?: boolean; + active?: boolean; + defaultDurationMinutes?: number; + remainingRuntimeSeconds?: number; + lastStartedAt?: string; + lastStoppedAt?: string; + attributes?: Record; +} + +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; +} + +export interface IRainbirdZoneDuration { + zoneId: number; + durationMinutes: number; +} + +export interface IRainbirdSchedule { + programs: IRainbirdProgram[]; + zoneSchedules?: IRainbirdZoneSchedule[]; + rainDelayDays?: number; + rainSensorActive?: boolean; + updatedAt?: string; + attributes?: Record; +} + +export interface IRainbirdZoneSchedule { + zoneId: number; + starts: string[]; + frequency?: TRainbirdProgramFrequency; + durationMinutes?: number; + daysOfWeek?: string[]; + periodDays?: number; + synchroDays?: number; + nextStartAt?: string; + attributes?: Record; +} + +export interface IRainbirdSnapshot { + controller: IRainbirdController; + zones: IRainbirdZone[]; + programs: IRainbirdProgram[]; + schedule?: IRainbirdSchedule; + events: IRainbirdEvent[]; + connected: boolean; + updatedAt: string; + raw?: Record; +} + +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; +} + +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; +} + +export interface IRainbirdLocalJsonRpcResponse { + 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; +} + +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; +} + +export interface IRainbirdMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + addresses?: string[]; + txt?: Record; +} + +export interface IRainbirdDhcpLease { + hostname?: string; + ipAddress?: string; + macAddress?: string; + vendorClassIdentifier?: string; + manufacturer?: string; + metadata?: Record; +} diff --git a/ts/integrations/snapcast/.generated-by-smarthome-exchange b/ts/integrations/snapcast/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/snapcast/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/snapcast/index.ts b/ts/integrations/snapcast/index.ts index a394666..82b1963 100644 --- a/ts/integrations/snapcast/index.ts +++ b/ts/integrations/snapcast/index.ts @@ -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'; diff --git a/ts/integrations/snapcast/snapcast.classes.client.ts b/ts/integrations/snapcast/snapcast.classes.client.ts new file mode 100644 index 0000000..875f82a --- /dev/null +++ b/ts/integrations/snapcast/snapcast.classes.client.ts @@ -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 { + const result = await this.rpc<{ server: ISnapcastServerStatus }>('Server.GetStatus'); + return result.server; + } + + public async getSnapshot(): Promise { + return { + server: await this.getStatus(), + capturedAt: new Date().toISOString(), + source: this.isSnapshotMode() ? 'manual' : 'jsonrpc', + }; + } + + public async setClientVolume(clientIdArg: string, percentArg: number): Promise { + 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 { + const volume = await this.currentClientVolume(clientIdArg); + return this.setClientVolumeState(clientIdArg, { + muted: mutedArg, + percent: volume.percent, + }); + } + + public async setClientLatency(clientIdArg: string, latencyArg: number): Promise { + 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 { + const result = await this.rpc<{ name: string }>('Client.SetName', { + id: clientIdArg, + name: nameArg, + }); + return result.name; + } + + public async setGroupMuted(groupIdArg: string, mutedArg: boolean): Promise { + const result = await this.rpc<{ mute: boolean }>('Group.SetMute', { + id: groupIdArg, + mute: mutedArg, + }); + return result.mute; + } + + public async setGroupStream(groupIdArg: string, streamIdArg: string): Promise { + 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 { + const result = await this.rpc<{ server: ISnapcastServerStatus }>('Group.SetClients', { + id: groupIdArg, + clients: clientIdsArg, + }); + return result.server; + } + + public async setGroupName(groupIdArg: string, nameArg: string): Promise { + 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): Promise { + return this.rpc('Stream.Control', { + id: streamIdArg, + command: commandArg, + params: paramsArg || {}, + }); + } + + public async snapshot(): Promise { + this.restorePoint = await this.getSnapshot(); + return this.cloneSnapshot(this.restorePoint); + } + + public async restore(snapshotArg = this.restorePoint): Promise { + 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 { + 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 {} + + private async setClientVolumeState(clientIdArg: string, volumeArg: ISnapcastVolume): Promise { + 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 { + 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(methodArg: TSnapcastRpcMethod, paramsArg?: Record): Promise { + if (this.isSnapshotMode()) { + return this.snapshotRpc(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(request, transport); + } + if (transport !== 'tcp') { + throw new Error(`Unsupported Snapcast JSON-RPC transport: ${transport}`); + } + return this.requestTcp(request); + } + + private async requestTcp(requestArg: ISnapcastRpcRequest): Promise { + 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((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 | Array>; + const value = this.unwrapRpcResponse(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(requestArg: ISnapcastRpcRequest, transportArg: 'http' | 'https'): Promise { + 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(JSON.parse(text), requestArg.id, requestArg.method) as TResult; + } finally { + globalThis.clearTimeout(timeout); + } + } + + private unwrapRpcResponse(responseArg: ISnapcastRpcResponse | Array>, 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(methodArg: TSnapcastRpcMethod, paramsArg?: Record): 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 { + 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 | 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 | 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 | 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 | 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 | undefined): ISnapcastVolume { + const value = paramsArg?.volume; + if (!value || typeof value !== 'object') { + throw new Error('Snapcast volume parameter is required.'); + } + const volume = value as Partial; + 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'; + } +} diff --git a/ts/integrations/snapcast/snapcast.classes.configflow.ts b/ts/integrations/snapcast/snapcast.classes.configflow.ts new file mode 100644 index 0000000..e017046 --- /dev/null +++ b/ts/integrations/snapcast/snapcast.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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); + } +} diff --git a/ts/integrations/snapcast/snapcast.classes.integration.ts b/ts/integrations/snapcast/snapcast.classes.integration.ts index dfa45f8..9e0da10 100644 --- a/ts/integrations/snapcast/snapcast.classes.integration.ts +++ b/ts/integrations/snapcast/snapcast.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new SnapcastRuntime(new SnapcastClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantSnapcastIntegration extends SnapcastIntegration {} + +class SnapcastRuntime implements IIntegrationRuntime { + public domain = 'snapcast'; + + constructor(private readonly client: SnapcastClient) {} + + public async devices(): Promise { + return SnapcastMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return SnapcastMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(): Promise<() => Promise> { + 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 { + 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 { + await this.client.destroy(); + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } } diff --git a/ts/integrations/snapcast/snapcast.discovery.ts b/ts/integrations/snapcast/snapcast.discovery.ts new file mode 100644 index 0000000..43d1370 --- /dev/null +++ b/ts/integrations/snapcast/snapcast.discovery.ts @@ -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 { + 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 { + 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 { + public id = 'snapcast-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Snapcast setup entries.'; + + public async matches(inputArg: ISnapcastManualEntry): Promise { + 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 { + 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; +}; diff --git a/ts/integrations/snapcast/snapcast.mapper.ts b/ts/integrations/snapcast/snapcast.mapper.ts new file mode 100644 index 0000000..48246cf --- /dev/null +++ b/ts/integrations/snapcast/snapcast.mapper.ts @@ -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; + } +} diff --git a/ts/integrations/snapcast/snapcast.types.ts b/ts/integrations/snapcast/snapcast.types.ts index 35e827f..f96c9b9 100644 --- a/ts/integrations/snapcast/snapcast.types.ts +++ b/ts/integrations/snapcast/snapcast.types.ts @@ -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; +} + +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; +} + +export interface ISnapcastRpcRequest> { + id: number | string; + jsonrpc: '2.0'; + method: TSnapcastRpcMethod | string; + params?: TParams; +} + +export interface ISnapcastRpcError { + code: number; + message: string; + data?: unknown; +} + +export interface ISnapcastRpcResponse { + id?: number | string; + jsonrpc?: '2.0'; + result?: TResult; + error?: ISnapcastRpcError; +} + +export interface ISnapcastRpcNotification> { + 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; +} + +export interface ISnapcastClientEvent { + method: Extract; + clientId?: string; + client?: ISnapcastClient; + volume?: ISnapcastVolume; + latency?: number; + name?: string; +} + +export interface ISnapcastGroupEvent { + method: Extract; + groupId?: string; + muted?: boolean; + streamId?: string; + name?: string; +} + +export interface ISnapcastStreamEvent { + method: Extract; + streamId?: string; + stream?: ISnapcastStream; + properties?: Record; +} + +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; +} + +export interface ISnapcastManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + transport?: TSnapcastRpcTransport; + manufacturer?: string; + model?: string; + metadata?: Record; +} + +export interface ISnapcastDiscoveryRecord { + source: 'mdns' | 'manual'; + host?: string; + port?: number; + transport?: TSnapcastRpcTransport; + mdnsType?: string; + name?: string; + id?: string; +} diff --git a/ts/integrations/volumio/.generated-by-smarthome-exchange b/ts/integrations/volumio/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/volumio/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/volumio/index.ts b/ts/integrations/volumio/index.ts index 683e399..18d774d 100644 --- a/ts/integrations/volumio/index.ts +++ b/ts/integrations/volumio/index.ts @@ -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'; diff --git a/ts/integrations/volumio/volumio.classes.client.ts b/ts/integrations/volumio/volumio.classes.client.ts new file mode 100644 index 0000000..dfeab13 --- /dev/null +++ b/ts/integrations/volumio/volumio.classes.client.ts @@ -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 { + 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 { + const response = await this.requestText('ping'); + return response.trim().toLowerCase() === 'pong'; + } + + public async getSystemVersion(): Promise { + return this.getJson('getSystemVersion'); + } + + public async getSystemInfo(): Promise { + return this.getJson('getSystemInfo'); + } + + public async getState(): Promise { + return this.getJson('getState'); + } + + public async getPlaylists(): Promise { + return this.getJson('listplaylists'); + } + + public async getQueue(): Promise { + return this.getJson('getQueue'); + } + + public async sendCommand(paramsArg: Record): Promise { + 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 { + await this.command('play'); + } + + public async pause(): Promise { + await this.command('pause'); + } + + public async toggle(): Promise { + await this.command('toggle'); + } + + public async stop(): Promise { + await this.command('stop'); + } + + public async next(): Promise { + await this.command('next'); + } + + public async previous(): Promise { + await this.command('prev'); + } + + public async setVolumeLevel(volumeLevelArg: number): Promise { + await this.command('volume', { volume: this.volumePercent(volumeLevelArg) }); + } + + public async volumeUp(): Promise { + await this.command('volume', { volume: 'plus' }); + } + + public async volumeDown(): Promise { + await this.command('volume', { volume: 'minus' }); + } + + public async setMuted(mutedArg: boolean): Promise { + await this.command('volume', { volume: mutedArg ? 'mute' : 'unmute' }); + } + + public async setShuffle(shuffleArg: boolean): Promise { + await this.command('random', { value: String(shuffleArg) }); + } + + public async repeatAll(repeatArg: boolean): Promise { + await this.command('repeat', { value: String(repeatArg) }); + } + + public async playPlaylist(playlistArg: string): Promise { + await this.command('playplaylist', { name: playlistArg }); + } + + public async clearPlaylist(): Promise { + await this.command('clearQueue'); + } + + public async seek(positionSecondsArg: number): Promise { + await this.command('seek', { position: Math.max(0, Math.round(positionSecondsArg)) }); + } + + public async replaceAndPlay(itemArg: unknown): Promise { + 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 { + 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 {} + + private async fetchSnapshot(): Promise { + 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 = {}): Promise { + await this.sendCommand({ cmd: commandArg, ...paramsArg }); + } + + private async getJson(pathArg: string, paramsArg?: URLSearchParams): Promise { + return this.requestJson('GET', pathArg, paramsArg); + } + + private async postJson(pathArg: string, bodyArg: unknown): Promise { + return this.requestJson('POST', pathArg, undefined, bodyArg); + } + + private async requestJson(methodArg: 'GET' | 'POST', pathArg: string, paramsArg?: URLSearchParams, bodyArg?: unknown): Promise { + 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 { + 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 { + 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): 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): 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): 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; +}; diff --git a/ts/integrations/volumio/volumio.classes.configflow.ts b/ts/integrations/volumio/volumio.classes.configflow.ts new file mode 100644 index 0000000..39cd2fc --- /dev/null +++ b/ts/integrations/volumio/volumio.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +} diff --git a/ts/integrations/volumio/volumio.classes.integration.ts b/ts/integrations/volumio/volumio.classes.integration.ts index e91da4c..5497500 100644 --- a/ts/integrations/volumio/volumio.classes.integration.ts +++ b/ts/integrations/volumio/volumio.classes.integration.ts @@ -1,26 +1,229 @@ -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 { VolumioClient } from './volumio.classes.client.js'; +import { VolumioConfigFlow } from './volumio.classes.configflow.js'; +import { createVolumioDiscoveryDescriptor } from './volumio.discovery.js'; +import { VolumioMapper } from './volumio.mapper.js'; +import type { IVolumioConfig } from './volumio.types.js'; -export class HomeAssistantVolumioIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "volumio", - displayName: "Volumio", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/volumio", - "upstreamDomain": "volumio", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "pyvolumio==0.1.5" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@OnFreund" - ] -}, - }); +export class VolumioIntegration extends BaseIntegration { + public readonly domain = 'volumio'; + public readonly displayName = 'Volumio'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createVolumioDiscoveryDescriptor(); + public readonly configFlow = new VolumioConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/volumio', + upstreamDomain: 'volumio', + integrationType: 'device', + iotClass: 'local_polling', + requirements: ['pyvolumio==0.1.5'], + dependencies: [], + afterDependencies: [], + codeowners: ['@OnFreund'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/volumio', + zeroconf: ['_Volumio._tcp.local.'], + }; + + public async setup(configArg: IVolumioConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new VolumioRuntime(new VolumioClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantVolumioIntegration extends VolumioIntegration {} + +class VolumioRuntime implements IIntegrationRuntime { + public domain = 'volumio'; + + constructor(private readonly client: VolumioClient) {} + + public async devices(): Promise { + return VolumioMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return VolumioMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(): Promise<() => Promise> { + return this.client.watchSocketIoPush(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === 'media_player') { + return await this.callMediaPlayerService(requestArg); + } + if (requestArg.domain === 'volumio') { + return await this.callVolumioService(requestArg); + } + return { success: false, error: `Unsupported Volumio service domain: ${requestArg.domain}` }; + } catch (errorArg) { + return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + 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 === 'play_pause' || requestArg.service === 'media_play_pause') { + await this.client.toggle(); + return { success: true }; + } + if (requestArg.service === 'stop' || requestArg.service === 'media_stop') { + await this.client.stop(); + return { success: true }; + } + if (requestArg.service === 'next_track' || requestArg.service === 'media_next_track') { + await this.client.next(); + return { success: true }; + } + if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') { + await this.client.previous(); + return { success: true }; + } + if (requestArg.service === 'volume_set') { + const level = this.numberData(requestArg, 'volume_level'); + if (typeof level !== 'number') { + return { success: false, error: 'Volumio 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') { + const muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'muted') ?? this.boolData(requestArg, 'mute'); + if (typeof muted !== 'boolean') { + return { success: false, error: 'Volumio volume_mute requires data.is_volume_muted.' }; + } + await this.client.setMuted(muted); + return { success: true }; + } + if (requestArg.service === 'select_source') { + const source = this.stringData(requestArg, 'source'); + if (!source) { + return { success: false, error: 'Volumio select_source requires data.source.' }; + } + await this.client.playPlaylist(source); + return { success: true }; + } + if (requestArg.service === 'media_seek' || requestArg.service === 'seek') { + const position = this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position'); + if (typeof position !== 'number') { + return { success: false, error: 'Volumio media_seek requires data.seek_position.' }; + } + await this.client.seek(position); + return { success: true }; + } + if (requestArg.service === 'shuffle_set') { + const shuffle = this.boolData(requestArg, 'shuffle'); + if (typeof shuffle !== 'boolean') { + return { success: false, error: 'Volumio shuffle_set requires data.shuffle.' }; + } + await this.client.setShuffle(shuffle); + return { success: true }; + } + if (requestArg.service === 'repeat_set') { + const repeat = this.stringData(requestArg, 'repeat') || this.stringData(requestArg, 'repeat_mode'); + await this.client.repeatAll(repeat !== 'off'); + return { success: true }; + } + if (requestArg.service === 'clear_playlist') { + await this.client.clearPlaylist(); + return { success: true }; + } + if (requestArg.service === 'play_media') { + const mediaId = requestArg.data?.media_content_id ?? requestArg.data?.mediaId ?? requestArg.data?.item; + if (mediaId === undefined) { + return { success: false, error: 'Volumio play_media requires data.media_content_id.' }; + } + await this.client.replaceAndPlay(this.mediaPayload(mediaId)); + return { success: true }; + } + return { success: false, error: `Unsupported Volumio media_player service: ${requestArg.service}` }; + } + + private async callVolumioService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'command') { + const command = this.stringData(requestArg, 'cmd') || this.stringData(requestArg, 'command'); + if (!command) { + return { success: false, error: 'Volumio command service requires data.cmd.' }; + } + const params = { ...requestArg.data }; + delete params.command; + params.cmd = command; + return { success: true, data: await this.client.sendCommand(params) }; + } + if (requestArg.service === 'socketio_subscribe') { + await this.client.watchSocketIoPush(); + } + return { success: false, error: `Unsupported Volumio service: ${requestArg.service}` }; + } + + private mediaPayload(valueArg: unknown): unknown { + if (typeof valueArg === 'string') { + try { + return JSON.parse(valueArg) as unknown; + } catch { + return { uri: valueArg }; + } + } + return valueArg; + } + + private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + const value = requestArg.data?.[keyArg]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + + private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined { + const value = requestArg.data?.[keyArg]; + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + if (value.toLowerCase() === 'true') { + return true; + } + if (value.toLowerCase() === 'false') { + return false; + } + } + return undefined; } } diff --git a/ts/integrations/volumio/volumio.discovery.ts b/ts/integrations/volumio/volumio.discovery.ts new file mode 100644 index 0000000..977b11c --- /dev/null +++ b/ts/integrations/volumio/volumio.discovery.ts @@ -0,0 +1,144 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IVolumioManualEntry, IVolumioMdnsRecord } from './volumio.types.js'; +import { volumioDefaultPort } from './volumio.types.js'; + +const volumioDomain = 'volumio'; +const volumioMdnsType = '_volumio._tcp.local'; + +export class VolumioMdnsMatcher implements IDiscoveryMatcher { + public id = 'volumio-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Volumio zeroconf advertisements.'; + + public async matches(recordArg: IVolumioMdnsRecord): Promise { + const type = normalizeType(recordArg.type); + const properties = { ...recordArg.txt, ...recordArg.properties }; + const uuid = valueForKey(properties, 'UUID') || valueForKey(properties, 'uuid') || valueForKey(properties, 'id'); + const name = cleanName(valueForKey(properties, 'volumioName') || valueForKey(properties, 'name') || recordArg.name || recordArg.hostname); + const matched = type === volumioMdnsType || Boolean(uuid && includesVolumio(name || recordArg.name)) || includesVolumio(`${recordArg.name || ''} ${type}`); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Volumio advertisement.' }; + } + + return { + matched: true, + confidence: uuid ? 'certain' : 'high', + reason: 'mDNS record matches Volumio zeroconf metadata.', + normalizedDeviceId: uuid, + candidate: { + source: 'mdns', + integrationDomain: volumioDomain, + id: uuid, + host: recordArg.host || recordArg.addresses?.[0], + port: recordArg.port || volumioDefaultPort, + name, + manufacturer: 'Volumio', + model: valueForKey(properties, 'hardware') || valueForKey(properties, 'model'), + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: properties, + }, + }, + }; + } +} + +export class VolumioManualMatcher implements IDiscoveryMatcher { + public id = 'volumio-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Volumio setup entries.'; + + public async matches(inputArg: IVolumioManualEntry): Promise { + const matched = Boolean( + inputArg.host + || includesVolumio(inputArg.name) + || includesVolumio(inputArg.model) + || includesVolumio(inputArg.hardware) + || includesVolumio(inputArg.manufacturer) + || inputArg.metadata?.volumio + ); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Volumio setup hints.' }; + } + + const id = inputArg.uuid || inputArg.id; + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Volumio setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: volumioDomain, + id, + host: inputArg.host, + port: inputArg.port || volumioDefaultPort, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'Volumio', + model: inputArg.model || inputArg.hardware, + metadata: { + ...inputArg.metadata, + hardware: inputArg.hardware, + }, + }, + }; + } +} + +export class VolumioCandidateValidator implements IDiscoveryValidator { + public id = 'volumio-candidate-validator'; + public description = 'Validate Volumio candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const matched = candidateArg.integrationDomain === volumioDomain + || includesVolumio(candidateArg.manufacturer) + || includesVolumio(candidateArg.model) + || includesVolumio(candidateArg.name) + || Boolean(candidateArg.metadata?.volumio || candidateArg.metadata?.volumioName); + + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Volumio metadata.' : 'Candidate is not Volumio.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id, + metadata: matched ? { validatedAs: volumioDomain } : undefined, + }; + } +} + +export const createVolumioDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: volumioDomain, displayName: 'Volumio' }) + .addMatcher(new VolumioMdnsMatcher()) + .addMatcher(new VolumioManualMatcher()) + .addValidator(new VolumioCandidateValidator()); +}; + +const normalizeType = (valueArg?: string): string => { + return (valueArg || '').toLowerCase().replace(/\.$/, ''); +}; + +const valueForKey = (recordArg: Record | 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 | undefined => { + return valueArg?.replace(/\._volumio\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined; +}; + +const includesVolumio = (valueArg: string | undefined): boolean => { + return Boolean(valueArg?.toLowerCase().includes('volumio')); +}; diff --git a/ts/integrations/volumio/volumio.mapper.ts b/ts/integrations/volumio/volumio.mapper.ts new file mode 100644 index 0000000..23309de --- /dev/null +++ b/ts/integrations/volumio/volumio.mapper.ts @@ -0,0 +1,203 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { IVolumioPlaylist, IVolumioPlaylistCollection, IVolumioSnapshot, IVolumioState } from './volumio.types.js'; + +export class VolumioMapper { + public static toDevices(snapshotArg: IVolumioSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const state = snapshotArg.state; + return [{ + id: this.deviceId(snapshotArg), + integrationDomain: 'volumio', + name: this.deviceName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.deviceInfo.manufacturer || 'Volumio', + model: snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.hardware, + 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: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true }, + { id: 'source', capability: 'media', name: 'Source', readable: true, writable: true }, + { id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false }, + { id: 'media_position', capability: 'media', name: 'Media position', readable: true, writable: true, unit: 's' }, + { id: 'media_duration', capability: 'media', name: 'Media duration', readable: true, writable: false, unit: 's' }, + { id: 'shuffle', capability: 'media', name: 'Shuffle', readable: true, writable: true }, + { id: 'repeat', capability: 'media', name: 'Repeat', readable: true, writable: true }, + ], + state: [ + { featureId: 'playback', value: this.mediaState(snapshotArg), updatedAt }, + { featureId: 'volume', value: this.volumePercent(state) ?? null, updatedAt }, + { featureId: 'muted', value: this.booleanValue(state.mute) ?? null, updatedAt }, + { featureId: 'source', value: state.service || null, updatedAt }, + { featureId: 'current_title', value: state.title || null, updatedAt }, + { featureId: 'media_position', value: this.mediaPosition(state), updatedAt }, + { featureId: 'media_duration', value: this.numberValue(state.duration) ?? null, updatedAt }, + { featureId: 'shuffle', value: this.booleanValue(state.random) ?? null, updatedAt }, + { featureId: 'repeat', value: this.booleanValue(state.repeat) ?? null, updatedAt }, + ], + metadata: { + uuid: snapshotArg.deviceInfo.uuid, + host: snapshotArg.deviceInfo.host, + port: snapshotArg.deviceInfo.port, + hardware: snapshotArg.deviceInfo.hardware, + systemVersion: snapshotArg.deviceInfo.systemVersion, + service: state.service, + trackType: state.trackType, + uri: state.uri, + }, + }]; + } + + public static toEntities(snapshotArg: IVolumioSnapshot): IIntegrationEntity[] { + const state = snapshotArg.state; + const volume = this.volumePercent(state); + return [{ + id: `media_player.${this.slug(this.deviceName(snapshotArg))}`, + uniqueId: `volumio_${this.uniqueBase(snapshotArg)}`, + integrationDomain: 'volumio', + deviceId: this.deviceId(snapshotArg), + platform: 'media_player', + name: this.deviceName(snapshotArg), + state: this.mediaState(snapshotArg), + attributes: { + volumeLevel: typeof volume === 'number' ? volume / 100 : undefined, + volumePercent: volume, + isVolumeMuted: this.booleanValue(state.mute), + mediaContentId: state.uri, + mediaContentType: this.mediaContentType(state), + mediaDuration: this.numberValue(state.duration), + mediaPosition: this.mediaPosition(state), + mediaTitle: state.title, + mediaArtist: state.artist, + mediaAlbumName: state.album, + mediaImageUrl: state.albumart, + source: state.service, + sourceList: this.playlistNames(snapshotArg.playlists), + shuffle: this.booleanValue(state.random), + repeat: this.booleanValue(state.repeat) ? 'all' : 'off', + repeatSingle: this.booleanValue(state.repeatSingle), + position: this.numberValue(state.position), + service: state.service, + trackType: state.trackType, + sampleRate: this.numberValue(state.samplerate) ?? state.samplerate, + bitDepth: this.numberValue(state.bitdepth) ?? state.bitdepth, + bitrate: this.numberValue(state.bitrate) ?? state.bitrate, + channels: this.numberValue(state.channels) ?? state.channels, + consume: this.booleanValue(state.consume), + volatile: this.booleanValue(state.volatile), + }, + available: snapshotArg.online, + }]; + } + + public static deviceId(snapshotArg: IVolumioSnapshot): string { + return `volumio.device.${this.uniqueBase(snapshotArg)}`; + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'volumio'; + } + + private static mediaState(snapshotArg: IVolumioSnapshot): string { + if (!snapshotArg.online) { + return 'off'; + } + const status = snapshotArg.state.status; + if (status === 'play') { + return 'playing'; + } + if (status === 'pause') { + return 'paused'; + } + return 'idle'; + } + + private static mediaContentType(stateArg: IVolumioState): string { + if (stateArg.trackType === 'webradio') { + return 'channel'; + } + return stateArg.trackType || 'music'; + } + + private static mediaPosition(stateArg: IVolumioState): number | null { + const seek = this.numberValue(stateArg.seek); + if (typeof seek !== 'number') { + return null; + } + const duration = this.numberValue(stateArg.duration); + if (seek > 1000 && (duration === undefined || seek > duration + 5)) { + return Math.round(seek / 1000); + } + return seek; + } + + private static volumePercent(stateArg: IVolumioState): number | undefined { + const value = this.numberValue(stateArg.volume); + if (typeof value !== 'number') { + return undefined; + } + return Math.max(0, Math.min(100, Math.round(value))); + } + + private static playlistNames(playlistsArg: IVolumioPlaylistCollection | undefined): string[] { + if (!playlistsArg) { + return []; + } + if (Array.isArray(playlistsArg)) { + return playlistsArg.map((itemArg) => this.playlistName(itemArg)).filter((itemArg): itemArg is string => Boolean(itemArg)); + } + if (Array.isArray(playlistsArg.playlists)) { + return playlistsArg.playlists.map((itemArg) => this.playlistName(itemArg)).filter((itemArg): itemArg is string => Boolean(itemArg)); + } + if (Array.isArray(playlistsArg.lists)) { + return playlistsArg.lists.flatMap((listArg) => (listArg.items || []).map((itemArg) => this.playlistName(itemArg))).filter((itemArg): itemArg is string => Boolean(itemArg)); + } + if (Array.isArray(playlistsArg.navigation?.lists)) { + return playlistsArg.navigation.lists.flatMap((listArg) => (listArg.items || []).map((itemArg) => this.playlistName(itemArg))).filter((itemArg): itemArg is string => Boolean(itemArg)); + } + return []; + } + + private static playlistName(valueArg: string | IVolumioPlaylist): string | undefined { + if (typeof valueArg === 'string') { + return valueArg; + } + return valueArg.name || valueArg.title; + } + + private static uniqueBase(snapshotArg: IVolumioSnapshot): string { + return this.slug(snapshotArg.deviceInfo.uuid || snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg)); + } + + private static deviceName(snapshotArg: IVolumioSnapshot): string { + return snapshotArg.deviceInfo.name || snapshotArg.systemInfo?.name || 'Volumio'; + } + + 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 booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + const normalized = valueArg.toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') { + return true; + } + if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') { + return false; + } + } + return undefined; + } +} diff --git a/ts/integrations/volumio/volumio.types.ts b/ts/integrations/volumio/volumio.types.ts index 1124a07..14f0987 100644 --- a/ts/integrations/volumio/volumio.types.ts +++ b/ts/integrations/volumio/volumio.types.ts @@ -1,4 +1,156 @@ -export interface IHomeAssistantVolumioConfig { - // TODO: replace with the TypeScript-native config for volumio. +export const volumioDefaultPort = 3000; + +export interface IVolumioConfig { + host?: string; + port?: number; + timeoutMs?: number; + name?: string; + uniqueId?: string; + connected?: boolean; + ssl?: boolean; + deviceInfo?: IVolumioDeviceInfo; + systemInfo?: IVolumioSystemInfo; + systemVersion?: IVolumioSystemVersion; + state?: IVolumioState; + playlists?: IVolumioPlaylistCollection; + queue?: IVolumioQueue; + snapshot?: IVolumioSnapshot; +} + +export interface IHomeAssistantVolumioConfig extends IVolumioConfig {} + +export interface IVolumioDeviceInfo { + id?: string; + uuid?: string; + name?: string; + host?: string; + port?: number; + manufacturer?: string; + model?: string; + hardware?: string; + systemVersion?: string; + softwareVersion?: string; +} + +export interface IVolumioSystemInfo { + id?: string; + uuid?: string; + name?: string; + hardware?: string; + systemversion?: string; + systemVersion?: string; + version?: string; + volumioVersion?: string; [key: string]: unknown; } + +export interface IVolumioSystemVersion { + hardware?: string; + systemversion?: string; + systemVersion?: string; + version?: string; + volumioVersion?: string; + [key: string]: unknown; +} + +export interface IVolumioState { + status?: 'play' | 'pause' | 'stop' | string; + position?: number | string; + title?: string; + artist?: string; + album?: string; + albumart?: string; + uri?: string; + trackType?: string; + seek?: number | string; + duration?: number | string; + samplerate?: number | string; + bitdepth?: number | string; + channels?: number | string; + bitrate?: number | string; + random?: boolean | string; + repeat?: boolean | string; + repeatSingle?: boolean | string; + consume?: boolean | string; + volume?: number | string; + dbVolume?: number | string; + mute?: boolean | string; + disableVolumeControl?: boolean | string; + stream?: boolean | string; + volatile?: boolean | string; + service?: string; + updatedb?: boolean | string; + [key: string]: unknown; +} + +export interface IVolumioPlaylist { + name?: string; + title?: string; + uri?: string; + [key: string]: unknown; +} + +export type IVolumioPlaylistCollection = + | string[] + | IVolumioPlaylist[] + | { + playlists?: Array; + lists?: Array<{ items?: IVolumioPlaylist[]; title?: string; [key: string]: unknown }>; + navigation?: { + lists?: Array<{ items?: IVolumioPlaylist[]; title?: string; [key: string]: unknown }>; + }; + [key: string]: unknown; + }; + +export interface IVolumioQueue { + queue?: IVolumioPlaylist[]; + [key: string]: unknown; +} + +export interface IVolumioSnapshot { + deviceInfo: IVolumioDeviceInfo; + systemInfo?: IVolumioSystemInfo; + systemVersion?: IVolumioSystemVersion; + state: IVolumioState; + playlists?: IVolumioPlaylistCollection; + queue?: IVolumioQueue; + online: boolean; + updatedAt?: string; +} + +export interface IVolumioMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + addresses?: string[]; + hostname?: string; + txt?: Record; + properties?: Record; +} + +export interface IVolumioManualEntry { + host?: string; + port?: number; + id?: string; + uuid?: string; + name?: string; + model?: string; + hardware?: string; + manufacturer?: string; + metadata?: Record; +} + +export type TVolumioCommand = + | 'play' + | 'pause' + | 'stop' + | 'next' + | 'prev' + | 'volume' + | 'playplaylist' + | 'seek' + | 'repeat' + | 'random' + | 'clearQueue' + | string; diff --git a/ts/integrations/yamaha_musiccast/.generated-by-smarthome-exchange b/ts/integrations/yamaha_musiccast/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/yamaha_musiccast/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/yamaha_musiccast/index.ts b/ts/integrations/yamaha_musiccast/index.ts index 3f3e230..70c242c 100644 --- a/ts/integrations/yamaha_musiccast/index.ts +++ b/ts/integrations/yamaha_musiccast/index.ts @@ -1,2 +1,6 @@ +export * from './yamaha_musiccast.classes.client.js'; +export * from './yamaha_musiccast.classes.configflow.js'; export * from './yamaha_musiccast.classes.integration.js'; +export * from './yamaha_musiccast.discovery.js'; +export * from './yamaha_musiccast.mapper.js'; export * from './yamaha_musiccast.types.js'; diff --git a/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.client.ts b/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.client.ts new file mode 100644 index 0000000..87aeb9c --- /dev/null +++ b/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.client.ts @@ -0,0 +1,512 @@ +import type { + IYamahaMusiccastCommandRequest, + IYamahaMusiccastConfig, + IYamahaMusiccastDeviceInfo, + IYamahaMusiccastDistributionInfo, + IYamahaMusiccastFeatures, + IYamahaMusiccastFuncStatus, + IYamahaMusiccastNetusbPlayInfo, + IYamahaMusiccastNetworkStatus, + IYamahaMusiccastRangeStep, + IYamahaMusiccastSnapshot, + IYamahaMusiccastTunerPlayInfo, + IYamahaMusiccastZoneFeatures, + IYamahaMusiccastZoneState, + IYamahaMusiccastZoneStatus, + TYamahaMusiccastZoneId, +} from './yamaha_musiccast.types.js'; + +const defaultZone = 'main'; +const defaultPort = 80; +const zoneOrder = ['main', 'zone2', 'zone3', 'zone4']; + +export class YamahaMusiccastClient { + constructor(private readonly config: IYamahaMusiccastConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.config.snapshot; + } + + if (!this.config.host) { + return this.manualSnapshot(); + } + + const deviceInfo = await this.getDeviceInfo(); + const [networkStatus, funcStatus, features, distribution] = await Promise.all([ + this.optionalJson('/system/getNetworkStatus'), + this.optionalJson('/system/getFuncStatus'), + this.optionalJson('/system/getFeatures'), + this.optionalJson('/dist/getDistributionInfo'), + ]); + const zoneIds = this.zoneIds(features); + const [netusb, tuner] = await Promise.all([ + this.optionalJson('/netusb/getPlayInfo'), + this.optionalJson('/tuner/getPlayInfo'), + ]); + const zones = await Promise.all(zoneIds.map((zoneArg) => this.getZoneState(zoneArg, features))); + + return { + deviceInfo: { ...this.manualDeviceInfo(), ...deviceInfo }, + networkStatus, + funcStatus, + features, + zones, + inputNames: this.config.inputNames, + netusb, + tuner, + distribution, + capabilities: this.config.capabilities, + lastUpdated: new Date().toISOString(), + }; + } + + public async getDeviceInfo(): Promise { + if (this.config.deviceInfo && !this.config.host) { + return this.config.deviceInfo; + } + if (!this.config.host) { + return this.manualDeviceInfo(); + } + return { ...this.manualDeviceInfo(), ...(await this.fetchJson('/system/getDeviceInfo')) }; + } + + public async execute(requestArg: IYamahaMusiccastCommandRequest): Promise { + if (requestArg.command === 'raw_get') { + if (!requestArg.path) { + throw new Error('Yamaha MusicCast raw_get requires a path.'); + } + return this.command(requestArg.path); + } + + const zone = this.normalizeZone(requestArg.zone); + if (requestArg.command === 'turn_on') { + return this.command(this.path(`/${zone}/setPower`, { power: 'on' })); + } + if (requestArg.command === 'turn_off') { + return this.command(this.path(`/${zone}/setPower`, { power: 'standby' })); + } + if (requestArg.command === 'volume_up') { + return this.command(this.path(`/${zone}/setVolume`, { volume: 'up' })); + } + if (requestArg.command === 'volume_down') { + return this.command(this.path(`/${zone}/setVolume`, { volume: 'down' })); + } + if (requestArg.command === 'set_volume') { + const volume = typeof requestArg.volume === 'number' + ? requestArg.volume + : await this.volumeLevelToRaw(zone, requestArg.volumeLevel ?? 0); + return this.command(this.path(`/${zone}/setVolume`, { volume })); + } + if (requestArg.command === 'mute') { + return this.command(this.path(`/${zone}/setMute`, { enable: this.boolString(Boolean(requestArg.muted)) })); + } + if (requestArg.command === 'select_source') { + if (!requestArg.source) { + throw new Error('Yamaha MusicCast select_source requires a source.'); + } + return this.command(this.path(`/${zone}/setInput`, { input: requestArg.source })); + } + if (requestArg.command === 'select_sound_mode') { + if (!requestArg.soundMode) { + throw new Error('Yamaha MusicCast select_sound_mode requires soundMode.'); + } + return this.command(this.path(`/${zone}/setSoundProgram`, { program: requestArg.soundMode })); + } + if (requestArg.command === 'play' || requestArg.command === 'pause' || requestArg.command === 'stop' || requestArg.command === 'play_pause') { + const playback = requestArg.command === 'play' ? 'play' : requestArg.command === 'pause' ? 'pause' : requestArg.command === 'stop' ? 'stop' : 'play_pause'; + return this.command(this.path('/netusb/setPlayback', { playback })); + } + if (requestArg.command === 'previous_track' || requestArg.command === 'next_track') { + const playback = requestArg.command === 'previous_track' ? 'previous' : 'next'; + const snapshot = await this.getSnapshot().catch(() => undefined); + const source = snapshot?.zones.find((zoneArg) => zoneArg.zone === zone)?.input; + if (source === 'tuner') { + return this.command(this.path('/tuner/switchPreset', { dir: playback })); + } + return this.command(this.path('/netusb/setPlayback', { playback })); + } + if (requestArg.command === 'set_repeat') { + return this.command(this.path('/netusb/setRepeat', { mode: requestArg.repeat || requestArg.option || 'off' })); + } + if (requestArg.command === 'set_shuffle') { + const mode = typeof requestArg.shuffle === 'string' ? requestArg.shuffle : requestArg.shuffle ? 'on' : 'off'; + return this.command(this.path('/netusb/setShuffle', { mode })); + } + if (requestArg.command === 'set_switch' || requestArg.command === 'select_option' || requestArg.command === 'set_number') { + return this.executeCapability(requestArg, zone); + } + + throw new Error(`Unsupported Yamaha MusicCast command: ${requestArg.command}`); + } + + public async destroy(): Promise {} + + private async getZoneState(zoneArg: TYamahaMusiccastZoneId, featuresArg: IYamahaMusiccastFeatures | undefined): Promise { + const [status, soundPrograms, signalInfo, nameText] = await Promise.all([ + this.optionalJson(`/${zoneArg}/getStatus`), + this.optionalJson<{ sound_program_list?: string[] }>(`/${zoneArg}/getSoundProgramList`), + this.optionalJson>(`/${zoneArg}/getSignalInfo`), + this.optionalJson<{ text?: string }>(this.path('/system/getNameText', { id: zoneArg })), + ]); + return this.normalizeZoneState(zoneArg, status, featuresArg, soundPrograms?.sound_program_list, signalInfo, nameText?.text, status !== undefined); + } + + private manualSnapshot(): IYamahaMusiccastSnapshot { + const zones = this.config.zones?.length + ? this.config.zones.map((zoneArg) => this.normalizeManualZone(zoneArg)) + : [this.normalizeManualZone({ zone: defaultZone, name: this.config.name || 'Main Zone', power: 'standby', available: false })]; + + return { + deviceInfo: this.config.deviceInfo || this.manualDeviceInfo(), + networkStatus: this.config.networkStatus, + funcStatus: this.config.funcStatus, + features: this.config.features, + zones, + inputNames: this.config.inputNames, + netusb: this.config.netusb, + tuner: this.config.tuner, + distribution: this.config.distribution, + capabilities: this.config.capabilities, + lastUpdated: new Date().toISOString(), + }; + } + + private manualDeviceInfo(): IYamahaMusiccastDeviceInfo { + return { + model_name: this.config.model, + serial_number: this.config.serialNumber, + system_id: this.config.systemId || this.config.serialNumber, + device_id: this.config.deviceId || this.config.systemId || this.config.serialNumber, + }; + } + + private normalizeManualZone(zoneArg: IYamahaMusiccastZoneState): IYamahaMusiccastZoneState { + const feature = this.config.features?.zone?.find((featureArg) => featureArg.id === zoneArg.zone); + const status = zoneArg.status || this.zoneStateToStatus(zoneArg); + return { + ...this.normalizeZoneState(zoneArg.zone, status, this.config.features, zoneArg.soundProgramList, zoneArg.signalInfo, zoneArg.name, zoneArg.available !== false), + ...zoneArg, + inputList: zoneArg.inputList || feature?.input_list, + features: zoneArg.features || feature?.func_list, + rangeStep: zoneArg.rangeStep || feature?.range_step, + }; + } + + private normalizeZoneState( + zoneArg: TYamahaMusiccastZoneId, + statusArg: IYamahaMusiccastZoneStatus | undefined, + featuresArg: IYamahaMusiccastFeatures | undefined, + soundProgramListArg: string[] | undefined, + signalInfoArg: Record | undefined, + nameArg: string | undefined, + availableArg: boolean + ): IYamahaMusiccastZoneState { + const feature = featuresArg?.zone?.find((featureArg) => featureArg.id === zoneArg); + const volumeRange = this.range(feature, 'volume'); + const minVolume = statusArg?.min_volume ?? volumeRange?.min ?? 0; + const maxVolume = statusArg?.max_volume ?? volumeRange?.max ?? 100; + const volume = statusArg?.volume; + + return { + zone: zoneArg, + name: nameArg || this.config.zoneNames?.[zoneArg] || this.defaultZoneName(zoneArg), + power: statusArg?.power, + available: availableArg, + sleep: statusArg?.sleep, + volume, + minVolume, + maxVolume, + volumeLevel: typeof volume === 'number' && maxVolume > minVolume ? (volume - minVolume) / (maxVolume - minVolume) : undefined, + muted: statusArg?.mute, + input: statusArg?.input, + inputText: statusArg?.input_text, + inputList: feature?.input_list, + soundProgram: statusArg?.sound_program, + soundProgramList: soundProgramListArg || feature?.sound_program_list, + surrDecoderType: statusArg?.surr_decoder_type, + surrDecoderTypeList: feature?.surr_decoder_type_list, + toneControlModeList: feature?.tone_control_mode_list, + equalizerModeList: feature?.equalizer_mode_list, + linkControlList: feature?.link_control_list, + linkAudioDelayList: feature?.link_audio_delay_list, + linkAudioQualityList: feature?.link_audio_quality_list, + rangeStep: feature?.range_step, + features: feature?.func_list, + status: statusArg, + signalInfo: signalInfoArg, + actualVolume: statusArg?.actual_volume, + toneControl: statusArg?.tone_control, + equalizer: statusArg?.equalizer, + balance: this.numberValue(statusArg?.balance), + dialogueLevel: this.numberValue(statusArg?.dialogue_level), + dialogueLift: this.numberValue(statusArg?.dialogue_lift), + dtsDialogueControl: this.numberValue(statusArg?.dts_dialogue_control), + subwooferVolume: this.numberValue(statusArg?.subwoofer_volume), + linkControl: statusArg?.link_control, + linkAudioDelay: statusArg?.link_audio_delay, + linkAudioQuality: statusArg?.link_audio_quality, + extraBass: this.booleanValue(statusArg?.extra_bass), + bassExtension: this.booleanValue(statusArg?.bass_extension), + enhancer: this.booleanValue(statusArg?.enhancer), + pureDirect: this.booleanValue(statusArg?.pure_direct), + adaptiveDrc: this.booleanValue(statusArg?.adaptive_drc), + clearVoice: this.booleanValue(statusArg?.clear_voice), + surround3d: this.booleanValue(statusArg?.surround_3d ?? statusArg?.['3d_surround']), + mono: this.booleanValue(statusArg?.mono), + }; + } + + private zoneStateToStatus(zoneArg: IYamahaMusiccastZoneState): IYamahaMusiccastZoneStatus { + return { + power: zoneArg.power, + sleep: zoneArg.sleep, + volume: zoneArg.volume, + max_volume: zoneArg.maxVolume, + min_volume: zoneArg.minVolume, + mute: zoneArg.muted, + input: zoneArg.input, + input_text: zoneArg.inputText, + sound_program: zoneArg.soundProgram, + surr_decoder_type: zoneArg.surrDecoderType, + tone_control: zoneArg.toneControl, + equalizer: zoneArg.equalizer, + balance: zoneArg.balance, + dialogue_level: zoneArg.dialogueLevel, + dialogue_lift: zoneArg.dialogueLift, + dts_dialogue_control: zoneArg.dtsDialogueControl, + subwoofer_volume: zoneArg.subwooferVolume, + link_control: zoneArg.linkControl, + link_audio_delay: zoneArg.linkAudioDelay, + link_audio_quality: zoneArg.linkAudioQuality, + extra_bass: zoneArg.extraBass, + bass_extension: zoneArg.bassExtension, + enhancer: zoneArg.enhancer, + pure_direct: zoneArg.pureDirect, + adaptive_drc: zoneArg.adaptiveDrc, + clear_voice: zoneArg.clearVoice, + surround_3d: zoneArg.surround3d, + mono: zoneArg.mono, + }; + } + + private async executeCapability(requestArg: IYamahaMusiccastCommandRequest, zoneArg: TYamahaMusiccastZoneId): Promise { + if (!requestArg.capabilityId) { + throw new Error('Yamaha MusicCast capability service calls require capabilityId.'); + } + const value = requestArg.value ?? requestArg.option; + const capability = requestArg.capabilityId; + + if (capability === 'enhancer') { + return this.command(this.path(`/${zoneArg}/setEnhancer`, { enable: this.boolString(Boolean(value)) })); + } + if (capability === 'pure_direct') { + return this.command(this.path(`/${zoneArg}/setPureDirect`, { enable: this.boolString(Boolean(value)) })); + } + if (capability === 'extra_bass') { + return this.command(this.path(`/${zoneArg}/setExtraBass`, { enable: this.boolString(Boolean(value)) })); + } + if (capability === 'bass_extension') { + return this.command(this.path(`/${zoneArg}/setBassExtension`, { enable: this.boolString(Boolean(value)) })); + } + if (capability === 'adaptive_drc') { + return this.command(this.path(`/${zoneArg}/setAdaptiveDrc`, { enable: this.boolString(Boolean(value)) })); + } + if (capability === 'clear_voice') { + return this.command(this.path(`/${zoneArg}/setClearVoice`, { enable: this.boolString(Boolean(value)) })); + } + if (capability === 'surround_3d') { + return this.command(this.path(`/${zoneArg}/set3dSurround`, { enable: this.boolString(Boolean(value)) })); + } + if (capability === 'sleep') { + return this.command(this.path(`/${zoneArg}/setSleep`, { sleep: this.sleepValue(value) })); + } + if (capability === 'tone_control_mode') { + return this.command(this.path(`/${zoneArg}/setToneControl`, { mode: value })); + } + if (capability === 'tone_control_bass') { + return this.command(this.path(`/${zoneArg}/setToneControl`, { bass: value })); + } + if (capability === 'tone_control_treble') { + return this.command(this.path(`/${zoneArg}/setToneControl`, { treble: value })); + } + if (capability === 'equalizer_mode') { + return this.command(this.path(`/${zoneArg}/setEqualizer`, { mode: value })); + } + if (capability === 'equalizer_low') { + return this.command(this.path(`/${zoneArg}/setEqualizer`, { low: value })); + } + if (capability === 'equalizer_mid') { + return this.command(this.path(`/${zoneArg}/setEqualizer`, { mid: value })); + } + if (capability === 'equalizer_high') { + return this.command(this.path(`/${zoneArg}/setEqualizer`, { high: value })); + } + if (capability === 'balance') { + return this.command(this.path(`/${zoneArg}/setBalance`, { value })); + } + if (capability === 'dialogue_level') { + return this.command(this.path(`/${zoneArg}/setDialogueLevel`, { value })); + } + if (capability === 'dialogue_lift') { + return this.command(this.path(`/${zoneArg}/setDialogueLift`, { value })); + } + if (capability === 'dts_dialogue_control') { + return this.command(this.path(`/${zoneArg}/setDtsDialogueControl`, { num: value })); + } + if (capability === 'subwoofer_volume') { + return this.command(this.path(`/${zoneArg}/setSubwooferVolume`, { volume: value })); + } + if (capability === 'surr_decoder_type') { + return this.command(this.path(`/${zoneArg}/setSurroundDecoderType`, { type: value })); + } + if (capability === 'link_control') { + return this.command(this.path(`/${zoneArg}/setLinkControl`, { control: value })); + } + if (capability === 'link_audio_delay') { + return this.command(this.path(`/${zoneArg}/setLinkAudioDelay`, { delay: value })); + } + if (capability === 'link_audio_quality') { + return this.command(this.path(`/${zoneArg}/setLinkAudioQuality`, { mode: value })); + } + + throw new Error(`Unsupported Yamaha MusicCast capability: ${capability}`); + } + + private async volumeLevelToRaw(zoneArg: TYamahaMusiccastZoneId, volumeLevelArg: number): Promise { + const snapshot = await this.getSnapshot(); + const zone = snapshot.zones.find((zoneItemArg) => zoneItemArg.zone === zoneArg); + const minVolume = zone?.minVolume ?? 0; + const maxVolume = zone?.maxVolume ?? 100; + return Math.round(minVolume + Math.max(0, Math.min(1, volumeLevelArg)) * (maxVolume - minVolume)); + } + + private async command(pathArg: string): Promise { + return this.fetchJson(pathArg.startsWith('/') ? pathArg : `/${pathArg}`); + } + + private async optionalJson(pathArg: string): Promise { + try { + return await this.fetchJson(pathArg); + } catch { + return undefined; + } + } + + private async fetchJson(pathArg: string): Promise { + if (!this.config.host) { + throw new Error('Yamaha MusicCast host is required for local HTTP API calls.'); + } + const controller = this.config.requestTimeoutMs ? new AbortController() : undefined; + const timeout = controller ? setTimeout(() => controller.abort(), this.config.requestTimeoutMs) : undefined; + try { + const response = await globalThis.fetch(`${this.baseUrl()}${pathArg}`, { + headers: { + 'X-AppName': 'MusicCast/1.0', + 'X-AppPort': '41100', + }, + signal: controller?.signal, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Yamaha MusicCast request ${pathArg} failed with HTTP ${response.status}: ${text}`); + } + const data = JSON.parse(text) as T & { response_code?: number }; + if (typeof data.response_code === 'number' && data.response_code !== 0) { + throw new Error(`Yamaha MusicCast request ${pathArg} failed with response_code ${data.response_code}.`); + } + return data; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + } + + private zoneIds(featuresArg: IYamahaMusiccastFeatures | undefined): TYamahaMusiccastZoneId[] { + const configured = this.config.zones?.map((zoneArg) => zoneArg.zone) || []; + const fromFeatures = featuresArg?.zone?.map((zoneArg) => zoneArg.id) || []; + const values = configured.length ? configured : fromFeatures.length ? fromFeatures : [defaultZone]; + return [...new Set(values)].sort((leftArg, rightArg) => this.zoneSort(leftArg) - this.zoneSort(rightArg)); + } + + private zoneSort(zoneArg: string): number { + const index = zoneOrder.indexOf(zoneArg); + return index === -1 ? 999 : index; + } + + private range(featureArg: IYamahaMusiccastZoneFeatures | undefined, idArg: string): IYamahaMusiccastRangeStep | undefined { + return featureArg?.range_step?.find((rangeArg) => rangeArg.id === idArg); + } + + private normalizeZone(zoneArg: TYamahaMusiccastZoneId | undefined): TYamahaMusiccastZoneId { + if (!zoneArg || zoneArg === '1') { + return defaultZone; + } + if (zoneArg === '2') { + return 'zone2'; + } + if (zoneArg === '3') { + return 'zone3'; + } + if (zoneArg === '4') { + return 'zone4'; + } + return zoneArg; + } + + private defaultZoneName(zoneArg: string): string { + if (zoneArg === defaultZone) { + return this.config.name || 'Main Zone'; + } + return zoneArg.replace(/^zone/, 'Zone '); + } + + private baseUrl(): string { + const rawHost = this.config.host || 'localhost'; + if (/^https?:\/\//i.test(rawHost)) { + const url = new URL(rawHost); + if (this.config.port && !url.port) { + url.port = String(this.config.port); + } + return url.origin; + } + const host = rawHost.replace(/\/.*$/, ''); + const port = this.config.port || defaultPort; + return /:\d+$/.test(host) ? `http://${host}` : `http://${host}:${port}`; + } + + private path(baseArg: string, paramsArg: Record): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(paramsArg)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + const query = params.toString(); + return query ? `${baseArg}?${query}` : baseArg; + } + + private boolString(valueArg: boolean): string { + return valueArg ? 'true' : 'false'; + } + + private sleepValue(valueArg: unknown): number { + if (typeof valueArg === 'number') { + return valueArg; + } + if (valueArg === 'off') { + return 0; + } + const match = String(valueArg || '').match(/\d+/); + return match ? Number(match[0]) : 0; + } + + 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; + } +} diff --git a/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.configflow.ts b/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.configflow.ts new file mode 100644 index 0000000..9e9859a --- /dev/null +++ b/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.configflow.ts @@ -0,0 +1,73 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IYamahaMusiccastConfig } from './yamaha_musiccast.types.js'; + +export class YamahaMusiccastConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Yamaha MusicCast Device', + description: 'Configure the local Yamaha MusicCast HTTP endpoint.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'name', label: 'Name', type: 'text' }, + { name: 'model', label: 'Model', type: 'text' }, + ], + submit: async (valuesArg) => { + const host = stringValue(valuesArg.host) || candidateArg.host; + if (!host) { + return { + kind: 'error', + title: 'Yamaha MusicCast setup failed', + error: 'Host is required.', + }; + } + const port = numberValue(valuesArg.port) ?? candidateArg.port ?? 80; + const name = stringValue(valuesArg.name) || candidateArg.name; + const model = stringValue(valuesArg.model) || candidateArg.model; + const systemId = stringMetadata(candidateArg.metadata?.systemId); + const deviceId = stringMetadata(candidateArg.metadata?.deviceId); + return { + kind: 'done', + title: 'Yamaha MusicCast configured', + config: { + host, + port, + name, + model, + manufacturer: candidateArg.manufacturer, + serialNumber: candidateArg.serialNumber, + systemId, + deviceId, + deviceInfo: name || model || candidateArg.serialNumber || systemId || deviceId ? { + model_name: model, + serial_number: candidateArg.serialNumber, + system_id: systemId, + device_id: deviceId, + } : undefined, + }, + }; + }, + }; + } +} + +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 numberValue = Number(valueArg); + return Number.isFinite(numberValue) ? numberValue : undefined; + } + return undefined; +}; + +const stringMetadata = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' && valueArg ? valueArg : undefined; +}; diff --git a/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.integration.ts b/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.integration.ts index 7aa4cac..ed7ea5f 100644 --- a/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.integration.ts +++ b/ts/integrations/yamaha_musiccast/yamaha_musiccast.classes.integration.ts @@ -1,29 +1,244 @@ -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 { YamahaMusiccastClient } from './yamaha_musiccast.classes.client.js'; +import { YamahaMusiccastConfigFlow } from './yamaha_musiccast.classes.configflow.js'; +import { createYamahaMusiccastDiscoveryDescriptor } from './yamaha_musiccast.discovery.js'; +import { YamahaMusiccastMapper } from './yamaha_musiccast.mapper.js'; +import type { IYamahaMusiccastCapability, IYamahaMusiccastConfig, IYamahaMusiccastCommandRequest, TYamahaMusiccastCommand, TYamahaMusiccastZoneId } from './yamaha_musiccast.types.js'; -export class HomeAssistantYamahaMusiccastIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "yamaha_musiccast", - displayName: "MusicCast", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/yamaha_musiccast", - "upstreamDomain": "yamaha_musiccast", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "aiomusiccast==0.15.0" - ], - "dependencies": [ - "ssdp" - ], - "afterDependencies": [], - "codeowners": [ - "@vigonotion", - "@micha91" - ] -}, +export class YamahaMusiccastIntegration extends BaseIntegration { + public readonly domain = 'yamaha_musiccast'; + public readonly displayName = 'MusicCast'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createYamahaMusiccastDiscoveryDescriptor(); + public readonly configFlow = new YamahaMusiccastConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/yamaha_musiccast', + upstreamDomain: 'yamaha_musiccast', + integrationType: 'device', + iotClass: 'local_push', + requirements: ['aiomusiccast==0.15.0'], + dependencies: ['ssdp'], + afterDependencies: [], + codeowners: ['@vigonotion', '@micha91'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/yamaha_musiccast', + }; + + public async setup(configArg: IYamahaMusiccastConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new YamahaMusiccastRuntime(new YamahaMusiccastClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantYamahaMusiccastIntegration extends YamahaMusiccastIntegration {} + +class YamahaMusiccastRuntime implements IIntegrationRuntime { + public domain = 'yamaha_musiccast'; + + constructor(private readonly client: YamahaMusiccastClient) {} + + public async devices(): Promise { + return YamahaMusiccastMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return YamahaMusiccastMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === 'media_player') { + return await this.callMediaPlayerService(requestArg); + } + if (requestArg.domain === 'switch') { + return await this.callSwitchService(requestArg); + } + if (requestArg.domain === 'select') { + return await this.callSelectService(requestArg); + } + if (requestArg.domain === 'number') { + return await this.callNumberService(requestArg); + } + return { success: false, error: `Unsupported Yamaha MusicCast service domain: ${requestArg.domain}` }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + const command = this.commandFromMediaService(requestArg); + if (!command) { + return { success: false, error: `Unsupported Yamaha MusicCast media_player service: ${requestArg.service}` }; + } + const zone = await this.zoneFromRequest(requestArg); + const result = await this.client.execute({ + command, + zone, + source: this.stringData(requestArg, 'source') || this.stringData(requestArg, 'input'), + soundMode: this.stringData(requestArg, 'sound_mode') || this.stringData(requestArg, 'soundMode'), + volumeLevel: this.numberData(requestArg, 'volume_level') ?? this.numberData(requestArg, 'volumeLevel'), + volume: this.numberData(requestArg, 'volume'), + muted: this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute') ?? this.boolData(requestArg, 'muted'), + repeat: this.stringData(requestArg, 'repeat'), + shuffle: this.boolData(requestArg, 'shuffle') ?? this.stringData(requestArg, 'shuffle'), + option: this.stringData(requestArg, 'option'), }); + return { success: true, data: result }; + } + + private async callSwitchService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service !== 'turn_on' && requestArg.service !== 'turn_off') { + return { success: false, error: `Unsupported Yamaha MusicCast switch service: ${requestArg.service}` }; + } + const context = await this.capabilityContextFromRequest(requestArg); + if (!context.capabilityId) { + return { success: false, error: 'Yamaha MusicCast switch service requires a capability target.' }; + } + const result = await this.client.execute({ + command: 'set_switch', + zone: context.zone, + capabilityId: context.capabilityId, + value: requestArg.service === 'turn_on', + }); + return { success: true, data: result }; + } + + private async callSelectService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service !== 'select_option') { + return { success: false, error: `Unsupported Yamaha MusicCast select service: ${requestArg.service}` }; + } + const context = await this.capabilityContextFromRequest(requestArg); + const option = this.stringData(requestArg, 'option'); + if (!context.capabilityId || !option) { + return { success: false, error: 'Yamaha MusicCast select service requires capability target and option.' }; + } + const result = await this.client.execute({ command: 'select_option', zone: context.zone, capabilityId: context.capabilityId, option, value: option }); + return { success: true, data: result }; + } + + private async callNumberService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service !== 'set_value' && requestArg.service !== 'set_native_value') { + return { success: false, error: `Unsupported Yamaha MusicCast number service: ${requestArg.service}` }; + } + const context = await this.capabilityContextFromRequest(requestArg); + const value = this.numberData(requestArg, 'value') ?? this.numberData(requestArg, 'native_value') ?? this.numberData(requestArg, 'nativeValue'); + if (!context.capabilityId || typeof value !== 'number') { + return { success: false, error: 'Yamaha MusicCast number service requires capability target and numeric value.' }; + } + const result = await this.client.execute({ command: 'set_number', zone: context.zone, capabilityId: context.capabilityId, value }); + return { success: true, data: result }; + } + + private commandFromMediaService(requestArg: IServiceCallRequest): TYamahaMusiccastCommand | undefined { + if (requestArg.service === 'turn_on') { + return 'turn_on'; + } + if (requestArg.service === 'turn_off') { + return 'turn_off'; + } + if (requestArg.service === 'volume_up') { + return 'volume_up'; + } + if (requestArg.service === 'volume_down') { + return 'volume_down'; + } + if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') { + return 'set_volume'; + } + if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') { + return 'mute'; + } + if (requestArg.service === 'select_source' || requestArg.service === 'select_input') { + return 'select_source'; + } + if (requestArg.service === 'select_sound_mode') { + return 'select_sound_mode'; + } + if (requestArg.service === 'media_play' || requestArg.service === 'play') { + return 'play'; + } + if (requestArg.service === 'media_pause' || requestArg.service === 'pause') { + return 'pause'; + } + if (requestArg.service === 'media_stop' || requestArg.service === 'stop') { + return 'stop'; + } + if (requestArg.service === 'media_play_pause' || requestArg.service === 'play_pause') { + return 'play_pause'; + } + if (requestArg.service === 'media_previous_track' || requestArg.service === 'previous_track') { + return 'previous_track'; + } + if (requestArg.service === 'media_next_track' || requestArg.service === 'next_track') { + return 'next_track'; + } + if (requestArg.service === 'repeat_set' || requestArg.service === 'set_repeat') { + return 'set_repeat'; + } + if (requestArg.service === 'shuffle_set' || requestArg.service === 'set_shuffle') { + return 'set_shuffle'; + } + return undefined; + } + + private async zoneFromRequest(requestArg: IServiceCallRequest): Promise { + const explicit = this.stringData(requestArg, 'zone'); + if (explicit) { + return explicit; + } + const entityId = requestArg.target.entityId; + if (entityId) { + const snapshot = await this.client.getSnapshot(); + const entity = YamahaMusiccastMapper.toEntities(snapshot).find((entityArg) => entityArg.id === entityId); + const zone = entity?.attributes?.zone; + if (typeof zone === 'string') { + return zone; + } + } + return 'main'; + } + + private async capabilityContextFromRequest(requestArg: IServiceCallRequest): Promise<{ capabilityId?: string; zone?: TYamahaMusiccastZoneId }> { + const explicitCapability = this.stringData(requestArg, 'capabilityId') || this.stringData(requestArg, 'capability'); + const explicitZone = this.stringData(requestArg, 'zone'); + if (explicitCapability) { + return { capabilityId: explicitCapability, zone: explicitZone || 'main' }; + } + + const entityId = requestArg.target.entityId; + if (entityId) { + const snapshot = await this.client.getSnapshot(); + const entity = YamahaMusiccastMapper.toEntities(snapshot).find((entityArg) => entityArg.id === entityId); + const capabilityId = entity?.attributes?.capabilityId; + const zone = entity?.attributes?.zone; + if (typeof capabilityId === 'string') { + return { capabilityId, zone: typeof zone === 'string' ? zone : 'main' }; + } + } + return { zone: explicitZone || 'main' }; + } + + private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' && value ? value : undefined; + } + + private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + } + + private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'boolean' ? value : undefined; } } diff --git a/ts/integrations/yamaha_musiccast/yamaha_musiccast.discovery.ts b/ts/integrations/yamaha_musiccast/yamaha_musiccast.discovery.ts new file mode 100644 index 0000000..5808a89 --- /dev/null +++ b/ts/integrations/yamaha_musiccast/yamaha_musiccast.discovery.ts @@ -0,0 +1,217 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IYamahaMusiccastManualEntry, IYamahaMusiccastMdnsRecord, IYamahaMusiccastSsdpRecord } from './yamaha_musiccast.types.js'; + +const yamahaManufacturer = 'Yamaha Corporation'; +const musiccastDomain = 'yamaha_musiccast'; + +export class YamahaMusiccastMdnsMatcher implements IDiscoveryMatcher { + public id = 'yamaha-musiccast-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Yamaha MusicCast mDNS records.'; + + public async matches(recordArg: IYamahaMusiccastMdnsRecord): Promise { + const txt = recordArg.txt || {}; + const model = valueForKey(txt, 'model') || valueForKey(txt, 'modelName') || valueForKey(txt, 'model_name'); + const serialNumber = valueForKey(txt, 'serial') || valueForKey(txt, 'serialNumber') || valueForKey(txt, 'serial_number'); + const systemId = valueForKey(txt, 'system_id') || valueForKey(txt, 'systemid'); + const deviceId = valueForKey(txt, 'device_id') || valueForKey(txt, 'deviceid') || valueForKey(txt, 'id'); + const manufacturer = valueForKey(txt, 'manufacturer') || valueForKey(txt, 'brand'); + const haystack = `${recordArg.name || ''} ${recordArg.type || ''} ${manufacturer || ''} ${model || ''} ${JSON.stringify(txt)}`.toLowerCase(); + const matched = haystack.includes('musiccast') || haystack.includes('yamaha') || haystack.includes('yamahaextendedcontrol'); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Yamaha MusicCast advertisement.' }; + } + + const id = systemId || deviceId || serialNumber || recordArg.name; + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'mDNS record matches Yamaha MusicCast metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: musiccastDomain, + id, + host: recordArg.host, + port: recordArg.port || 80, + name: recordArg.name, + manufacturer: yamahaManufacturer, + model, + serialNumber, + metadata: { mdnsName: recordArg.name, mdnsType: recordArg.type, txt, systemId, deviceId }, + }, + }; + } +} + +export class YamahaMusiccastSsdpMatcher implements IDiscoveryMatcher { + public id = 'yamaha-musiccast-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Yamaha MusicCast SSDP advertisements with the YXC control URL.'; + + public async matches(recordArg: IYamahaMusiccastSsdpRecord): Promise { + const location = header(recordArg, 'location'); + const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType'); + const usn = header(recordArg, 'usn'); + const manufacturer = upnp(recordArg, 'manufacturer'); + const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model'); + const serialNumber = upnp(recordArg, 'serialNumber') || upnp(recordArg, 'serial'); + const friendlyName = upnp(recordArg, 'friendlyName'); + const controlUrl = upnp(recordArg, 'X_yxcControlURL') || upnp(recordArg, 'yamaha:X_yxcControlURL'); + const haystack = `${manufacturer || ''} ${model || ''} ${friendlyName || ''} ${controlUrl || ''} ${st || ''} ${usn || ''}`.toLowerCase(); + const matched = haystack.includes('musiccast') || haystack.includes('yamahaextendedcontrol') || (isYamaha(manufacturer) && Boolean(location)); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'SSDP record is not a Yamaha MusicCast device.' }; + } + + const url = parseUrl(location); + const id = serialNumber || stripUuid(usn); + return { + matched: true, + confidence: controlUrl || serialNumber ? 'certain' : 'high', + reason: 'SSDP record matches Yamaha MusicCast metadata.', + normalizedDeviceId: id, + candidate: { + source: 'ssdp', + integrationDomain: musiccastDomain, + id, + host: url?.hostname, + port: url?.port ? Number(url.port) : 80, + name: friendlyName, + manufacturer: yamahaManufacturer, + model, + serialNumber, + metadata: { st, usn, location, controlUrl }, + }, + }; + } +} + +export class YamahaMusiccastManualMatcher implements IDiscoveryMatcher { + public id = 'yamaha-musiccast-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Yamaha MusicCast setup entries.'; + + public async matches(inputArg: IYamahaMusiccastManualEntry): Promise { + const matched = Boolean( + inputArg.host + || isYamaha(inputArg.manufacturer) + || includesMusiccastHint(inputArg.name) + || includesMusiccastHint(inputArg.model) + || inputArg.metadata?.yamaha_musiccast + || inputArg.metadata?.musiccast + ); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Yamaha MusicCast setup hints.' }; + } + + const id = inputArg.systemId || inputArg.deviceId || inputArg.serialNumber || inputArg.id; + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Yamaha MusicCast setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: musiccastDomain, + id, + host: inputArg.host, + port: inputArg.port || 80, + name: inputArg.name, + manufacturer: inputArg.manufacturer || yamahaManufacturer, + model: inputArg.model, + serialNumber: inputArg.serialNumber, + metadata: { ...inputArg.metadata, systemId: inputArg.systemId, deviceId: inputArg.deviceId }, + }, + }; + } +} + +export class YamahaMusiccastCandidateValidator implements IDiscoveryValidator { + public id = 'yamaha-musiccast-candidate-validator'; + public description = 'Validate Yamaha MusicCast candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const matched = candidateArg.integrationDomain === musiccastDomain + || isYamaha(candidateArg.manufacturer) + || includesMusiccastHint(candidateArg.model) + || includesMusiccastHint(candidateArg.name) + || Boolean(metadata.musiccast || metadata.yamaha_musiccast || metadata.systemId || metadata.deviceId); + + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Yamaha MusicCast metadata.' : 'Candidate is not a Yamaha MusicCast device.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: normalizedCandidateId(candidateArg), + metadata: matched ? { validatedAs: musiccastDomain } : undefined, + }; + } +} + +export const createYamahaMusiccastDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: musiccastDomain, displayName: 'MusicCast' }) + .addMatcher(new YamahaMusiccastMdnsMatcher()) + .addMatcher(new YamahaMusiccastSsdpMatcher()) + .addMatcher(new YamahaMusiccastManualMatcher()) + .addValidator(new YamahaMusiccastCandidateValidator()); +}; + +const header = (recordArg: IYamahaMusiccastSsdpRecord, keyArg: string): string | undefined => { + return recordArg[keyArg as keyof IYamahaMusiccastSsdpRecord] as string | undefined || valueForKey(recordArg.headers, keyArg); +}; + +const upnp = (recordArg: IYamahaMusiccastSsdpRecord, keyArg: string): string | undefined => { + return valueForKey(recordArg.upnp, keyArg) || valueForKey(recordArg.headers, keyArg); +}; + +const valueForKey = (recordArg: Record | 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 parseUrl = (valueArg: string | undefined): URL | undefined => { + if (!valueArg) { + return undefined; + } + try { + return new URL(valueArg); + } catch { + return undefined; + } +}; + +const stripUuid = (valueArg: string | undefined): string | undefined => { + return valueArg?.replace(/^uuid:/i, '').split('::')[0]; +}; + +const normalizedCandidateId = (candidateArg: IDiscoveryCandidate): string | undefined => { + const metadata = candidateArg.metadata || {}; + return stringMetadata(metadata.systemId) || stringMetadata(metadata.deviceId) || candidateArg.serialNumber || candidateArg.id; +}; + +const stringMetadata = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' && valueArg ? valueArg : undefined; +}; + +const isYamaha = (valueArg: string | undefined): boolean => { + return Boolean(valueArg?.toLowerCase().includes('yamaha')); +}; + +const includesMusiccastHint = (valueArg: string | undefined): boolean => { + const value = valueArg?.toLowerCase() || ''; + return value.includes('musiccast') || value.includes('yamaha'); +}; diff --git a/ts/integrations/yamaha_musiccast/yamaha_musiccast.mapper.ts b/ts/integrations/yamaha_musiccast/yamaha_musiccast.mapper.ts new file mode 100644 index 0000000..6eb64ca --- /dev/null +++ b/ts/integrations/yamaha_musiccast/yamaha_musiccast.mapper.ts @@ -0,0 +1,342 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { + IYamahaMusiccastCapability, + IYamahaMusiccastRangeStep, + IYamahaMusiccastSnapshot, + IYamahaMusiccastZoneState, +} from './yamaha_musiccast.types.js'; + +export class YamahaMusiccastMapper { + public static toDevices(snapshotArg: IYamahaMusiccastSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + return snapshotArg.zones.map((zoneArg) => { + const capabilities = this.capabilitiesForZone(zoneArg); + const volumePercent = this.volumePercent(zoneArg); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { 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 }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'power', value: this.powerState(zoneArg), updatedAt }, + { featureId: 'playback', value: this.mediaState(snapshotArg, zoneArg), updatedAt }, + { featureId: 'source', value: zoneArg.input || null, updatedAt }, + { featureId: 'volume', value: typeof volumePercent === 'number' ? volumePercent : null, updatedAt }, + { featureId: 'muted', value: typeof zoneArg.muted === 'boolean' ? zoneArg.muted : null, updatedAt }, + ]; + + if (zoneArg.soundProgram || zoneArg.soundProgramList?.length) { + features.push({ id: 'sound_program', capability: 'media', name: 'Sound Program', readable: true, writable: true }); + state.push({ featureId: 'sound_program', value: zoneArg.soundProgram || null, updatedAt }); + } + + for (const capability of capabilities) { + features.push({ + id: `capability_${capability.id}`, + capability: capability.type === 'switch' ? 'switch' : 'media', + name: capability.name, + readable: true, + writable: true, + unit: capability.type === 'number' ? this.unitForCapability(capability) : undefined, + }); + state.push({ featureId: `capability_${capability.id}`, value: this.deviceStateValue(capability.current), updatedAt }); + } + + return { + id: this.deviceId(snapshotArg, zoneArg), + integrationDomain: 'yamaha_musiccast', + name: this.deviceName(snapshotArg, zoneArg), + protocol: 'http', + manufacturer: 'Yamaha Corporation', + model: snapshotArg.deviceInfo.model_name, + online: zoneArg.available !== false, + features, + state, + metadata: { + host: snapshotArg.networkStatus?.ip_address, + zone: zoneArg.zone, + systemId: snapshotArg.deviceInfo.system_id, + deviceId: snapshotArg.deviceInfo.device_id, + serialNumber: snapshotArg.deviceInfo.serial_number, + systemVersion: snapshotArg.deviceInfo.system_version, + apiVersion: snapshotArg.deviceInfo.api_version, + networkName: snapshotArg.networkStatus?.network_name, + macAddresses: snapshotArg.networkStatus?.mac_address, + viaDeviceId: zoneArg.zone === 'main' ? undefined : this.mainDeviceId(snapshotArg), + }, + }; + }); + } + + public static toEntities(snapshotArg: IYamahaMusiccastSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + for (const zone of snapshotArg.zones) { + const base = this.entityBase(snapshotArg, zone); + entities.push({ + id: `media_player.${base}`, + uniqueId: `yamaha_musiccast_${this.uniqueBase(snapshotArg)}_${this.slug(zone.zone)}`, + integrationDomain: 'yamaha_musiccast', + deviceId: this.deviceId(snapshotArg, zone), + platform: 'media_player', + name: this.deviceName(snapshotArg, zone), + state: this.mediaState(snapshotArg, zone), + attributes: { + deviceClass: 'speaker', + zone: zone.zone, + power: zone.power, + volumeLevel: this.volumeLevel(zone), + volume: zone.volume, + minVolume: zone.minVolume, + maxVolume: zone.maxVolume, + isVolumeMuted: zone.muted, + source: this.sourceLabel(snapshotArg, zone.input, zone), + sourceId: zone.input, + sourceList: this.sourceList(snapshotArg, zone), + soundMode: zone.soundProgram, + soundModeList: zone.soundProgramList, + mediaTitle: this.mediaTitle(snapshotArg, zone), + mediaArtist: this.mediaArtist(snapshotArg, zone), + mediaAlbumName: snapshotArg.netusb?.album, + mediaImageUrl: snapshotArg.netusb?.albumart_url, + mediaDuration: snapshotArg.netusb?.total_time, + mediaPosition: snapshotArg.netusb?.play_time, + repeat: this.isNetusbZone(snapshotArg, zone) ? snapshotArg.netusb?.repeat : undefined, + shuffle: this.isNetusbZone(snapshotArg, zone) ? snapshotArg.netusb?.shuffle === 'on' : undefined, + groupId: snapshotArg.distribution?.group_id, + groupName: snapshotArg.distribution?.group_name, + groupRole: snapshotArg.distribution?.role, + }, + available: zone.available !== false, + }); + + for (const capability of this.capabilitiesForZone(zone)) { + const entityBase = `${base}_${this.slug(capability.id)}`; + entities.push({ + id: `${capability.type}.${entityBase}`, + uniqueId: `yamaha_musiccast_${this.uniqueBase(snapshotArg)}_${this.slug(zone.zone)}_${this.slug(capability.id)}`, + integrationDomain: 'yamaha_musiccast', + deviceId: this.deviceId(snapshotArg, zone), + platform: capability.type, + name: `${this.deviceName(snapshotArg, zone)} ${capability.name}`, + state: this.entityState(capability), + attributes: this.capabilityAttributes(capability, zone), + available: zone.available !== false, + }); + } + } + return entities; + } + + public static capabilitiesForZone(zoneArg: IYamahaMusiccastZoneState): IYamahaMusiccastCapability[] { + const explicit = zoneArg.capabilities || []; + const generated: IYamahaMusiccastCapability[] = []; + + this.pushSwitch(generated, zoneArg, 'enhancer', 'Enhancer', zoneArg.enhancer); + this.pushSwitch(generated, zoneArg, 'pure_direct', 'Pure Direct', zoneArg.pureDirect); + this.pushSwitch(generated, zoneArg, 'extra_bass', 'Extra Bass', zoneArg.extraBass); + this.pushSwitch(generated, zoneArg, 'bass_extension', 'Bass Extension', zoneArg.bassExtension); + this.pushSwitch(generated, zoneArg, 'adaptive_drc', 'Adaptive DRC', zoneArg.adaptiveDrc); + this.pushSwitch(generated, zoneArg, 'clear_voice', 'Clear Voice', zoneArg.clearVoice); + this.pushSwitch(generated, zoneArg, 'surround_3d', '3D Surround', zoneArg.surround3d); + this.pushSwitch(generated, zoneArg, 'mono', 'Mono', zoneArg.mono); + + this.pushSelect(generated, zoneArg, 'sleep', 'Sleep Timer', zoneArg.sleep === 0 ? 'off' : typeof zoneArg.sleep === 'number' ? `${zoneArg.sleep}_min` : undefined, ['off', '30_min', '60_min', '90_min', '120_min']); + this.pushSelect(generated, zoneArg, 'tone_control_mode', 'Tone Control Mode', zoneArg.toneControl?.mode, zoneArg.toneControlModeList); + this.pushSelect(generated, zoneArg, 'equalizer_mode', 'Equalizer Mode', zoneArg.equalizer?.mode, zoneArg.equalizerModeList); + this.pushSelect(generated, zoneArg, 'surr_decoder_type', 'Surround Decoder Type', zoneArg.surrDecoderType, zoneArg.surrDecoderTypeList); + this.pushSelect(generated, zoneArg, 'link_control', 'Link Control', zoneArg.linkControl, zoneArg.linkControlList); + this.pushSelect(generated, zoneArg, 'link_audio_delay', 'Link Audio Delay', zoneArg.linkAudioDelay, zoneArg.linkAudioDelayList); + this.pushSelect(generated, zoneArg, 'link_audio_quality', 'Link Audio Quality', zoneArg.linkAudioQuality, zoneArg.linkAudioQualityList); + + this.pushNumber(generated, zoneArg, 'tone_control_bass', 'Tone Control Bass', zoneArg.toneControl?.bass, this.range(zoneArg, 'tone_control')); + this.pushNumber(generated, zoneArg, 'tone_control_treble', 'Tone Control Treble', zoneArg.toneControl?.treble, this.range(zoneArg, 'tone_control')); + this.pushNumber(generated, zoneArg, 'equalizer_low', 'Equalizer Low', zoneArg.equalizer?.low, this.range(zoneArg, 'equalizer')); + this.pushNumber(generated, zoneArg, 'equalizer_mid', 'Equalizer Mid', zoneArg.equalizer?.mid, this.range(zoneArg, 'equalizer')); + this.pushNumber(generated, zoneArg, 'equalizer_high', 'Equalizer High', zoneArg.equalizer?.high, this.range(zoneArg, 'equalizer')); + this.pushNumber(generated, zoneArg, 'balance', 'Balance', zoneArg.balance, this.range(zoneArg, 'balance')); + this.pushNumber(generated, zoneArg, 'dialogue_level', 'Dialogue Level', zoneArg.dialogueLevel, this.range(zoneArg, 'dialogue_level')); + this.pushNumber(generated, zoneArg, 'dialogue_lift', 'Dialogue Lift', zoneArg.dialogueLift, this.range(zoneArg, 'dialogue_lift')); + this.pushNumber(generated, zoneArg, 'dts_dialogue_control', 'DTS Dialogue Control', zoneArg.dtsDialogueControl, this.range(zoneArg, 'dts_dialogue_control')); + this.pushNumber(generated, zoneArg, 'subwoofer_volume', 'Subwoofer Volume', zoneArg.subwooferVolume, this.range(zoneArg, 'subwoofer_volume')); + + const byId = new Map(); + for (const capability of [...generated, ...explicit]) { + byId.set(capability.id, { category: 'config', zoneId: zoneArg.zone, ...capability }); + } + return [...byId.values()]; + } + + private static pushSwitch(listArg: IYamahaMusiccastCapability[], zoneArg: IYamahaMusiccastZoneState, idArg: string, nameArg: string, valueArg: boolean | undefined): void { + if (typeof valueArg === 'boolean') { + listArg.push({ id: idArg, name: nameArg, type: 'switch', zoneId: zoneArg.zone, current: valueArg, category: 'config' }); + } + } + + private static pushSelect(listArg: IYamahaMusiccastCapability[], zoneArg: IYamahaMusiccastZoneState, idArg: string, nameArg: string, valueArg: string | undefined, optionsArg: string[] | undefined): void { + if (valueArg !== undefined || optionsArg?.length) { + listArg.push({ id: idArg, name: nameArg, type: 'select', zoneId: zoneArg.zone, current: valueArg ?? null, options: optionsArg, category: 'config' }); + } + } + + private static pushNumber(listArg: IYamahaMusiccastCapability[], zoneArg: IYamahaMusiccastZoneState, idArg: string, nameArg: string, valueArg: number | undefined, rangeArg: IYamahaMusiccastRangeStep | undefined): void { + if (typeof valueArg === 'number' || rangeArg) { + listArg.push({ id: idArg, name: nameArg, type: 'number', zoneId: zoneArg.zone, current: valueArg ?? null, range: rangeArg, category: 'config' }); + } + } + + private static entityState(capabilityArg: IYamahaMusiccastCapability): unknown { + if (capabilityArg.type === 'switch') { + return Boolean(capabilityArg.current); + } + return capabilityArg.current ?? null; + } + + private static capabilityAttributes(capabilityArg: IYamahaMusiccastCapability, zoneArg: IYamahaMusiccastZoneState): Record { + return { + zone: zoneArg.zone, + capabilityId: capabilityArg.id, + category: capabilityArg.category, + options: this.optionValues(capabilityArg.options), + nativeMinValue: capabilityArg.range?.min, + nativeMaxValue: capabilityArg.range?.max, + nativeStep: capabilityArg.range?.step, + }; + } + + private static optionValues(optionsArg: Record | string[] | undefined): string[] | undefined { + if (!optionsArg) { + return undefined; + } + return Array.isArray(optionsArg) ? optionsArg : Object.values(optionsArg); + } + + private static mediaState(snapshotArg: IYamahaMusiccastSnapshot, zoneArg: IYamahaMusiccastZoneState): string { + if (this.powerState(zoneArg) === 'off') { + return 'off'; + } + const explicitState = zoneArg.state?.toLowerCase(); + if (explicitState === 'playing' || explicitState === 'paused' || explicitState === 'idle') { + return explicitState; + } + if (this.isNetusbZone(snapshotArg, zoneArg)) { + if (snapshotArg.netusb?.playback === 'pause') { + return 'paused'; + } + if (snapshotArg.netusb?.playback === 'stop') { + return 'idle'; + } + } + return 'playing'; + } + + private static powerState(zoneArg: IYamahaMusiccastZoneState): string { + const power = zoneArg.power?.toLowerCase(); + return power === 'on' ? 'on' : 'off'; + } + + private static volumeLevel(zoneArg: IYamahaMusiccastZoneState): number | undefined { + if (typeof zoneArg.volumeLevel === 'number') { + return Math.max(0, Math.min(1, zoneArg.volumeLevel)); + } + if (typeof zoneArg.volume === 'number' && typeof zoneArg.minVolume === 'number' && typeof zoneArg.maxVolume === 'number' && zoneArg.maxVolume > zoneArg.minVolume) { + return Math.max(0, Math.min(1, (zoneArg.volume - zoneArg.minVolume) / (zoneArg.maxVolume - zoneArg.minVolume))); + } + return undefined; + } + + private static volumePercent(zoneArg: IYamahaMusiccastZoneState): number | undefined { + const level = this.volumeLevel(zoneArg); + return typeof level === 'number' ? Math.round(level * 100) : undefined; + } + + private static sourceList(snapshotArg: IYamahaMusiccastSnapshot, zoneArg: IYamahaMusiccastZoneState): string[] | undefined { + return zoneArg.inputList?.map((inputArg) => this.sourceLabel(snapshotArg, inputArg, zoneArg)).filter((valueArg): valueArg is string => Boolean(valueArg)); + } + + private static sourceLabel(snapshotArg: IYamahaMusiccastSnapshot, inputArg: string | undefined, zoneArg?: IYamahaMusiccastZoneState): string | undefined { + if (!inputArg) { + return undefined; + } + return zoneArg?.sourceMap?.[inputArg] || snapshotArg.inputNames?.[inputArg] || (zoneArg?.input === inputArg ? zoneArg.inputText : undefined) || this.titleize(inputArg); + } + + private static mediaTitle(snapshotArg: IYamahaMusiccastSnapshot, zoneArg: IYamahaMusiccastZoneState): string | undefined { + if (this.isNetusbZone(snapshotArg, zoneArg)) { + return snapshotArg.netusb?.track || this.sourceLabel(snapshotArg, zoneArg.input, zoneArg); + } + if (zoneArg.input === 'tuner') { + return snapshotArg.tuner?.rds?.radio_text_a || snapshotArg.tuner?.rds?.program_service || this.sourceLabel(snapshotArg, zoneArg.input, zoneArg); + } + return this.sourceLabel(snapshotArg, zoneArg.input, zoneArg); + } + + private static mediaArtist(snapshotArg: IYamahaMusiccastSnapshot, zoneArg: IYamahaMusiccastZoneState): string | undefined { + if (this.isNetusbZone(snapshotArg, zoneArg)) { + return snapshotArg.netusb?.artist; + } + if (zoneArg.input === 'tuner') { + return snapshotArg.tuner?.rds?.radio_text_b || snapshotArg.tuner?.band; + } + return undefined; + } + + private static isNetusbZone(snapshotArg: IYamahaMusiccastSnapshot, zoneArg: IYamahaMusiccastZoneState): boolean { + return Boolean(snapshotArg.netusb?.input && snapshotArg.netusb.input === zoneArg.input); + } + + private static deviceName(snapshotArg: IYamahaMusiccastSnapshot, zoneArg: IYamahaMusiccastZoneState): string { + const receiver = this.receiverName(snapshotArg); + if (zoneArg.zone === 'main') { + return receiver; + } + return `${receiver} ${zoneArg.name || this.titleize(zoneArg.zone)}`; + } + + private static receiverName(snapshotArg: IYamahaMusiccastSnapshot): string { + return snapshotArg.networkStatus?.network_name || snapshotArg.deviceInfo.model_name || 'Yamaha MusicCast'; + } + + private static mainDeviceId(snapshotArg: IYamahaMusiccastSnapshot): string { + return this.deviceId(snapshotArg, { zone: 'main' }); + } + + private static deviceId(snapshotArg: IYamahaMusiccastSnapshot, zoneArg: Pick): string { + const suffix = zoneArg.zone === 'main' ? '' : `.${this.slug(zoneArg.zone)}`; + return `yamaha_musiccast.player.${this.uniqueBase(snapshotArg)}${suffix}`; + } + + private static entityBase(snapshotArg: IYamahaMusiccastSnapshot, zoneArg: IYamahaMusiccastZoneState): string { + const suffix = zoneArg.zone === 'main' ? '' : `_${this.slug(zoneArg.zone)}`; + return `${this.slug(this.receiverName(snapshotArg))}${suffix}`; + } + + private static uniqueBase(snapshotArg: IYamahaMusiccastSnapshot): string { + return this.slug(snapshotArg.deviceInfo.system_id || snapshotArg.deviceInfo.device_id || snapshotArg.deviceInfo.serial_number || snapshotArg.deviceInfo.model_name || this.receiverName(snapshotArg)); + } + + private static range(zoneArg: IYamahaMusiccastZoneState, idArg: string): IYamahaMusiccastRangeStep | undefined { + return zoneArg.rangeStep?.find((rangeArg) => rangeArg.id === idArg); + } + + private static unitForCapability(capabilityArg: IYamahaMusiccastCapability): string | undefined { + return capabilityArg.id.includes('volume') ? 'level' : undefined; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) { + return valueArg; + } + return null; + } + + private static titleize(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); + } + + private static slug(valueArg: string | undefined): string { + return (valueArg || 'yamaha_musiccast').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'yamaha_musiccast'; + } +} diff --git a/ts/integrations/yamaha_musiccast/yamaha_musiccast.types.ts b/ts/integrations/yamaha_musiccast/yamaha_musiccast.types.ts index 0872b95..697d325 100644 --- a/ts/integrations/yamaha_musiccast/yamaha_musiccast.types.ts +++ b/ts/integrations/yamaha_musiccast/yamaha_musiccast.types.ts @@ -1,4 +1,415 @@ -export interface IHomeAssistantYamahaMusiccastConfig { - // TODO: replace with the TypeScript-native config for yamaha_musiccast. +export type TYamahaMusiccastZoneId = string; + +export type TYamahaMusiccastPowerState = 'on' | 'standby' | 'toggle' | string; + +export type TYamahaMusiccastMediaState = 'off' | 'on' | 'playing' | 'paused' | 'idle' | string; + +export type TYamahaMusiccastPlaybackState = + | 'play' + | 'stop' + | 'pause' + | 'play_pause' + | 'previous' + | 'next' + | 'fast_reverse_start' + | 'fast_reverse_end' + | 'fast_forward_start' + | 'fast_forward_end' + | string; + +export type TYamahaMusiccastCapabilityType = 'switch' | 'select' | 'number'; + +export type TYamahaMusiccastCapabilityCategory = 'regular' | 'config' | 'diagnostic'; + +export type TYamahaMusiccastCommand = + | 'turn_on' + | 'turn_off' + | 'volume_up' + | 'volume_down' + | 'set_volume' + | 'mute' + | 'select_source' + | 'select_sound_mode' + | 'play' + | 'pause' + | 'stop' + | 'play_pause' + | 'previous_track' + | 'next_track' + | 'set_repeat' + | 'set_shuffle' + | 'set_switch' + | 'select_option' + | 'set_number' + | 'raw_get'; + +export interface IYamahaMusiccastConfig { + host?: string; + port?: number; + name?: string; + model?: string; + serialNumber?: string; + systemId?: string; + deviceId?: string; + manufacturer?: string; + upnpDescription?: string; + requestTimeoutMs?: number; + inputNames?: Record; + zoneNames?: Record; + deviceInfo?: IYamahaMusiccastDeviceInfo; + networkStatus?: IYamahaMusiccastNetworkStatus; + funcStatus?: IYamahaMusiccastFuncStatus; + features?: IYamahaMusiccastFeatures; + zones?: IYamahaMusiccastZoneState[]; + netusb?: IYamahaMusiccastNetusbPlayInfo; + tuner?: IYamahaMusiccastTunerPlayInfo; + distribution?: IYamahaMusiccastDistributionInfo; + capabilities?: IYamahaMusiccastCapability[]; + snapshot?: IYamahaMusiccastSnapshot; +} + +export interface IHomeAssistantYamahaMusiccastConfig extends IYamahaMusiccastConfig {} + +export interface IYamahaMusiccastDeviceInfo { + response_code?: number; + model_name?: string; + destination?: string; + device_id?: string; + system_id?: string; + system_version?: string | number; + api_version?: string | number; + netmodule_generation?: number; + netmodule_version?: string; + netmodule_checksum?: string; + serial_number?: string; + category_code?: number; + operation_mode?: string; + update_error_code?: string; + net_module_num?: number; + update_data_type?: number; [key: string]: unknown; } + +export interface IYamahaMusiccastNetworkStatus { + response_code?: number; + network_name?: string; + connection?: string; + dhcp?: boolean; + ip_address?: string; + subnet_mask?: string; + default_gateway?: string; + dns_server_1?: string; + dns_server_2?: string; + wireless_lan?: Record; + musiccast_network?: Record; + mac_address?: Record; + airplay_pin?: string; + ipv6?: Record; + each_module_ip_list?: string[]; + [key: string]: unknown; +} + +export interface IYamahaMusiccastFuncStatus { + response_code?: number; + headphone?: boolean; + hdmi_out_1?: boolean; + hdmi_out_2?: boolean; + party_mode?: boolean; + party_enable?: boolean; + speaker_a?: boolean; + speaker_b?: boolean; + dimmer?: number; + [key: string]: unknown; +} + +export interface IYamahaMusiccastFeatures { + response_code?: number; + system?: IYamahaMusiccastSystemFeatures; + zone?: IYamahaMusiccastZoneFeatures[]; + tuner?: Record; + netusb?: Record; + distribution?: Record; + ccs?: Record; + [key: string]: unknown; +} + +export interface IYamahaMusiccastSystemFeatures { + func_list?: string[]; + zone_num?: number; + input_list?: IYamahaMusiccastInputFeature[]; + web_control_url?: string; + party_volume_list?: string[]; + hdmi_standby_through_list?: string[]; + range_step?: IYamahaMusiccastRangeStep[]; + [key: string]: unknown; +} + +export interface IYamahaMusiccastInputFeature { + id: string; + distribution_enable?: boolean; + rename_enable?: boolean; + account_enable?: boolean; + play_info_type?: 'netusb' | 'tuner' | 'cd' | 'none' | string; + [key: string]: unknown; +} + +export interface IYamahaMusiccastZoneFeatures { + id: TYamahaMusiccastZoneId; + zone_b?: boolean; + func_list?: string[]; + input_list?: string[]; + sound_program_list?: string[]; + surr_decoder_type_list?: string[]; + tone_control_mode_list?: string[]; + equalizer_mode_list?: string[]; + link_control_list?: string[]; + link_audio_delay_list?: string[]; + link_audio_quality_list?: string[]; + range_step?: IYamahaMusiccastRangeStep[]; + scene_num?: number; + cursor_list?: string[]; + menu_list?: string[]; + actual_volume_mode_list?: string[]; + ccs_supported?: string[]; + [key: string]: unknown; +} + +export interface IYamahaMusiccastRangeStep { + id: string; + min: number; + max: number; + step: number; +} + +export interface IYamahaMusiccastZoneStatus { + response_code?: number; + power?: TYamahaMusiccastPowerState; + sleep?: number; + volume?: number; + mute?: boolean; + max_volume?: number; + min_volume?: number; + input?: string; + input_text?: string; + distribution_enable?: boolean; + sound_program?: string; + surr_decoder_type?: string; + pure_direct?: boolean; + enhancer?: boolean; + tone_control?: { + mode?: string; + bass?: number; + treble?: number; + }; + equalizer?: { + mode?: string; + low?: number; + mid?: number; + high?: number; + }; + balance?: number; + dialogue_level?: number; + dialogue_lift?: number; + dts_dialogue_control?: number; + subwoofer_volume?: number; + link_control?: string; + link_audio_delay?: string; + link_audio_quality?: string; + actual_volume?: { + mode?: string; + value?: number; + unit?: string; + }; + party_enable?: boolean; + extra_bass?: boolean; + bass_extension?: boolean; + adaptive_drc?: boolean; + clear_voice?: boolean; + surround_3d?: boolean; + '3d_surround'?: boolean; + mono?: boolean; + disable_flags?: number; + [key: string]: unknown; +} + +export interface IYamahaMusiccastZoneState { + zone: TYamahaMusiccastZoneId; + name?: string; + power?: TYamahaMusiccastPowerState; + state?: TYamahaMusiccastMediaState; + available?: boolean; + sleep?: number; + volume?: number; + minVolume?: number; + maxVolume?: number; + volumeLevel?: number; + muted?: boolean; + input?: string; + inputText?: string; + inputList?: string[]; + sourceMap?: Record; + soundProgram?: string; + soundProgramList?: string[]; + surrDecoderType?: string; + surrDecoderTypeList?: string[]; + toneControlModeList?: string[]; + equalizerModeList?: string[]; + linkControlList?: string[]; + linkAudioDelayList?: string[]; + linkAudioQualityList?: string[]; + rangeStep?: IYamahaMusiccastRangeStep[]; + features?: string[]; + status?: IYamahaMusiccastZoneStatus; + signalInfo?: Record; + actualVolume?: { + mode?: string; + value?: number; + unit?: string; + }; + toneControl?: { + mode?: string; + bass?: number; + treble?: number; + }; + equalizer?: { + mode?: string; + low?: number; + mid?: number; + high?: number; + }; + balance?: number; + dialogueLevel?: number; + dialogueLift?: number; + dtsDialogueControl?: number; + subwooferVolume?: number; + linkControl?: string; + linkAudioDelay?: string; + linkAudioQuality?: string; + extraBass?: boolean; + bassExtension?: boolean; + enhancer?: boolean; + pureDirect?: boolean; + adaptiveDrc?: boolean; + clearVoice?: boolean; + surround3d?: boolean; + mono?: boolean; + capabilities?: IYamahaMusiccastCapability[]; +} + +export interface IYamahaMusiccastCapability { + id: string; + name: string; + type: TYamahaMusiccastCapabilityType; + zoneId?: TYamahaMusiccastZoneId; + category?: TYamahaMusiccastCapabilityCategory; + current?: string | number | boolean | null; + options?: Record | string[]; + range?: IYamahaMusiccastRangeStep; + command?: TYamahaMusiccastCommand; +} + +export interface IYamahaMusiccastNetusbPlayInfo { + response_code?: number; + input?: string; + playback?: TYamahaMusiccastPlaybackState; + repeat?: string; + shuffle?: string; + play_time?: number; + total_time?: number; + artist?: string; + album?: string; + track?: string; + albumart_url?: string; + albumart_id?: number; + repeat_available?: string[]; + shuffle_available?: string[]; + [key: string]: unknown; +} + +export interface IYamahaMusiccastTunerPlayInfo { + response_code?: number; + band?: string; + auto_scan?: boolean; + am?: Record; + fm?: Record; + dab?: Record; + rds?: { + program_type?: string; + program_service?: string; + radio_text_a?: string; + radio_text_b?: string; + }; + [key: string]: unknown; +} + +export interface IYamahaMusiccastDistributionInfo { + response_code?: number; + group_id?: string; + group_name?: string; + role?: string; + server_zone?: string; + client_list?: string[]; + build_disable?: string[]; + audio_dropout?: boolean; + [key: string]: unknown; +} + +export interface IYamahaMusiccastSnapshot { + deviceInfo: IYamahaMusiccastDeviceInfo; + networkStatus?: IYamahaMusiccastNetworkStatus; + funcStatus?: IYamahaMusiccastFuncStatus; + features?: IYamahaMusiccastFeatures; + zones: IYamahaMusiccastZoneState[]; + inputNames?: Record; + netusb?: IYamahaMusiccastNetusbPlayInfo; + tuner?: IYamahaMusiccastTunerPlayInfo; + distribution?: IYamahaMusiccastDistributionInfo; + capabilities?: IYamahaMusiccastCapability[]; + lastUpdated?: string; +} + +export interface IYamahaMusiccastCommandRequest { + command: TYamahaMusiccastCommand; + zone?: TYamahaMusiccastZoneId; + source?: string; + soundMode?: string; + volumeLevel?: number; + volume?: number; + muted?: boolean; + playback?: TYamahaMusiccastPlaybackState; + repeat?: string; + shuffle?: boolean | string; + capabilityId?: string; + value?: string | number | boolean; + option?: string; + path?: string; +} + +export interface IYamahaMusiccastMdnsRecord { + name?: string; + type?: string; + host?: string; + port?: number; + txt?: Record; +} + +export interface IYamahaMusiccastSsdpRecord { + st?: string; + usn?: string; + location?: string; + headers?: Record; + upnp?: Record; +} + +export interface IYamahaMusiccastManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + systemId?: string; + deviceId?: string; + metadata?: Record; +}