diff --git a/test/androidtv/test.androidtv.discovery.node.ts b/test/androidtv/test.androidtv.discovery.node.ts new file mode 100644 index 0000000..8fd3692 --- /dev/null +++ b/test/androidtv/test.androidtv.discovery.node.ts @@ -0,0 +1,52 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createAndroidtvDiscoveryDescriptor } from '../../ts/integrations/androidtv/index.js'; + +tap.test('matches Android TV mDNS setup hints', async () => { + const descriptor = createAndroidtvDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_androidtvremote2._tcp.local.', + name: 'Living Room TV._androidtvremote2._tcp.local.', + host: 'living-room-tv.local', + port: 6466, + txt: { + id: 'androidtv-123', + fn: 'Living Room TV', + md: 'Android TV', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('living-room-tv.local'); + expect(result.candidate?.port).toEqual(5555); + expect(result.normalizedDeviceId).toEqual('androidtv-123'); +}); + +tap.test('matches manual Android TV host entries', async () => { + const descriptor = createAndroidtvDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ + host: '192.168.1.55', + deviceName: 'Den Fire TV', + manufacturer: 'Amazon', + model: 'Fire TV Stick', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.55'); + expect(result.candidate?.metadata?.deviceClass).toEqual('firetv'); +}); + +tap.test('validates ADB host candidates', async () => { + const descriptor = createAndroidtvDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'custom', + integrationDomain: 'androidtv', + host: '192.168.1.56', + port: 5555, + model: 'Android TV', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('192.168.1.56'); +}); + +export default tap.start(); diff --git a/test/androidtv/test.androidtv.mapper.node.ts b/test/androidtv/test.androidtv.mapper.node.ts new file mode 100644 index 0000000..5e6dec6 --- /dev/null +++ b/test/androidtv/test.androidtv.mapper.node.ts @@ -0,0 +1,40 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AndroidtvMapper } from '../../ts/integrations/androidtv/index.js'; + +const snapshot = { + deviceInfo: { + id: 'shield-tv-123', + name: 'Living Room Shield', + manufacturer: 'NVIDIA', + model: 'SHIELD Android TV', + deviceClass: 'androidtv' as const, + host: '192.168.1.57', + port: 5555, + }, + state: { + rawState: 'playing', + available: true, + currentAppId: 'com.netflix.ninja', + runningAppIds: ['com.netflix.ninja', 'org.xbmc.kodi'], + volumeLevel: 0.42, + isVolumeMuted: false, + hdmiInput: 'HW1', + }, + apps: [ + { id: 'com.netflix.ninja' }, + { id: 'org.xbmc.kodi', name: 'Kodi' }, + ], +}; + +tap.test('maps Android TV snapshots to media devices and entities', async () => { + const devices = AndroidtvMapper.toDevices(snapshot); + const entities = AndroidtvMapper.toEntities(snapshot); + expect(devices[0].id).toEqual('androidtv.device.shield_tv_123'); + expect(devices[0].state.some((stateArg) => stateArg.featureId === 'volume' && stateArg.value === 42)).toBeTrue(); + expect(entities[0].platform).toEqual('media_player'); + expect(entities[0].state).toEqual('playing'); + expect(entities[0].attributes?.source).toEqual('Netflix'); + expect((entities[0].attributes?.sourceList as string[]).includes('Kodi')).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/denonavr/test.denonavr.discovery.node.ts b/test/denonavr/test.denonavr.discovery.node.ts new file mode 100644 index 0000000..a15aa4d --- /dev/null +++ b/test/denonavr/test.denonavr.discovery.node.ts @@ -0,0 +1,54 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createDenonavrDiscoveryDescriptor } from '../../ts/integrations/denonavr/index.js'; + +tap.test('matches Denon AVR SSDP records', async () => { + const descriptor = createDenonavrDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + st: 'urn:schemas-upnp-org:device:MediaRenderer:1', + usn: 'uuid:denon-avr-123::urn:schemas-upnp-org:device:MediaRenderer:1', + location: 'http://192.168.1.50:8080/description.xml', + upnp: { + manufacturer: 'Denon', + modelName: 'AVR-X1700H', + serialNumber: 'ABC12345', + friendlyName: 'Living Room AVR', + deviceType: 'urn:schemas-upnp-org:device:MediaRenderer:1', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.50'); + expect(result.candidate?.manufacturer).toEqual('Denon'); + expect(result.normalizedDeviceId).toEqual('AVR-X1700H-ABC12345'); +}); + +tap.test('rejects HEOS speaker models', async () => { + const descriptor = createDenonavrDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + st: 'urn:schemas-upnp-org:device:MediaRenderer:1', + location: 'http://192.168.1.51:8080/description.xml', + upnp: { + manufacturer: 'Denon', + modelName: 'HEOS 5', + serialNumber: 'HEOS123', + }, + }, {}); + expect(result.matched).toBeFalse(); +}); + +tap.test('validates manual Marantz candidates', async () => { + const descriptor = createDenonavrDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'manual', + integrationDomain: 'denonavr', + host: '192.168.1.52', + manufacturer: 'Marantz', + model: 'SR6015', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('high'); +}); + +export default tap.start(); diff --git a/test/denonavr/test.denonavr.mapper.node.ts b/test/denonavr/test.denonavr.mapper.node.ts new file mode 100644 index 0000000..307a518 --- /dev/null +++ b/test/denonavr/test.denonavr.mapper.node.ts @@ -0,0 +1,69 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { DenonavrMapper, type IDenonavrSnapshot } from '../../ts/integrations/denonavr/index.js'; + +const snapshot: IDenonavrSnapshot = { + receiverInfo: { + host: '192.168.1.50', + port: 80, + name: 'Living Room AVR', + manufacturer: 'Denon', + modelName: 'AVR-X1700H', + serialNumber: 'ABC12345', + receiverType: 'avr-x', + }, + zones: [{ + zone: 'Main', + name: 'Main Zone', + power: 'ON', + state: 'playing', + volumeDb: -35, + muted: false, + source: 'Media Server', + sourceList: ['TV', 'Media Server', 'Bluetooth'], + soundMode: 'DOLBY DIGITAL', + soundModeRaw: 'DOLBY AUDIO - DOLBY DIGITAL', + dynamicEq: true, + ecoMode: 'Auto', + media: { + title: 'Track One', + artist: 'Artist One', + album: 'Album One', + contentType: 'music', + }, + available: true, + }, { + zone: 'Zone2', + name: 'Patio', + power: 'STANDBY', + state: 'off', + volumeLevel: 0.25, + muted: true, + source: 'Tuner', + available: true, + }], +}; + +tap.test('maps Denon AVR snapshots to canonical devices', async () => { + const devices = DenonavrMapper.toDevices(snapshot); + expect(devices[0].id).toEqual('denonavr.receiver.abc12345'); + expect(devices[0].manufacturer).toEqual('Denon'); + expect(devices[0].state.some((stateArg) => stateArg.featureId === 'main_source' && stateArg.value === 'Media Server')).toBeTrue(); + expect(devices[0].state.some((stateArg) => stateArg.featureId === 'zone2_power' && stateArg.value === 'off')).toBeTrue(); +}); + +tap.test('maps Denon AVR zones to media, sensor, and switch entities', async () => { + const entities = DenonavrMapper.toEntities(snapshot); + const media = entities.find((entityArg) => entityArg.id === 'media_player.living_room_avr'); + const source = entities.find((entityArg) => entityArg.id === 'sensor.living_room_avr_source'); + const mute = entities.find((entityArg) => entityArg.id === 'switch.living_room_avr_zone2_mute'); + const dynamicEq = entities.find((entityArg) => entityArg.id === 'switch.living_room_avr_dynamic_eq'); + + expect(media?.platform).toEqual('media_player'); + expect(media?.state).toEqual('playing'); + expect(media?.attributes?.volumeLevel).toEqual(0.45); + expect(source?.state).toEqual('Media Server'); + expect(mute?.state).toEqual(true); + expect(dynamicEq?.state).toEqual(true); +}); + +export default tap.start(); diff --git a/test/kodi/test.kodi.discovery.node.ts b/test/kodi/test.kodi.discovery.node.ts new file mode 100644 index 0000000..f9d0c16 --- /dev/null +++ b/test/kodi/test.kodi.discovery.node.ts @@ -0,0 +1,48 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createKodiDiscoveryDescriptor } from '../../ts/integrations/kodi/index.js'; + +tap.test('matches Kodi JSON-RPC mDNS records', async () => { + const descriptor = createKodiDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_xbmc-jsonrpc-h._tcp.local.', + name: 'Living Room Kodi._xbmc-jsonrpc-h._tcp.local.', + host: 'living-room-kodi.local', + port: 8080, + txt: { + uuid: 'kodi-uuid-123', + version: '21.0', + }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('living-room-kodi.local'); + expect(result.candidate?.port).toEqual(8080); + expect(result.normalizedDeviceId).toEqual('kodi-uuid-123'); +}); + +tap.test('matches manual Kodi host entries', async () => { + const descriptor = createKodiDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ + host: '192.168.1.70', + name: 'Living Room Kodi', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.70'); + expect(result.candidate?.port).toEqual(8080); +}); + +tap.test('validates Kodi candidates', async () => { + const descriptor = createKodiDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'mdns', + integrationDomain: 'kodi', + host: '192.168.1.71', + port: 8080, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('high'); +}); + +export default tap.start(); diff --git a/test/kodi/test.kodi.mapper.node.ts b/test/kodi/test.kodi.mapper.node.ts new file mode 100644 index 0000000..bbd8fb2 --- /dev/null +++ b/test/kodi/test.kodi.mapper.node.ts @@ -0,0 +1,58 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KodiMapper, type IKodiSnapshot } from '../../ts/integrations/kodi/index.js'; + +const snapshot: IKodiSnapshot = { + deviceInfo: { + uuid: 'kodi-uuid-123', + name: 'Living Room Kodi', + host: '192.168.1.70', + port: 8080, + manufacturer: 'Kodi', + version: '21.0', + }, + application: { + name: 'Kodi', + volume: 42, + muted: false, + version: { major: 21, minor: 0 }, + }, + players: [{ playerid: 1, type: 'video', playertype: 'internal' }], + player: { playerid: 1, type: 'video', playertype: 'internal' }, + playerProperties: { + speed: 1, + time: { hours: 0, minutes: 3, seconds: 4 }, + totaltime: { hours: 1, minutes: 2, seconds: 3 }, + live: false, + }, + item: { + id: 77, + type: 'movie', + title: 'The Test Movie', + file: 'smb://media/test.mkv', + thumbnail: 'image://poster.jpg/', + streamdetails: { video: [{ hdrtype: 'hdr10' }] }, + }, + online: true, +}; + +tap.test('maps Kodi JSON-RPC snapshots to media devices', async () => { + const devices = KodiMapper.toDevices(snapshot); + expect(devices[0].id).toEqual('kodi.device.kodi_uuid_123'); + expect(devices[0].protocol).toEqual('http'); + 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 === 'The Test Movie')).toBeTrue(); +}); + +tap.test('maps Kodi JSON-RPC snapshots to media player entities', async () => { + const entities = KodiMapper.toEntities(snapshot); + expect(entities[0].id).toEqual('media_player.living_room_kodi'); + 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?.mediaContentType).toEqual('movie'); + expect(entities[0].attributes?.mediaDuration).toEqual(3723); + expect(entities[0].attributes?.mediaPosition).toEqual(184); + expect(entities[0].attributes?.dynamicRange).toEqual('hdr10'); +}); + +export default tap.start(); diff --git a/test/samsungtv/test.samsungtv.discovery.node.ts b/test/samsungtv/test.samsungtv.discovery.node.ts new file mode 100644 index 0000000..9e0053c --- /dev/null +++ b/test/samsungtv/test.samsungtv.discovery.node.ts @@ -0,0 +1,58 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createSamsungtvDiscoveryDescriptor } from '../../ts/integrations/samsungtv/index.js'; + +tap.test('matches Samsung TV SSDP MainTVAgent records', async () => { + const descriptor = createSamsungtvDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + st: 'urn:samsung.com:service:MainTVAgent2:1', + usn: 'uuid:tv-udn-123::urn:samsung.com:service:MainTVAgent2:1', + location: 'http://192.168.1.55:8001/api/v2/', + headers: { + manufacturer: 'Samsung Electronics', + modelName: 'QN90A', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.55'); + expect(result.normalizedDeviceId).toEqual('tv-udn-123'); + expect(result.candidate?.metadata?.ssdpMainTvAgentLocation).toEqual('http://192.168.1.55:8001/api/v2/'); +}); + +tap.test('matches Samsung TV mDNS AirPlay records', async () => { + const descriptor = createSamsungtvDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ + type: '_airplay._tcp.local.', + name: 'Living Room TV', + host: 'living-room-tv.local', + port: 7000, + properties: { + manufacturer: 'Samsung Electronics', + deviceid: 'AA:BB:CC:DD:EE:FF', + model: 'Tizen TV', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('living-room-tv.local'); + expect(result.candidate?.port).toEqual(8001); + expect(result.normalizedDeviceId).toEqual('AA:BB:CC:DD:EE:FF'); +}); + +tap.test('validates Samsung TV candidates', async () => { + const descriptor = createSamsungtvDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'manual', + integrationDomain: 'samsungtv', + host: '192.168.1.55', + manufacturer: 'Samsung', + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('high'); +}); + +export default tap.start(); diff --git a/test/samsungtv/test.samsungtv.mapper.node.ts b/test/samsungtv/test.samsungtv.mapper.node.ts new file mode 100644 index 0000000..fe2ec3b --- /dev/null +++ b/test/samsungtv/test.samsungtv.mapper.node.ts @@ -0,0 +1,39 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { SamsungtvMapper } from '../../ts/integrations/samsungtv/index.js'; + +const snapshot = { + deviceInfo: { + id: 'tv-udn-123', + device: { + type: 'Samsung SmartTV', + name: '[TV] Living Room', + modelName: 'QN90A', + wifiMac: 'AA:BB:CC:DD:EE:FF', + PowerState: 'on', + }, + }, + state: { + playback: 'playing' as const, + volumeLevel: 35, + muted: false, + }, + apps: [ + { id: '11101200001', name: 'Netflix' }, + { id: '3201512006785', name: 'Prime Video' }, + ], + activeApp: { id: '11101200001', name: 'Netflix' }, +}; + +tap.test('maps Samsung TV snapshots to media devices and entities', async () => { + const devices = SamsungtvMapper.toDevices(snapshot); + const entities = SamsungtvMapper.toEntities(snapshot); + + expect(devices[0].id).toEqual('samsungtv.device.tv_udn_123'); + expect(devices[0].state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Netflix')).toBeTrue(); + expect(entities[0].id).toEqual('media_player.living_room'); + expect(entities[0].platform).toEqual('media_player'); + expect(entities[0].state).toEqual('playing'); + expect((entities[0].attributes?.sourceList as string[]).includes('Netflix')).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/tplink/test.tplink.discovery.node.ts b/test/tplink/test.tplink.discovery.node.ts new file mode 100644 index 0000000..c61ca47 --- /dev/null +++ b/test/tplink/test.tplink.discovery.node.ts @@ -0,0 +1,59 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createTplinkDiscoveryDescriptor } from '../../ts/integrations/tplink/index.js'; + +tap.test('matches TP-Link Kasa/Tapo mDNS records', async () => { + const descriptor = createTplinkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_tplink._tcp.local.', + name: 'Living Plug._tplink._tcp.local.', + host: 'living-plug.local', + port: 80, + txt: { + model: 'KP125M', + mac: 'F0-A7-31-00-11-22', + alias: 'Living Plug', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('tplink'); + expect(result.normalizedDeviceId).toEqual('f0:a7:31:00:11:22'); +}); + +tap.test('matches Home Assistant TP-Link DHCP rules', async () => { + const descriptor = createTplinkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ + hostname: 'HS110-Office', + ipAddress: '192.168.1.44', + macAddress: '50:C7:BF:AA:BB:CC', + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.44'); +}); + +tap.test('validates manual snapshot candidates', async () => { + const descriptor = createTplinkDiscoveryDescriptor(); + const manualMatcher = descriptor.getMatchers()[2]; + const validator = descriptor.getValidators()[0]; + const manual = await manualMatcher.matches({ + host: '192.168.1.55', + model: 'Tapo P110', + alias: 'Desk Plug', + snapshot: { + connected: true, + devices: [], + entities: [], + events: [], + }, + }, {}); + const validated = await validator.validate(manual.candidate!, {}); + + expect(manual.matched).toBeTrue(); + expect(validated.matched).toBeTrue(); + expect(validated.metadata?.encryptedLocalProtocolImplemented).toEqual(false); +}); + +export default tap.start(); diff --git a/test/tplink/test.tplink.mapper.node.ts b/test/tplink/test.tplink.mapper.node.ts new file mode 100644 index 0000000..f4bb2c7 --- /dev/null +++ b/test/tplink/test.tplink.mapper.node.ts @@ -0,0 +1,97 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { TplinkMapper, type ITplinkSnapshot } from '../../ts/integrations/tplink/index.js'; + +const snapshot: ITplinkSnapshot = { + connected: true, + devices: [ + { + id: 'bulb-1', + alias: 'Living Lamp', + model: 'KL130', + type: 'bulb', + macAddress: '1C:3B:F3:00:11:22', + state: { + state: true, + brightness: 80, + color_temperature: 3000, + rgb: { r: 255, g: 100, b: 10 }, + current_consumption: 7.5, + rssi: -53, + }, + }, + { + id: 'strip-1', + alias: 'Office Strip', + model: 'HS300', + type: 'strip', + state: { state: true }, + children: [ + { + id: 'strip-1-outlet-1', + alias: 'Printer', + model: 'HS300 Outlet', + type: 'plug', + state: { + state: false, + current_consumption: 2.25, + }, + }, + ], + }, + { + id: 'sensor-1', + alias: 'Back Door', + model: 'T110', + type: 'sensor', + state: { + is_open: true, + battery_level: 92, + }, + }, + ], + entities: [], + events: [], +}; + +tap.test('maps TP-Link plugs, strips, bulbs, and sensors', async () => { + const devices = TplinkMapper.toDevices(snapshot); + const entities = TplinkMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'tplink.device.1c_3b_f3_00_11_22')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'tplink.device.strip_1_outlet_1')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'light.living_lamp')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.printer')?.state).toEqual('off'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.living_lamp_current_consumption')?.state).toEqual(7.5); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.back_door_is_open')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.back_door_battery_level')?.state).toEqual(92); +}); + +tap.test('maps canonical services to TP-Link feature commands', async () => { + const turnOnCommand = TplinkMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_on', + target: { entityId: 'light.living_lamp' }, + data: { brightness_pct: 50, color_temp_kelvin: 2700, rgb_color: [10, 20, 30] }, + }); + const switchCommand = TplinkMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.printer' }, + }); + const numberCommand = TplinkMapper.commandForService(snapshot, { + domain: 'number', + service: 'set_value', + target: { deviceId: 'tplink.device.1c_3b_f3_00_11_22' }, + data: { featureId: 'brightness', value: 30 }, + }); + + expect(turnOnCommand?.payload.brightness).toEqual(50); + expect(turnOnCommand?.payload.color_temperature).toEqual(2700); + expect(turnOnCommand?.payload.rgb).toEqual({ r: 10, g: 20, b: 30 }); + expect(switchCommand?.featureId).toEqual('state'); + expect(switchCommand?.value).toEqual(false); + expect(numberCommand?.featureId).toEqual('brightness'); + expect(numberCommand?.value).toEqual(30); +}); + +export default tap.start(); diff --git a/test/unifi/test.unifi.discovery.node.ts b/test/unifi/test.unifi.discovery.node.ts new file mode 100644 index 0000000..adc0c6d --- /dev/null +++ b/test/unifi/test.unifi.discovery.node.ts @@ -0,0 +1,59 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createUnifiDiscoveryDescriptor } from '../../ts/integrations/unifi/index.js'; + +tap.test('matches UniFi mDNS, SSDP, manual, and discovery records', async () => { + const descriptor = createUnifiDiscoveryDescriptor(); + const mdnsMatcher = descriptor.getMatchers()[0]; + const ssdpMatcher = descriptor.getMatchers()[1]; + const manualMatcher = descriptor.getMatchers()[2]; + const discoveryMatcher = descriptor.getMatchers()[3]; + + const mdnsResult = await mdnsMatcher.matches({ + type: '_unifi._tcp.local.', + name: 'UniFi Network._unifi._tcp.local.', + host: 'unifi.local', + txt: { + mac: 'b4fbe4123456', + model: 'UniFi Dream Machine', + controller_uuid: 'controller-1', + }, + }, {}); + const ssdpResult = await ssdpMatcher.matches({ + location: 'https://192.168.1.1:443/description.xml', + manufacturer: 'Ubiquiti Networks', + modelDescription: 'UniFi Dream Machine', + usn: 'uuid:udm-1', + }, {}); + const manualResult = await manualMatcher.matches({ + host: '192.168.1.2', + site: 'default', + model: 'UniFi Network', + }, {}); + const discoveryResult = await discoveryMatcher.matches({ + source_ip: '192.168.1.1', + hw_addr: 'B4:FB:E4:12:34:56', + services: { network: true }, + }, {}); + + expect(mdnsResult.matched).toBeTrue(); + expect(mdnsResult.normalizedDeviceId).toEqual('controller-1'); + expect(ssdpResult.matched).toBeTrue(); + expect(ssdpResult.candidate?.host).toEqual('192.168.1.1'); + expect(manualResult.matched).toBeTrue(); + expect(manualResult.candidate?.port).toEqual(443); + expect(discoveryResult.normalizedDeviceId).toEqual('b4:fb:e4:12:34:56'); +}); + +tap.test('validates UniFi candidates', async () => { + const validator = createUnifiDiscoveryDescriptor().getValidators()[0]; + const result = await validator.validate({ + source: 'ssdp', + host: '192.168.1.1', + manufacturer: 'Ubiquiti Networks', + model: 'UniFi Dream Machine SE', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('high'); +}); + +export default tap.start(); diff --git a/test/unifi/test.unifi.mapper.node.ts b/test/unifi/test.unifi.mapper.node.ts new file mode 100644 index 0000000..99c0abe --- /dev/null +++ b/test/unifi/test.unifi.mapper.node.ts @@ -0,0 +1,113 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { UnifiMapper, type IUnifiSnapshot } from '../../ts/integrations/unifi/index.js'; + +const now = Math.floor(Date.now() / 1000); + +const snapshot: IUnifiSnapshot = { + connected: true, + host: '192.168.1.1', + port: 443, + site: 'default', + controller: { + id: 'controller-1', + name: 'UniFi Network', + version: '9.0.0', + connected: true, + }, + sites: [{ id: 'site-1', name: 'default', description: 'Default' }], + clients: [ + { + mac: 'aa:bb:cc:dd:ee:ff', + name: 'Kitchen Phone', + ip: '192.168.1.55', + essid: 'Guest WiFi', + is_wired: false, + blocked: false, + last_seen: now, + ap_mac: 'b4:fb:e4:12:34:56', + 'rx_bytes-r': 2000000, + 'tx_bytes-r': 1000000, + rssi: -55, + }, + ], + devices: [ + { + mac: 'b4:fb:e4:12:34:56', + name: 'Switch 24', + model: 'USW-24-PoE', + version: '6.6.1', + ip: '192.168.1.10', + state: 1, + uptime: 3600, + general_temperature: 42, + 'system-stats': { cpu: '12.5', mem: '31.1' }, + port_table: [ + { + deviceMac: 'b4:fb:e4:12:34:56', + port_idx: 1, + name: 'Office AP', + enable: true, + up: true, + port_poe: true, + poe_mode: 'auto', + poe_power: '7.2', + speed: 1000, + 'rx_bytes-r': 1024, + 'tx_bytes-r': 2048, + }, + ], + }, + ], + wlans: [ + { + _id: 'wlan-1', + name: 'Guest WiFi', + enabled: true, + security: 'wpapsk', + is_guest: true, + }, + ], + ports: [], + events: [], +}; + +tap.test('maps UniFi clients, devices, WLANs, and ports', async () => { + const devices = UnifiMapper.toDevices(snapshot); + const entities = UnifiMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'unifi.client.aa_bb_cc_dd_ee_ff')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'unifi.device.b4_fb_e4_12_34_56')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'unifi.wlan.wlan_1')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.kitchen_phone_connected')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.guest_wifi')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.switch_24_office_ap_poe')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.switch_24_office_ap_poe_power')?.state).toEqual(7.2); +}); + +tap.test('maps services to UniFi commands', async () => { + const blockCommand = UnifiMapper.commandForService(snapshot, { + domain: 'unifi', + service: 'block_client', + target: {}, + data: { mac: 'aa:bb:cc:dd:ee:ff' }, + }); + const wlanCommand = UnifiMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.guest_wifi' }, + }); + const poeCommand = UnifiMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.switch_24_office_ap_poe' }, + }); + + expect(blockCommand?.type).toEqual('blockClient'); + expect(blockCommand?.block).toBeTrue(); + expect(wlanCommand?.type).toEqual('setWlanEnabled'); + expect(wlanCommand?.enabled).toBeFalse(); + expect(poeCommand?.type).toEqual('setPoePortEnabled'); + expect(poeCommand?.portIdx).toEqual('1'); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index bdcff9d..a8d34d6 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -3,17 +3,23 @@ export * from './protocols/index.js'; export * from './integrations/index.js'; import { HueIntegration } from './integrations/hue/index.js'; +import { AndroidtvIntegration } from './integrations/androidtv/index.js'; import { CastIntegration } from './integrations/cast/index.js'; import { DeconzIntegration } from './integrations/deconz/index.js'; +import { DenonavrIntegration } from './integrations/denonavr/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js'; import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js'; +import { KodiIntegration } from './integrations/kodi/index.js'; import { MatterIntegration } from './integrations/matter/index.js'; import { MqttIntegration } from './integrations/mqtt/index.js'; import { NanoleafIntegration } from './integrations/nanoleaf/index.js'; import { RokuIntegration } from './integrations/roku/index.js'; +import { SamsungtvIntegration } from './integrations/samsungtv/index.js'; import { ShellyIntegration } from './integrations/shelly/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 { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js'; import { WizIntegration } from './integrations/wiz/index.js'; import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/index.js'; @@ -24,18 +30,24 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated import { IntegrationRegistry } from './core/index.js'; export const integrations = [ + new AndroidtvIntegration(), new CastIntegration(), new DeconzIntegration(), + new DenonavrIntegration(), new EsphomeIntegration(), new HomekitControllerIntegration(), new HueIntegration(), + new KodiIntegration(), new MatterIntegration(), new MqttIntegration(), new NanoleafIntegration(), new RokuIntegration(), + new SamsungtvIntegration(), new ShellyIntegration(), new SonosIntegration(), + new TplinkIntegration(), new TradfriIntegration(), + new UnifiIntegration(), new WolfSmartsetIntegration(), new WizIntegration(), new XiaomiMiioIntegration(), diff --git a/ts/integrations/androidtv/.generated-by-smarthome-exchange b/ts/integrations/androidtv/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/androidtv/.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/androidtv/androidtv.classes.client.ts b/ts/integrations/androidtv/androidtv.classes.client.ts new file mode 100644 index 0000000..77d9a2b --- /dev/null +++ b/ts/integrations/androidtv/androidtv.classes.client.ts @@ -0,0 +1,140 @@ +import { androidtvDefaultPort, androidtvKnownApps } from './androidtv.constants.js'; +import type { IAndroidtvCommand, IAndroidtvConfig, IAndroidtvDeviceInfo, IAndroidtvDeviceState, IAndroidtvSnapshot } from './androidtv.types.js'; + +export class AndroidtvUnsupportedProtocolError extends Error { + constructor(commandArg: IAndroidtvCommand) { + super(`Android TV live ADB control is not implemented in this TypeScript port. Cannot execute ${commandArg.action} without a real ADB protocol client.`); + this.name = 'AndroidtvUnsupportedProtocolError'; + } +} + +export class AndroidtvClient { + private readonly snapshot?: IAndroidtvSnapshot; + + constructor(private readonly config: IAndroidtvConfig) { + this.snapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined; + } + + public async getSnapshot(): Promise { + return this.normalizeSnapshot(this.snapshot || this.snapshotFromManualConfig()); + } + + public async turnOn(): Promise { + return this.unsupported({ action: 'turn_on' }); + } + + public async turnOff(): Promise { + return this.unsupported({ action: 'turn_off' }); + } + + public async mediaPlay(): Promise { + return this.unsupported({ action: 'media_play' }); + } + + public async mediaPause(): Promise { + return this.unsupported({ action: 'media_pause' }); + } + + public async mediaPlayPause(): Promise { + return this.unsupported({ action: 'media_play_pause' }); + } + + public async mediaStop(): Promise { + return this.unsupported({ action: 'media_stop' }); + } + + public async setVolumeLevel(volumeLevelArg: number): Promise { + return this.unsupported({ action: 'volume_set', volumeLevel: volumeLevelArg }); + } + + public async stepVolume(volumeStepArg: number): Promise { + return this.unsupported({ action: 'volume_step', volumeStep: volumeStepArg }); + } + + public async muteVolume(mutedArg: boolean): Promise { + return this.unsupported({ action: 'volume_mute', muted: mutedArg }); + } + + public async selectSource(sourceArg: string): Promise { + const snapshot = await this.getSnapshot(); + const app = snapshot.apps.find((appArg) => sourceArg === appArg.id || sourceArg === (appArg.name || androidtvKnownApps[appArg.id])); + return this.unsupported({ action: 'select_source', source: sourceArg, appId: app?.id }); + } + + public async sendCommand(commandsArg: string[], repeatsArg = 1): Promise { + return this.unsupported({ action: 'remote_send_command', keys: commandsArg, repeats: repeatsArg }); + } + + public async adbCommand(commandArg: string): Promise { + return this.unsupported({ action: 'adb_command', shell: commandArg }); + } + + public async destroy(): Promise {} + + private async unsupported(commandArg: IAndroidtvCommand): Promise { + throw new AndroidtvUnsupportedProtocolError(commandArg); + } + + private snapshotFromManualConfig(): IAndroidtvSnapshot { + const deviceInfo: IAndroidtvDeviceInfo = { + ...this.config.deviceInfo, + host: this.config.deviceInfo?.host || this.config.host, + port: this.config.deviceInfo?.port || this.config.port || androidtvDefaultPort, + name: this.config.deviceInfo?.name || this.config.deviceName || this.config.host || 'Android TV', + model: this.config.deviceInfo?.model || this.config.model, + manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer, + deviceClass: this.config.deviceInfo?.deviceClass || this.config.deviceClass || 'androidtv', + }; + const state: IAndroidtvDeviceState = { + rawState: 'unknown', + available: false, + ...this.config.state, + }; + return { + deviceInfo, + state, + apps: [...(this.config.apps || [])], + }; + } + + private normalizeSnapshot(snapshotArg: IAndroidtvSnapshot): IAndroidtvSnapshot { + const deviceInfo: IAndroidtvDeviceInfo = { + ...snapshotArg.deviceInfo, + host: snapshotArg.deviceInfo.host || this.config.host, + port: snapshotArg.deviceInfo.port || this.config.port || androidtvDefaultPort, + deviceClass: snapshotArg.deviceInfo.deviceClass || this.config.deviceClass || 'androidtv', + }; + if (!deviceInfo.name) { + deviceInfo.name = this.config.deviceName || deviceInfo.host || 'Android TV'; + } + const apps = snapshotArg.apps.map((appArg) => ({ + ...appArg, + name: appArg.name || androidtvKnownApps[appArg.id], + })); + const state = { ...snapshotArg.state }; + if (!state.currentAppName && state.currentAppId) { + state.currentAppName = apps.find((appArg) => appArg.id === state.currentAppId)?.name || androidtvKnownApps[state.currentAppId]; + } + if (state.available === undefined) { + state.available = state.rawState !== 'unknown'; + } + return { + deviceInfo, + state, + apps, + updatedAt: snapshotArg.updatedAt, + }; + } + + private cloneSnapshot(snapshotArg: IAndroidtvSnapshot): IAndroidtvSnapshot { + return { + deviceInfo: { ...snapshotArg.deviceInfo }, + state: { + ...snapshotArg.state, + runningAppIds: snapshotArg.state.runningAppIds ? [...snapshotArg.state.runningAppIds] : undefined, + }, + apps: snapshotArg.apps.map((appArg) => ({ ...appArg })), + updatedAt: snapshotArg.updatedAt, + }; + } +} diff --git a/ts/integrations/androidtv/androidtv.classes.configflow.ts b/ts/integrations/androidtv/androidtv.classes.configflow.ts new file mode 100644 index 0000000..711dd52 --- /dev/null +++ b/ts/integrations/androidtv/androidtv.classes.configflow.ts @@ -0,0 +1,68 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { androidtvDefaultPort } from './androidtv.constants.js'; +import type { IAndroidtvConfig, TAndroidtvDeviceClass } from './androidtv.types.js'; + +export class AndroidtvConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Android Debug Bridge', + description: 'Configure an Android TV or Fire TV ADB host. Port defaults to 5555.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'deviceName', label: 'Device 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', title: 'Android TV configuration failed', error: 'Host is required.' }; + } + const port = this.numberValue(valuesArg.port) || candidateArg.port || androidtvDefaultPort; + const deviceName = this.stringValue(valuesArg.deviceName) || candidateArg.name; + const model = this.stringValue(valuesArg.model) || candidateArg.model; + return { + kind: 'done', + title: 'Android Debug Bridge configured', + config: { + host, + port, + deviceName, + model, + manufacturer: candidateArg.manufacturer, + deviceClass: this.deviceClass(candidateArg), + deviceInfo: { + name: deviceName, + host, + port, + model, + manufacturer: candidateArg.manufacturer, + serialNumber: candidateArg.serialNumber, + deviceClass: this.deviceClass(candidateArg), + }, + }, + }; + }, + }; + } + + 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 > 0 ? valueArg : undefined; + } + + private deviceClass(candidateArg: IDiscoveryCandidate): TAndroidtvDeviceClass { + const hintedClass = candidateArg.metadata?.deviceClass; + if (hintedClass === 'firetv' || hintedClass === 'androidtv') { + return hintedClass; + } + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + return manufacturer.includes('amazon') || model.includes('fire tv') || model.includes('firetv') ? 'firetv' : 'androidtv'; + } +} diff --git a/ts/integrations/androidtv/androidtv.classes.integration.ts b/ts/integrations/androidtv/androidtv.classes.integration.ts index 586c88a..d8d714c 100644 --- a/ts/integrations/androidtv/androidtv.classes.integration.ts +++ b/ts/integrations/androidtv/androidtv.classes.integration.ts @@ -1,28 +1,153 @@ -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 { AndroidtvClient } from './androidtv.classes.client.js'; +import { AndroidtvConfigFlow } from './androidtv.classes.configflow.js'; +import { createAndroidtvDiscoveryDescriptor } from './androidtv.discovery.js'; +import { AndroidtvMapper } from './androidtv.mapper.js'; +import type { IAndroidtvConfig } from './androidtv.types.js'; -export class HomeAssistantAndroidtvIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "androidtv", - displayName: "Android Debug Bridge", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/androidtv", - "upstreamDomain": "androidtv", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "adb-shell[async]==0.4.4", - "androidtv[async]==0.0.75" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@JeffLIrion", - "@ollo69" - ] -}, - }); +export class AndroidtvIntegration extends BaseIntegration { + public readonly domain = 'androidtv'; + public readonly displayName = 'Android Debug Bridge'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createAndroidtvDiscoveryDescriptor(); + public readonly configFlow = new AndroidtvConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/androidtv', + upstreamDomain: 'androidtv', + integrationType: 'device', + iotClass: 'local_polling', + requirements: ['adb-shell[async]==0.4.4', 'androidtv[async]==0.0.75'], + dependencies: [], + afterDependencies: [], + codeowners: ['@JeffLIrion', '@ollo69'], + }; + + public async setup(configArg: IAndroidtvConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new AndroidtvRuntime(new AndroidtvClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantAndroidtvIntegration extends AndroidtvIntegration {} + +class AndroidtvRuntime implements IIntegrationRuntime { + public domain = 'androidtv'; + + constructor(private readonly client: AndroidtvClient) {} + + public async devices(): Promise { + return AndroidtvMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return AndroidtvMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === 'remote') { + return await this.callRemoteService(requestArg); + } + if (requestArg.domain === 'androidtv') { + return await this.callAndroidtvService(requestArg); + } + if (requestArg.domain !== 'media_player') { + return { success: false, error: `Unsupported Android 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 Android 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') : []; + if (!commands.length) { + return { success: false, error: 'Android TV remote.send_command requires data.command.' }; + } + const repeats = typeof requestArg.data?.num_repeats === 'number' ? requestArg.data.num_repeats : 1; + await this.client.sendCommand(commands, repeats); + return { success: true }; + } + + private async callAndroidtvService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'adb_command') { + const command = requestArg.data?.command; + if (typeof command !== 'string' || !command) { + return { success: false, error: 'Android TV adb_command requires data.command.' }; + } + await this.client.adbCommand(command); + return { success: true }; + } + return { success: false, error: `Unsupported Android 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.mediaPlayPause(); + return { success: true }; + } + if (requestArg.service === 'stop' || requestArg.service === 'media_stop') { + await this.client.mediaStop(); + return { success: true }; + } + if (requestArg.service === 'volume_set') { + const level = requestArg.data?.volume_level; + if (typeof level !== 'number') { + return { success: false, error: 'Android TV volume_set requires data.volume_level.' }; + } + await this.client.setVolumeLevel(level); + return { success: true }; + } + if (requestArg.service === 'volume_up' || requestArg.service === 'volume_down') { + await this.client.stepVolume(requestArg.service === 'volume_up' ? 1 : -1); + 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: 'Android 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: 'Android TV select_source requires data.source.' }; + } + await this.client.selectSource(source); + return { success: true }; + } + return { success: false, error: `Unsupported Android TV media_player service: ${requestArg.service}` }; } } diff --git a/ts/integrations/androidtv/androidtv.constants.ts b/ts/integrations/androidtv/androidtv.constants.ts new file mode 100644 index 0000000..7f004d1 --- /dev/null +++ b/ts/integrations/androidtv/androidtv.constants.ts @@ -0,0 +1,76 @@ +import type { TAndroidtvKeyCommand } from './androidtv.types.js'; + +export const androidtvDefaultPort = 5555; +export const androidtvDefaultAdbServerPort = 5037; + +export const androidtvKeyCodes: Record = { + BACK: 4, + BLUE: 186, + CENTER: 23, + COMPONENT1: 249, + COMPONENT2: 250, + COMPOSITE1: 247, + COMPOSITE2: 248, + DOWN: 20, + END: 123, + ENTER: 66, + ESCAPE: 111, + FAST_FORWARD: 90, + GREEN: 184, + HDMI1: 243, + HDMI2: 244, + HDMI3: 245, + HDMI4: 246, + HOME: 3, + INPUT: 178, + LEFT: 21, + MENU: 82, + MOVE_HOME: 122, + MUTE: 164, + PAIRING: 225, + POWER: 26, + RED: 183, + RESUME: 224, + REWIND: 89, + RIGHT: 22, + SAT: 237, + SEARCH: 84, + SETTINGS: 176, + SLEEP: 223, + SUSPEND: 276, + SYSDOWN: 281, + SYSLEFT: 282, + SYSRIGHT: 283, + SYSUP: 280, + TEXT: 233, + TOP: 122, + UP: 19, + VGA: 251, + VOLUME_DOWN: 25, + VOLUME_UP: 24, + WAKEUP: 224, + YELLOW: 185, +}; + +export const androidtvKnownApps: Record = { + 'com.amazon.avod': 'Amazon Video', + 'com.amazon.avod.thirdpartyclient': 'Amazon Prime Video', + 'com.amazon.firetv.youtube': 'YouTube (FireTV)', + 'com.amazon.tv.launcher': 'Fire TV Launcher', + 'com.android.tv.settings': 'Settings', + 'com.disney.disneyplus': 'Disney+', + 'com.google.android.apps.tv.launcherx': 'Google TV Launcher', + 'com.google.android.tvlauncher': 'Android TV Launcher', + 'com.google.android.youtube.tv': 'YouTube', + 'com.google.android.youtube.tvkids': 'YouTube Kids', + 'com.google.android.youtube.tvmusic': 'YouTube Music', + 'com.hbo.hbonow': 'HBO Max', + 'com.hulu.plus': 'Hulu', + 'com.netflix.ninja': 'Netflix', + 'com.plexapp.android': 'Plex', + 'com.spotify.tv.android': 'Spotify', + 'org.jellyfin.androidtv': 'Jellyfin', + 'org.videolan.vlc': 'VLC', + 'org.xbmc.kodi': 'Kodi', + 'tv.twitch.android.app': 'Twitch', +}; diff --git a/ts/integrations/androidtv/androidtv.discovery.ts b/ts/integrations/androidtv/androidtv.discovery.ts new file mode 100644 index 0000000..ec46fe0 --- /dev/null +++ b/ts/integrations/androidtv/androidtv.discovery.ts @@ -0,0 +1,179 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { androidtvDefaultPort } from './androidtv.constants.js'; +import type { IAndroidtvAdbHostRecord, IAndroidtvManualEntry, IAndroidtvMdnsRecord, TAndroidtvDeviceClass } from './androidtv.types.js'; + +export class AndroidtvMdnsMatcher implements IDiscoveryMatcher { + public id = 'androidtv-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Android TV mDNS setup hints.'; + + public async matches(recordArg: IAndroidtvMdnsRecord): Promise { + const type = recordArg.type?.toLowerCase() || ''; + const name = recordArg.name || recordArg.txt?.fn || ''; + const model = recordArg.txt?.md || ''; + const matched = type.includes('androidtvremote') || this.hasAndroidTvHint(name, model); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record does not contain Android TV hints.' }; + } + const id = recordArg.txt?.id; + return { + matched: true, + confidence: recordArg.host ? 'medium' : 'low', + reason: 'mDNS record contains Android TV metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'androidtv', + id, + host: recordArg.host, + port: androidtvDefaultPort, + name, + manufacturer: recordArg.txt?.mf, + model, + metadata: { + type: recordArg.type, + remotePort: recordArg.port, + txt: recordArg.txt, + deviceClass: this.deviceClass(recordArg.txt?.mf, model), + }, + }, + }; + } + + private hasAndroidTvHint(...valuesArg: string[]): boolean { + return valuesArg.some((valueArg) => { + const value = valueArg.toLowerCase(); + return value.includes('android tv') || value.includes('androidtv') || value.includes('fire tv') || value.includes('firetv'); + }); + } + + private deviceClass(manufacturerArg?: string, modelArg?: string): TAndroidtvDeviceClass { + const value = `${manufacturerArg || ''} ${modelArg || ''}`.toLowerCase(); + return value.includes('amazon') || value.includes('fire tv') || value.includes('firetv') ? 'firetv' : 'androidtv'; + } +} + +export class AndroidtvManualMatcher implements IDiscoveryMatcher { + public id = 'androidtv-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Android TV ADB setup entries.'; + + public async matches(inputArg: IAndroidtvManualEntry): Promise { + const matched = Boolean(inputArg.host || inputArg.deviceClass || inputArg.metadata?.androidtv || inputArg.metadata?.adb || this.hasAndroidTvHint(inputArg.name, inputArg.deviceName, inputArg.model, inputArg.manufacturer)); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Android TV setup hints.' }; + } + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Android TV ADB setup.', + normalizedDeviceId: inputArg.id, + candidate: { + source: 'manual', + integrationDomain: 'androidtv', + id: inputArg.id, + host: inputArg.host, + port: inputArg.port || androidtvDefaultPort, + name: inputArg.deviceName || inputArg.name, + manufacturer: inputArg.manufacturer, + model: inputArg.model, + metadata: { + ...inputArg.metadata, + deviceClass: inputArg.deviceClass || this.deviceClass(inputArg.manufacturer, inputArg.model), + }, + }, + }; + } + + private hasAndroidTvHint(...valuesArg: Array): boolean { + return valuesArg.some((valueArg) => { + const value = valueArg?.toLowerCase() || ''; + return value.includes('android tv') || value.includes('androidtv') || value.includes('fire tv') || value.includes('firetv'); + }); + } + + private deviceClass(manufacturerArg?: string, modelArg?: string): TAndroidtvDeviceClass { + const value = `${manufacturerArg || ''} ${modelArg || ''}`.toLowerCase(); + return value.includes('amazon') || value.includes('fire tv') || value.includes('firetv') ? 'firetv' : 'androidtv'; + } +} + +export class AndroidtvAdbHostMatcher implements IDiscoveryMatcher { + public id = 'androidtv-adb-host-match'; + public source = 'custom' as const; + public description = 'Recognize ADB host discovery records for Android TV.'; + + public async matches(recordArg: IAndroidtvAdbHostRecord): Promise { + const protocol = String(recordArg.protocol || recordArg.metadata?.protocol || '').toLowerCase(); + const service = String(recordArg.service || recordArg.metadata?.service || '').toLowerCase(); + const matched = protocol === 'adb' || service === 'adb' || recordArg.port === androidtvDefaultPort || Boolean(recordArg.metadata?.adb) || Boolean(recordArg.metadata?.androidtv); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Record is not an Android TV ADB host.' }; + } + return { + matched: true, + confidence: recordArg.host && recordArg.port === androidtvDefaultPort ? 'certain' : recordArg.host ? 'high' : 'medium', + reason: 'Record contains ADB host metadata.', + normalizedDeviceId: recordArg.id || recordArg.macAddress || recordArg.serialNumber, + candidate: { + source: 'custom', + integrationDomain: 'androidtv', + id: recordArg.id, + host: recordArg.host, + port: recordArg.port || androidtvDefaultPort, + name: recordArg.name, + manufacturer: recordArg.manufacturer, + model: recordArg.model, + serialNumber: recordArg.serialNumber, + macAddress: recordArg.macAddress, + metadata: { + ...recordArg.metadata, + protocol: protocol || 'adb', + deviceClass: recordArg.deviceClass || this.deviceClass(recordArg.manufacturer, recordArg.model), + }, + }, + }; + } + + private deviceClass(manufacturerArg?: string, modelArg?: string): TAndroidtvDeviceClass { + const value = `${manufacturerArg || ''} ${modelArg || ''}`.toLowerCase(); + return value.includes('amazon') || value.includes('fire tv') || value.includes('firetv') ? 'firetv' : 'androidtv'; + } +} + +export class AndroidtvCandidateValidator implements IDiscoveryValidator { + public id = 'androidtv-candidate-validator'; + public description = 'Validate Android TV ADB candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const hint = this.hasAndroidTvHint(candidateArg) || candidateArg.integrationDomain === 'androidtv' || candidateArg.port === androidtvDefaultPort || Boolean(candidateArg.metadata?.adb); + const matched = Boolean(hint && candidateArg.host); + return { + matched, + confidence: matched && (candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber) ? 'certain' : matched ? 'high' : 'low', + reason: matched ? 'Candidate has Android TV ADB metadata and a host.' : 'Candidate is missing Android TV ADB metadata or host.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || candidateArg.host, + metadata: { + port: candidateArg.port || androidtvDefaultPort, + }, + }; + } + + private hasAndroidTvHint(candidateArg: IDiscoveryCandidate): boolean { + const values = [candidateArg.name, candidateArg.manufacturer, candidateArg.model, String(candidateArg.metadata?.deviceClass || ''), String(candidateArg.metadata?.protocol || '')]; + return values.some((valueArg) => { + const value = valueArg?.toLowerCase() || ''; + return value.includes('android tv') || value.includes('androidtv') || value.includes('fire tv') || value.includes('firetv') || value === 'adb'; + }); + } +} + +export const createAndroidtvDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'androidtv', displayName: 'Android Debug Bridge' }) + .addMatcher(new AndroidtvMdnsMatcher()) + .addMatcher(new AndroidtvManualMatcher()) + .addMatcher(new AndroidtvAdbHostMatcher()) + .addValidator(new AndroidtvCandidateValidator()); +}; diff --git a/ts/integrations/androidtv/androidtv.mapper.ts b/ts/integrations/androidtv/androidtv.mapper.ts new file mode 100644 index 0000000..e8e5ab6 --- /dev/null +++ b/ts/integrations/androidtv/androidtv.mapper.ts @@ -0,0 +1,165 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import { androidtvKnownApps } from './androidtv.constants.js'; +import type { IAndroidtvSnapshot } from './androidtv.types.js'; + +export class AndroidtvMapper { + public static toDevices(snapshotArg: IAndroidtvSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + return [{ + id: this.deviceId(snapshotArg), + integrationDomain: 'androidtv', + name: this.deviceName(snapshotArg), + protocol: 'unknown', + manufacturer: snapshotArg.deviceInfo.manufacturer || this.defaultManufacturer(snapshotArg), + model: snapshotArg.deviceInfo.model || this.deviceTypeLabel(snapshotArg), + online: this.available(snapshotArg), + features: [ + { id: 'power', capability: 'media', name: 'Power', readable: true, writable: true }, + { id: 'media_state', capability: 'media', name: 'Media state', readable: true, writable: false }, + { id: 'source', capability: 'media', name: 'Source', readable: true, writable: true }, + { id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true }, + { id: 'mute', capability: 'media', name: 'Mute', readable: true, writable: true }, + { id: 'remote_key', capability: 'media', name: 'Remote key', readable: false, writable: true }, + ], + state: [ + { featureId: 'power', value: this.powerState(snapshotArg), updatedAt }, + { featureId: 'media_state', value: this.mediaState(snapshotArg), updatedAt }, + { featureId: 'source', value: this.source(snapshotArg) || null, updatedAt }, + { featureId: 'volume', value: this.volumePercent(snapshotArg), updatedAt }, + { featureId: 'mute', value: snapshotArg.state.isVolumeMuted ?? null, updatedAt }, + ], + metadata: { + host: snapshotArg.deviceInfo.host, + port: snapshotArg.deviceInfo.port, + deviceClass: snapshotArg.deviceInfo.deviceClass, + serialNumber: snapshotArg.deviceInfo.serialNumber, + softwareVersion: snapshotArg.deviceInfo.softwareVersion, + productId: snapshotArg.deviceInfo.productId, + wifiMac: snapshotArg.deviceInfo.wifiMac, + ethernetMac: snapshotArg.deviceInfo.ethernetMac, + hdmiInput: snapshotArg.state.hdmiInput, + apps: snapshotArg.apps.map((appArg) => ({ id: appArg.id, name: appArg.name || androidtvKnownApps[appArg.id] })), + }, + }]; + } + + public static toEntities(snapshotArg: IAndroidtvSnapshot): IIntegrationEntity[] { + return [{ + id: `media_player.${this.slug(this.deviceName(snapshotArg))}`, + uniqueId: `androidtv_${this.slug(this.stableDeviceKey(snapshotArg))}`, + integrationDomain: 'androidtv', + deviceId: this.deviceId(snapshotArg), + platform: 'media_player', + name: this.deviceName(snapshotArg), + state: this.mediaState(snapshotArg), + attributes: { + source: this.source(snapshotArg), + appId: snapshotArg.state.currentAppId, + appName: snapshotArg.state.currentAppName, + sourceList: this.sourceList(snapshotArg), + volumeLevel: snapshotArg.state.volumeLevel, + isVolumeMuted: snapshotArg.state.isVolumeMuted, + hdmiInput: snapshotArg.state.hdmiInput, + adbResponse: snapshotArg.state.adbResponse, + mediaTitle: snapshotArg.state.mediaTitle, + mediaArtist: snapshotArg.state.mediaArtist, + mediaAlbumName: snapshotArg.state.mediaAlbumName, + rawState: snapshotArg.state.rawState, + deviceClass: snapshotArg.deviceInfo.deviceClass, + }, + available: this.available(snapshotArg), + }]; + } + + private static mediaState(snapshotArg: IAndroidtvSnapshot): string { + if (!this.available(snapshotArg)) { + return 'unavailable'; + } + const state = String(snapshotArg.state.rawState || snapshotArg.state.powerState || 'idle').toLowerCase(); + if (state === 'off' || snapshotArg.state.powerState === 'off') { + return 'off'; + } + if (state === 'playing') { + return 'playing'; + } + if (state === 'paused') { + return 'paused'; + } + if (state === 'standby' || state === 'idle' || state === 'stopped') { + return 'idle'; + } + return this.source(snapshotArg) ? 'on' : 'idle'; + } + + private static powerState(snapshotArg: IAndroidtvSnapshot): string { + if (snapshotArg.state.powerState === 'off' || String(snapshotArg.state.rawState).toLowerCase() === 'off') { + return 'off'; + } + if (!this.available(snapshotArg)) { + return 'unknown'; + } + return 'on'; + } + + private static available(snapshotArg: IAndroidtvSnapshot): boolean { + return snapshotArg.state.available !== false; + } + + private static source(snapshotArg: IAndroidtvSnapshot): string | undefined { + if (snapshotArg.state.source) { + return snapshotArg.state.source; + } + if (snapshotArg.state.currentAppName) { + return snapshotArg.state.currentAppName; + } + const appId = snapshotArg.state.currentAppId; + return appId ? this.appName(snapshotArg, appId) : undefined; + } + + private static sourceList(snapshotArg: IAndroidtvSnapshot): string[] { + const sourceSet = new Set(); + for (const appArg of snapshotArg.apps) { + const name = appArg.name || androidtvKnownApps[appArg.id] || appArg.id; + if (name) { + sourceSet.add(name); + } + } + for (const appId of snapshotArg.state.runningAppIds || []) { + sourceSet.add(this.appName(snapshotArg, appId) || appId); + } + return [...sourceSet]; + } + + private static appName(snapshotArg: IAndroidtvSnapshot, appIdArg: string): string | undefined { + return snapshotArg.apps.find((appArg) => appArg.id === appIdArg)?.name || androidtvKnownApps[appIdArg]; + } + + private static volumePercent(snapshotArg: IAndroidtvSnapshot): number | null { + return typeof snapshotArg.state.volumeLevel === 'number' ? Math.round(Math.max(0, Math.min(1, snapshotArg.state.volumeLevel)) * 100) : null; + } + + private static deviceId(snapshotArg: IAndroidtvSnapshot): string { + return `androidtv.device.${this.slug(this.stableDeviceKey(snapshotArg))}`; + } + + private static stableDeviceKey(snapshotArg: IAndroidtvSnapshot): string { + return snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.serialNumber || snapshotArg.deviceInfo.ethernetMac || snapshotArg.deviceInfo.wifiMac || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg); + } + + private static deviceName(snapshotArg: IAndroidtvSnapshot): string { + return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.host || 'Android TV'; + } + + private static defaultManufacturer(snapshotArg: IAndroidtvSnapshot): string { + return snapshotArg.deviceInfo.deviceClass === 'firetv' ? 'Amazon' : 'Android'; + } + + private static deviceTypeLabel(snapshotArg: IAndroidtvSnapshot): string { + return snapshotArg.deviceInfo.deviceClass === 'firetv' ? 'Fire TV' : 'Android TV'; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'androidtv'; + } +} diff --git a/ts/integrations/androidtv/androidtv.types.ts b/ts/integrations/androidtv/androidtv.types.ts index c8b6e24..79a61cb 100644 --- a/ts/integrations/androidtv/androidtv.types.ts +++ b/ts/integrations/androidtv/androidtv.types.ts @@ -1,4 +1,191 @@ -export interface IHomeAssistantAndroidtvConfig { - // TODO: replace with the TypeScript-native config for androidtv. - [key: string]: unknown; +export type TAndroidtvDeviceClass = 'auto' | 'androidtv' | 'firetv'; + +export type TAndroidtvMediaState = 'off' | 'idle' | 'standby' | 'playing' | 'paused' | 'stopped' | 'unknown'; + +export type TAndroidtvPowerState = 'on' | 'off' | 'unknown'; + +export type TAndroidtvKeyCommand = + | 'BACK' + | 'BLUE' + | 'CENTER' + | 'COMPONENT1' + | 'COMPONENT2' + | 'COMPOSITE1' + | 'COMPOSITE2' + | 'DOWN' + | 'END' + | 'ENTER' + | 'ESCAPE' + | 'FAST_FORWARD' + | 'GREEN' + | 'HDMI1' + | 'HDMI2' + | 'HDMI3' + | 'HDMI4' + | 'HOME' + | 'INPUT' + | 'LEFT' + | 'MENU' + | 'MOVE_HOME' + | 'MUTE' + | 'PAIRING' + | 'POWER' + | 'RED' + | 'RESUME' + | 'REWIND' + | 'RIGHT' + | 'SAT' + | 'SEARCH' + | 'SETTINGS' + | 'SLEEP' + | 'SUSPEND' + | 'SYSDOWN' + | 'SYSLEFT' + | 'SYSRIGHT' + | 'SYSUP' + | 'TEXT' + | 'TOP' + | 'UP' + | 'VGA' + | 'VOLUME_DOWN' + | 'VOLUME_UP' + | 'WAKEUP' + | 'YELLOW'; + +export type TAndroidtvCommandAction = + | 'turn_on' + | 'turn_off' + | 'media_play' + | 'media_pause' + | 'media_play_pause' + | 'media_stop' + | 'volume_set' + | 'volume_step' + | 'volume_mute' + | 'select_source' + | 'remote_send_command' + | 'adb_command'; + +export interface IAndroidtvConfig { + host?: string; + port?: number; + deviceClass?: TAndroidtvDeviceClass; + deviceName?: string; + model?: string; + manufacturer?: string; + adbKeyPath?: string; + adbServerIp?: string; + adbServerPort?: number; + deviceInfo?: IAndroidtvDeviceInfo; + state?: IAndroidtvDeviceState; + apps?: IAndroidtvApp[]; + snapshot?: IAndroidtvSnapshot; } + +export interface IAndroidtvDeviceInfo { + id?: string; + name?: string; + host?: string; + port?: number; + deviceClass?: TAndroidtvDeviceClass; + manufacturer?: string; + model?: string; + serialNumber?: string; + softwareVersion?: string; + productId?: string; + wifiMac?: string; + ethernetMac?: string; +} + +export interface IAndroidtvDeviceState { + rawState?: TAndroidtvMediaState | string; + powerState?: TAndroidtvPowerState; + available?: boolean; + currentAppId?: string; + currentAppName?: string; + runningAppIds?: string[]; + source?: string; + volumeLevel?: number; + isVolumeMuted?: boolean; + hdmiInput?: string; + adbResponse?: string; + mediaTitle?: string; + mediaArtist?: string; + mediaAlbumName?: string; +} + +export interface IAndroidtvApp { + id: string; + name?: string; + version?: string; + isRunning?: boolean; + isCurrent?: boolean; +} + +export interface IAndroidtvCommand { + action: TAndroidtvCommandAction; + key?: TAndroidtvKeyCommand | string; + keys?: Array; + shell?: string; + source?: string; + appId?: string; + volumeLevel?: number; + volumeStep?: number; + muted?: boolean; + repeats?: number; +} + +export interface IAndroidtvEvent { + type: 'snapshot' | 'command' | 'error'; + command?: IAndroidtvCommand; + snapshot?: IAndroidtvSnapshot; + message?: string; + timestamp: number; +} + +export interface IAndroidtvSnapshot { + deviceInfo: IAndroidtvDeviceInfo; + state: IAndroidtvDeviceState; + apps: IAndroidtvApp[]; + updatedAt?: string; +} + +export interface IAndroidtvManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + deviceName?: string; + model?: string; + manufacturer?: string; + deviceClass?: TAndroidtvDeviceClass; + metadata?: Record; +} + +export interface IAndroidtvAdbHostRecord { + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + manufacturer?: string; + serialNumber?: string; + macAddress?: string; + deviceClass?: TAndroidtvDeviceClass; + protocol?: string; + service?: string; + metadata?: Record; +} + +export interface IAndroidtvMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + txt?: Record; + metadata?: Record; +} + +export type TAndroidtvDiscoveryRecord = IAndroidtvManualEntry | IAndroidtvAdbHostRecord | IAndroidtvMdnsRecord; + +export type IHomeAssistantAndroidtvConfig = IAndroidtvConfig; diff --git a/ts/integrations/androidtv/index.ts b/ts/integrations/androidtv/index.ts index 6b1b027..0683218 100644 --- a/ts/integrations/androidtv/index.ts +++ b/ts/integrations/androidtv/index.ts @@ -1,2 +1,6 @@ export * from './androidtv.classes.integration.js'; +export * from './androidtv.classes.client.js'; +export * from './androidtv.classes.configflow.js'; +export * from './androidtv.discovery.js'; +export * from './androidtv.mapper.js'; export * from './androidtv.types.js'; diff --git a/ts/integrations/denonavr/.generated-by-smarthome-exchange b/ts/integrations/denonavr/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/denonavr/.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/denonavr/denonavr.classes.client.ts b/ts/integrations/denonavr/denonavr.classes.client.ts new file mode 100644 index 0000000..47f2785 --- /dev/null +++ b/ts/integrations/denonavr/denonavr.classes.client.ts @@ -0,0 +1,448 @@ +import type { IDenonavrConfig, IDenonavrReceiverInfo, IDenonavrSnapshot, IDenonavrZoneState, TDenonavrCommand, TDenonavrZone } from './denonavr.types.js'; + +const zoneNumbers: Record = { Main: 1, Zone2: 2, Zone3: 3 }; +const statusPaths: Record = { + Main: '/goform/formMainZone_MainZoneXmlStatus.xml', + Zone2: '/goform/formZone2_Zone2XmlStatus.xml', + Zone3: '/goform/formZone3_Zone3XmlStatus.xml', +}; +const sourcePrefixes: Record = { Main: 'SI', Zone2: 'Z2', Zone3: 'Z3' }; +const volumeUpCommands: Record = { Main: 'MVUP', Zone2: 'Z2UP', Zone3: 'Z3UP' }; +const volumeDownCommands: Record = { Main: 'MVDOWN', Zone2: 'Z2DOWN', Zone3: 'Z3DOWN' }; + +const defaultSourceMap: Record = { + 'TV AUDIO': 'TV', + TV: 'TV', + 'Blu-ray': 'BD', + 'BLU-RAY': 'BD', + BD: 'BD', + 'CBL/SAT': 'SAT/CBL', + 'SAT/CBL': 'SAT/CBL', + DVD: 'DVD', + 'Media Player': 'MPLAY', + 'MEDIA PLAYER': 'MPLAY', + MPLAY: 'MPLAY', + GAME: 'GAME', + AUX: 'AUX1', + AUX1: 'AUX1', + CD: 'CD', + PHONO: 'PHONO', + Tuner: 'TUNER', + TUNER: 'TUNER', + NETWORK: 'NET', + NET: 'NET', + Bluetooth: 'BT', + BT: 'BT', + 'iPod/USB': 'USB/IPOD', + USB: 'USB/IPOD', + 'USB/IPOD': 'USB/IPOD', + 'Internet Radio': 'IRADIO', + IRADIO: 'IRADIO', + 'Media Server': 'SERVER', + SERVER: 'SERVER', + Favorites: 'FAVORITES', + FAVORITES: 'FAVORITES', + Spotify: 'SPOTIFY', + SpotifyConnect: 'SPOTIFY', + 'Spotify Connect': 'SPOTIFY', +}; + +export class DenonavrClient { + constructor(private readonly config: IDenonavrConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.config.snapshot; + } + const receiverInfo = await this.getReceiverInfo(); + const zones = await this.getZones(); + return { receiverInfo, zones, lastUpdated: new Date().toISOString() }; + } + + public async getReceiverInfo(): Promise { + if (this.config.receiverInfo) { + return { ...this.manualReceiverInfo(), ...this.config.receiverInfo }; + } + if (!this.config.host) { + return this.manualReceiverInfo(); + } + + const descriptions = await this.tryDescriptionXml(); + if (descriptions) { + return { ...this.manualReceiverInfo(), ...descriptions }; + } + + const deviceInfo = await this.tryDeviceInfoXml(); + return { ...this.manualReceiverInfo(), ...deviceInfo }; + } + + public async getZones(): Promise { + if (this.config.zones) { + return this.config.zones; + } + if (!this.config.host) { + return [{ zone: 'Main', name: this.config.name || 'Main Zone', power: 'OFF', state: 'off', available: false }]; + } + + const zones: TDenonavrZone[] = ['Main']; + if (this.config.zone2) { + zones.push('Zone2'); + } + if (this.config.zone3) { + zones.push('Zone3'); + } + + return Promise.all(zones.map((zoneArg) => this.getZoneState(zoneArg))); + } + + public async execute(requestArg: { command: TDenonavrCommand; zone?: TDenonavrZone; source?: string; volumeLevel?: number; volumeDb?: number; muted?: boolean; path?: string }): Promise { + const zone = requestArg.zone || 'Main'; + if (requestArg.command === 'turn_on') { + return this.command(`/goform/formiPhoneAppPower.xml?${zoneNumbers[zone]}+PowerOn`); + } + if (requestArg.command === 'turn_off') { + return this.command(`/goform/formiPhoneAppPower.xml?${zoneNumbers[zone]}+PowerStandby`); + } + if (requestArg.command === 'volume_up') { + return this.direct(volumeUpCommands[zone]); + } + if (requestArg.command === 'volume_down') { + return this.direct(volumeDownCommands[zone]); + } + if (requestArg.command === 'set_volume') { + const volumeDb = typeof requestArg.volumeDb === 'number' ? requestArg.volumeDb : this.volumeLevelToDb(requestArg.volumeLevel ?? 0); + return this.command(`/goform/formiPhoneAppVolume.xml?${zoneNumbers[zone]}+${volumeDb.toFixed(1)}`); + } + if (requestArg.command === 'mute') { + return this.command(`/goform/formiPhoneAppMute.xml?${zoneNumbers[zone]}+${requestArg.muted ? 'MuteOn' : 'MuteOff'}`); + } + if (requestArg.command === 'select_source') { + if (!requestArg.source) { + throw new Error('Denon AVR select_source requires a source.'); + } + return this.direct(`${sourcePrefixes[zone]}${this.toSourceCode(requestArg.source, zone)}`); + } + if (requestArg.command === 'play') { + return this.direct('NS9A'); + } + if (requestArg.command === 'pause') { + return this.direct('NS9B'); + } + if (requestArg.command === 'stop') { + return this.direct('NS9C'); + } + if (requestArg.command === 'play_pause') { + const snapshot = await this.getSnapshot(); + const zoneState = snapshot.zones.find((zoneArg) => zoneArg.zone === zone); + return this.direct(zoneState?.state === 'playing' ? 'NS9B' : 'NS9A'); + } + if (requestArg.command === 'previous_track') { + await this.netaudioCommand('CurUp'); + return undefined; + } + if (requestArg.command === 'next_track') { + await this.netaudioCommand('CurDown'); + return undefined; + } + if (requestArg.command === 'get_command') { + if (!requestArg.path) { + throw new Error('Denon AVR get_command requires a path.'); + } + return this.command(requestArg.path); + } + throw new Error(`Unsupported Denon AVR command: ${requestArg.command}`); + } + + public async destroy(): Promise {} + + private async getZoneState(zoneArg: TDenonavrZone): Promise { + const [statusXml, mainXml] = await Promise.all([ + this.fetchText(statusPaths[zoneArg]).catch(() => ''), + zoneArg === 'Main' ? this.fetchText('/goform/formMainZone_MainZoneXml.xml').catch(() => '') : Promise.resolve(''), + ]); + const xml = `${statusXml}\n${mainXml}`; + const source = this.firstContainerValue(xml, ['InputFuncSelect', 'InputFunc']) || undefined; + const volumeDb = this.numberValue(this.firstContainerValue(xml, ['MasterVolume', 'Volume'])); + const muted = this.boolOnOff(this.firstContainerValue(xml, ['Mute'])); + const media = await this.getMediaInfo(source); + const power = this.firstContainerValue(xml, ['ZonePower', 'Power']) || undefined; + return { + zone: zoneArg, + name: zoneArg === 'Main' ? (this.config.name || this.firstContainerValue(mainXml, ['FriendlyName']) || 'Main Zone') : zoneArg, + power, + state: this.stateFromPower(power, source, media), + volumeDb, + volumeLevel: typeof volumeDb === 'number' ? this.volumeDbToLevel(volumeDb) : undefined, + muted, + source, + sourceList: this.readInputList(mainXml), + sourceMap: this.config.sourceMap, + soundModeRaw: this.firstContainerValue(xml, ['selectSurround', 'SurrMode']) || undefined, + soundMode: this.firstContainerValue(xml, ['selectSurround', 'SurrMode']) || undefined, + ecoMode: this.firstContainerValue(xml, ['ECOMode']) || undefined, + media, + available: Boolean(statusXml || mainXml), + }; + } + + private async getMediaInfo(sourceArg: string | undefined) { + const source = sourceArg?.toLowerCase() || ''; + if (['internet radio', 'media server', 'network', 'net', 'bluetooth', 'bt', 'spotify', 'spotifyconnect', 'ipod/usb', 'usb/ipod'].includes(source)) { + const xml = await this.fetchText('/goform/formNetAudio_StatusXml.xml').catch(() => ''); + const lines = this.readSzLines(xml); + return { + title: lines[1], + artist: lines[2], + album: lines[4], + imageUrl: this.config.host ? `http://${this.config.host}:${this.port()}/img/album%20art_S.png` : undefined, + contentType: 'music', + }; + } + if (source === 'tuner') { + const xml = await this.fetchText('/goform/formTuner_TunerXml.xml').catch(() => ''); + return { + band: this.firstContainerValue(xml, ['Band']) || undefined, + frequency: this.firstContainerValue(xml, ['Frequency']) || undefined, + contentType: 'channel', + }; + } + if (source === 'hd radio' || source === 'hdradio') { + const xml = await this.fetchText('/goform/formTuner_HdXml.xml').catch(() => ''); + return { + title: this.firstContainerValue(xml, ['Title']) || undefined, + artist: this.firstContainerValue(xml, ['Artist']) || undefined, + album: this.firstContainerValue(xml, ['Album']) || undefined, + band: this.firstContainerValue(xml, ['Band']) || undefined, + frequency: this.firstContainerValue(xml, ['Frequency']) || undefined, + station: this.firstContainerValue(xml, ['StationNameSh']) || undefined, + contentType: 'music', + }; + } + return undefined; + } + + private async tryDescriptionXml(): Promise { + const attempts = [ + { port: this.config.descriptionPort || this.config.port || 8080, path: '/description.xml' }, + { port: 60006, path: '/upnp/desc/aios_device/aios_device.xml' }, + { port: 8080, path: '/description.xml' }, + { port: 80, path: '/description.xml' }, + ]; + const seen = new Set(); + for (const attempt of attempts) { + const key = `${attempt.port}${attempt.path}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + const xml = await this.fetchText(attempt.path, attempt.port).catch(() => ''); + if (!xml) { + continue; + } + const manufacturer = this.readXmlTag(xml, 'manufacturer'); + const modelName = this.readXmlTag(xml, 'modelName'); + const serialNumber = this.readXmlTag(xml, 'serialNumber'); + if (manufacturer || modelName || serialNumber) { + return { + host: this.config.host, + port: this.port(), + friendlyName: this.readXmlTag(xml, 'friendlyName'), + name: this.readXmlTag(xml, 'friendlyName'), + manufacturer, + modelName, + modelNumber: this.readXmlTag(xml, 'modelNumber'), + serialNumber, + presentationUrl: this.readXmlTag(xml, 'presentationURL'), + }; + } + } + return undefined; + } + + private async tryDeviceInfoXml(): Promise { + const xml = await this.fetchText('/goform/Deviceinfo.xml').catch(() => ''); + if (!xml) { + return undefined; + } + return { + host: this.config.host, + port: this.port(), + manufacturer: this.readXmlTag(xml, 'Manufacturer') || this.config.manufacturer, + modelName: this.readXmlTag(xml, 'ModelName') || this.config.model, + serialNumber: this.readXmlTag(xml, 'SerialNumber') || this.config.serialNumber, + receiverType: this.readXmlTag(xml, 'CommApiVers') ? 'avr-x' : this.config.receiverType, + }; + } + + private manualReceiverInfo(): IDenonavrReceiverInfo { + return { + host: this.config.host, + port: this.config.port || 80, + name: this.config.name, + friendlyName: this.config.name, + manufacturer: this.config.manufacturer || 'Denon', + modelName: this.config.model, + serialNumber: this.config.serialNumber, + receiverType: this.config.receiverType, + receiverPort: this.config.port || 80, + }; + } + + private async command(pathArg: string): Promise { + return this.fetchText(pathArg.startsWith('/') ? pathArg : `/${pathArg}`); + } + + private async direct(commandArg: string): Promise { + return this.command(`/goform/formiPhoneAppDirect.xml?${encodeURIComponent(commandArg).replace(/%2F/g, '/').replace(/%20/g, '%20')}`); + } + + private async netaudioCommand(commandArg: 'CurUp' | 'CurDown'): Promise { + if (!this.config.host) { + throw new Error('Denon AVR host is required for local HTTP commands.'); + } + const body = new URLSearchParams({ + cmd0: `PutNetAudioCommand/${commandArg}`, + cmd1: 'aspMainZone_WebUpdateStatus/', + ZoneName: 'MAIN ZONE', + }); + const response = await globalThis.fetch(`${this.baseUrl()}/NetAudio/index.put.asp`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!response.ok) { + throw new Error(`Denon AVR NetAudio command failed with HTTP ${response.status}: ${await response.text()}`); + } + } + + private async fetchText(pathArg: string, portArg = this.port()): Promise { + if (!this.config.host) { + throw new Error('Denon AVR host is required when snapshot data is not provided.'); + } + const response = await globalThis.fetch(`${this.baseUrl(portArg)}${pathArg}`); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Denon AVR request ${pathArg} failed with HTTP ${response.status}: ${text}`); + } + return text; + } + + private stateFromPower(powerArg: string | undefined, sourceArg: string | undefined, mediaArg: unknown): string { + if (!powerArg || powerArg === 'OFF' || powerArg === 'STANDBY') { + return 'off'; + } + if (mediaArg && sourceArg) { + return 'playing'; + } + return 'on'; + } + + private toSourceCode(sourceArg: string, zoneArg: TDenonavrZone): string { + const zone = this.config.zones?.find((itemArg) => itemArg.zone === zoneArg); + const sourceMap = { ...defaultSourceMap, ...this.config.sourceMap, ...zone?.sourceMap }; + return sourceMap[sourceArg] || sourceMap[sourceArg.toUpperCase()] || sourceArg.toUpperCase().replace(/\s+/g, ''); + } + + private volumeLevelToDb(valueArg: number): number { + return Math.max(-80, Math.min(18, valueArg * 100 - 80)); + } + + private volumeDbToLevel(valueArg: number): number { + return Math.max(0, Math.min(1, (valueArg + 80) / 100)); + } + + private numberValue(valueArg: string | undefined): number | undefined { + if (!valueArg || valueArg === '--') { + return undefined; + } + const numberValue = Number(valueArg); + return Number.isFinite(numberValue) ? numberValue : undefined; + } + + private boolOnOff(valueArg: string | undefined): boolean | undefined { + if (!valueArg) { + return undefined; + } + const value = valueArg.toLowerCase(); + if (value === 'on') { + return true; + } + if (value === 'off') { + return false; + } + return undefined; + } + + private readInputList(xmlArg: string): string[] | undefined { + const container = this.readContainer(xmlArg, 'InputFuncList'); + if (!container) { + return undefined; + } + const values = this.readAllTags(container, 'value'); + return values.length ? values : undefined; + } + + private readSzLines(xmlArg: string): Record { + const lines: Record = {}; + const container = this.readContainer(xmlArg, 'szLine') || ''; + const values = this.readAllTags(container, 'value'); + values.forEach((valueArg, indexArg) => { + lines[indexArg] = valueArg; + }); + return lines; + } + + private firstContainerValue(xmlArg: string, tagsArg: string[]): string | undefined { + for (const tag of tagsArg) { + const container = this.readContainer(xmlArg, tag); + if (container) { + return this.readXmlTag(container, 'value') || this.stripTags(container).trim() || undefined; + } + const value = this.readXmlTag(xmlArg, tag); + if (value) { + return value; + } + } + return undefined; + } + + private readContainer(xmlArg: string, tagArg: string): string | undefined { + const escapedTag = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>`, 'i'); + return regex.exec(xmlArg)?.[1]?.trim(); + } + + private readXmlTag(xmlArg: string, tagArg: string): string | undefined { + const escapedTag = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>`, 'i'); + const match = regex.exec(xmlArg); + return match?.[1] ? this.unescapeXml(this.stripTags(match[1]).trim()) : undefined; + } + + private readAllTags(xmlArg: string, tagArg: string): string[] { + const escapedTag = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>`, 'gi'); + const values: string[] = []; + let match: RegExpExecArray | null; + while ((match = regex.exec(xmlArg))) { + values.push(this.unescapeXml(this.stripTags(match[1]).trim())); + } + return values.filter(Boolean); + } + + private stripTags(valueArg: string): string { + return valueArg.replace(/<[^>]+>/g, ''); + } + + private port(): number { + return this.config.port || 80; + } + + private baseUrl(portArg = this.port()): string { + return `http://${this.config.host}:${portArg}`; + } + + private unescapeXml(valueArg: string): string { + return valueArg.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&'); + } +} diff --git a/ts/integrations/denonavr/denonavr.classes.configflow.ts b/ts/integrations/denonavr/denonavr.classes.configflow.ts new file mode 100644 index 0000000..ae64a02 --- /dev/null +++ b/ts/integrations/denonavr/denonavr.classes.configflow.ts @@ -0,0 +1,47 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IDenonavrConfig } from './denonavr.types.js'; + +export class DenonavrConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Denon AVR Network Receiver', + description: 'Configure the local Denon/Marantz AVR 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 port = typeof valuesArg.port === 'number' ? valuesArg.port : Number(valuesArg.port || candidateArg.port || 80); + const name = stringValue(valuesArg.name) || candidateArg.name; + const model = stringValue(valuesArg.model) || candidateArg.model; + return { + kind: 'done', + title: 'Denon AVR configured', + config: { + host: stringValue(valuesArg.host) || candidateArg.host || '', + port: Number.isFinite(port) ? port : 80, + name, + model, + manufacturer: candidateArg.manufacturer, + serialNumber: candidateArg.serialNumber, + receiverInfo: name || model || candidateArg.manufacturer || candidateArg.serialNumber ? { + name, + friendlyName: name, + manufacturer: candidateArg.manufacturer, + modelName: model, + serialNumber: candidateArg.serialNumber, + } : undefined, + }, + }; + }, + }; + } +} + +const stringValue = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +}; diff --git a/ts/integrations/denonavr/denonavr.classes.integration.ts b/ts/integrations/denonavr/denonavr.classes.integration.ts index 2088320..7f13fc8 100644 --- a/ts/integrations/denonavr/denonavr.classes.integration.ts +++ b/ts/integrations/denonavr/denonavr.classes.integration.ts @@ -1,27 +1,157 @@ -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 { DenonavrClient } from './denonavr.classes.client.js'; +import { DenonavrConfigFlow } from './denonavr.classes.configflow.js'; +import { createDenonavrDiscoveryDescriptor } from './denonavr.discovery.js'; +import { DenonavrMapper } from './denonavr.mapper.js'; +import type { IDenonavrConfig, TDenonavrCommand, TDenonavrZone } from './denonavr.types.js'; -export class HomeAssistantDenonavrIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "denonavr", - displayName: "Denon AVR Network Receivers", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/denonavr", - "upstreamDomain": "denonavr", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "denonavr==1.3.2" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@ol-iver", - "@starkillerOG" - ] -}, - }); +export class DenonavrIntegration extends BaseIntegration { + public readonly domain = 'denonavr'; + public readonly displayName = 'Denon AVR Network Receivers'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createDenonavrDiscoveryDescriptor(); + public readonly configFlow = new DenonavrConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/denonavr', + upstreamDomain: 'denonavr', + integrationType: 'device', + iotClass: 'local_push', + requirements: ['denonavr==1.3.2'], + dependencies: [], + afterDependencies: [], + codeowners: ['@ol-iver', '@starkillerOG'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/denonavr', + }; + + public async setup(configArg: IDenonavrConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new DenonavrRuntime(new DenonavrClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantDenonavrIntegration extends DenonavrIntegration {} + +class DenonavrRuntime implements IIntegrationRuntime { + public domain = 'denonavr'; + + constructor(private readonly client: DenonavrClient) {} + + public async devices(): Promise { + return DenonavrMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return DenonavrMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain !== 'media_player') { + return { success: false, error: `Unsupported Denon AVR service domain: ${requestArg.domain}` }; + } + + const zone = this.zoneFromRequest(requestArg); + const command = this.commandFromService(requestArg); + if (!command) { + return { success: false, error: `Unsupported Denon AVR media_player service: ${requestArg.service}` }; + } + + try { + const result = await this.client.execute({ + command, + zone, + source: this.stringData(requestArg, 'source'), + volumeLevel: this.numberData(requestArg, 'volume_level'), + muted: this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute'), + }); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private commandFromService(requestArg: IServiceCallRequest): TDenonavrCommand | 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') { + return 'set_volume'; + } + if (requestArg.service === 'volume_mute') { + return 'mute'; + } + if (requestArg.service === 'select_source') { + return 'select_source'; + } + 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'; + } + return undefined; + } + + private zoneFromRequest(requestArg: IServiceCallRequest): TDenonavrZone { + const zone = this.stringData(requestArg, 'zone'); + if (zone === 'Zone2' || zone === 'zone2') { + return 'Zone2'; + } + if (zone === 'Zone3' || zone === 'zone3') { + return 'Zone3'; + } + const entityId = requestArg.target.entityId?.toLowerCase() || ''; + if (entityId.includes('zone2')) { + return 'Zone2'; + } + if (entityId.includes('zone3')) { + return 'Zone3'; + } + return 'Main'; + } + + private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' ? value : undefined; + } + + private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'number' ? 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/denonavr/denonavr.discovery.ts b/ts/integrations/denonavr/denonavr.discovery.ts new file mode 100644 index 0000000..c9b01f3 --- /dev/null +++ b/ts/integrations/denonavr/denonavr.discovery.ts @@ -0,0 +1,239 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IDenonavrManualEntry, IDenonavrMdnsRecord, IDenonavrSsdpRecord } from './denonavr.types.js'; + +const supportedManufacturers = ['denon', 'denon professional', 'marantz']; +const supportedDeviceTypes = [ + 'urn:schemas-upnp-org:device:mediarenderer:1', + 'urn:schemas-upnp-org:device:mediaserver:1', + 'urn:schemas-denon-com:device:aiosdevice:1', +]; +const ignoredModels = ['heos 1', 'heos 3', 'heos 5', 'heos 7']; + +export class DenonavrSsdpMatcher implements IDiscoveryMatcher { + public id = 'denonavr-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Denon and Marantz AVR SSDP advertisements.'; + + public async matches(recordArg: IDenonavrSsdpRecord): Promise { + const st = header(recordArg, 'st') || recordArg.upnp?.deviceType; + const usn = header(recordArg, 'usn'); + const location = header(recordArg, 'location'); + const manufacturer = upnp(recordArg, 'manufacturer'); + const model = cleanModel(upnp(recordArg, 'modelName')); + const serialNumber = upnp(recordArg, 'serialNumber') || upnp(recordArg, 'serial'); + const friendlyName = upnp(recordArg, 'friendlyName'); + const deviceType = upnp(recordArg, 'deviceType') || st; + + const matchedManufacturer = isSupportedManufacturer(manufacturer); + const matchedType = Boolean(deviceType && supportedDeviceTypes.includes(deviceType.toLowerCase())); + const matchedUsn = Boolean(usn?.toLowerCase().includes('denon') || usn?.toLowerCase().includes('marantz')); + + if (!matchedManufacturer && !matchedType && !matchedUsn) { + return { matched: false, confidence: 'low', reason: 'SSDP record is not a Denon/Marantz AVR.' }; + } + if (isIgnoredModel(model)) { + return { matched: false, confidence: 'low', reason: 'SSDP record is a HEOS speaker, not an AVR.' }; + } + + const url = parseUrl(location); + const id = uniqueId(model, serialNumber) || stripUuid(usn); + return { + matched: true, + confidence: id && matchedManufacturer ? 'certain' : matchedManufacturer || matchedType ? 'high' : 'medium', + reason: 'SSDP record matches Denon/Marantz AVR metadata.', + normalizedDeviceId: id, + candidate: { + source: 'ssdp', + integrationDomain: 'denonavr', + id, + host: url?.hostname, + port: url?.port ? Number(url.port) : undefined, + name: friendlyName, + manufacturer: normalizedManufacturer(manufacturer), + model, + serialNumber, + metadata: { st, usn, location, deviceType }, + }, + }; + } +} + +export class DenonavrMdnsMatcher implements IDiscoveryMatcher { + public id = 'denonavr-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Denon and Marantz AVR mDNS advertisements.'; + + public async matches(recordArg: IDenonavrMdnsRecord): Promise { + const name = recordArg.name || ''; + const type = recordArg.type || ''; + const manufacturer = recordArg.txt?.manufacturer || recordArg.txt?.brand || recordArg.txt?.Manufacturer; + const model = cleanModel(recordArg.txt?.model || recordArg.txt?.modelName || recordArg.txt?.ModelName); + const serialNumber = recordArg.txt?.serial || recordArg.txt?.serialNumber || recordArg.txt?.SerialNumber; + const haystack = `${name} ${type} ${manufacturer || ''} ${model || ''}`.toLowerCase(); + const matched = isSupportedManufacturer(manufacturer) || haystack.includes('denon') || haystack.includes('marantz'); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Denon/Marantz advertisement.' }; + } + if (isIgnoredModel(model)) { + return { matched: false, confidence: 'low', reason: 'mDNS record is a HEOS speaker, not an AVR.' }; + } + + const id = uniqueId(model, serialNumber) || recordArg.txt?.id || name; + return { + matched: true, + confidence: serialNumber ? 'certain' : 'medium', + reason: 'mDNS record matches Denon/Marantz metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'denonavr', + id, + host: recordArg.host, + port: recordArg.port, + name, + manufacturer: normalizedManufacturer(manufacturer), + model, + serialNumber, + metadata: { mdnsName: name, mdnsType: type, txt: recordArg.txt }, + }, + }; + } +} + +export class DenonavrManualMatcher implements IDiscoveryMatcher { + public id = 'denonavr-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Denon/Marantz AVR setup entries.'; + + public async matches(inputArg: IDenonavrManualEntry): Promise { + const model = cleanModel(inputArg.model); + const matched = Boolean( + inputArg.host + || isSupportedManufacturer(inputArg.manufacturer) + || model?.toLowerCase().includes('denon') + || model?.toLowerCase().includes('marantz') + || inputArg.metadata?.denonavr + ); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Denon AVR setup hints.' }; + } + const id = uniqueId(model, inputArg.serialNumber) || inputArg.id; + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Denon AVR setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'denonavr', + id, + host: inputArg.host, + port: inputArg.port || 80, + name: inputArg.name, + manufacturer: normalizedManufacturer(inputArg.manufacturer), + model, + serialNumber: inputArg.serialNumber, + metadata: inputArg.metadata, + }, + }; + } +} + +export class DenonavrCandidateValidator implements IDiscoveryValidator { + public id = 'denonavr-candidate-validator'; + public description = 'Validate Denon/Marantz AVR candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'denonavr' + || isSupportedManufacturer(manufacturer) + || model.includes('denon') + || model.includes('marantz') + || model.includes('avr') + || model.includes('sr'); + const rejected = isIgnoredModel(model); + + return { + matched: matched && !rejected, + confidence: matched && !rejected && candidateArg.host ? 'high' : matched && !rejected ? 'medium' : 'low', + reason: rejected ? 'Candidate is a HEOS speaker, not an AVR.' : matched ? 'Candidate has Denon/Marantz AVR metadata.' : 'Candidate is not a Denon AVR.', + candidate: matched && !rejected ? candidateArg : undefined, + normalizedDeviceId: uniqueId(candidateArg.model, candidateArg.serialNumber) || candidateArg.id, + }; + } +} + +export const createDenonavrDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'denonavr', displayName: 'Denon AVR Network Receivers' }) + .addMatcher(new DenonavrSsdpMatcher()) + .addMatcher(new DenonavrMdnsMatcher()) + .addMatcher(new DenonavrManualMatcher()) + .addValidator(new DenonavrCandidateValidator()); +}; + +const header = (recordArg: IDenonavrSsdpRecord, keyArg: string): string | undefined => { + return recordArg[keyArg as keyof IDenonavrSsdpRecord] as string | undefined + || valueForKey(recordArg.headers, keyArg); +}; + +const upnp = (recordArg: IDenonavrSsdpRecord, 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 cleanModel = (valueArg: string | undefined): string | undefined => { + return valueArg?.replace(/\*/g, '').trim() || undefined; +}; + +const isSupportedManufacturer = (valueArg: string | undefined): boolean => { + const value = valueArg?.toLowerCase().trim(); + return Boolean(value && supportedManufacturers.includes(value)); +}; + +const normalizedManufacturer = (valueArg: string | undefined): string | undefined => { + if (!valueArg) { + return undefined; + } + return valueArg.toLowerCase().includes('marantz') ? 'Marantz' : valueArg.toLowerCase().includes('denon') ? 'Denon' : valueArg; +}; + +const isIgnoredModel = (valueArg: string | undefined): boolean => { + return Boolean(valueArg && ignoredModels.includes(valueArg.toLowerCase())); +}; + +const uniqueId = (modelArg: string | undefined, serialArg: string | undefined): string | undefined => { + if (modelArg && serialArg) { + return `${cleanModel(modelArg)}-${serialArg}`; + } + return serialArg; +}; diff --git a/ts/integrations/denonavr/denonavr.mapper.ts b/ts/integrations/denonavr/denonavr.mapper.ts new file mode 100644 index 0000000..5ad8d92 --- /dev/null +++ b/ts/integrations/denonavr/denonavr.mapper.ts @@ -0,0 +1,193 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { IDenonavrReceiverInfo, IDenonavrSnapshot, IDenonavrZoneState } from './denonavr.types.js'; + +export class DenonavrMapper { + public static toDevices(snapshotArg: IDenonavrSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = []; + const state: plugins.shxInterfaces.data.IDeviceState[] = []; + + for (const zone of snapshotArg.zones) { + const zoneSlug = this.slug(zone.zone); + features.push( + { id: `${zoneSlug}_power`, capability: 'media', name: `${this.zoneName(zone)} power`, readable: true, writable: true }, + { id: `${zoneSlug}_source`, capability: 'media', name: `${this.zoneName(zone)} source`, readable: true, writable: true }, + { id: `${zoneSlug}_volume`, capability: 'media', name: `${this.zoneName(zone)} volume`, readable: true, writable: true, unit: '%' }, + { id: `${zoneSlug}_muted`, capability: 'media', name: `${this.zoneName(zone)} muted`, readable: true, writable: true }, + ); + state.push( + { featureId: `${zoneSlug}_power`, value: this.powerState(zone), updatedAt }, + { featureId: `${zoneSlug}_source`, value: zone.source || null, updatedAt }, + { featureId: `${zoneSlug}_volume`, value: typeof this.volumeLevel(zone) === 'number' ? Math.round((this.volumeLevel(zone) || 0) * 100) : null, updatedAt }, + { featureId: `${zoneSlug}_muted`, value: zone.muted ?? null, updatedAt }, + ); + if (zone.soundMode || zone.soundModeRaw) { + features.push({ id: `${zoneSlug}_sound_mode`, capability: 'media', name: `${this.zoneName(zone)} sound mode`, readable: true, writable: true }); + state.push({ featureId: `${zoneSlug}_sound_mode`, value: zone.soundMode || zone.soundModeRaw || null, updatedAt }); + } + } + + return [{ + id: this.deviceId(snapshotArg), + integrationDomain: 'denonavr', + name: this.receiverName(snapshotArg.receiverInfo), + protocol: 'http', + manufacturer: snapshotArg.receiverInfo.manufacturer || 'Denon', + model: snapshotArg.receiverInfo.modelName || snapshotArg.receiverInfo.modelNumber, + online: snapshotArg.zones.some((zoneArg) => zoneArg.available !== false), + features, + state, + metadata: { + host: snapshotArg.receiverInfo.host, + port: snapshotArg.receiverInfo.port, + serialNumber: snapshotArg.receiverInfo.serialNumber, + receiverType: snapshotArg.receiverInfo.receiverType, + softwareVersion: snapshotArg.receiverInfo.softwareVersion, + }, + }]; + } + + public static toEntities(snapshotArg: IDenonavrSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + for (const zone of snapshotArg.zones) { + const zoneEntityBase = this.entityBase(snapshotArg, zone); + entities.push({ + id: `media_player.${zoneEntityBase}`, + uniqueId: `denonavr_${this.uniqueBase(snapshotArg)}_${this.slug(zone.zone)}`, + integrationDomain: 'denonavr', + deviceId: this.deviceId(snapshotArg), + platform: 'media_player', + name: this.mediaEntityName(snapshotArg.receiverInfo, zone), + state: this.mediaState(zone), + attributes: { + deviceClass: 'receiver', + zone: zone.zone, + power: zone.power, + volumeLevel: this.volumeLevel(zone), + volumeDb: zone.volumeDb, + isVolumeMuted: zone.muted, + source: zone.source, + sourceList: zone.sourceList, + soundMode: zone.soundMode, + soundModeRaw: zone.soundModeRaw, + soundModeList: zone.soundModeList, + mediaTitle: zone.media?.title || (!zone.media ? zone.source : undefined), + mediaArtist: zone.media?.artist || zone.media?.band, + mediaAlbumName: zone.media?.album || zone.media?.station, + mediaImageUrl: zone.media?.imageUrl, + mediaContentType: zone.media?.contentType, + dynamicEq: zone.dynamicEq, + ecoMode: zone.ecoMode, + }, + available: zone.available !== false, + }); + + entities.push({ + id: `sensor.${zoneEntityBase}_source`, + uniqueId: `denonavr_${this.uniqueBase(snapshotArg)}_${this.slug(zone.zone)}_source`, + integrationDomain: 'denonavr', + deviceId: this.deviceId(snapshotArg), + platform: 'sensor', + name: `${this.mediaEntityName(snapshotArg.receiverInfo, zone)} Source`, + state: zone.source || 'unknown', + attributes: { zone: zone.zone, soundMode: zone.soundMode || zone.soundModeRaw, ecoMode: zone.ecoMode }, + available: zone.available !== false, + }); + + if (typeof zone.muted === 'boolean') { + entities.push({ + id: `switch.${zoneEntityBase}_mute`, + uniqueId: `denonavr_${this.uniqueBase(snapshotArg)}_${this.slug(zone.zone)}_mute`, + integrationDomain: 'denonavr', + deviceId: this.deviceId(snapshotArg), + platform: 'switch', + name: `${this.mediaEntityName(snapshotArg.receiverInfo, zone)} Mute`, + state: zone.muted, + attributes: { zone: zone.zone }, + available: zone.available !== false, + }); + } + + if (typeof zone.dynamicEq === 'boolean') { + entities.push({ + id: `switch.${zoneEntityBase}_dynamic_eq`, + uniqueId: `denonavr_${this.uniqueBase(snapshotArg)}_${this.slug(zone.zone)}_dynamic_eq`, + integrationDomain: 'denonavr', + deviceId: this.deviceId(snapshotArg), + platform: 'switch', + name: `${this.mediaEntityName(snapshotArg.receiverInfo, zone)} Dynamic EQ`, + state: zone.dynamicEq, + attributes: { zone: zone.zone }, + available: zone.available !== false, + }); + } + } + return entities; + } + + private static mediaState(zoneArg: IDenonavrZoneState): string { + if (this.powerState(zoneArg) === 'off') { + return 'off'; + } + const state = zoneArg.state?.toLowerCase(); + if (state === 'playing' || state === 'paused') { + return state; + } + if (state === 'stopped') { + return 'idle'; + } + return 'on'; + } + + private static powerState(zoneArg: IDenonavrZoneState): string { + const power = zoneArg.power?.toLowerCase(); + if (power === 'off' || power === 'standby') { + return 'off'; + } + if (!power && zoneArg.state?.toLowerCase() === 'off') { + return 'off'; + } + return 'on'; + } + + private static volumeLevel(zoneArg: IDenonavrZoneState): number | undefined { + if (typeof zoneArg.volumeLevel === 'number') { + return Math.max(0, Math.min(1, zoneArg.volumeLevel)); + } + if (typeof zoneArg.volumeDb === 'number') { + return Math.max(0, Math.min(1, (zoneArg.volumeDb + 80) / 100)); + } + return undefined; + } + + private static deviceId(snapshotArg: IDenonavrSnapshot): string { + return `denonavr.receiver.${this.uniqueBase(snapshotArg)}`; + } + + private static entityBase(snapshotArg: IDenonavrSnapshot, zoneArg: IDenonavrZoneState): string { + const suffix = zoneArg.zone === 'Main' ? '' : `_${this.slug(zoneArg.zone)}`; + return `${this.slug(this.receiverName(snapshotArg.receiverInfo))}${suffix}`; + } + + private static uniqueBase(snapshotArg: IDenonavrSnapshot): string { + return this.slug(snapshotArg.receiverInfo.serialNumber || snapshotArg.receiverInfo.modelName || snapshotArg.receiverInfo.host || this.receiverName(snapshotArg.receiverInfo)); + } + + private static receiverName(infoArg: IDenonavrReceiverInfo): string { + return infoArg.name || infoArg.friendlyName || infoArg.modelName || 'Denon AVR'; + } + + private static mediaEntityName(infoArg: IDenonavrReceiverInfo, zoneArg: IDenonavrZoneState): string { + const name = zoneArg.name || this.zoneName(zoneArg); + return zoneArg.zone === 'Main' ? this.receiverName(infoArg) : `${this.receiverName(infoArg)} ${name}`; + } + + private static zoneName(zoneArg: IDenonavrZoneState): string { + return zoneArg.name || (zoneArg.zone === 'Main' ? 'Main Zone' : zoneArg.zone); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'denonavr'; + } +} diff --git a/ts/integrations/denonavr/denonavr.types.ts b/ts/integrations/denonavr/denonavr.types.ts index d4351d3..5f557a4 100644 --- a/ts/integrations/denonavr/denonavr.types.ts +++ b/ts/integrations/denonavr/denonavr.types.ts @@ -1,4 +1,142 @@ -export interface IHomeAssistantDenonavrConfig { - // TODO: replace with the TypeScript-native config for denonavr. - [key: string]: unknown; +export type TDenonavrZone = 'Main' | 'Zone2' | 'Zone3'; + +export type TDenonavrPowerState = 'ON' | 'STANDBY' | 'OFF' | string; + +export type TDenonavrMediaState = 'on' | 'off' | 'playing' | 'paused' | 'stopped' | 'idle' | string; + +export type TDenonavrReceiverType = 'avr' | 'avr-x' | 'avr-x-2016' | string; + +export type TDenonavrCommand = + | 'turn_on' + | 'turn_off' + | 'volume_up' + | 'volume_down' + | 'set_volume' + | 'mute' + | 'select_source' + | 'play' + | 'pause' + | 'stop' + | 'play_pause' + | 'previous_track' + | 'next_track' + | 'get_command'; + +export interface IDenonavrConfig { + host?: string; + port?: number; + name?: string; + model?: string; + manufacturer?: string; + serialNumber?: string; + receiverType?: TDenonavrReceiverType; + descriptionPort?: number; + showAllSources?: boolean; + zone2?: boolean; + zone3?: boolean; + sourceMap?: Record; + receiverInfo?: IDenonavrReceiverInfo; + zones?: IDenonavrZoneState[]; + snapshot?: IDenonavrSnapshot; +} + +export interface IHomeAssistantDenonavrConfig extends IDenonavrConfig {} + +export interface IDenonavrReceiverInfo { + host?: string; + port?: number; + name?: string; + friendlyName?: string; + manufacturer?: string; + modelName?: string; + modelNumber?: string; + serialNumber?: string; + receiverType?: TDenonavrReceiverType; + receiverPort?: number; + presentationUrl?: string; + softwareVersion?: string; +} + +export interface IDenonavrMediaInfo { + title?: string; + artist?: string; + album?: string; + band?: string; + frequency?: string; + station?: string; + imageUrl?: string; + contentType?: string; +} + +export interface IDenonavrZoneState { + zone: TDenonavrZone; + name?: string; + power?: TDenonavrPowerState; + state?: TDenonavrMediaState; + volumeDb?: number; + volumeLevel?: number; + muted?: boolean; + source?: string; + sourceList?: string[]; + sourceMap?: Record; + soundMode?: string; + soundModeRaw?: string; + soundModeList?: string[]; + dynamicEq?: boolean; + ecoMode?: string; + media?: IDenonavrMediaInfo; + available?: boolean; +} + +export interface IDenonavrSnapshot { + receiverInfo: IDenonavrReceiverInfo; + zones: IDenonavrZoneState[]; + events?: IDenonavrEvent[]; + lastUpdated?: string; +} + +export interface IDenonavrCommandRequest { + command: TDenonavrCommand; + zone?: TDenonavrZone; + source?: string; + volumeLevel?: number; + volumeDb?: number; + muted?: boolean; + path?: string; +} + +export interface IDenonavrEvent { + type: 'telnet' | 'http' | 'state'; + zone?: TDenonavrZone; + event?: string; + parameter?: string; + command?: TDenonavrCommand; + timestamp: number; +} + +export interface IDenonavrSsdpRecord { + st?: string; + usn?: string; + location?: string; + headers?: Record; + upnp?: Record; +} + +export interface IDenonavrMdnsRecord { + name?: string; + type?: string; + host?: string; + port?: number; + txt?: Record; +} + +export interface IDenonavrManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + metadata?: Record; } diff --git a/ts/integrations/denonavr/index.ts b/ts/integrations/denonavr/index.ts index ba21be1..b0ebffd 100644 --- a/ts/integrations/denonavr/index.ts +++ b/ts/integrations/denonavr/index.ts @@ -1,2 +1,6 @@ +export * from './denonavr.classes.client.js'; +export * from './denonavr.classes.configflow.js'; export * from './denonavr.classes.integration.js'; +export * from './denonavr.discovery.js'; +export * from './denonavr.mapper.js'; export * from './denonavr.types.js'; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index d9872d6..79811aa 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -54,7 +54,6 @@ import { HomeAssistantAmpioIntegration } from '../ampio/index.js'; import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js'; import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js'; import { HomeAssistantAndroidIpWebcamIntegration } from '../android_ip_webcam/index.js'; -import { HomeAssistantAndroidtvIntegration } from '../androidtv/index.js'; import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js'; import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js'; import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js'; @@ -240,7 +239,6 @@ import { HomeAssistantDelugeIntegration } from '../deluge/index.js'; import { HomeAssistantDemoIntegration } from '../demo/index.js'; import { HomeAssistantDenonIntegration } from '../denon/index.js'; import { HomeAssistantDenonRs232Integration } from '../denon_rs232/index.js'; -import { HomeAssistantDenonavrIntegration } from '../denonavr/index.js'; import { HomeAssistantDerivativeIntegration } from '../derivative/index.js'; import { HomeAssistantDevialetIntegration } from '../devialet/index.js'; import { HomeAssistantDeviceAutomationIntegration } from '../device_automation/index.js'; @@ -635,7 +633,6 @@ import { HomeAssistantKiwiIntegration } from '../kiwi/index.js'; import { HomeAssistantKmtronicIntegration } from '../kmtronic/index.js'; import { HomeAssistantKnockiIntegration } from '../knocki/index.js'; import { HomeAssistantKnxIntegration } from '../knx/index.js'; -import { HomeAssistantKodiIntegration } from '../kodi/index.js'; import { HomeAssistantKonnectedIntegration } from '../konnected/index.js'; import { HomeAssistantKonnectedEsphomeIntegration } from '../konnected_esphome/index.js'; import { HomeAssistantKostalPlenticoreIntegration } from '../kostal_plenticore/index.js'; @@ -1062,7 +1059,6 @@ import { HomeAssistantRymproIntegration } from '../rympro/index.js'; import { HomeAssistantSabnzbdIntegration } from '../sabnzbd/index.js'; import { HomeAssistantSajIntegration } from '../saj/index.js'; import { HomeAssistantSamsamIntegration } from '../samsam/index.js'; -import { HomeAssistantSamsungtvIntegration } from '../samsungtv/index.js'; import { HomeAssistantSanixIntegration } from '../sanix/index.js'; import { HomeAssistantSatelIntegraIntegration } from '../satel_integra/index.js'; import { HomeAssistantSaunumIntegration } from '../saunum/index.js'; @@ -1271,7 +1267,6 @@ import { HomeAssistantTorqueIntegration } from '../torque/index.js'; import { HomeAssistantTotalconnectIntegration } from '../totalconnect/index.js'; import { HomeAssistantTouchlineIntegration } from '../touchline/index.js'; import { HomeAssistantTouchlineSlIntegration } from '../touchline_sl/index.js'; -import { HomeAssistantTplinkIntegration } from '../tplink/index.js'; import { HomeAssistantTplinkLteIntegration } from '../tplink_lte/index.js'; import { HomeAssistantTplinkOmadaIntegration } from '../tplink_omada/index.js'; import { HomeAssistantTplinkTapoIntegration } from '../tplink_tapo/index.js'; @@ -1306,7 +1301,6 @@ import { HomeAssistantUhooIntegration } from '../uhoo/index.js'; import { HomeAssistantUkTransportIntegration } from '../uk_transport/index.js'; import { HomeAssistantUkraineAlarmIntegration } from '../ukraine_alarm/index.js'; import { HomeAssistantUltraloqIntegration } from '../ultraloq/index.js'; -import { HomeAssistantUnifiIntegration } from '../unifi/index.js'; import { HomeAssistantUnifiAccessIntegration } from '../unifi_access/index.js'; import { HomeAssistantUnifiDirectIntegration } from '../unifi_direct/index.js'; import { HomeAssistantUnifiDiscoveryIntegration } from '../unifi_discovery/index.js'; @@ -1497,7 +1491,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration()) generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidIpWebcamIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration()); @@ -1683,7 +1676,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelugeIntegration() generatedHomeAssistantPortIntegrations.push(new HomeAssistantDemoIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonRs232Integration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonavrIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDerivativeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevialetIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceAutomationIntegration()); @@ -2078,7 +2070,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantKiwiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKmtronicIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnockiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnxIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantKodiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedEsphomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKostalPlenticoreIntegration()); @@ -2505,7 +2496,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRymproIntegration() generatedHomeAssistantPortIntegrations.push(new HomeAssistantSabnzbdIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSajIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSamsamIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantSamsungtvIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSanixIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSatelIntegraIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSaunumIntegration()); @@ -2714,7 +2704,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantTorqueIntegration() generatedHomeAssistantPortIntegrations.push(new HomeAssistantTotalconnectIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTouchlineIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTouchlineSlIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkLteIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkOmadaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkTapoIntegration()); @@ -2749,7 +2738,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantUhooIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUkTransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUkraineAlarmIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUltraloqIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiAccessIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiDirectIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiDiscoveryIntegration()); @@ -2886,20 +2874,26 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1441; +export const generatedHomeAssistantPortCount = 1435; export const handwrittenHomeAssistantPortDomains = [ + "androidtv", "cast", "deconz", + "denonavr", "esphome", "homekit_controller", "hue", + "kodi", "matter", "mqtt", "nanoleaf", "roku", + "samsungtv", "shelly", "sonos", + "tplink", "tradfri", + "unifi", "wiz", "xiaomi_miio", "yeelight", diff --git a/ts/integrations/kodi/.generated-by-smarthome-exchange b/ts/integrations/kodi/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/kodi/.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/kodi/index.ts b/ts/integrations/kodi/index.ts index cd739a3..48a04e6 100644 --- a/ts/integrations/kodi/index.ts +++ b/ts/integrations/kodi/index.ts @@ -1,2 +1,6 @@ +export * from './kodi.classes.client.js'; +export * from './kodi.classes.configflow.js'; export * from './kodi.classes.integration.js'; +export * from './kodi.discovery.js'; +export * from './kodi.mapper.js'; export * from './kodi.types.js'; diff --git a/ts/integrations/kodi/kodi.classes.client.ts b/ts/integrations/kodi/kodi.classes.client.ts new file mode 100644 index 0000000..296bda8 --- /dev/null +++ b/ts/integrations/kodi/kodi.classes.client.ts @@ -0,0 +1,372 @@ +import type { + IKodiActivePlayer, + IKodiApplicationProperties, + IKodiConfig, + IKodiDeviceInfo, + IKodiJsonRpcRequest, + IKodiJsonRpcResponse, + IKodiMediaItem, + IKodiPlayerProperties, + IKodiSnapshot, + IKodiTime, + TKodiInputCommand, + TKodiJsonRpcParams, + TKodiMediaType, +} from './kodi.types.js'; + +const defaultPort = 8080; +const defaultWsPort = 9090; +const defaultTimeoutMs = 5000; + +const playerProperties = ['time', 'totaltime', 'speed', 'live', 'percentage', 'playlistid', 'position', 'repeat', 'shuffled']; +const itemProperties = ['title', 'file', 'uniqueid', 'thumbnail', 'artist', 'albumartist', 'showtitle', 'album', 'season', 'episode', 'streamdetails']; + +export class KodiJsonRpcError extends Error { + constructor(public readonly method: string, public readonly code: number | undefined, messageArg: string, public readonly data?: unknown) { + super(`Kodi JSON-RPC ${method} failed${typeof code === 'number' ? ` (${code})` : ''}: ${messageArg}`); + this.name = 'KodiJsonRpcError'; + } +} + +export class KodiClient { + private requestId = 1; + + constructor(private readonly config: IKodiConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot)); + } + if (!this.config.host) { + return this.normalizeSnapshot({ + deviceInfo: this.deviceInfoFromConfig(), + players: [], + online: false, + updatedAt: new Date().toISOString(), + }); + } + + const [application, players] = await Promise.all([ + this.getApplicationProperties().catch(() => undefined), + this.getActivePlayers(), + ]); + const player = players[0]; + const [playerPropertiesResult, item] = player ? await Promise.all([ + this.getPlayerProperties(player.playerid).catch(() => undefined), + this.getPlayerItem(player.playerid).catch(() => undefined), + ]) : [undefined, undefined]; + + return this.normalizeSnapshot({ + deviceInfo: { + ...this.deviceInfoFromConfig(), + name: this.config.name || this.config.deviceInfo?.name || application?.name || this.config.host, + version: this.versionString(application), + }, + application, + players, + player, + playerProperties: playerPropertiesResult, + item, + online: true, + updatedAt: new Date().toISOString(), + }); + } + + public async ping(): Promise { + const result = await this.callMethod('JSONRPC.Ping'); + return result === 'pong'; + } + + public async getApplicationProperties(): Promise { + return this.callMethod('Application.GetProperties', { + properties: ['name', 'version', 'volume', 'muted'], + }); + } + + public async getActivePlayers(): Promise { + return this.callMethod('Player.GetActivePlayers'); + } + + public async getPlayerProperties(playerIdArg: number): Promise { + return this.callMethod('Player.GetProperties', { + playerid: playerIdArg, + properties: playerProperties, + }); + } + + public async getPlayerItem(playerIdArg: number): Promise { + const result = await this.callMethod<{ item?: IKodiMediaItem }>('Player.GetItem', { + playerid: playerIdArg, + properties: itemProperties, + }); + return result.item; + } + + public async setVolumeLevel(volumeLevelArg: number): Promise { + return this.callMethod('Application.SetVolume', { + volume: Math.max(0, Math.min(100, Math.round(volumeLevelArg * 100))), + }); + } + + public async stepVolume(directionArg: 'increment' | 'decrement'): Promise { + return this.callMethod('Application.SetVolume', { volume: directionArg }); + } + + public async setMuted(mutedArg: boolean): Promise { + return this.callMethod('Application.SetMute', { mute: mutedArg }); + } + + public async playPause(): Promise { + return this.playState('toggle'); + } + + public async play(): Promise { + return this.playState(true); + } + + public async pause(): Promise { + return this.playState(false); + } + + public async stop(): Promise { + return this.callMethod('Player.Stop', { playerid: await this.activePlayerId() }); + } + + public async nextTrack(): Promise { + return this.callMethod('Player.GoTo', { playerid: await this.activePlayerId(), to: 'next' }); + } + + public async previousTrack(): Promise { + return this.callMethod('Player.GoTo', { playerid: await this.activePlayerId(), to: 'previous' }); + } + + public async seek(positionSecondsArg: number): Promise { + return this.callMethod('Player.Seek', { + playerid: await this.activePlayerId(), + value: { time: this.secondsToTime(positionSecondsArg) }, + }); + } + + public async playMedia(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number): Promise { + return this.callMethod('Player.Open', { item: this.mediaItem(mediaTypeArg, mediaIdArg) }); + } + + public async clearPlaylist(playlistIdArg = 0): Promise { + return this.callMethod('Playlist.Clear', { playlistid: playlistIdArg }); + } + + public async addToPlaylist(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number, playlistIdArg = 0): Promise { + return this.callMethod('Playlist.Add', { + playlistid: playlistIdArg, + item: this.playlistItem(mediaTypeArg, mediaIdArg), + }); + } + + public async showNotification(titleArg: string, messageArg: string, iconArg = 'info', displayTimeMsArg = 10000): Promise { + return this.callMethod('GUI.ShowNotification', { + title: titleArg, + message: messageArg, + image: iconArg, + displaytime: Math.max(1500, Math.round(displayTimeMsArg)), + }); + } + + public async input(commandArg: TKodiInputCommand): Promise { + const method = this.inputMethod(commandArg); + return this.callMethod(method); + } + + public async quit(): Promise { + return this.callMethod('Application.Quit'); + } + + public async callMethod(methodArg: string, paramsArg?: TKodiJsonRpcParams): Promise { + if (!this.config.host) { + throw new Error('Kodi host is required when snapshot data is not provided.'); + } + + const request: IKodiJsonRpcRequest = { + jsonrpc: '2.0', + method: methodArg, + id: this.requestId++, + }; + if (paramsArg !== undefined) { + request.params = paramsArg; + } + + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs); + try { + const response = await globalThis.fetch(this.jsonRpcUrl(), { + method: 'POST', + headers: this.headers(), + body: JSON.stringify(request), + signal: abortController.signal, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Kodi request ${methodArg} failed with HTTP ${response.status}: ${text}`); + } + const payload = JSON.parse(text) as IKodiJsonRpcResponse; + if (payload.error) { + throw new KodiJsonRpcError(methodArg, payload.error.code, payload.error.message || 'Unknown JSON-RPC error', payload.error.data); + } + return payload.result as T; + } finally { + clearTimeout(timeout); + } + } + + public async destroy(): Promise {} + + private async playState(playArg: boolean | 'toggle'): Promise { + return this.callMethod('Player.PlayPause', { + playerid: await this.activePlayerId(), + play: playArg, + }); + } + + private async activePlayerId(): Promise { + const snapshot = await this.getSnapshot(); + const playerId = snapshot.player?.playerid ?? snapshot.players[0]?.playerid; + if (typeof playerId !== 'number') { + throw new Error('Kodi has no active player.'); + } + return playerId; + } + + private mediaItem(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number): Record { + const type = mediaTypeArg.toLowerCase(); + if (type === 'playlist') { + return { playlistid: Number(mediaIdArg) }; + } + if (type === 'channel') { + return { channelid: Number(mediaIdArg) }; + } + if (type === 'directory') { + return { path: String(mediaIdArg), recursive: true }; + } + if (type === 'movie') { + return { movieid: Number(mediaIdArg) }; + } + if (type === 'episode') { + return { episodeid: Number(mediaIdArg) }; + } + if (type === 'season') { + return { seasonid: Number(mediaIdArg) }; + } + if (type === 'tvshow') { + return { tvshowid: Number(mediaIdArg) }; + } + if (type === 'album') { + return { albumid: Number(mediaIdArg) }; + } + if (type === 'artist') { + return { artistid: Number(mediaIdArg) }; + } + if (type === 'song' || type === 'track') { + return { songid: Number(mediaIdArg) }; + } + return { file: String(mediaIdArg) }; + } + + private playlistItem(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number): Record { + const type = mediaTypeArg.toLowerCase(); + if (type === 'album') { + return { albumid: Number(mediaIdArg) }; + } + if (type === 'artist') { + return { artistid: Number(mediaIdArg) }; + } + if (type === 'song' || type === 'track') { + return { songid: Number(mediaIdArg) }; + } + return this.mediaItem(type, mediaIdArg); + } + + private secondsToTime(secondsArg: number): IKodiTime { + const safeSeconds = Math.max(0, Math.floor(secondsArg)); + return { + hours: Math.floor(safeSeconds / 3600), + minutes: Math.floor((safeSeconds % 3600) / 60), + seconds: safeSeconds % 60, + milliseconds: Math.max(0, Math.round((secondsArg - Math.floor(secondsArg)) * 1000)), + }; + } + + private inputMethod(commandArg: string): string { + const normalized = commandArg.replace(/[_\s-]+/g, '').toLowerCase(); + const methods: Record = { + up: 'Input.Up', + down: 'Input.Down', + left: 'Input.Left', + right: 'Input.Right', + select: 'Input.Select', + ok: 'Input.Select', + enter: 'Input.Select', + back: 'Input.Back', + home: 'Input.Home', + info: 'Input.Info', + contextmenu: 'Input.ContextMenu', + }; + return methods[normalized] || (commandArg.includes('.') ? commandArg : `Input.${commandArg}`); + } + + private normalizeSnapshot(snapshotArg: IKodiSnapshot): IKodiSnapshot { + const deviceInfo = { + ...this.deviceInfoFromConfig(), + ...snapshotArg.deviceInfo, + }; + if (!deviceInfo.name) { + deviceInfo.name = snapshotArg.application?.name || this.config.name || this.config.host || 'Kodi'; + } + if (!deviceInfo.version) { + deviceInfo.version = this.versionString(snapshotArg.application); + } + return { + ...snapshotArg, + deviceInfo, + players: snapshotArg.players || [], + player: snapshotArg.player || snapshotArg.players?.[0], + online: snapshotArg.online, + }; + } + + private deviceInfoFromConfig(): IKodiDeviceInfo { + return { + ...this.config.deviceInfo, + id: this.config.deviceInfo?.id || this.config.uniqueId, + uuid: this.config.deviceInfo?.uuid || this.config.uniqueId, + name: this.config.deviceInfo?.name || this.config.name, + host: this.config.deviceInfo?.host || this.config.host, + port: this.config.deviceInfo?.port || this.config.port || defaultPort, + wsPort: this.config.deviceInfo?.wsPort || this.config.wsPort || defaultWsPort, + manufacturer: this.config.deviceInfo?.manufacturer || 'Kodi', + }; + } + + private versionString(applicationArg: IKodiApplicationProperties | undefined): string | undefined { + const version = applicationArg?.version; + if (!version || typeof version.major !== 'number') { + return undefined; + } + return `${version.major}.${typeof version.minor === 'number' ? version.minor : 0}${version.revision ? `-${version.revision}` : ''}`; + } + + private cloneSnapshot(snapshotArg: IKodiSnapshot): IKodiSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IKodiSnapshot; + } + + private jsonRpcUrl(): string { + const protocol = this.config.ssl ? 'https' : 'http'; + return `${protocol}://${this.config.host}:${this.config.port || defaultPort}/jsonrpc`; + } + + private headers(): Record { + const headers: Record = { 'content-type': 'application/json' }; + if (this.config.username !== undefined && this.config.password !== undefined) { + headers.authorization = `Basic ${globalThis.btoa(`${this.config.username}:${this.config.password}`)}`; + } + return headers; + } +} diff --git a/ts/integrations/kodi/kodi.classes.configflow.ts b/ts/integrations/kodi/kodi.classes.configflow.ts new file mode 100644 index 0000000..9606d78 --- /dev/null +++ b/ts/integrations/kodi/kodi.classes.configflow.ts @@ -0,0 +1,63 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IKodiConfig } from './kodi.types.js'; + +const defaultPort = 8080; +const defaultWsPort = 9090; +const defaultTimeoutMs = 5000; + +export class KodiConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Kodi', + description: 'Configure the local Kodi JSON-RPC endpoint.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'HTTP JSON-RPC port', type: 'number' }, + { name: 'ssl', label: 'Use HTTPS', type: 'boolean' }, + { name: 'wsPort', label: 'WebSocket port', type: 'number' }, + { name: 'username', label: 'Username', type: 'text' }, + { name: 'password', label: 'Password', type: 'password' }, + { name: 'name', label: 'Name', type: 'text' }, + ], + submit: async (valuesArg) => ({ + kind: 'done', + title: 'Kodi configured', + config: { + host: this.stringValue(valuesArg.host) || candidateArg.host || '', + port: this.numberValue(valuesArg.port) || candidateArg.port || defaultPort, + wsPort: this.numberValue(valuesArg.wsPort) || this.numberMetadata(candidateArg, 'wsPort') || defaultWsPort, + ssl: this.booleanValue(valuesArg.ssl) ?? this.booleanMetadata(candidateArg, 'ssl') ?? false, + username: this.stringValue(valuesArg.username), + password: this.stringValue(valuesArg.password), + 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 { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } + + private numberMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): number | undefined { + const value = candidateArg.metadata?.[keyArg]; + return typeof value === 'number' && Number.isFinite(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/kodi/kodi.classes.integration.ts b/ts/integrations/kodi/kodi.classes.integration.ts index 2dcfd4c..937285b 100644 --- a/ts/integrations/kodi/kodi.classes.integration.ts +++ b/ts/integrations/kodi/kodi.classes.integration.ts @@ -1,28 +1,216 @@ -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 { KodiClient } from './kodi.classes.client.js'; +import { KodiConfigFlow } from './kodi.classes.configflow.js'; +import { createKodiDiscoveryDescriptor } from './kodi.discovery.js'; +import { KodiMapper } from './kodi.mapper.js'; +import type { IKodiConfig, TKodiMediaType } from './kodi.types.js'; -export class HomeAssistantKodiIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "kodi", - displayName: "Kodi", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/kodi", - "upstreamDomain": "kodi", - "integrationType": "service", - "iotClass": "local_push", - "requirements": [ - "pykodi==0.2.7" - ], - "dependencies": [], - "afterDependencies": [ - "media_source" - ], - "codeowners": [ - "@OnFreund" - ] -}, - }); +export class KodiIntegration extends BaseIntegration { + public readonly domain = 'kodi'; + public readonly displayName = 'Kodi'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createKodiDiscoveryDescriptor(); + public readonly configFlow = new KodiConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/kodi', + upstreamDomain: 'kodi', + integrationType: 'service', + iotClass: 'local_push', + requirements: ['pykodi==0.2.7'], + dependencies: [], + afterDependencies: ['media_source'], + codeowners: ['@OnFreund'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/kodi', + }; + + public async setup(configArg: IKodiConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new KodiRuntime(new KodiClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantKodiIntegration extends KodiIntegration {} + +class KodiRuntime implements IIntegrationRuntime { + public domain = 'kodi'; + + constructor(private readonly client: KodiClient) {} + + public async devices(): Promise { + return KodiMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return KodiMapper.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 === 'kodi') { + return await this.callKodiService(requestArg); + } + if (requestArg.domain === 'notify') { + return await this.callNotifyService(requestArg); + } + if (requestArg.domain === 'remote') { + return await this.callRemoteService(requestArg); + } + return { success: false, error: `Unsupported Kodi 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 === 'turn_on') { + return { success: true, data: { event: 'kodi.turn_on', entityId: requestArg.target.entityId } }; + } + if (requestArg.service === 'turn_off') { + await this.client.quit(); + return { success: true }; + } + 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.playPause(); + 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.nextTrack(); + return { success: true }; + } + if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') { + await this.client.previousTrack(); + 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: 'Kodi seek requires data.seek_position.' }; + } + await this.client.seek(position); + return { success: true }; + } + if (requestArg.service === 'volume_set') { + const level = requestArg.data?.volume_level; + if (typeof level !== 'number') { + return { success: false, error: 'Kodi volume_set requires data.volume_level.' }; + } + await this.client.setVolumeLevel(level); + return { success: true }; + } + if (requestArg.service === 'volume_up' || requestArg.service === 'volume_down') { + await this.client.stepVolume(requestArg.service === 'volume_up' ? 'increment' : 'decrement'); + 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: 'Kodi volume_mute requires data.is_volume_muted.' }; + } + await this.client.setMuted(muted); + return { success: true }; + } + if (requestArg.service === 'play_media') { + const mediaId = requestArg.data?.media_content_id ?? requestArg.data?.uri; + const mediaType = requestArg.data?.media_content_type ?? requestArg.data?.media_type ?? 'file'; + if ((typeof mediaId !== 'string' && typeof mediaId !== 'number') || mediaId === '') { + return { success: false, error: 'Kodi play_media requires data.media_content_id or data.uri.' }; + } + await this.client.playMedia(typeof mediaType === 'string' ? mediaType : 'file', mediaId); + return { success: true }; + } + return { success: false, error: `Unsupported Kodi media_player service: ${requestArg.service}` }; + } + + private async callKodiService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'call_method') { + const method = requestArg.data?.method; + if (typeof method !== 'string' || !method) { + return { success: false, error: 'Kodi call_method requires data.method.' }; + } + const params = this.callMethodParams(requestArg); + return { success: true, data: await this.client.callMethod(method, params) }; + } + if (requestArg.service === 'add_to_playlist') { + const mediaType = requestArg.data?.media_type; + const mediaId = requestArg.data?.media_id; + if (typeof mediaType !== 'string' || (typeof mediaId !== 'string' && typeof mediaId !== 'number')) { + return { success: false, error: 'Kodi add_to_playlist requires data.media_type and data.media_id.' }; + } + await this.client.addToPlaylist(mediaType as TKodiMediaType, mediaId); + return { success: true }; + } + if (requestArg.service === 'show_notification') { + return this.callNotifyService(requestArg); + } + return { success: false, error: `Unsupported Kodi service: ${requestArg.service}` }; + } + + private async callNotifyService(requestArg: IServiceCallRequest): Promise { + const message = requestArg.data?.message; + if (typeof message !== 'string') { + return { success: false, error: 'Kodi notification requires data.message.' }; + } + const title = typeof requestArg.data?.title === 'string' ? requestArg.data.title : 'Home Assistant'; + const icon = typeof requestArg.data?.icon === 'string' ? requestArg.data.icon : 'info'; + const displayTime = typeof requestArg.data?.displaytime === 'number' ? requestArg.data.displaytime : 10000; + await this.client.showNotification(title, message, icon, displayTime); + return { success: true }; + } + + private async callRemoteService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service !== 'send_command') { + return { success: false, error: `Unsupported Kodi 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') : []; + if (!commands.length) { + return { success: false, error: 'Kodi remote.send_command requires data.command.' }; + } + for (const item of commands) { + await this.client.input(item); + } + return { success: true }; + } + + private callMethodParams(requestArg: IServiceCallRequest): Record | unknown[] | undefined { + const explicitParams = requestArg.data?.params; + if (Array.isArray(explicitParams)) { + return explicitParams; + } + if (explicitParams && typeof explicitParams === 'object') { + return explicitParams as Record; + } + const params: Record = {}; + for (const [key, value] of Object.entries(requestArg.data || {})) { + if (key !== 'method') { + params[key] = value; + } + } + return Object.keys(params).length ? params : undefined; } } diff --git a/ts/integrations/kodi/kodi.discovery.ts b/ts/integrations/kodi/kodi.discovery.ts new file mode 100644 index 0000000..c94b381 --- /dev/null +++ b/ts/integrations/kodi/kodi.discovery.ts @@ -0,0 +1,135 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IKodiManualEntry, IKodiMdnsRecord } from './kodi.types.js'; + +const defaultPort = 8080; +const defaultWsPort = 9090; +const kodiMdnsType = '_xbmc-jsonrpc-h._tcp.local'; + +export class KodiMdnsMatcher implements IDiscoveryMatcher { + public id = 'kodi-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Kodi JSON-RPC mDNS advertisements.'; + + public async matches(recordArg: IKodiMdnsRecord): Promise { + const type = normalizeType(recordArg.type); + const properties = { ...recordArg.txt, ...recordArg.properties }; + const uuid = valueForKey(properties, 'uuid') || valueForKey(properties, 'id'); + const name = cleanName(valueForKey(properties, 'name') || recordArg.name || recordArg.hostname); + const matched = type === kodiMdnsType || Boolean(uuid && type.includes('xbmc-jsonrpc')) || Boolean(name?.toLowerCase().includes('kodi')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Kodi JSON-RPC advertisement.' }; + } + return { + matched: true, + confidence: uuid ? 'certain' : 'high', + reason: 'mDNS record matches Kodi JSON-RPC metadata.', + normalizedDeviceId: uuid, + candidate: { + source: 'mdns', + integrationDomain: 'kodi', + id: uuid, + host: recordArg.host || recordArg.addresses?.[0], + port: recordArg.port || defaultPort, + name, + manufacturer: 'Kodi', + model: valueForKey(properties, 'version') ? 'Kodi Media Center' : undefined, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: properties, + wsPort: defaultWsPort, + }, + }, + }; + } +} + +export class KodiManualMatcher implements IDiscoveryMatcher { + public id = 'kodi-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Kodi setup entries.'; + + public async matches(inputArg: IKodiManualEntry): Promise { + const haystack = `${inputArg.name || ''} ${inputArg.model || ''} ${inputArg.manufacturer || ''}`.toLowerCase(); + const matched = Boolean(inputArg.host || inputArg.metadata?.kodi || haystack.includes('kodi') || haystack.includes('xbmc')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Kodi setup hints.' }; + } + const id = inputArg.uuid || inputArg.id; + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Kodi setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'kodi', + id, + host: inputArg.host, + port: inputArg.port || defaultPort, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'Kodi', + model: inputArg.model, + metadata: { + ...inputArg.metadata, + wsPort: inputArg.wsPort || defaultWsPort, + ssl: inputArg.ssl, + }, + }, + }; + } +} + +export class KodiCandidateValidator implements IDiscoveryValidator { + public id = 'kodi-candidate-validator'; + public description = 'Validate Kodi candidate metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const name = candidateArg.name?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'kodi' + || manufacturer.includes('kodi') + || manufacturer.includes('xbmc') + || model.includes('kodi') + || model.includes('xbmc') + || name.includes('kodi') + || Boolean(candidateArg.metadata?.kodi); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Kodi metadata.' : 'Candidate is not Kodi.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id, + }; + } +} + +export const createKodiDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'kodi', displayName: 'Kodi' }) + .addMatcher(new KodiMdnsMatcher()) + .addMatcher(new KodiManualMatcher()) + .addValidator(new KodiCandidateValidator()); +}; + +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(/\._xbmc-jsonrpc-h\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined; +}; diff --git a/ts/integrations/kodi/kodi.mapper.ts b/ts/integrations/kodi/kodi.mapper.ts new file mode 100644 index 0000000..67c00f2 --- /dev/null +++ b/ts/integrations/kodi/kodi.mapper.ts @@ -0,0 +1,169 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { IKodiMediaItem, IKodiSnapshot, IKodiTime } from './kodi.types.js'; + +const kodiMediaTypes: Record = { + music: 'music', + artist: 'music', + album: 'music', + song: 'music', + audio: 'music', + video: 'video', + musicvideo: 'video', + movie: 'movie', + episode: 'episode', + tvshow: 'tvshow', + season: 'tvshow', + channel: 'channel', + set: 'playlist', +}; + +export class KodiMapper { + public static toDevices(snapshotArg: IKodiSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const application = snapshotArg.application; + return [{ + id: this.deviceId(snapshotArg), + integrationDomain: 'kodi', + name: this.deviceName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.deviceInfo.manufacturer || 'Kodi', + model: snapshotArg.deviceInfo.model || application?.name, + 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: 'active_player', capability: 'media', name: 'Active player', readable: true, writable: false }, + { id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false }, + { id: 'notification', capability: 'media', name: 'Notification', readable: false, writable: true }, + ], + state: [ + { featureId: 'playback', value: this.mediaState(snapshotArg), updatedAt }, + { featureId: 'volume', value: typeof application?.volume === 'number' ? application.volume : null, updatedAt }, + { featureId: 'muted', value: typeof application?.muted === 'boolean' ? application.muted : null, updatedAt }, + { featureId: 'active_player', value: snapshotArg.player?.type || null, updatedAt }, + { featureId: 'current_title', value: this.mediaTitle(snapshotArg.item) || null, updatedAt }, + ], + metadata: { + uuid: snapshotArg.deviceInfo.uuid, + host: snapshotArg.deviceInfo.host, + port: snapshotArg.deviceInfo.port, + wsPort: snapshotArg.deviceInfo.wsPort, + version: snapshotArg.deviceInfo.version, + activePlayerId: snapshotArg.player?.playerid, + }, + }]; + } + + public static toEntities(snapshotArg: IKodiSnapshot): IIntegrationEntity[] { + const item = snapshotArg.item; + return [{ + id: `media_player.${this.slug(this.deviceName(snapshotArg))}`, + uniqueId: `kodi_${this.uniqueBase(snapshotArg)}`, + integrationDomain: 'kodi', + deviceId: this.deviceId(snapshotArg), + platform: 'media_player', + name: this.deviceName(snapshotArg), + state: this.mediaState(snapshotArg), + attributes: { + volumeLevel: typeof snapshotArg.application?.volume === 'number' ? snapshotArg.application.volume / 100 : undefined, + isVolumeMuted: snapshotArg.application?.muted, + mediaContentId: this.mediaContentId(item), + mediaContentType: this.mediaContentType(snapshotArg), + mediaDuration: snapshotArg.playerProperties?.live ? undefined : this.seconds(snapshotArg.playerProperties?.totaltime), + mediaPosition: this.seconds(snapshotArg.playerProperties?.time), + mediaTitle: this.mediaTitle(item), + mediaSeriesTitle: item?.showtitle, + mediaSeason: item?.season, + mediaEpisode: item?.episode, + mediaAlbumName: item?.album, + mediaArtist: this.firstString(item?.artist), + mediaAlbumArtist: this.firstString(item?.albumartist), + mediaImageUrl: item?.thumbnail, + playerId: snapshotArg.player?.playerid, + playerType: snapshotArg.player?.type, + live: snapshotArg.playerProperties?.live, + dynamicRange: this.dynamicRange(item), + }, + available: snapshotArg.online, + }]; + } + + private static mediaState(snapshotArg: IKodiSnapshot): string { + if (!snapshotArg.online) { + return 'off'; + } + if (!snapshotArg.players.length) { + return 'idle'; + } + if (snapshotArg.playerProperties?.speed === 0) { + return 'paused'; + } + if (typeof snapshotArg.playerProperties?.speed === 'number') { + return 'playing'; + } + return 'idle'; + } + + private static mediaContentType(snapshotArg: IKodiSnapshot): string | undefined { + const itemType = snapshotArg.item?.type; + if (itemType && kodiMediaTypes[itemType]) { + return kodiMediaTypes[itemType]; + } + const playerType = snapshotArg.player?.type; + return playerType ? kodiMediaTypes[playerType] || playerType : undefined; + } + + private static mediaContentId(itemArg: IKodiMediaItem | undefined): unknown { + if (!itemArg) { + return undefined; + } + if (typeof itemArg.uniqueid === 'string') { + return itemArg.uniqueid; + } + if (itemArg.uniqueid && typeof itemArg.uniqueid === 'object') { + const values = Object.values(itemArg.uniqueid).filter((valueArg) => valueArg !== undefined); + return values[0]; + } + return itemArg.id; + } + + private static mediaTitle(itemArg: IKodiMediaItem | undefined): string | undefined { + return itemArg?.title || itemArg?.label || itemArg?.file; + } + + private static dynamicRange(itemArg: IKodiMediaItem | undefined): string | undefined { + if (!itemArg) { + return undefined; + } + return itemArg.streamdetails?.video?.[0]?.hdrtype || 'sdr'; + } + + private static seconds(timeArg: IKodiTime | undefined): number | undefined { + if (!timeArg) { + return undefined; + } + return (timeArg.hours || 0) * 3600 + (timeArg.minutes || 0) * 60 + (timeArg.seconds || 0) + (timeArg.milliseconds || 0) / 1000; + } + + private static firstString(valueArg: string[] | string | undefined): string | undefined { + return Array.isArray(valueArg) ? valueArg[0] : valueArg; + } + + private static deviceId(snapshotArg: IKodiSnapshot): string { + return `kodi.device.${this.uniqueBase(snapshotArg)}`; + } + + private static uniqueBase(snapshotArg: IKodiSnapshot): string { + return this.slug(snapshotArg.deviceInfo.uuid || snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg)); + } + + private static deviceName(snapshotArg: IKodiSnapshot): string { + return snapshotArg.deviceInfo.name || snapshotArg.application?.name || 'Kodi'; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'kodi'; + } +} diff --git a/ts/integrations/kodi/kodi.types.ts b/ts/integrations/kodi/kodi.types.ts index 347a318..69ff110 100644 --- a/ts/integrations/kodi/kodi.types.ts +++ b/ts/integrations/kodi/kodi.types.ts @@ -1,4 +1,176 @@ -export interface IHomeAssistantKodiConfig { - // TODO: replace with the TypeScript-native config for kodi. +export interface IKodiConfig { + host?: string; + port?: number; + wsPort?: number; + ssl?: boolean; + username?: string; + password?: string; + timeoutMs?: number; + name?: string; + uniqueId?: string; + deviceInfo?: IKodiDeviceInfo; + snapshot?: IKodiSnapshot; +} + +export interface IHomeAssistantKodiConfig extends IKodiConfig {} + +export interface IKodiDeviceInfo { + id?: string; + uuid?: string; + name?: string; + host?: string; + port?: number; + wsPort?: number; + manufacturer?: string; + model?: string; + version?: string; +} + +export interface IKodiApplicationVersion { + major?: number; + minor?: number; + revision?: string; + tag?: string; +} + +export interface IKodiApplicationProperties { + name?: string; + version?: IKodiApplicationVersion; + volume?: number; + muted?: boolean; +} + +export interface IKodiActivePlayer { + playerid: number; + type?: 'audio' | 'video' | 'picture' | string; + playertype?: string; +} + +export interface IKodiTime { + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; +} + +export interface IKodiPlayerProperties { + speed?: number; + time?: IKodiTime; + totaltime?: IKodiTime; + live?: boolean; + percentage?: number; + playlistid?: number; + position?: number; + repeat?: string; + shuffled?: boolean; +} + +export interface IKodiMediaItem { + id?: number; + type?: string; + label?: string; + title?: string; + file?: string; + uniqueid?: string | Record; + thumbnail?: string; + artist?: string[] | string; + albumartist?: string[] | string; + showtitle?: string; + album?: string; + season?: number; + episode?: number; + streamdetails?: { + video?: Array<{ + hdrtype?: string; + width?: number; + height?: number; + codec?: string; + }>; + audio?: Array>; + }; [key: string]: unknown; } + +export interface IKodiSnapshot { + deviceInfo: IKodiDeviceInfo; + application?: IKodiApplicationProperties; + players: IKodiActivePlayer[]; + player?: IKodiActivePlayer; + playerProperties?: IKodiPlayerProperties; + item?: IKodiMediaItem; + online: boolean; + updatedAt?: string; +} + +export interface IKodiMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + addresses?: string[]; + hostname?: string; + txt?: Record; + properties?: Record; +} + +export interface IKodiManualEntry { + host?: string; + port?: number; + wsPort?: number; + ssl?: boolean; + id?: string; + uuid?: string; + name?: string; + model?: string; + manufacturer?: string; + metadata?: Record; +} + +export type TKodiJsonRpcParams = Record | unknown[] | undefined; + +export interface IKodiJsonRpcRequest { + jsonrpc: '2.0'; + method: string; + params?: TKodiJsonRpcParams; + id: number; +} + +export interface IKodiJsonRpcResponse { + jsonrpc?: string; + result?: T; + error?: { + code?: number; + message?: string; + data?: unknown; + }; + id?: number; +} + +export type TKodiMediaType = + | 'album' + | 'artist' + | 'channel' + | 'directory' + | 'episode' + | 'file' + | 'movie' + | 'music' + | 'playlist' + | 'season' + | 'song' + | 'track' + | 'tvshow' + | 'url' + | 'video' + | string; + +export type TKodiInputCommand = + | 'up' + | 'down' + | 'left' + | 'right' + | 'select' + | 'back' + | 'home' + | 'info' + | string; diff --git a/ts/integrations/samsungtv/.generated-by-smarthome-exchange b/ts/integrations/samsungtv/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/samsungtv/.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/samsungtv/index.ts b/ts/integrations/samsungtv/index.ts index aec2953..2a6dc24 100644 --- a/ts/integrations/samsungtv/index.ts +++ b/ts/integrations/samsungtv/index.ts @@ -1,2 +1,6 @@ +export * from './samsungtv.classes.client.js'; +export * from './samsungtv.classes.configflow.js'; export * from './samsungtv.classes.integration.js'; +export * from './samsungtv.discovery.js'; +export * from './samsungtv.mapper.js'; export * from './samsungtv.types.js'; diff --git a/ts/integrations/samsungtv/samsungtv.classes.client.ts b/ts/integrations/samsungtv/samsungtv.classes.client.ts new file mode 100644 index 0000000..7bcff20 --- /dev/null +++ b/ts/integrations/samsungtv/samsungtv.classes.client.ts @@ -0,0 +1,506 @@ +import type { + ISamsungtvApp, + ISamsungtvConfig, + ISamsungtvDeviceInfoResponse, + ISamsungtvEvent, + ISamsungtvSnapshot, + ISamsungtvState, + ISamsungtvWebsocketCommand, + TSamsungtvCommand, + TSamsungtvCommandAction, + TSamsungtvRemoteKey, +} from './samsungtv.types.js'; + +type TWebSocketMessage = { data: unknown }; +type TWebSocketHandler = (eventArg: any) => void; +type TWebSocketLike = { + send(dataArg: string): void; + close(codeArg?: number, reasonArg?: string): void; + addEventListener?: (eventArg: 'open' | 'message' | 'error' | 'close', handlerArg: TWebSocketHandler) => void; + removeEventListener?: (eventArg: 'open' | 'message' | 'error' | 'close', handlerArg: TWebSocketHandler) => void; + onopen?: TWebSocketHandler | null; + onmessage?: TWebSocketHandler | null; + onerror?: TWebSocketHandler | null; + onclose?: TWebSocketHandler | null; +}; +type TWebSocketConstructor = new (urlArg: string) => TWebSocketLike; + +const defaultWebsocketPort = 8001; +const encryptedWebsocketPort = 8000; +const legacyPort = 55000; +const defaultKeyPressDelayMs = 250; + +export class SamsungtvClient { + private readonly sockets = new Set(); + private token?: string; + + constructor(private readonly config: ISamsungtvConfig) { + this.token = config.token; + } + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.config.snapshot; + } + + const deviceInfo = await this.getDeviceInfo(); + const apps = await this.getApps(); + const activeApp = this.config.activeApp; + return { + deviceInfo, + apps, + activeApp, + state: this.getState(deviceInfo, activeApp), + }; + } + + public async getDeviceInfo(): Promise { + if (this.config.snapshot?.deviceInfo) { + return this.config.snapshot.deviceInfo; + } + if (this.config.deviceInfo) { + return this.config.deviceInfo; + } + + if (this.config.host) { + try { + return await this.requestRestDeviceInfo(); + } catch { + return this.manualDeviceInfo(); + } + } + + return this.manualDeviceInfo(); + } + + public async getApps(): Promise { + return this.config.snapshot?.apps ?? this.config.apps ?? []; + } + + public async sendKeys(keysArg: Array, actionArg: TSamsungtvCommandAction = 'Click'): Promise { + const keys = keysArg.map((keyArg) => this.normalizeKey(String(keyArg))).filter(Boolean); + if (!keys.length) { + return; + } + + await this.sendWebsocketCommands(keys.map((keyArg) => this.createRemoteKeyCommand(keyArg, actionArg))); + } + + public async sendCommand(commandArg: TSamsungtvCommand): Promise { + if ('type' in commandArg) { + if (commandArg.type === 'key') { + await this.sendKeys([commandArg.key], commandArg.action); + return; + } + await this.launchApp(commandArg.appId, commandArg.appType, commandArg.metaTag); + return; + } + + await this.sendWebsocketCommands([commandArg]); + } + + public async turnOn(): Promise { + await this.sendKeys(['KEY_POWERON']); + this.updateLocalState({ power: 'on' }); + } + + public async turnOff(): Promise { + await this.sendKeys(['KEY_POWEROFF']); + this.updateLocalState({ power: 'off', playback: 'off' }); + } + + public async play(): Promise { + await this.sendKeys(['KEY_PLAY']); + this.updateLocalState({ playback: 'playing' }); + } + + public async pause(): Promise { + await this.sendKeys(['KEY_PAUSE']); + this.updateLocalState({ playback: 'paused' }); + } + + public async playPause(): Promise { + await this.sendKeys(['KEY_PLAYPAUSE']); + } + + public async setVolumeLevel(volumeLevelArg: number): Promise { + void volumeLevelArg; + throw new Error('Samsung TV absolute volume_set requires UPnP RenderingControl and is not implemented by this native TypeScript port.'); + } + + public async launchApp(appIdArg: string, appTypeArg = 'DEEP_LINK', metaTagArg = ''): Promise { + if (!appIdArg) { + throw new Error('Samsung TV launch_app requires an app id.'); + } + + await this.sendWebsocketCommands([{ + method: 'ms.channel.emit', + params: { + event: 'ed.apps.launch', + to: 'host', + data: { + action_type: appTypeArg, + appId: appIdArg, + metaTag: metaTagArg, + }, + }, + }]); + + const app = (await this.getApps()).find((appArg) => appArg.id === appIdArg) ?? { id: appIdArg, name: appIdArg }; + this.setActiveApp(app); + } + + public async selectSource(sourceArg: string): Promise { + const source = sourceArg.trim(); + if (!source) { + throw new Error('Samsung TV select_source requires a source name.'); + } + + const sourceKeys: Record = { + tv: 'KEY_TV', + hdmi: 'KEY_HDMI', + source: 'KEY_SOURCE', + }; + const sourceKey = sourceKeys[source.toLowerCase()]; + if (sourceKey) { + await this.sendKeys([sourceKey]); + this.updateLocalState({ source }); + return; + } + + const app = (await this.getApps()).find((appArg) => appArg.id === source || appArg.name === source); + if (app) { + await this.launchApp(app.id, app.appType || app.type || 'DEEP_LINK'); + return; + } + + if (source.toUpperCase().startsWith('KEY_')) { + await this.sendKeys([source]); + this.updateLocalState({ source }); + return; + } + + throw new Error(`Samsung TV source is not known: ${source}`); + } + + public async destroy(): Promise { + for (const socket of this.sockets) { + socket.close(); + } + this.sockets.clear(); + } + + private async sendWebsocketCommands(commandsArg: ISamsungtvWebsocketCommand[]): Promise { + this.assertLiveWebsocketSupported(); + await this.withWebSocket(async (socketArg) => { + for (const command of commandsArg) { + socketArg.send(JSON.stringify(command)); + await this.delay(this.config.keyPressDelayMs ?? defaultKeyPressDelayMs); + } + }); + } + + private async withWebSocket(runArg: (socketArg: TWebSocketLike) => Promise): Promise { + const WebSocketCtor = (globalThis as typeof globalThis & { WebSocket?: TWebSocketConstructor }).WebSocket; + if (!WebSocketCtor) { + throw new Error('Global WebSocket is not available for Samsung TV websocket control.'); + } + + const socket = new WebSocketCtor(this.websocketUrl()); + this.sockets.add(socket); + try { + await this.waitForChannelConnect(socket); + await runArg(socket); + } finally { + this.sockets.delete(socket); + socket.close(); + } + } + + private waitForChannelConnect(socketArg: TWebSocketLike): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanups: Array<() => void> = []; + const timeout = setTimeout(() => finish(new Error('Samsung TV websocket did not complete channel connect.')), this.config.connectTimeoutMs ?? 5000); + + const finish = (errorArg?: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + for (const cleanup of cleanups) { + cleanup(); + } + if (errorArg) { + reject(errorArg); + return; + } + resolve(); + }; + + cleanups.push(this.addSocketListener(socketArg, 'message', (messageArg) => { + const event = this.parseWebSocketMessage((messageArg as TWebSocketMessage).data); + if (!event) { + return; + } + if (event.token) { + this.token = event.token; + } + if (event.event === 'ms.channel.unauthorized') { + finish(new Error('Samsung TV websocket access was denied or the token is invalid.')); + return; + } + if (event.event === 'ms.error') { + finish(new Error(`Samsung TV websocket returned an error event: ${JSON.stringify(event.data)}`)); + return; + } + if (event.event === 'ms.channel.connect') { + finish(); + } + })); + cleanups.push(this.addSocketListener(socketArg, 'error', (errorArg) => finish(new Error(`Samsung TV websocket failed: ${this.errorMessage(errorArg)}`)))); + cleanups.push(this.addSocketListener(socketArg, 'close', () => finish(new Error('Samsung TV websocket closed before channel connect.')))); + }); + } + + private addSocketListener(socketArg: TWebSocketLike, eventArg: 'open' | 'message' | 'error' | 'close', handlerArg: TWebSocketHandler): () => void { + if (socketArg.addEventListener) { + socketArg.addEventListener(eventArg, handlerArg); + return () => socketArg.removeEventListener?.(eventArg, handlerArg); + } + + const key = `on${eventArg}` as 'onopen' | 'onmessage' | 'onerror' | 'onclose'; + const previous = socketArg[key]; + socketArg[key] = handlerArg; + return () => { + if (socketArg[key] === handlerArg) { + socketArg[key] = previous; + } + }; + } + + private createRemoteKeyCommand(keyArg: string, actionArg: TSamsungtvCommandAction): ISamsungtvWebsocketCommand { + return { + method: 'ms.remote.control', + params: { + Cmd: actionArg, + DataOfCmd: keyArg, + Option: 'false', + TypeOfRemote: 'SendRemoteKey', + }, + }; + } + + private assertLiveWebsocketSupported(): void { + if (!this.config.host) { + throw new Error('Samsung TV host is required for live websocket control.'); + } + + const port = this.config.port ?? defaultWebsocketPort; + if (this.config.method === 'encrypted' || this.config.sessionId || port === encryptedWebsocketPort) { + throw new Error('Samsung TV encrypted websocket/PIN protocol is not supported by this native TypeScript port.'); + } + + if (this.config.method === 'legacy' || port === legacyPort) { + throw new Error('Samsung TV legacy TCP remote protocol is not supported by this native TypeScript port.'); + } + } + + private websocketUrl(): string { + const host = this.config.host; + if (!host) { + throw new Error('Samsung TV host is required for websocket control.'); + } + const port = this.config.port ?? defaultWebsocketPort; + const protocol = port === 8002 ? 'wss' : 'ws'; + const params = new URLSearchParams({ + name: Buffer.from(this.config.websocketName || 'smarthome.exchange').toString('base64'), + }); + if (this.token) { + params.set('token', this.token); + } + return `${protocol}://${host}:${port}/api/v2/channels/samsung.remote.control?${params.toString()}`; + } + + private async requestRestDeviceInfo(): Promise { + return this.requestJson(''); + } + + private async requestJson(routeArg: string): Promise { + if (!this.config.host) { + throw new Error('Samsung TV host is required for REST device information.'); + } + const response = await globalThis.fetch(`${this.restBaseUrl()}${routeArg}`); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Samsung TV REST request failed with HTTP ${response.status}: ${text}`); + } + return (text ? JSON.parse(text) : {}) as TResult; + } + + private restBaseUrl(): string { + const port = this.config.port ?? defaultWebsocketPort; + const protocol = port === 8002 ? 'https' : 'http'; + return `${protocol}://${this.config.host}:${port}/api/v2/`; + } + + private manualDeviceInfo(): ISamsungtvDeviceInfoResponse { + const name = this.config.name || this.config.model || this.config.host || 'Samsung Smart TV'; + return { + id: this.config.macAddress || this.config.host || name, + device: { + type: 'Samsung SmartTV', + name, + modelName: this.config.model, + wifiMac: this.config.macAddress, + manufacturer: this.config.manufacturer || 'Samsung', + PowerState: this.config.state?.power === 'on' ? 'on' : this.config.state?.power === 'off' ? 'standby' : undefined, + }, + }; + } + + private getState(deviceInfoArg: ISamsungtvDeviceInfoResponse, activeAppArg?: ISamsungtvApp): ISamsungtvState { + const configuredState = this.config.snapshot?.state ?? this.config.state ?? {}; + const power = configuredState.power ?? this.powerFromDeviceInfo(deviceInfoArg); + const appId = configuredState.appId ?? activeAppArg?.id; + const appName = configuredState.appName ?? activeAppArg?.name; + const source = configuredState.source ?? appName; + const playback = configuredState.playback ?? (power === 'off' ? 'off' : 'idle'); + return { + ...configuredState, + power, + playback, + appId, + appName, + source, + }; + } + + private powerFromDeviceInfo(deviceInfoArg: ISamsungtvDeviceInfoResponse): 'on' | 'off' | 'unknown' { + const value = String(deviceInfoArg.device?.PowerState || '').toLowerCase(); + if (value === 'on') { + return 'on'; + } + if (value.includes('off') || value.includes('standby') || value.includes('sleep')) { + return 'off'; + } + return 'unknown'; + } + + private normalizeKey(keyArg: string): string { + const trimmed = keyArg.trim(); + if (!trimmed) { + return ''; + } + + const normalized = trimmed.replace(/[_\s-]+/g, '').toLowerCase(); + const aliases: Record = { + power: 'KEY_POWER', + poweron: 'KEY_POWERON', + poweroff: 'KEY_POWEROFF', + play: 'KEY_PLAY', + pause: 'KEY_PAUSE', + playpause: 'KEY_PLAYPAUSE', + stop: 'KEY_STOP', + volumeup: 'KEY_VOLUP', + volup: 'KEY_VOLUP', + volumedown: 'KEY_VOLDOWN', + voldown: 'KEY_VOLDOWN', + mute: 'KEY_MUTE', + home: 'KEY_HOME', + menu: 'KEY_MENU', + source: 'KEY_SOURCE', + tv: 'KEY_TV', + hdmi: 'KEY_HDMI', + up: 'KEY_UP', + down: 'KEY_DOWN', + left: 'KEY_LEFT', + right: 'KEY_RIGHT', + enter: 'KEY_ENTER', + select: 'KEY_ENTER', + back: 'KEY_RETURN', + return: 'KEY_RETURN', + channelup: 'KEY_CHUP', + channeldown: 'KEY_CHDOWN', + chup: 'KEY_CHUP', + chdown: 'KEY_CHDOWN', + next: 'KEY_CHUP', + previous: 'KEY_CHDOWN', + }; + if (aliases[normalized]) { + return aliases[normalized]; + } + if (trimmed.toUpperCase().startsWith('KEY_')) { + return trimmed.toUpperCase(); + } + return `KEY_${trimmed.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`; + } + + private parseWebSocketMessage(dataArg: unknown): ISamsungtvEvent | undefined { + const text = this.messageText(dataArg); + if (!text) { + return undefined; + } + + try { + const parsed = JSON.parse(text) as Record; + const data = typeof parsed.data === 'object' && parsed.data !== null ? parsed.data as Record : undefined; + return { + type: 'websocket', + event: typeof parsed.event === 'string' ? parsed.event : undefined, + data: parsed.data, + token: typeof data?.token === 'string' ? data.token : undefined, + timestamp: Date.now(), + }; + } catch { + return undefined; + } + } + + private messageText(dataArg: unknown): string | undefined { + if (typeof dataArg === 'string') { + return dataArg; + } + if (Buffer.isBuffer(dataArg)) { + return dataArg.toString('utf8'); + } + if (dataArg instanceof ArrayBuffer) { + return Buffer.from(dataArg).toString('utf8'); + } + return undefined; + } + + 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 setActiveApp(appArg: ISamsungtvApp): void { + this.updateLocalState({ power: 'on', appId: appArg.id, appName: appArg.name, source: appArg.name }); + if (this.config.snapshot) { + this.config.snapshot.activeApp = appArg; + return; + } + this.config.activeApp = appArg; + } + + private errorMessage(errorArg: unknown): string { + if (errorArg instanceof Error) { + return errorArg.message; + } + if (typeof errorArg === 'object' && errorArg !== null && 'message' in errorArg) { + return String((errorArg as { message?: unknown }).message); + } + return String(errorArg); + } + + private async delay(msArg: number): Promise { + await new Promise((resolve) => setTimeout(resolve, msArg)); + } +} diff --git a/ts/integrations/samsungtv/samsungtv.classes.configflow.ts b/ts/integrations/samsungtv/samsungtv.classes.configflow.ts new file mode 100644 index 0000000..b2ec42c --- /dev/null +++ b/ts/integrations/samsungtv/samsungtv.classes.configflow.ts @@ -0,0 +1,44 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { ISamsungtvConfig } from './samsungtv.types.js'; + +export class SamsungtvConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Samsung Smart TV', + description: 'Configure the local Samsung TV websocket endpoint. Pairing prompts and encrypted PIN mode are not handled by this native port.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Websocket port', type: 'number', required: false }, + { name: 'token', label: 'Existing websocket token', type: 'password', required: false }, + { name: 'name', label: 'Name', type: 'text', required: false }, + { name: 'model', label: 'Model', type: 'text', required: false }, + ], + submit: async (valuesArg) => { + const host = String(valuesArg.host || candidateArg.host || '').trim(); + if (!host) { + return { kind: 'error', error: 'Samsung TV host is required.' }; + } + + const portValue = valuesArg.port ?? candidateArg.port ?? 8001; + const port = typeof portValue === 'number' ? portValue : Number(portValue || 8001); + return { + kind: 'done', + title: 'Samsung Smart TV configured', + config: { + host, + port: Number.isFinite(port) ? port : 8001, + token: typeof valuesArg.token === 'string' && valuesArg.token ? valuesArg.token : undefined, + name: typeof valuesArg.name === 'string' && valuesArg.name ? valuesArg.name : candidateArg.name, + model: typeof valuesArg.model === 'string' && valuesArg.model ? valuesArg.model : candidateArg.model, + manufacturer: candidateArg.manufacturer || 'Samsung', + macAddress: candidateArg.macAddress, + ssdpRenderingControlLocation: typeof candidateArg.metadata?.ssdpRenderingControlLocation === 'string' ? candidateArg.metadata.ssdpRenderingControlLocation : undefined, + ssdpMainTvAgentLocation: typeof candidateArg.metadata?.ssdpMainTvAgentLocation === 'string' ? candidateArg.metadata.ssdpMainTvAgentLocation : undefined, + }, + }; + }, + }; + } +} diff --git a/ts/integrations/samsungtv/samsungtv.classes.integration.ts b/ts/integrations/samsungtv/samsungtv.classes.integration.ts index 6945fb9..eb8891a 100644 --- a/ts/integrations/samsungtv/samsungtv.classes.integration.ts +++ b/ts/integrations/samsungtv/samsungtv.classes.integration.ts @@ -1,34 +1,168 @@ -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 { SamsungtvClient } from './samsungtv.classes.client.js'; +import { SamsungtvConfigFlow } from './samsungtv.classes.configflow.js'; +import { createSamsungtvDiscoveryDescriptor } from './samsungtv.discovery.js'; +import { SamsungtvMapper } from './samsungtv.mapper.js'; +import type { ISamsungtvConfig } from './samsungtv.types.js'; -export class HomeAssistantSamsungtvIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "samsungtv", - displayName: "Samsung Smart TV", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/samsungtv", - "upstreamDomain": "samsungtv", - "integrationType": "device", - "iotClass": "local_push", - "qualityScale": "gold", - "requirements": [ - "getmac==0.9.5", - "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.7.2", - "wakeonlan==3.1.0", - "async-upnp-client==0.46.2" - ], - "dependencies": [ - "ssdp" - ], - "afterDependencies": [], - "codeowners": [ - "@chemelli74", - "@epenet" - ] -}, - }); +export class SamsungtvIntegration extends BaseIntegration { + public readonly domain = 'samsungtv'; + public readonly displayName = 'Samsung Smart TV'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createSamsungtvDiscoveryDescriptor(); + public readonly configFlow = new SamsungtvConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/samsungtv', + upstreamDomain: 'samsungtv', + integrationType: 'device', + iotClass: 'local_push', + qualityScale: 'gold', + requirements: [ + 'getmac==0.9.5', + 'samsungctl[websocket]==0.7.1', + 'samsungtvws[async,encrypted]==2.7.2', + 'wakeonlan==3.1.0', + 'async-upnp-client==0.46.2', + ], + dependencies: ['ssdp'], + codeowners: ['@chemelli74', '@epenet'], + }; + + public async setup(configArg: ISamsungtvConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new SamsungtvRuntime(new SamsungtvClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantSamsungtvIntegration extends SamsungtvIntegration {} + +class SamsungtvRuntime implements IIntegrationRuntime { + public domain = 'samsungtv'; + + constructor(private readonly client: SamsungtvClient) {} + + public async devices(): Promise { + return SamsungtvMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return SamsungtvMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + return await this.callServiceUnsafe(requestArg); + } catch (error) { + return { success: false, error: this.errorMessage(error) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async callServiceUnsafe(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain === 'remote') { + return this.callRemoteService(requestArg); + } + + if (requestArg.domain !== 'media_player') { + return { success: false, error: `Unsupported Samsung TV service domain: ${requestArg.domain}` }; + } + + 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 === 'media_play' || requestArg.service === 'play') { + await this.client.play(); + return { success: true }; + } + if (requestArg.service === 'media_pause' || requestArg.service === 'pause') { + await this.client.pause(); + return { success: true }; + } + if (requestArg.service === 'media_play_pause' || requestArg.service === 'play_pause') { + await this.client.playPause(); + return { success: true }; + } + if (requestArg.service === 'media_stop' || requestArg.service === 'stop') { + await this.client.sendKeys(['KEY_STOP']); + return { success: true }; + } + if (requestArg.service === 'media_next_track' || requestArg.service === 'next_track') { + await this.client.sendKeys(['KEY_CHUP']); + return { success: true }; + } + if (requestArg.service === 'media_previous_track' || requestArg.service === 'previous_track') { + await this.client.sendKeys(['KEY_CHDOWN']); + return { success: true }; + } + if (requestArg.service === 'volume_up') { + await this.client.sendKeys(['KEY_VOLUP']); + return { success: true }; + } + if (requestArg.service === 'volume_down') { + await this.client.sendKeys(['KEY_VOLDOWN']); + return { success: true }; + } + if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') { + await this.client.sendKeys(['KEY_MUTE']); + return { success: true }; + } + if (requestArg.service === 'volume_set') { + const volumeLevel = requestArg.data?.volume_level; + if (typeof volumeLevel !== 'number') { + return { success: false, error: 'Samsung TV volume_set requires data.volume_level.' }; + } + await this.client.setVolumeLevel(volumeLevel); + return { success: true }; + } + if (requestArg.service === 'select_source') { + const source = requestArg.data?.source; + if (typeof source !== 'string' || !source) { + return { success: false, error: 'Samsung TV select_source requires data.source.' }; + } + await this.client.selectSource(source); + return { success: true }; + } + + return { success: false, error: `Unsupported Samsung TV media_player service: ${requestArg.service}` }; + } + + private async callRemoteService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service !== 'send_command') { + return { success: false, error: `Unsupported Samsung TV remote service: ${requestArg.service}` }; + } + + const command = requestArg.data?.command; + const commands = Array.isArray(command) ? command : [command]; + const keys = commands.filter((commandArg): commandArg is string => typeof commandArg === 'string' && Boolean(commandArg)); + if (!keys.length) { + return { success: false, error: 'Samsung 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) ? Math.max(1, Math.floor(repeatsValue)) : 1; + for (let index = 0; index < repeats; index += 1) { + await this.client.sendKeys(keys); + } + return { success: true }; + } + + private errorMessage(errorArg: unknown): string { + if (errorArg instanceof Error) { + return errorArg.message; + } + return String(errorArg); } } diff --git a/ts/integrations/samsungtv/samsungtv.discovery.ts b/ts/integrations/samsungtv/samsungtv.discovery.ts new file mode 100644 index 0000000..0b2a3aa --- /dev/null +++ b/ts/integrations/samsungtv/samsungtv.discovery.ts @@ -0,0 +1,190 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { ISamsungtvManualEntry, ISamsungtvMdnsRecord, ISamsungtvSsdpRecord } from './samsungtv.types.js'; + +const remoteControlReceiver = 'urn:samsung.com:device:RemoteControlReceiver:1'; +const mainTvAgent = 'urn:samsung.com:service:MainTVAgent2:1'; +const renderingControl = 'urn:schemas-upnp-org:service:RenderingControl:1'; + +export class SamsungtvSsdpMatcher implements IDiscoveryMatcher { + public id = 'samsungtv-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Samsung TV SSDP and UPnP advertisements.'; + + public async matches(recordArg: ISamsungtvSsdpRecord): Promise { + const st = readRecordValue(recordArg, 'st', 'ST', 'ssdp_st') || recordArg.ssdp_st; + const usn = readRecordValue(recordArg, 'usn', 'USN', 'udn', 'UDN', 'ssdp_usn') || recordArg.ssdp_usn; + const location = readRecordValue(recordArg, 'location', 'LOCATION', 'ssdp_location') || recordArg.ssdp_location; + const manufacturer = readRecordValue(recordArg, 'manufacturer', 'MANUFACTURER', 'upnp:manufacturer') || ''; + const model = readRecordValue(recordArg, 'modelName', 'model_name', 'model', 'upnp:modelName'); + const matchedByService = st === remoteControlReceiver || st === mainTvAgent; + const matchedByRendering = st === renderingControl && startsSamsung(manufacturer); + if (!matchedByService && !matchedByRendering) { + return { matched: false, confidence: 'low', reason: 'SSDP record is not a Samsung TV advertisement.' }; + } + + const url = safeUrl(location); + const id = stripUuid(usn || readRecordValue(recordArg, 'udn', 'UDN')); + const metadata: Record = { st, usn, location }; + if (st === renderingControl && location) { + metadata.ssdpRenderingControlLocation = location; + } + if (st === mainTvAgent && location) { + metadata.ssdpMainTvAgentLocation = location; + } + + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'SSDP record matches Samsung TV metadata.', + normalizedDeviceId: id, + candidate: { + source: 'ssdp', + integrationDomain: 'samsungtv', + id, + host: url?.hostname, + port: 8001, + manufacturer: manufacturer || 'Samsung', + model, + metadata, + }, + }; + } +} + +export class SamsungtvMdnsMatcher implements IDiscoveryMatcher { + public id = 'samsungtv-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Samsung TV mDNS AirPlay advertisements.'; + + public async matches(recordArg: ISamsungtvMdnsRecord): Promise { + const txt = { ...(recordArg.txt ?? {}), ...(recordArg.properties ?? {}) }; + const type = recordArg.type?.toLowerCase() || ''; + const manufacturer = txt.manufacturer || txt.Manufacturer || ''; + const name = recordArg.name || txt.name || txt.friendlyName; + const model = txt.model || txt.modelName || txt.modelid; + const matched = type === '_airplay._tcp.local.' && startsSamsung(manufacturer); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a Samsung TV AirPlay advertisement.' }; + } + + const id = txt.deviceid || txt.deviceId || txt.id || name; + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'mDNS record matches Samsung TV AirPlay metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'samsungtv', + id, + host: recordArg.host, + port: 8001, + name, + manufacturer: 'Samsung', + model, + macAddress: txt.deviceid || txt.deviceId, + metadata: { mdnsType: recordArg.type, mdnsName: recordArg.name, mdnsPort: recordArg.port, txt }, + }, + }; + } +} + +export class SamsungtvManualMatcher implements IDiscoveryMatcher { + public id = 'samsungtv-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Samsung TV setup entries.'; + + public async matches(inputArg: ISamsungtvManualEntry): Promise { + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const model = inputArg.model?.toLowerCase() || ''; + const matched = Boolean(inputArg.host || startsSamsung(manufacturer) || model.includes('samsung') || model.includes('tizen') || inputArg.metadata?.samsungtv); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Samsung TV setup hints.' }; + } + + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Samsung TV setup.', + normalizedDeviceId: inputArg.id || inputArg.macAddress, + candidate: { + source: 'manual', + integrationDomain: 'samsungtv', + id: inputArg.id || inputArg.macAddress, + host: inputArg.host, + port: inputArg.port || 8001, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'Samsung', + model: inputArg.model, + macAddress: inputArg.macAddress, + metadata: { ...(inputArg.metadata ?? {}), token: inputArg.token }, + }, + }; + } +} + +export class SamsungtvCandidateValidator implements IDiscoveryValidator { + public id = 'samsungtv-candidate-validator'; + public description = 'Validate Samsung TV discovery candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'samsungtv' + || startsSamsung(manufacturer) + || model.includes('samsung') + || model.includes('tizen') + || Boolean(candidateArg.metadata?.samsungtv); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Samsung TV metadata.' : 'Candidate is not Samsung TV.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id || candidateArg.macAddress, + }; + } +} + +export const createSamsungtvDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'samsungtv', displayName: 'Samsung Smart TV' }) + .addMatcher(new SamsungtvSsdpMatcher()) + .addMatcher(new SamsungtvMdnsMatcher()) + .addMatcher(new SamsungtvManualMatcher()) + .addValidator(new SamsungtvCandidateValidator()); +}; + +const startsSamsung = (valueArg: string | undefined): boolean => Boolean(valueArg?.toLowerCase().startsWith('samsung')); + +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 readRecordValue = (recordArg: ISamsungtvSsdpRecord, ...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 && value) { + return value; + } + } + } + } + return undefined; +}; diff --git a/ts/integrations/samsungtv/samsungtv.mapper.ts b/ts/integrations/samsungtv/samsungtv.mapper.ts new file mode 100644 index 0000000..0010ba9 --- /dev/null +++ b/ts/integrations/samsungtv/samsungtv.mapper.ts @@ -0,0 +1,142 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js'; +import type { ISamsungtvEvent, ISamsungtvSnapshot, ISamsungtvState } from './samsungtv.types.js'; + +export class SamsungtvMapper { + public static toDevices(snapshotArg: ISamsungtvSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const state = this.state(snapshotArg); + return [{ + id: this.deviceId(snapshotArg), + integrationDomain: 'samsungtv', + name: this.deviceName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.deviceInfo.device?.manufacturer || 'Samsung', + model: snapshotArg.deviceInfo.device?.modelName || snapshotArg.deviceInfo.device?.modelNumber, + online: 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_key', capability: 'media', name: 'Remote key', readable: false, writable: true }, + ], + state: [ + { featureId: 'power', value: state.power, updatedAt }, + { featureId: 'playback', value: state.playback, updatedAt }, + { featureId: 'source', value: state.source ?? null, updatedAt }, + { featureId: 'volume', value: state.volumeLevel ?? null, updatedAt }, + { featureId: 'muted', value: state.muted ?? null, updatedAt }, + ], + metadata: { + id: snapshotArg.deviceInfo.id, + udn: snapshotArg.deviceInfo.device?.udn, + serialNumber: snapshotArg.deviceInfo.device?.serialNumber, + macAddress: snapshotArg.deviceInfo.device?.wifiMac, + frameTvSupport: snapshotArg.deviceInfo.device?.FrameTVSupport, + apps: snapshotArg.apps.map((appArg) => ({ id: appArg.id, name: appArg.name, type: appArg.type })), + }, + }]; + } + + public static toEntities(snapshotArg: ISamsungtvSnapshot): IIntegrationEntity[] { + const state = this.state(snapshotArg); + return [{ + id: `media_player.${this.slug(this.deviceName(snapshotArg))}`, + uniqueId: `samsungtv_${this.slug(this.identity(snapshotArg))}`, + integrationDomain: 'samsungtv', + deviceId: this.deviceId(snapshotArg), + platform: 'media_player', + name: this.deviceName(snapshotArg), + state: this.mediaState(state), + attributes: { + source: state.source, + appId: state.appId, + appName: state.appName, + sourceList: this.sourceList(snapshotArg), + volumeLevel: state.volumeLevel, + isVolumeMuted: state.muted, + mediaTitle: state.mediaTitle, + model: snapshotArg.deviceInfo.device?.modelName || snapshotArg.deviceInfo.device?.modelNumber, + }, + available: state.power !== 'off', + }]; + } + + public static toIntegrationEvent(eventArg: ISamsungtvEvent): IIntegrationEvent { + return { + type: eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: 'samsungtv', + data: eventArg, + timestamp: eventArg.timestamp, + }; + } + + public static deviceId(snapshotArg: ISamsungtvSnapshot): string { + return `samsungtv.device.${this.slug(this.identity(snapshotArg))}`; + } + + private static state(snapshotArg: ISamsungtvSnapshot): Required> & ISamsungtvState { + const power = snapshotArg.state?.power ?? this.powerFromDeviceInfo(snapshotArg); + const appId = snapshotArg.state?.appId ?? snapshotArg.activeApp?.id; + const appName = snapshotArg.state?.appName ?? snapshotArg.activeApp?.name; + const source = snapshotArg.state?.source ?? appName; + return { + ...(snapshotArg.state ?? {}), + power, + playback: snapshotArg.state?.playback ?? (power === 'off' ? 'off' : 'idle'), + appId, + appName, + source, + }; + } + + private static mediaState(stateArg: Required> & ISamsungtvState): string { + 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 ? 'on' : 'idle'; + } + + private static sourceList(snapshotArg: ISamsungtvSnapshot): string[] { + return [...new Set(['TV', 'HDMI', ...snapshotArg.apps.map((appArg) => appArg.name)])]; + } + + private static powerFromDeviceInfo(snapshotArg: ISamsungtvSnapshot): 'on' | 'off' | 'unknown' { + const value = String(snapshotArg.deviceInfo.device?.PowerState || '').toLowerCase(); + if (value === 'on') { + return 'on'; + } + if (value.includes('off') || value.includes('standby') || value.includes('sleep')) { + return 'off'; + } + return 'unknown'; + } + + private static deviceName(snapshotArg: ISamsungtvSnapshot): string { + const name = snapshotArg.deviceInfo.device?.name || snapshotArg.deviceInfo.device?.modelName || 'Samsung Smart TV'; + return name.replace(/^\[TV\]\s*/i, '') || 'Samsung Smart TV'; + } + + private static identity(snapshotArg: ISamsungtvSnapshot): string { + return snapshotArg.deviceInfo.id + || snapshotArg.deviceInfo.device?.udn + || snapshotArg.deviceInfo.device?.wifiMac + || snapshotArg.deviceInfo.device?.serialNumber + || this.deviceName(snapshotArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'samsungtv'; + } +} diff --git a/ts/integrations/samsungtv/samsungtv.types.ts b/ts/integrations/samsungtv/samsungtv.types.ts index 5de2c9d..4d18d19 100644 --- a/ts/integrations/samsungtv/samsungtv.types.ts +++ b/ts/integrations/samsungtv/samsungtv.types.ts @@ -1,4 +1,140 @@ -export interface IHomeAssistantSamsungtvConfig { - // TODO: replace with the TypeScript-native config for samsungtv. +export type TSamsungtvProtocolMethod = 'websocket' | 'legacy' | 'encrypted'; + +export type TSamsungtvPowerState = 'on' | 'off' | 'unknown'; + +export type TSamsungtvRemoteKey = `KEY_${string}` | string; + +export type TSamsungtvCommandAction = 'Click' | 'Press' | 'Release'; + +export interface ISamsungtvConfig { + host?: string; + port?: number; + token?: string; + name?: string; + model?: string; + manufacturer?: string; + macAddress?: string; + method?: TSamsungtvProtocolMethod; + sessionId?: string; + deviceInfo?: ISamsungtvDeviceInfoResponse; + state?: ISamsungtvState; + apps?: ISamsungtvApp[]; + activeApp?: ISamsungtvApp; + snapshot?: ISamsungtvSnapshot; + ssdpRenderingControlLocation?: string; + ssdpMainTvAgentLocation?: string; + websocketName?: string; + keyPressDelayMs?: number; + connectTimeoutMs?: number; +} + +export interface IHomeAssistantSamsungtvConfig extends ISamsungtvConfig {} + +export interface ISamsungtvDeviceInfoResponse { + id?: string; + device?: ISamsungtvDeviceInfo; [key: string]: unknown; } + +export interface ISamsungtvDeviceInfo { + type?: string; + name?: string; + modelName?: string; + modelNumber?: string; + serialNumber?: string; + udn?: string; + wifiMac?: string; + manufacturer?: string; + networkType?: string; + PowerState?: string; + FrameTVSupport?: string; + [key: string]: unknown; +} + +export interface ISamsungtvState { + power?: TSamsungtvPowerState; + playback?: 'playing' | 'paused' | 'idle' | 'off' | 'unknown'; + volumeLevel?: number; + muted?: boolean; + source?: string; + appId?: string; + appName?: string; + mediaTitle?: string; +} + +export interface ISamsungtvApp { + id: string; + name: string; + type?: string; + appType?: string; + version?: string; +} + +export interface ISamsungtvSnapshot { + deviceInfo: ISamsungtvDeviceInfoResponse; + state?: ISamsungtvState; + apps: ISamsungtvApp[]; + activeApp?: ISamsungtvApp; +} + +export interface ISamsungtvKeyCommand { + type: 'key'; + key: TSamsungtvRemoteKey; + action?: TSamsungtvCommandAction; +} + +export interface ISamsungtvLaunchAppCommand { + type: 'launch_app'; + appId: string; + appType?: 'DEEP_LINK' | 'NATIVE_LAUNCH' | string; + metaTag?: string; +} + +export interface ISamsungtvWebsocketCommand { + method: string; + params: Record; +} + +export type TSamsungtvCommand = ISamsungtvKeyCommand | ISamsungtvLaunchAppCommand | ISamsungtvWebsocketCommand; + +export interface ISamsungtvEvent { + type: 'websocket' | 'state' | 'apps' | 'error'; + event?: string; + data?: unknown; + token?: string; + timestamp: number; +} + +export interface ISamsungtvSsdpRecord { + st?: string; + usn?: string; + location?: string; + headers?: Record; + upnp?: Record; + ssdp_st?: string; + ssdp_usn?: string; + ssdp_location?: string; +} + +export interface ISamsungtvMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + txt?: Record; + properties?: Record; +} + +export interface ISamsungtvManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + model?: string; + manufacturer?: string; + macAddress?: string; + token?: string; + metadata?: Record; +} + +export type TSamsungtvDiscoveryRecord = ISamsungtvSsdpRecord | ISamsungtvMdnsRecord | ISamsungtvManualEntry; diff --git a/ts/integrations/tplink/.generated-by-smarthome-exchange b/ts/integrations/tplink/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/tplink/.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/tplink/index.ts b/ts/integrations/tplink/index.ts index 86b0af5..36a28a1 100644 --- a/ts/integrations/tplink/index.ts +++ b/ts/integrations/tplink/index.ts @@ -1,2 +1,6 @@ +export * from './tplink.classes.client.js'; +export * from './tplink.classes.configflow.js'; export * from './tplink.classes.integration.js'; +export * from './tplink.discovery.js'; +export * from './tplink.mapper.js'; export * from './tplink.types.js'; diff --git a/ts/integrations/tplink/tplink.classes.client.ts b/ts/integrations/tplink/tplink.classes.client.ts new file mode 100644 index 0000000..b7027fc --- /dev/null +++ b/ts/integrations/tplink/tplink.classes.client.ts @@ -0,0 +1,93 @@ +import type { + ITplinkClientCommand, + ITplinkCommandResult, + ITplinkConfig, + ITplinkEvent, + ITplinkSnapshot, +} from './tplink.types.js'; +import { TplinkMapper } from './tplink.mapper.js'; + +type TTplinkEventHandler = (eventArg: ITplinkEvent) => void; + +export class TplinkClient { + private readonly events: ITplinkEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: ITplinkConfig) {} + + public async getSnapshot(): Promise { + return TplinkMapper.toSnapshot(this.config, undefined, this.events); + } + + public onEvent(handlerArg: TTplinkEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: ITplinkClientCommand): Promise { + this.emit({ + type: 'command_mapped', + command: commandArg, + deviceId: commandArg.deviceId, + entityId: commandArg.entityId, + uniqueId: commandArg.uniqueId, + timestamp: Date.now(), + }); + + if (this.config.commandExecutor) { + const result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg); + this.emit({ + type: result.success ? 'command_executed' : 'command_failed', + command: commandArg, + data: result, + deviceId: commandArg.deviceId, + entityId: commandArg.entityId, + uniqueId: commandArg.uniqueId, + timestamp: Date.now(), + }); + return result; + } + + const result: ITplinkCommandResult = { + success: false, + error: this.unsupportedLiveControlMessage(), + data: { command: commandArg }, + }; + this.emit({ + type: 'command_failed', + command: commandArg, + data: result, + deviceId: commandArg.deviceId, + entityId: commandArg.entityId, + uniqueId: commandArg.uniqueId, + timestamp: Date.now(), + }); + return result; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private emit(eventArg: ITplinkEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private commandResult(resultArg: unknown, commandArg: ITplinkClientCommand): ITplinkCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is ITplinkCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } + + private unsupportedLiveControlMessage(): string { + return 'TP-Link Kasa/Tapo live local writes require full python-kasa-equivalent protocol selection and encrypted transports (legacy IOT XOR plus SMART AES/KLAP). This dependency-free TypeScript port is snapshot/manual unless commandExecutor is provided.'; + } +} diff --git a/ts/integrations/tplink/tplink.classes.configflow.ts b/ts/integrations/tplink/tplink.classes.configflow.ts new file mode 100644 index 0000000..eb96189 --- /dev/null +++ b/ts/integrations/tplink/tplink.classes.configflow.ts @@ -0,0 +1,108 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { ITplinkConfig, ITplinkCredentials, ITplinkSnapshot } from './tplink.types.js'; +import { tplinkDefaultHttpPort } from './tplink.types.js'; + +export class TplinkConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const metadata = candidateArg.metadata || {}; + const host = candidateArg.host || this.stringValue(metadata.host) || ''; + const model = candidateArg.model || this.stringValue(metadata.model) || ''; + const alias = candidateArg.name || this.stringValue(metadata.alias) || this.stringValue(metadata.name) || ''; + const requiresAuth = metadata.requiresAuth === true || metadata.encryptionType !== undefined || metadata.connectionParameters !== undefined; + + return { + kind: 'form', + title: 'Connect TP-Link Smart Home device', + description: requiresAuth + ? 'Provide the device host and TP-Link cloud credentials used by Kasa/Tapo devices. A snapshot can be supplied for read-only setup.' + : 'Provide the device host. Credentials are optional for legacy Kasa devices and required by many newer Kasa/Tapo devices. A snapshot can be supplied for read-only setup.', + fields: [ + { name: 'host', label: host ? `Host (${host})` : 'Host', type: 'text', required: true }, + { name: 'port', label: `Port (${candidateArg.port || tplinkDefaultHttpPort})`, type: 'number' }, + { name: 'username', label: 'TP-Link username', type: 'text', required: requiresAuth }, + { name: 'password', label: 'TP-Link password', type: 'password', required: requiresAuth }, + { name: 'alias', label: alias ? `Alias (${alias})` : 'Alias', type: 'text' }, + { name: 'model', label: model ? `Model (${model})` : 'Model', type: 'text' }, + { name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' }, + ], + submit: async (valuesArg) => this.submit(candidateArg, valuesArg), + }; + } + + private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record): Promise> { + const host = this.stringValue(valuesArg.host) || candidateArg.host; + if (!host) { + return { kind: 'error', title: 'Host required', error: 'TP-Link setup requires a host unless a config is created directly from a snapshot.' }; + } + + const snapshot = this.snapshotFromInput(valuesArg.snapshotJson); + if (snapshot instanceof Error) { + return { kind: 'error', title: 'Invalid snapshot', error: snapshot.message }; + } + + const username = this.stringValue(valuesArg.username); + const password = this.stringValue(valuesArg.password); + const credentials: ITplinkCredentials | undefined = username || password ? { username, password } : undefined; + const config: ITplinkConfig = { + host, + port: this.numberValue(valuesArg.port) || candidateArg.port || tplinkDefaultHttpPort, + alias: this.stringValue(valuesArg.alias) || candidateArg.name, + model: this.stringValue(valuesArg.model) || candidateArg.model, + macAddress: candidateArg.macAddress, + deviceId: candidateArg.id, + credentials, + snapshot, + connectionParameters: this.record(candidateArg.metadata?.connectionParameters) + ? candidateArg.metadata.connectionParameters + : undefined, + usesHttp: candidateArg.metadata?.usesHttp === true ? true : undefined, + metadata: { + discoverySource: candidateArg.source, + discoveryMetadata: candidateArg.metadata, + liveLocalWritesImplemented: false, + }, + }; + + return { + kind: 'done', + title: 'TP-Link Smart Home device configured', + config, + }; + } + + private snapshotFromInput(valueArg: unknown): ITplinkSnapshot | undefined | Error { + const text = this.stringValue(valueArg); + if (!text) { + return undefined; + } + try { + const parsed = JSON.parse(text) as ITplinkSnapshot; + if (!parsed || !Array.isArray(parsed.devices)) { + return new Error('Snapshot JSON must include a devices array.'); + } + return parsed; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + } + + 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 record(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/tplink/tplink.classes.integration.ts b/ts/integrations/tplink/tplink.classes.integration.ts index 63fc5b4..e8d9cc8 100644 --- a/ts/integrations/tplink/tplink.classes.integration.ts +++ b/ts/integrations/tplink/tplink.classes.integration.ts @@ -1,33 +1,72 @@ -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 { TplinkClient } from './tplink.classes.client.js'; +import { TplinkConfigFlow } from './tplink.classes.configflow.js'; +import { createTplinkDiscoveryDescriptor } from './tplink.discovery.js'; +import { TplinkMapper } from './tplink.mapper.js'; +import type { ITplinkConfig } from './tplink.types.js'; -export class HomeAssistantTplinkIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "tplink", - displayName: "TP-Link Smart Home", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/tplink", - "upstreamDomain": "tplink", - "integrationType": "device", - "iotClass": "local_polling", - "qualityScale": "platinum", - "requirements": [ - "python-kasa[speedups]==0.10.2" - ], - "dependencies": [ - "network", - "ffmpeg", - "stream" - ], - "afterDependencies": [], - "codeowners": [ - "@rytilahti", - "@bdraco", - "@sdb9696" - ] -}, - }); +export class TplinkIntegration extends BaseIntegration { + public readonly domain = 'tplink'; + public readonly displayName = 'TP-Link Smart Home'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createTplinkDiscoveryDescriptor(); + public readonly configFlow = new TplinkConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/tplink', + upstreamDomain: 'tplink', + documentation: 'https://www.home-assistant.io/integrations/tplink', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: 'platinum', + requirements: ['python-kasa[speedups]==0.10.2'], + dependencies: ['network', 'ffmpeg', 'stream'], + afterDependencies: [] as string[], + codeowners: ['@rytilahti', '@bdraco', '@sdb9696'], + dhcpDiscoveryPorts: [9999, 20002], + liveLocalWritesImplemented: false, + }; + + public async setup(configArg: ITplinkConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new TplinkRuntime(new TplinkClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantTplinkIntegration extends TplinkIntegration {} + +class TplinkRuntime implements IIntegrationRuntime { + public domain = 'tplink'; + + constructor(private readonly client: TplinkClient) {} + + public async devices(): Promise { + return TplinkMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return TplinkMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(TplinkMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const command = TplinkMapper.commandForService(await this.client.getSnapshot(), requestArg); + if (!command) { + return { success: false, error: `Unsupported TP-Link service mapping: ${requestArg.domain}.${requestArg.service}` }; + } + return this.client.sendCommand(command); + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/tplink/tplink.discovery.ts b/ts/integrations/tplink/tplink.discovery.ts new file mode 100644 index 0000000..9b90822 --- /dev/null +++ b/ts/integrations/tplink/tplink.discovery.ts @@ -0,0 +1,270 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { ITplinkDhcpRecord, ITplinkManualDiscoveryRecord, ITplinkMdnsRecord } from './tplink.types.js'; +import { tplinkDefaultHttpPort, tplinkLegacyDiscoveryPort, tplinkSmartDiscoveryPort } from './tplink.types.js'; + +const tplinkMacPrefixes = [ + '3c52a1', '54af97', 'e848b8', '1c61b4', '003192', 'b4b024', '9c5322', '5091e3', + '1c3bf3', '50c7bf', '68ff7b', '98dac4', 'b09575', 'c006c3', '60a4b7', '005f67', + '1027f5', 'b0a7b9', '403f8c', 'c0c9e3', '909a4a', '6c5ab0', 'ac15a2', '788cb5', + '3460f9', '5ce931', '5c628b', '14ebb6', '482254', '30de4b', 'a842a1', '704f57', + '74da88', 'cc32e5', 'd80d17', 'd84732', 'f0a731', +]; + +const tplinkHostnamePatterns = [/^e[sp]/i, /^hs/i, /^k[lps]/i, /^p[13]/i, /^s5/i, /^l[59]/i, /^tp/i, /^h1/i, /^ks2/i, /^kh1/i]; +const tplinkTextHints = ['tp-link', 'tplink', 'kasa', 'tapo', 'smart plug', 'smart bulb', 'smart switch', 'smart dimmer']; + +export class TplinkMdnsMatcher implements IDiscoveryMatcher { + public id = 'tplink-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize TP-Link Kasa/Tapo mDNS records by service, host, TXT, MAC, or model metadata.'; + + public async matches(recordArg: ITplinkMdnsRecord, contextArg?: IDiscoveryContext): Promise { + void contextArg; + const txt = recordArg.txt || recordArg.properties || {}; + const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0]; + const hostname = recordArg.hostname || recordArg.host || recordArg.name; + const model = this.txt(txt, 'model') || this.txt(txt, 'modelid') || recordArg.model; + const manufacturer = recordArg.manufacturer || this.txt(txt, 'manufacturer') || this.txt(txt, 'vendor'); + const macAddress = normalizeMac(recordArg.macAddress || this.txt(txt, 'mac') || this.txt(txt, 'macaddress') || this.txt(txt, 'mac_address')); + const text = [recordArg.type, recordArg.serviceType, recordArg.name, hostname, host, model, manufacturer, this.txt(txt, 'brand')] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const macMatched = isTplinkMac(macAddress); + const hostMatched = isTplinkHostname(hostname); + const textMatched = hasTplinkTextHint(text); + const matched = macMatched || textMatched || hostMatched && text.includes('local'); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a TP-Link Kasa/Tapo advertisement.' }; + } + + const id = macAddress || this.txt(txt, 'device_id') || this.txt(txt, 'deviceid') || recordArg.name || host; + return { + matched: true, + confidence: macMatched && host ? 'certain' : host && (textMatched || hostMatched) ? 'high' : 'medium', + reason: macMatched ? 'mDNS record contains a known TP-Link MAC prefix.' : 'mDNS record contains Kasa/Tapo/TP-Link metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'tplink', + id, + host, + port: recordArg.port || tplinkDefaultHttpPort, + name: this.txt(txt, 'alias') || this.txt(txt, 'name') || recordArg.name || model, + manufacturer: manufacturer || 'TP-Link', + model, + macAddress, + metadata: { + tplink: true, + mdnsName: recordArg.name, + mdnsType: recordArg.type || recordArg.serviceType, + txt, + model, + hostMatched, + macMatched, + discoveryPorts: [tplinkLegacyDiscoveryPort, tplinkSmartDiscoveryPort], + }, + }, + metadata: { model, macAddress, hostMatched, macMatched }, + }; + } + + private txt(txtArg: Record, keyArg: string): string | undefined { + return txtArg[keyArg] || txtArg[keyArg.toUpperCase()]; + } +} + +export class TplinkDhcpMatcher implements IDiscoveryMatcher { + public id = 'tplink-dhcp-match'; + public source = 'dhcp' as const; + public description = 'Recognize Kasa/Tapo DHCP leases using Home Assistant TP-Link hostname and MAC rules.'; + + public async matches(recordArg: ITplinkDhcpRecord, contextArg?: IDiscoveryContext): Promise { + void contextArg; + const metadata = recordArg.metadata || {}; + const host = recordArg.host || recordArg.ipAddress || recordArg.address || recordArg.ip; + const hostname = recordArg.hostname || recordArg.hostName; + const macAddress = normalizeMac(recordArg.macAddress || recordArg.mac || this.stringValue(metadata.macAddress)); + const model = recordArg.model || this.stringValue(metadata.model); + const manufacturer = recordArg.manufacturer || this.stringValue(metadata.manufacturer); + const text = [hostname, manufacturer, model, recordArg.vendorClassIdentifier, metadata.brand, metadata.deviceType] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const macMatched = isTplinkMac(macAddress); + const hostMatched = isTplinkHostname(hostname); + const textMatched = hasTplinkTextHint(text); + const matched = recordArg.integrationDomain === 'tplink' + || metadata.tplink === true + || metadata.kasa === true + || metadata.tapo === true + || macMatched + || hostMatched && textMatched; + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'DHCP record does not match TP-Link Kasa/Tapo metadata.' }; + } + + const id = macAddress || this.stringValue(metadata.deviceId) || hostname || host; + return { + matched: true, + confidence: macMatched && host ? 'certain' : host && (hostMatched || textMatched) ? 'high' : 'medium', + reason: macMatched ? 'DHCP MAC prefix matches Home Assistant TP-Link manifest rules.' : 'DHCP hostname or metadata matches TP-Link Kasa/Tapo.', + normalizedDeviceId: id, + candidate: { + source: 'dhcp', + integrationDomain: 'tplink', + id, + host, + port: tplinkDefaultHttpPort, + name: hostname || model || 'TP-Link Smart Home device', + manufacturer: manufacturer || 'TP-Link', + model, + macAddress, + metadata: { + ...metadata, + tplink: true, + hostname, + macMatched, + hostMatched, + discoveryPorts: [tplinkLegacyDiscoveryPort, tplinkSmartDiscoveryPort], + }, + }, + metadata: { macMatched, hostMatched, model }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } +} + +export class TplinkManualMatcher implements IDiscoveryMatcher { + public id = 'tplink-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Kasa/Tapo setup entries, including snapshot-only records.'; + + public async matches(inputArg: ITplinkManualDiscoveryRecord, contextArg?: IDiscoveryContext): Promise { + void contextArg; + const metadata = inputArg.metadata || {}; + const host = inputArg.host; + const model = inputArg.model; + const macAddress = normalizeMac(inputArg.macAddress || inputArg.mac); + const text = [inputArg.integrationDomain, inputArg.manufacturer, inputArg.brand, inputArg.model, inputArg.alias, inputArg.name, metadata.brand, metadata.model] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const snapshot = inputArg.snapshot || metadata.snapshot; + const matched = inputArg.integrationDomain === 'tplink' + || metadata.tplink === true + || metadata.kasa === true + || metadata.tapo === true + || Boolean(snapshot) + || Boolean(host && (hasTplinkTextHint(text) || model || macAddress)) + || Boolean(host && !text); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain TP-Link setup data.' }; + } + + const id = inputArg.id || inputArg.deviceId || macAddress || host || `snapshot-${Date.now()}`; + return { + matched: true, + confidence: snapshot ? 'certain' : host && (macAddress || model) ? 'high' : host ? 'medium' : 'low', + reason: snapshot ? 'Manual entry includes a TP-Link snapshot.' : 'Manual entry can start TP-Link setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'tplink', + id, + host, + port: inputArg.port || tplinkDefaultHttpPort, + name: inputArg.alias || inputArg.name || model || 'TP-Link Smart Home device', + manufacturer: inputArg.manufacturer || 'TP-Link', + model, + macAddress, + metadata: { + ...metadata, + tplink: true, + manual: true, + deviceType: inputArg.deviceType, + snapshot, + device: inputArg.device, + devices: inputArg.devices, + credentialsConfigured: Boolean(inputArg.credentials?.username || inputArg.credentials?.credentialsHash), + }, + }, + metadata: { snapshotConfigured: Boolean(snapshot), credentialsConfigured: Boolean(inputArg.credentials) }, + }; + } +} + +export class TplinkCandidateValidator implements IDiscoveryValidator { + public id = 'tplink-candidate-validator'; + public description = 'Validate TP-Link Kasa/Tapo candidates from mDNS, DHCP, and manual setup.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg?: IDiscoveryContext): Promise { + void contextArg; + const metadata = candidateArg.metadata || {}; + const macAddress = normalizeMac(candidateArg.macAddress || this.stringValue(metadata.macAddress)); + const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.brand, metadata.model, metadata.deviceType] + .filter((valueArg): valueArg is string => typeof valueArg === 'string') + .join(' ') + .toLowerCase(); + const snapshotConfigured = metadata.snapshot !== undefined; + const macMatched = isTplinkMac(macAddress); + const textMatched = hasTplinkTextHint(text); + const matched = candidateArg.integrationDomain === 'tplink' + || metadata.tplink === true + || metadata.kasa === true + || metadata.tapo === true + || snapshotConfigured + || macMatched + || textMatched + || candidateArg.source === 'manual' && Boolean(candidateArg.host); + + return { + matched, + confidence: matched && (macMatched || snapshotConfigured || candidateArg.integrationDomain === 'tplink') && candidateArg.host ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has TP-Link Kasa/Tapo metadata or manual setup data.' : 'Candidate is not TP-Link Kasa/Tapo.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: macAddress || candidateArg.id || candidateArg.host, + metadata: matched ? { macMatched, snapshotConfigured, encryptedLocalProtocolImplemented: false } : undefined, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } +} + +export const createTplinkDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'tplink', displayName: 'TP-Link Smart Home' }) + .addMatcher(new TplinkMdnsMatcher()) + .addMatcher(new TplinkDhcpMatcher()) + .addMatcher(new TplinkManualMatcher()) + .addValidator(new TplinkCandidateValidator()); +}; + +const normalizeMac = (valueArg?: string): string | undefined => { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : undefined; +}; + +const isTplinkMac = (valueArg?: string): boolean => { + const compact = (valueArg || '').replace(/[^0-9a-f]/gi, '').toLowerCase(); + return tplinkMacPrefixes.some((prefixArg) => compact.startsWith(prefixArg)); +}; + +const isTplinkHostname = (valueArg?: string): boolean => { + return Boolean(valueArg && tplinkHostnamePatterns.some((patternArg) => patternArg.test(valueArg))); +}; + +const hasTplinkTextHint = (valueArg: string): boolean => { + return tplinkTextHints.some((hintArg) => valueArg.includes(hintArg)); +}; diff --git a/ts/integrations/tplink/tplink.mapper.ts b/ts/integrations/tplink/tplink.mapper.ts new file mode 100644 index 0000000..f5084cf --- /dev/null +++ b/ts/integrations/tplink/tplink.mapper.ts @@ -0,0 +1,887 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + ITplinkClientCommand, + ITplinkConfig, + ITplinkDevice, + ITplinkEntityDescriptor, + ITplinkEvent, + ITplinkFeature, + ITplinkManualEntry, + ITplinkSnapshot, + ITplinkStateRecord, + TTplinkDeviceKind, +} from './tplink.types.js'; +import { tplinkDefaultHttpPort } from './tplink.types.js'; + +const primaryControlKeys = new Set(['state', 'is_on', 'on', 'power', 'relay_state', 'device_on', 'light_on', 'brightness', 'dimmer', 'dimming', 'color_temperature', 'color_temp', 'color_temp_kelvin', 'hsv', 'rgb', 'rgb_color']); +const binarySensorKeys = new Set(['overheated', 'overloaded', 'battery_low', 'cloud_connection', 'temperature_warning', 'humidity_warning', 'is_open', 'water_alert', 'motion_detected', 'occupancy', 'tamper_detection', 'person_detection', 'baby_cry_detection']); +const switchFeatureKeys = new Set(['led', 'auto_update_enabled', 'auto_off_enabled', 'smooth_transitions', 'fan_sleep_mode', 'child_lock', 'pir_enabled', 'motion_detection', 'person_detection', 'tamper_detection', 'baby_cry_detection', 'carpet_boost']); +const numberControlKeys = new Set(['smooth_transition_on', 'smooth_transition_off', 'auto_off_minutes', 'temperature_offset', 'pan_step', 'tilt_step', 'power_protection_threshold', 'clean_count', 'fan_speed_level', 'target_temperature']); +const sensorUnits: Record = { + current_consumption: 'W', + current_power_w: 'W', + power: 'W', + voltage: 'V', + current: 'A', + consumption_today: 'kWh', + consumption_total: 'kWh', + consumption_this_month: 'kWh', + today_energy_kwh: 'kWh', + total_energy_kwh: 'kWh', + temperature: 'C', + humidity: '%', + rssi: 'dBm', + signal_level: 'dBm', + battery_level: '%', +}; + +export class TplinkMapper { + public static toSnapshot(configArg: ITplinkConfig, connectedArg?: boolean, eventsArg: ITplinkEvent[] = []): ITplinkSnapshot { + const source = configArg.snapshot; + const primaryDevice = this.primaryDevice(configArg, source); + const devices = this.uniqueDevices([ + ...(source?.devices || []), + ...(configArg.devices || []), + ...(configArg.device ? [configArg.device] : []), + ...(primaryDevice ? [primaryDevice] : []), + ...this.devicesFromManualEntries(configArg.manualEntries || []), + ]); + const host = configArg.host || source?.host; + const port = configArg.port || source?.port || tplinkDefaultHttpPort; + + return { + connected: connectedArg ?? source?.connected ?? Boolean(source || devices.some((deviceArg) => this.hasState(deviceArg))), + configured: Boolean(host || source || devices.length), + host, + port, + alias: configArg.alias || configArg.name || source?.alias, + model: configArg.model || source?.model, + macAddress: configArg.macAddress || source?.macAddress, + devices, + entities: [...(source?.entities || []), ...(configArg.entities || [])], + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + transport: { + protocol: source?.transport?.protocol || (host ? 'manual' : 'snapshot'), + host, + port, + credentialsConfigured: Boolean(configArg.credentials || configArg.username || configArg.password || configArg.credentialsHash || source?.transport?.credentialsConfigured), + connectionParameters: configArg.connectionParameters || source?.transport?.connectionParameters, + legacyXorImplemented: false, + encryptedLocalProtocolImplemented: false, + }, + metadata: { + ...source?.metadata, + ...configArg.metadata, + liveLocalWritesImplemented: false, + }, + }; + } + + public static toDevices(snapshotArg: ITplinkSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + return this.allDevices(snapshotArg).map((deviceArg) => this.toDevice(deviceArg, snapshotArg)); + } + + public static toEntities(snapshotArg: ITplinkSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const seen = new Set(); + const addEntity = (entityArg: IIntegrationEntity | undefined) => { + if (!entityArg || seen.has(entityArg.id)) { + return; + } + seen.add(entityArg.id); + entities.push(entityArg); + }; + + for (const descriptor of snapshotArg.entities) { + addEntity(this.entityFromDescriptor(snapshotArg, descriptor, usedIds)); + } + + for (const device of this.allDevices(snapshotArg)) { + const kind = this.deviceKind(device); + const control = this.controlState(device); + if (this.isLightKind(kind, device)) { + addEntity(this.primaryLightEntity(device, control, usedIds)); + } else if (this.isSwitchKind(kind, device)) { + addEntity(this.primarySwitchEntity(device, control, usedIds)); + } + + for (const property of this.propertiesForDevice(device)) { + addEntity(this.entityForProperty(device, property, usedIds)); + } + } + + return entities; + } + + public static toIntegrationEvent(eventArg: ITplinkEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: 'tplink', + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static commandForService(snapshotArg: ITplinkSnapshot, requestArg: IServiceCallRequest): ITplinkClientCommand | undefined { + if (requestArg.domain === 'tplink' && requestArg.service === 'raw_command' && this.isRecord(requestArg.data?.payload)) { + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + return this.command(requestArg, targetEntity, this.findTargetDevice(snapshotArg, requestArg, targetEntity), 'raw_command', requestArg.data.payload as Record); + } + + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + const targetDevice = this.findTargetDevice(snapshotArg, requestArg, targetEntity); + if (!targetEntity && !targetDevice) { + return undefined; + } + + if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') { + const payload: Record = { state: requestArg.service === 'turn_on' }; + if (requestArg.service === 'turn_on' && (targetEntity?.platform === 'light' || requestArg.domain === 'light')) { + this.applyLightServiceData(payload, requestArg); + } + return this.command(requestArg, targetEntity, targetDevice, 'set_state', payload, 'state', payload.state); + } + + if (requestArg.service === 'set_brightness' || requestArg.service === 'set_percentage') { + const percentage = this.percentageFromData(requestArg.data, requestArg.service); + if (percentage === undefined) { + return undefined; + } + const featureId = targetEntity?.platform === 'fan' || requestArg.domain === 'fan' ? 'fan_speed_level' : 'brightness'; + const payload = featureId === 'fan_speed_level' + ? { [featureId]: percentage } + : { state: percentage > 0, brightness: percentage }; + return this.command(requestArg, targetEntity, targetDevice, 'set_feature_value', payload, featureId, percentage); + } + + if (requestArg.service === 'set_color_temp' || requestArg.service === 'set_color_temperature') { + const kelvin = this.kelvinFromData(requestArg.data); + if (kelvin === undefined) { + return undefined; + } + return this.command(requestArg, targetEntity, targetDevice, 'set_feature_value', { color_temperature: kelvin }, 'color_temperature', kelvin); + } + + if (requestArg.service === 'set_rgb_color' || requestArg.service === 'set_color') { + const rgb = this.rgbFromData(requestArg.data, 'rgb_color') || this.rgbFromData(requestArg.data, 'rgb'); + if (!rgb) { + return undefined; + } + const value = { r: rgb[0], g: rgb[1], b: rgb[2] }; + return this.command(requestArg, targetEntity, targetDevice, 'set_feature_value', { rgb: value }, 'rgb', value); + } + + if (requestArg.service === 'set_value') { + const value = requestArg.data?.value; + const featureId = this.stringValue(requestArg.data?.featureId || requestArg.data?.feature_id || requestArg.data?.field || requestArg.data?.key || targetEntity?.attributes?.tplinkFeatureId); + if (featureId === undefined || value === undefined) { + return undefined; + } + return this.command(requestArg, targetEntity, targetDevice, 'set_feature_value', { [featureId]: value }, featureId, value); + } + + if (requestArg.service === 'select_option') { + const option = this.stringValue(requestArg.data?.option || requestArg.data?.value); + const featureId = this.stringValue(targetEntity?.attributes?.tplinkFeatureId) || 'light_preset'; + if (!option) { + return undefined; + } + return this.command(requestArg, targetEntity, targetDevice, 'set_feature_value', { [featureId]: option }, featureId, option); + } + + if (requestArg.service === 'press') { + const featureId = this.stringValue(targetEntity?.attributes?.tplinkFeatureId); + return featureId ? this.command(requestArg, targetEntity, targetDevice, 'action', { [featureId]: true }, featureId, true) : undefined; + } + + return undefined; + } + + private static toDevice(deviceArg: ITplinkDevice, snapshotArg: ITplinkSnapshot): plugins.shxInterfaces.data.IDeviceDefinition { + const updatedAt = deviceArg.updatedAt || new Date().toISOString(); + const kind = this.deviceKind(deviceArg); + const control = this.controlState(deviceArg); + const properties = this.propertiesForDevice(deviceArg); + 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: deviceArg.available === false || deviceArg.online === false ? 'offline' : 'online', updatedAt }, + ]; + + if (this.isLightKind(kind, deviceArg)) { + features.push({ id: 'state', capability: 'light', name: 'Power', readable: true, writable: true }); + this.pushDeviceState(state, 'state', control.on, updatedAt); + if (control.brightness !== undefined || this.hasFeature(deviceArg, 'brightness')) { + features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }); + this.pushDeviceState(state, 'brightness', control.brightness, updatedAt); + } + if (control.colorTemperature !== undefined || this.hasFeature(deviceArg, 'color_temperature')) { + features.push({ id: 'color_temperature', capability: 'light', name: 'Color Temperature', readable: true, writable: true, unit: 'K' }); + this.pushDeviceState(state, 'color_temperature', control.colorTemperature, updatedAt); + } + if (control.rgb || this.hasFeature(deviceArg, 'hsv')) { + features.push({ id: 'rgb', capability: 'light', name: 'RGB Color', readable: true, writable: true }); + this.pushDeviceState(state, 'rgb', control.rgb, updatedAt); + } + } else if (this.isSwitchKind(kind, deviceArg)) { + features.push({ id: 'state', capability: 'switch', name: 'Power', readable: true, writable: true }); + this.pushDeviceState(state, 'state', control.on, updatedAt); + } + + for (const property of properties) { + if (this.shouldSkipDeviceProperty(property, kind)) { + continue; + } + features.push(this.featureForProperty(property)); + this.pushDeviceState(state, property.key, property.value, updatedAt); + } + + return { + id: this.deviceId(deviceArg), + integrationDomain: 'tplink', + name: this.deviceName(deviceArg), + protocol: 'unknown', + manufacturer: deviceArg.manufacturer || 'TP-Link', + model: deviceArg.model, + online: deviceArg.available !== false && deviceArg.online !== false && (snapshotArg.connected || this.hasState(deviceArg) || Boolean(deviceArg.host)), + features: this.uniqueFeatures(features), + state, + metadata: { + ...deviceArg.metadata, + host: deviceArg.host || snapshotArg.host, + port: deviceArg.port || snapshotArg.port || tplinkDefaultHttpPort, + macAddress: this.mac(deviceArg), + deviceId: deviceArg.deviceId || deviceArg.device_id || deviceArg.id, + kind, + hwVersion: deviceArg.hwVersion || deviceArg.hardwareVersion, + swVersion: deviceArg.swVersion || deviceArg.firmwareVersion, + liveLocalWritesImplemented: false, + }, + }; + } + + private static primaryLightEntity(deviceArg: ITplinkDevice, controlArg: ReturnType, usedIdsArg: Map): IIntegrationEntity { + const name = this.deviceName(deviceArg); + return this.entity('light', name, this.deviceId(deviceArg), this.uniqueId('light', deviceArg), controlArg.on ? 'on' : 'off', usedIdsArg, { + ...this.baseAttributes(deviceArg), + brightness: controlArg.brightness, + brightness255: controlArg.brightness === undefined ? undefined : this.clamp(Math.round(controlArg.brightness / 100 * 255), 0, 255), + colorTemperatureKelvin: controlArg.colorTemperature, + rgbColor: controlArg.rgb ? [controlArg.rgb.r, controlArg.rgb.g, controlArg.rgb.b] : undefined, + effect: controlArg.effect, + writable: true, + }, deviceArg.available !== false && deviceArg.online !== false); + } + + private static primarySwitchEntity(deviceArg: ITplinkDevice, controlArg: ReturnType, usedIdsArg: Map): IIntegrationEntity { + const name = this.deviceName(deviceArg); + return this.entity('switch', name, this.deviceId(deviceArg), this.uniqueId('switch', deviceArg), controlArg.on ? 'on' : 'off', usedIdsArg, { + ...this.baseAttributes(deviceArg), + writable: true, + }, deviceArg.available !== false && deviceArg.online !== false); + } + + private static entityForProperty(deviceArg: ITplinkDevice, propertyArg: ITplinkFeature & { key: string }, usedIdsArg: Map): IIntegrationEntity | undefined { + const kind = this.deviceKind(deviceArg); + if (this.shouldSkipEntityProperty(propertyArg, kind)) { + return undefined; + } + const platform = this.platformForProperty(propertyArg, kind); + const name = platform === 'button' + ? `${this.deviceName(deviceArg)} ${this.title(propertyArg.name || propertyArg.key)}` + : `${this.deviceName(deviceArg)} ${this.title(propertyArg.name || propertyArg.key)}`; + const state = this.entityState(propertyArg.value, platform); + return this.entity(platform, name, this.deviceId(deviceArg), `${this.uniqueId(platform, deviceArg)}_${this.slug(propertyArg.key)}`, state, usedIdsArg, { + ...this.baseAttributes(deviceArg), + tplinkFeatureId: propertyArg.key, + deviceClass: propertyArg.deviceClass, + unit: propertyArg.unit || sensorUnits[propertyArg.key], + writable: propertyArg.writable === true, + min: propertyArg.minimumValue ?? propertyArg.min, + max: propertyArg.maximumValue ?? propertyArg.max, + options: propertyArg.choices, + }, propertyArg.available !== false && deviceArg.available !== false && deviceArg.online !== false); + } + + private static entityFromDescriptor(snapshotArg: ITplinkSnapshot, entityArg: ITplinkEntityDescriptor, usedIdsArg: Map): IIntegrationEntity { + const platform = this.corePlatform(entityArg.platform || 'sensor'); + const name = entityArg.name || entityArg.entityId || entityArg.id || 'TP-Link entity'; + return this.entity(platform, name, this.entityDeviceId(snapshotArg, entityArg), entityArg.uniqueId || entityArg.unique_id || `tplink_${this.slug(entityArg.id || entityArg.entityId || name)}`, this.entityState(entityArg.state ?? entityArg.value, platform), usedIdsArg, { + ...entityArg.attributes, + tplinkFeatureId: entityArg.key, + deviceClass: entityArg.deviceClass || entityArg.device_class, + unit: entityArg.unit, + writable: entityArg.writable === true, + }, entityArg.available !== false, entityArg.entityId || entityArg.id); + } + + private static command(requestArg: IServiceCallRequest, entityArg: IIntegrationEntity | undefined, deviceArg: ITplinkDevice | undefined, methodArg: string, payloadArg: Record, featureIdArg?: string, valueArg?: unknown): ITplinkClientCommand { + return { + type: `tplink.${methodArg}`, + service: requestArg.service, + method: methodArg, + platform: entityArg?.platform || requestArg.domain, + protocol: 'snapshot', + deviceId: entityArg?.deviceId || (deviceArg ? this.deviceId(deviceArg) : requestArg.target.deviceId), + entityId: entityArg?.id || requestArg.target.entityId, + uniqueId: entityArg?.uniqueId, + featureId: featureIdArg, + value: valueArg, + target: requestArg.target, + payload: { ...payloadArg, data: requestArg.data || {} }, + }; + } + + private static applyLightServiceData(payloadArg: Record, requestArg: IServiceCallRequest): void { + const brightness = this.percentageFromData(requestArg.data, 'turn_on'); + if (brightness !== undefined) { + payloadArg.brightness = brightness; + } + const kelvin = this.kelvinFromData(requestArg.data); + if (kelvin !== undefined) { + payloadArg.color_temperature = kelvin; + } + const rgb = this.rgbFromData(requestArg.data, 'rgb_color') || this.rgbFromData(requestArg.data, 'rgb'); + if (rgb) { + payloadArg.rgb = { r: rgb[0], g: rgb[1], b: rgb[2] }; + } + } + + private static primaryDevice(configArg: ITplinkConfig, sourceArg?: ITplinkSnapshot): ITplinkDevice | undefined { + if (!configArg.host && !configArg.model && !configArg.alias && !configArg.name && !configArg.state && !configArg.features && !configArg.modules && !configArg.children?.length) { + return undefined; + } + return { + id: configArg.deviceId || configArg.macAddress || configArg.host || 'configured', + host: configArg.host || sourceArg?.host, + port: configArg.port || sourceArg?.port || tplinkDefaultHttpPort, + alias: configArg.alias || configArg.name, + model: configArg.model, + macAddress: configArg.macAddress, + type: configArg.deviceType, + brand: configArg.brand, + state: configArg.state, + features: configArg.features, + modules: configArg.modules, + children: configArg.children, + metadata: configArg.metadata, + }; + } + + private static devicesFromManualEntries(entriesArg: ITplinkManualEntry[]): ITplinkDevice[] { + const devices: ITplinkDevice[] = []; + for (const entry of entriesArg) { + if (entry.snapshot) { + devices.push(...entry.snapshot.devices); + } + if (entry.devices) { + devices.push(...entry.devices); + } + if (entry.device) { + devices.push(entry.device); + } + if (!entry.snapshot && !entry.devices?.length && !entry.device && (entry.host || entry.model || entry.alias || entry.name || entry.state || entry.features)) { + devices.push({ + id: entry.deviceId || entry.id || entry.macAddress || entry.mac || entry.host, + host: entry.host, + port: entry.port || tplinkDefaultHttpPort, + macAddress: entry.macAddress || entry.mac, + alias: entry.alias || entry.name, + model: entry.model, + manufacturer: entry.manufacturer, + brand: entry.brand, + type: entry.deviceType, + state: entry.state, + features: entry.features, + metadata: entry.metadata, + }); + } + } + return devices; + } + + private static allDevices(snapshotArg: ITplinkSnapshot): ITplinkDevice[] { + const devices: ITplinkDevice[] = []; + const visit = (deviceArg: ITplinkDevice, parentArg?: ITplinkDevice) => { + devices.push(parentArg && !deviceArg.parentId ? { ...deviceArg, parentId: this.deviceId(parentArg), host: deviceArg.host || parentArg.host, port: deviceArg.port || parentArg.port } : deviceArg); + for (const child of deviceArg.children || []) { + visit(child, deviceArg); + } + }; + for (const device of snapshotArg.devices) { + visit(device); + } + return this.uniqueDevices(devices); + } + + private static propertiesForDevice(deviceArg: ITplinkDevice): Array { + const properties: Array = []; + const state = this.normalizedState(deviceArg); + const existing = new Set(); + for (const feature of this.featureList(deviceArg)) { + const key = feature.key || feature.id; + if (!key) { + continue; + } + existing.add(key); + properties.push({ ...feature, key, value: feature.value ?? state[key] }); + } + for (const [key, value] of Object.entries(state)) { + if (existing.has(key) || value === undefined || this.isRecord(value) && !['rgb', 'rgb_color', 'hsv'].includes(key)) { + continue; + } + properties.push({ key, id: key, name: this.title(key), value, readable: true, writable: this.isWritableStateKey(key), unit: sensorUnits[key] }); + } + return properties; + } + + private static featureList(deviceArg: ITplinkDevice): ITplinkFeature[] { + const features: ITplinkFeature[] = []; + if (Array.isArray(deviceArg.features)) { + features.push(...deviceArg.features); + } else if (this.isRecord(deviceArg.features)) { + for (const [key, value] of Object.entries(deviceArg.features)) { + if (this.isRecord(value)) { + features.push({ ...value, key: this.stringValue(value.key) || this.stringValue(value.id) || key } as ITplinkFeature); + } + } + } + for (const module of Object.values(deviceArg.modules || {})) { + const moduleFeatures = module.features; + if (Array.isArray(moduleFeatures)) { + features.push(...moduleFeatures); + } else if (this.isRecord(moduleFeatures)) { + for (const [key, value] of Object.entries(moduleFeatures)) { + if (this.isRecord(value)) { + features.push({ ...value, key: this.stringValue(value.key) || this.stringValue(value.id) || key } as ITplinkFeature); + } + } + } + } + if (deviceArg.sensors) { + features.push(...deviceArg.sensors); + } + return features; + } + + private static normalizedState(deviceArg: ITplinkDevice): ITplinkStateRecord { + const sysInfo = this.asRecord(deviceArg.sysInfo || deviceArg.systemInfo); + const lightState = this.asRecord(deviceArg.lightState || sysInfo.light_state); + const emeter = this.asRecord(deviceArg.emeter || deviceArg.emeter_realtime || deviceArg.emeterRealtime); + const state: ITplinkStateRecord = { + ...sysInfo, + ...emeter, + ...lightState, + ...this.asRecord(deviceArg.state), + }; + if (state.state === undefined && state.relay_state !== undefined) { + state.state = this.boolish(state.relay_state); + } + if (state.state === undefined && state.on_off !== undefined) { + state.state = this.boolish(state.on_off); + } + if (state.brightness === undefined && state.dimmer !== undefined) { + state.brightness = state.dimmer; + } + if (state.current_consumption === undefined && state.current_power_w !== undefined) { + state.current_consumption = state.current_power_w; + } + return state; + } + + private static controlState(deviceArg: ITplinkDevice): { on?: boolean; brightness?: number; colorTemperature?: number; rgb?: { r: number; g: number; b: number }; effect?: string } { + const state = this.normalizedState(deviceArg); + const on = this.boolish(state.state ?? state.is_on ?? state.on ?? state.device_on ?? state.light_on ?? state.relay_state ?? state.power); + const brightness = this.numberValue(state.brightness ?? state.brightness_pct ?? state.dimmer ?? state.dimming); + const colorTemperature = this.numberValue(state.color_temperature ?? state.colorTemperature ?? state.color_temp_kelvin ?? state.color_temp); + return { + on, + brightness: brightness === undefined ? undefined : this.clamp(Math.round(brightness), 0, 100), + colorTemperature: colorTemperature === undefined ? undefined : Math.round(colorTemperature), + rgb: this.rgbValue(state.rgb ?? state.rgb_color ?? state.rgbColor ?? state.hsv), + effect: this.stringValue(state.light_effect ?? state.effect), + }; + } + + private static featureForProperty(propertyArg: ITplinkFeature & { key: string }): plugins.shxInterfaces.data.IDeviceFeature { + const platform = this.platformForProperty(propertyArg, 'unknown'); + return { + id: this.slug(propertyArg.key), + capability: platform === 'light' ? 'light' : platform === 'switch' || platform === 'button' ? 'switch' : 'sensor', + name: propertyArg.name || this.title(propertyArg.key), + readable: propertyArg.readable !== false, + writable: propertyArg.writable === true, + unit: propertyArg.unit || sensorUnits[propertyArg.key], + }; + } + + private static platformForProperty(propertyArg: ITplinkFeature & { key: string }, kindArg: TTplinkDeviceKind): TEntityPlatform { + const explicit = this.stringValue(propertyArg.platform || propertyArg.type)?.toLowerCase(); + if (explicit === 'binarysensor') { + return 'binary_sensor'; + } + if (explicit && ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'number', 'select', 'fan', 'climate'].includes(explicit)) { + return explicit === 'choice' ? 'select' : explicit as TEntityPlatform; + } + if (explicit === 'choice' || propertyArg.choices?.length) { + return 'select'; + } + if (explicit === 'action') { + return 'button'; + } + if (binarySensorKeys.has(propertyArg.key)) { + return 'binary_sensor'; + } + if (switchFeatureKeys.has(propertyArg.key) || typeof propertyArg.value === 'boolean' && propertyArg.writable === true) { + return 'switch'; + } + if (numberControlKeys.has(propertyArg.key) || typeof propertyArg.value === 'number' && propertyArg.writable === true) { + return 'number'; + } + if (this.isLightKind(kindArg, undefined) && ['brightness', 'color_temperature', 'color_temp', 'hsv', 'rgb'].includes(propertyArg.key)) { + return 'light'; + } + return 'sensor'; + } + + private static shouldSkipDeviceProperty(propertyArg: ITplinkFeature & { key: string }, kindArg: TTplinkDeviceKind): boolean { + return primaryControlKeys.has(propertyArg.key) && (this.isLightKind(kindArg, undefined) || this.isSwitchKind(kindArg, undefined)); + } + + private static shouldSkipEntityProperty(propertyArg: ITplinkFeature & { key: string }, kindArg: TTplinkDeviceKind): boolean { + if (primaryControlKeys.has(propertyArg.key) && (this.isLightKind(kindArg, undefined) || this.isSwitchKind(kindArg, undefined))) { + return true; + } + if (propertyArg.key === 'current_consumption') { + return false; + } + if (!propertyArg.unit && propertyArg.writable !== true && this.platformForProperty(propertyArg, kindArg) === 'sensor' && !sensorUnits[propertyArg.key]) { + return !['rssi', 'signal_level', 'ssid', 'battery_level', 'temperature', 'humidity'].includes(propertyArg.key); + } + return false; + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record, availableArg: boolean, explicitEntityIdArg?: string): IIntegrationEntity { + return { + id: explicitEntityIdArg || this.entityId(platformArg, nameArg, usedIdsArg), + uniqueId: uniqueIdArg, + integrationDomain: 'tplink', + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: attributesArg, + available: availableArg, + }; + } + + private static findTargetEntity(snapshotArg: ITplinkSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain) + || entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && Boolean(entityArg.attributes?.writable)) + || entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId); + } + return entities.find((entityArg) => entityArg.platform === requestArg.domain) + || entities.find((entityArg) => Boolean(entityArg.attributes?.writable)); + } + + private static findTargetDevice(snapshotArg: ITplinkSnapshot, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): ITplinkDevice | undefined { + const deviceId = requestArg.target.deviceId || entityArg?.deviceId; + const devices = this.allDevices(snapshotArg); + if (deviceId) { + return devices.find((deviceArg) => this.deviceId(deviceArg) === deviceId || this.rawDeviceKey(deviceArg) === deviceId); + } + return devices[0]; + } + + private static entityDeviceId(snapshotArg: ITplinkSnapshot, entityArg: ITplinkEntityDescriptor): string { + if (entityArg.deviceId || entityArg.device_id) { + return entityArg.deviceId || entityArg.device_id as string; + } + const device = this.allDevices(snapshotArg)[0]; + return device ? this.deviceId(device) : 'tplink.device.unknown'; + } + + private static deviceKind(deviceArg?: ITplinkDevice): TTplinkDeviceKind { + if (!deviceArg) { + return 'unknown'; + } + const explicit = this.stringValue(deviceArg.kind || deviceArg.type || deviceArg.deviceType || deviceArg.device_type)?.toLowerCase().replace(/\s+/g, '_'); + if (explicit) { + if (explicit.includes('lightstrip') || explicit.includes('light_strip')) { + return 'light_strip'; + } + if (explicit.includes('wallswitch') || explicit.includes('switch')) { + return 'switch'; + } + if (explicit.includes('bulb')) { + return 'bulb'; + } + if (explicit.includes('plug') || explicit.includes('outlet')) { + return 'plug'; + } + return explicit; + } + const text = [deviceArg.model, deviceArg.alias, deviceArg.name].filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase(); + if (/\b(kl|lb|l5|l6)\d/i.test(deviceArg.model || '') || text.includes('bulb') || text.includes('lamp')) { + return 'bulb'; + } + if (/\b(kl4|l9)\d/i.test(deviceArg.model || '') || text.includes('light strip') || text.includes('lightstrip')) { + return 'light_strip'; + } + if (/\b(hs3|kp3|ep4|p3|p2|tp25)/i.test(deviceArg.model || '') || text.includes('strip')) { + return 'strip'; + } + if (text.includes('switch') || text.includes('dimmer') || /\b(ks|hs2|s5|ts15)/i.test(deviceArg.model || '')) { + return text.includes('dimmer') ? 'dimmer' : 'switch'; + } + if (text.includes('sensor') || text.includes('motion') || text.includes('door') || text.includes('water') || /^t(100|110|300|310|315)/i.test(deviceArg.model || '')) { + return 'sensor'; + } + if (text.includes('plug') || text.includes('socket') || /\b(hs1|kp1|ep|p1|tp1)/i.test(deviceArg.model || '')) { + return 'plug'; + } + return this.controlState(deviceArg).on !== undefined ? 'switch' : 'sensor'; + } + + private static isLightKind(kindArg: TTplinkDeviceKind, deviceArg?: ITplinkDevice): boolean { + const kind = String(kindArg).toLowerCase(); + return kind === 'bulb' || kind === 'light_strip' || kind === 'dimmer' || kind === 'light' || this.hasModule(deviceArg, 'light'); + } + + private static isSwitchKind(kindArg: TTplinkDeviceKind, deviceArg?: ITplinkDevice): boolean { + const kind = String(kindArg).toLowerCase(); + return kind === 'plug' || kind === 'strip' || kind === 'switch' || kind === 'outlet' || kind === 'wall_switch'; + } + + private static hasModule(deviceArg: ITplinkDevice | undefined, nameArg: string): boolean { + return Boolean(deviceArg?.modules && Object.keys(deviceArg.modules).some((keyArg) => keyArg.toLowerCase() === nameArg.toLowerCase())); + } + + private static hasFeature(deviceArg: ITplinkDevice, keyArg: string): boolean { + return this.featureList(deviceArg).some((featureArg) => featureArg.key === keyArg || featureArg.id === keyArg); + } + + private static hasState(deviceArg: ITplinkDevice): boolean { + return Boolean(Object.keys(this.normalizedState(deviceArg)).length || this.featureList(deviceArg).length || deviceArg.children?.length); + } + + private static uniqueDevices(devicesArg: ITplinkDevice[]): ITplinkDevice[] { + const devices = new Map(); + for (const device of devicesArg) { + devices.set(this.rawDeviceKey(device), this.mergeDefined(devices.get(this.rawDeviceKey(device)) || {}, device)); + } + return [...devices.values()]; + } + + private static mergeDefined(baseArg: ITplinkDevice, nextArg: ITplinkDevice): ITplinkDevice { + const merged: ITplinkDevice = { ...baseArg }; + for (const [key, value] of Object.entries(nextArg)) { + if (value !== undefined) { + merged[key] = value; + } + } + return merged; + } + + private static uniqueFeatures(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[]): plugins.shxInterfaces.data.IDeviceFeature[] { + const features = new Map(); + for (const feature of featuresArg) { + features.set(feature.id, { ...features.get(feature.id), ...feature }); + } + return [...features.values()]; + } + + private static rawDeviceKey(deviceArg: ITplinkDevice): string { + return this.mac(deviceArg) || deviceArg.deviceId || deviceArg.device_id || deviceArg.id || deviceArg.host || this.deviceName(deviceArg); + } + + private static deviceId(deviceArg: ITplinkDevice): string { + return `tplink.device.${this.slug(this.rawDeviceKey(deviceArg))}`; + } + + private static uniqueId(platformArg: string, deviceArg: ITplinkDevice): string { + return `tplink_${platformArg}_${this.slug(this.rawDeviceKey(deviceArg))}`; + } + + private static deviceName(deviceArg: ITplinkDevice): string { + return deviceArg.alias || deviceArg.name || deviceArg.model || (this.mac(deviceArg) ? `TP-Link ${this.shortMac(this.mac(deviceArg))}` : 'TP-Link device'); + } + + private static mac(deviceArg: ITplinkDevice): string | undefined { + return this.normalizeMac(deviceArg.macAddress || deviceArg.mac || this.stringValue(deviceArg.sysInfo?.mac) || this.stringValue(deviceArg.systemInfo?.mac)); + } + + private static baseAttributes(deviceArg: ITplinkDevice): Record { + return { + tplinkDeviceId: this.deviceId(deviceArg), + tplinkRawDeviceId: deviceArg.deviceId || deviceArg.device_id || deviceArg.id, + tplinkHost: deviceArg.host, + tplinkPort: deviceArg.port || tplinkDefaultHttpPort, + tplinkMac: this.mac(deviceArg), + model: deviceArg.model, + liveLocalWritesImplemented: false, + }; + } + + private static entityId(platformArg: TEntityPlatform, nameArg: string, usedIdsArg: Map): string { + const base = `${platformArg}.${this.slug(nameArg)}`; + const count = usedIdsArg.get(base) || 0; + usedIdsArg.set(base, count + 1); + return count ? `${base}_${count + 1}` : base; + } + + private static corePlatform(platformArg: TEntityPlatform | string): TEntityPlatform { + const platform = platformArg.toLowerCase(); + const allowed: TEntityPlatform[] = ['light', 'switch', 'sensor', 'binary_sensor', 'button', 'climate', 'cover', 'fan', 'media_player', 'number', 'select', 'text', 'update']; + return allowed.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor'; + } + + private static entityState(valueArg: unknown, platformArg: TEntityPlatform): unknown { + if (platformArg === 'light' || platformArg === 'switch' || platformArg === 'fan' || platformArg === 'binary_sensor') { + const value = this.boolish(valueArg); + return value === undefined ? valueArg ?? 'unknown' : value ? 'on' : 'off'; + } + return valueArg ?? 'unknown'; + } + + private static pushDeviceState(stateArg: plugins.shxInterfaces.data.IDeviceState[], featureIdArg: string, valueArg: unknown, updatedAtArg: string): void { + if (valueArg === undefined) { + return; + } + stateArg.push({ featureId: this.slug(featureIdArg), value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg }); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + if (Array.isArray(valueArg)) { + return { values: valueArg }; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static isWritableStateKey(keyArg: string): boolean { + return primaryControlKeys.has(keyArg) || switchFeatureKeys.has(keyArg) || numberControlKeys.has(keyArg); + } + + private static percentageFromData(dataArg: Record | undefined, serviceArg: string): number | undefined { + const pct = this.numberFromData(dataArg, ['percentage', 'brightness_pct']); + if (pct !== undefined) { + return this.clamp(Math.round(pct), 0, 100); + } + const brightness = this.numberFromData(dataArg, serviceArg === 'set_brightness' ? ['brightness', 'value'] : ['brightness']); + return brightness === undefined ? undefined : this.clamp(Math.round(brightness / 255 * 100), 0, 100); + } + + private static kelvinFromData(dataArg: Record | undefined): number | undefined { + const direct = this.numberFromData(dataArg, ['color_temp_kelvin', 'kelvin', 'color_temperature']); + if (direct !== undefined) { + return Math.round(direct); + } + const mired = this.numberFromData(dataArg, ['color_temp', 'color_temp_mired']); + return mired && mired > 0 ? Math.round(1000000 / mired) : undefined; + } + + private static rgbFromData(dataArg: Record | undefined, keyArg: string): number[] | undefined { + const value = dataArg?.[keyArg]; + if (!Array.isArray(value) || value.length < 3) { + return undefined; + } + const numbers = value.slice(0, 3).map((valueArg) => typeof valueArg === 'number' && Number.isFinite(valueArg) ? this.clamp(Math.round(valueArg), 0, 255) : undefined); + return numbers.every((valueArg) => valueArg !== undefined) ? numbers as number[] : undefined; + } + + private static rgbValue(valueArg: unknown): { r: number; g: number; b: number } | undefined { + if (Array.isArray(valueArg) && valueArg.length >= 3) { + const [r, g, b] = valueArg; + return typeof r === 'number' && typeof g === 'number' && typeof b === 'number' ? { r, g, b } : undefined; + } + if (this.isRecord(valueArg)) { + const r = this.numberValue(valueArg.r ?? valueArg.red); + const g = this.numberValue(valueArg.g ?? valueArg.green); + const b = this.numberValue(valueArg.b ?? valueArg.blue); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; + } + return undefined; + } + + private static boolish(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg > 0; + } + if (typeof valueArg === 'string') { + const value = valueArg.toLowerCase(); + if (['on', 'true', '1', 'yes', 'open'].includes(value)) { + return true; + } + if (['off', 'false', '0', 'no', 'closed'].includes(value)) { + return false; + } + } + return undefined; + } + + private static valueFromData(dataArg: Record | undefined, keysArg: string[]): unknown { + for (const key of keysArg) { + if (dataArg && key in dataArg) { + return dataArg[key]; + } + } + return undefined; + } + + private static numberFromData(dataArg: Record | undefined, keysArg: string[]): number | undefined { + return this.numberValue(this.valueFromData(dataArg, keysArg)); + } + + private static numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static asRecord(valueArg: unknown): Record { + return this.isRecord(valueArg) ? valueArg : {}; + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static normalizeMac(valueArg?: string): string | undefined { + const compact = (valueArg || '').replace(/[^0-9a-f]/gi, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : undefined; + } + + private static shortMac(valueArg?: string): string { + return (valueArg || '').replace(/[^0-9a-f]/gi, '').slice(-6).toUpperCase(); + } + + private static title(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'tplink'; + } + + private static clamp(valueArg: number, minArg: number, maxArg: number): number { + return Math.max(minArg, Math.min(maxArg, valueArg)); + } +} diff --git a/ts/integrations/tplink/tplink.types.ts b/ts/integrations/tplink/tplink.types.ts index 6347cf6..266800a 100644 --- a/ts/integrations/tplink/tplink.types.ts +++ b/ts/integrations/tplink/tplink.types.ts @@ -1,4 +1,300 @@ -export interface IHomeAssistantTplinkConfig { - // TODO: replace with the TypeScript-native config for tplink. +import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js'; + +export const tplinkDefaultHttpPort = 80; +export const tplinkLegacyDiscoveryPort = 9999; +export const tplinkSmartDiscoveryPort = 20002; + +export type TTplinkBrand = 'kasa' | 'tapo' | 'tplink' | string; +export type TTplinkProtocolFamily = 'iot' | 'smart' | 'tapo' | 'kasa' | 'snapshot' | 'manual' | string; +export type TTplinkDeviceKind = + | 'plug' + | 'strip' + | 'switch' + | 'dimmer' + | 'bulb' + | 'light_strip' + | 'sensor' + | 'hub' + | 'camera' + | 'fan' + | 'thermostat' + | 'vacuum' + | 'unknown' + | string; +export type TTplinkFeatureType = + | 'switch' + | 'sensor' + | 'binary_sensor' + | 'number' + | 'choice' + | 'action' + | 'light' + | 'fan' + | 'climate' + | string; + +export interface ITplinkCredentials { + username?: string; + password?: string; + credentialsHash?: string; + aesKeys?: Record; [key: string]: unknown; } + +export interface ITplinkConnectionParameters { + deviceFamily?: string; + encryptionType?: string; + loginVersion?: number; + usesHttp?: boolean; + httpPort?: number; + [key: string]: unknown; +} + +export type ITplinkStateRecord = Record; + +export interface ITplinkFeature { + id?: string; + key?: string; + name?: string; + type?: TTplinkFeatureType; + category?: 'primary' | 'config' | 'info' | 'debug' | string; + value?: unknown; + unit?: string; + readable?: boolean; + writable?: boolean; + minimumValue?: number; + maximumValue?: number; + min?: number; + max?: number; + precisionHint?: number; + choices?: string[]; + platform?: TEntityPlatform | string; + deviceClass?: string; + metadata?: Record; + [key: string]: unknown; +} + +export interface ITplinkModuleState { + name?: string; + features?: Record | ITplinkFeature[]; + state?: ITplinkStateRecord; + [key: string]: unknown; +} + +export interface ITplinkEntityDescriptor { + id?: string; + entityId?: string; + uniqueId?: string; + unique_id?: string; + deviceId?: string; + device_id?: string; + platform?: TEntityPlatform | string; + key?: string; + name?: string; + state?: unknown; + value?: unknown; + attributes?: Record; + available?: boolean; + writable?: boolean; + unit?: string; + deviceClass?: string; + device_class?: string; + [key: string]: unknown; +} + +export interface ITplinkDevice { + id?: string; + deviceId?: string; + device_id?: string; + parentId?: string; + host?: string; + port?: number; + mac?: string; + macAddress?: string; + alias?: string; + name?: string; + model?: string; + manufacturer?: string; + brand?: TTplinkBrand; + hwVersion?: string; + swVersion?: string; + firmwareVersion?: string; + hardwareVersion?: string; + serialNumber?: string; + type?: TTplinkDeviceKind; + kind?: TTplinkDeviceKind; + deviceType?: TTplinkDeviceKind; + device_type?: TTplinkDeviceKind; + children?: ITplinkChildDevice[]; + features?: Record | ITplinkFeature[]; + modules?: Record; + state?: ITplinkStateRecord; + sysInfo?: ITplinkStateRecord; + systemInfo?: ITplinkStateRecord; + lightState?: ITplinkStateRecord; + emeter?: ITplinkStateRecord; + sensors?: ITplinkFeature[]; + entities?: ITplinkEntityDescriptor[]; + available?: boolean; + online?: boolean; + updatedAt?: string; + metadata?: Record; + [key: string]: unknown; +} + +export interface ITplinkChildDevice extends ITplinkDevice {} + +export interface ITplinkTransportInfo { + protocol: TTplinkProtocolFamily; + host?: string; + port?: number; + credentialsConfigured?: boolean; + connectionParameters?: ITplinkConnectionParameters; + legacyXorImplemented: boolean; + encryptedLocalProtocolImplemented: boolean; + [key: string]: unknown; +} + +export interface ITplinkSnapshot { + connected: boolean; + configured?: boolean; + host?: string; + port?: number; + alias?: string; + model?: string; + macAddress?: string; + devices: ITplinkDevice[]; + entities: ITplinkEntityDescriptor[]; + events: ITplinkEvent[]; + transport?: ITplinkTransportInfo; + metadata?: Record; +} + +export interface ITplinkManualEntry { + host?: string; + port?: number; + id?: string; + deviceId?: string; + macAddress?: string; + mac?: string; + alias?: string; + name?: string; + model?: string; + manufacturer?: string; + brand?: TTplinkBrand; + deviceType?: TTplinkDeviceKind; + state?: ITplinkStateRecord; + features?: Record | ITplinkFeature[]; + device?: ITplinkDevice; + devices?: ITplinkDevice[]; + snapshot?: ITplinkSnapshot; + credentials?: ITplinkCredentials; + metadata?: Record; + integrationDomain?: string; + [key: string]: unknown; +} + +export interface ITplinkConfig { + host?: string; + port?: number; + alias?: string; + name?: string; + model?: string; + macAddress?: string; + deviceId?: string; + deviceType?: TTplinkDeviceKind; + brand?: TTplinkBrand; + username?: string; + password?: string; + credentials?: ITplinkCredentials; + credentialsHash?: string; + aesKeys?: Record; + connectionParameters?: ITplinkConnectionParameters; + usesHttp?: boolean; + snapshot?: ITplinkSnapshot; + device?: ITplinkDevice; + devices?: ITplinkDevice[]; + manualEntries?: ITplinkManualEntry[]; + state?: ITplinkStateRecord; + features?: Record | ITplinkFeature[]; + modules?: Record; + children?: ITplinkChildDevice[]; + entities?: ITplinkEntityDescriptor[]; + events?: ITplinkEvent[]; + timeoutMs?: number; + commandExecutor?: TTplinkCommandExecutor; + metadata?: Record; + [key: string]: unknown; +} + +export interface IHomeAssistantTplinkConfig extends ITplinkConfig {} + +export interface ITplinkEvent { + type: string; + timestamp?: number; + deviceId?: string; + entityId?: string; + uniqueId?: string; + command?: ITplinkClientCommand; + data?: unknown; + [key: string]: unknown; +} + +export interface ITplinkClientCommand { + type: string; + service: string; + method?: string; + platform?: TEntityPlatform | string; + protocol?: TTplinkProtocolFamily; + deviceId?: string; + entityId?: string; + uniqueId?: string; + featureId?: string; + value?: unknown; + target?: { + entityId?: string; + deviceId?: string; + }; + payload: Record; +} + +export interface ITplinkCommandResult extends IServiceCallResult {} + +export type TTplinkCommandExecutor = ( + commandArg: ITplinkClientCommand +) => Promise | ITplinkCommandResult | unknown; + +export interface ITplinkMdnsRecord { + type?: string; + serviceType?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; + macAddress?: string; + model?: string; + manufacturer?: string; + [key: string]: unknown; +} + +export interface ITplinkDhcpRecord { + host?: string; + ip?: string; + ipAddress?: string; + address?: string; + hostname?: string; + hostName?: string; + macAddress?: string; + mac?: string; + manufacturer?: string; + model?: string; + vendorClassIdentifier?: string; + integrationDomain?: string; + metadata?: Record; + [key: string]: unknown; +} + +export interface ITplinkManualDiscoveryRecord extends ITplinkManualEntry {} diff --git a/ts/integrations/unifi/.generated-by-smarthome-exchange b/ts/integrations/unifi/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/unifi/.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/unifi/index.ts b/ts/integrations/unifi/index.ts index bbb6e82..f90dae9 100644 --- a/ts/integrations/unifi/index.ts +++ b/ts/integrations/unifi/index.ts @@ -1,2 +1,6 @@ +export * from './unifi.classes.client.js'; +export * from './unifi.classes.configflow.js'; export * from './unifi.classes.integration.js'; +export * from './unifi.discovery.js'; +export * from './unifi.mapper.js'; export * from './unifi.types.js'; diff --git a/ts/integrations/unifi/unifi.classes.client.ts b/ts/integrations/unifi/unifi.classes.client.ts new file mode 100644 index 0000000..ee940ee --- /dev/null +++ b/ts/integrations/unifi/unifi.classes.client.ts @@ -0,0 +1,225 @@ +import { UnifiMapper } from './unifi.mapper.js'; +import type { IUnifiCommand, IUnifiCommandResult, IUnifiConfig, IUnifiEvent, IUnifiSnapshot } from './unifi.types.js'; +import { unifiDefaultPort, unifiDefaultSite } from './unifi.types.js'; + +type TUnifiApiResponse = { + meta?: { + rc?: string; + msg?: string; + }; + data?: TData; +}; + +export class UnifiClient { + private snapshot?: IUnifiSnapshot; + private isUnifiOs?: boolean; + private cookie?: string; + private csrfToken?: string; + private eventHandlers = new Set<(eventArg: IUnifiEvent) => void>(); + + constructor(private readonly config: IUnifiConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot || this.config.manualEntries || this.config.clients || this.config.devices || this.config.wlans || this.config.ports) { + this.snapshot = UnifiMapper.toSnapshot(this.config, this.config.snapshot?.connected ?? true); + return this.snapshot; + } + + if (this.canUseHttp()) { + this.snapshot = await this.fetchSnapshot(); + return this.snapshot; + } + + this.snapshot = UnifiMapper.toSnapshot(this.config, false); + return this.snapshot; + } + + public onEvent(handlerArg: (eventArg: IUnifiEvent) => void): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IUnifiCommand): Promise { + if (this.config.snapshot || !this.canUseHttp()) { + const snapshot = this.snapshot || await this.getSnapshot(); + this.applyCommandToSnapshot(snapshot, commandArg); + this.emit({ type: 'state_changed', data: commandArg, timestamp: Date.now() }); + return { success: true, data: commandArg }; + } + + return { + success: false, + error: 'UniFi live write commands are not enabled in this TypeScript port because full controller login/session and MFA handling is incomplete.', + }; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private async fetchSnapshot(): Promise { + await this.login(); + const site = this.config.site || unifiDefaultSite; + const [sites, clients, allClients, devices, wlans, systemInfo] = await Promise.all([ + this.requestData[]>('/self/sites', { global: true }).catch(() => []), + this.requestData[]>('/stat/sta').catch(() => []), + this.requestData[]>('/rest/user').catch(() => []), + this.requestData[]>('/stat/device').catch(() => []), + this.requestData[]>('/rest/wlanconf').catch(() => []), + this.requestData[]>('/stat/sysinfo').catch(() => []), + ]); + const activeMacs = new Set(clients.map((clientArg) => String(clientArg.mac || '').toLowerCase()).filter(Boolean)); + const mergedClients = [ + ...clients, + ...allClients.filter((clientArg) => !activeMacs.has(String(clientArg.mac || '').toLowerCase())), + ]; + + return UnifiMapper.toSnapshot({ + ...this.config, + site, + controller: { + id: String(systemInfo[0]?.anonymous_controller_id || this.config.host || 'unifi'), + name: String(systemInfo[0]?.name || 'UniFi Network'), + host: this.config.host, + port: this.config.port || unifiDefaultPort, + site, + version: typeof systemInfo[0]?.version === 'string' ? systemInfo[0].version : undefined, + deviceType: typeof systemInfo[0]?.ubnt_device_type === 'string' ? systemInfo[0].ubnt_device_type : undefined, + isUnifiOs: this.isUnifiOs, + connected: true, + }, + sites: sites.map((siteArg) => ({ + id: this.stringValue(siteArg._id), + name: this.stringValue(siteArg.name), + description: this.stringValue(siteArg.desc), + role: this.stringValue(siteArg.role), + })), + clients: mergedClients.map((clientArg) => ({ ...clientArg, mac: String(clientArg.mac || '') })), + devices: devices.map((deviceArg) => ({ ...deviceArg, mac: String(deviceArg.mac || '') })), + wlans: wlans.map((wlanArg) => ({ ...wlanArg, name: String(wlanArg.name || wlanArg._id || 'WLAN') })), + }, true); + } + + private async login(): Promise { + if (!this.config.host || !this.config.username || !this.config.password) { + throw new Error('UniFi host, username, and password are required for controller API access.'); + } + + if (this.isUnifiOs === undefined) { + this.isUnifiOs = await this.detectUnifiOs(); + } + + const path = this.isUnifiOs ? '/api/auth/login' : '/api/login'; + const response = await this.fetchPath(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + username: this.config.username, + password: this.config.password, + rememberMe: true, + }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`UniFi login failed with HTTP ${response.status}.`); + } + const parsed = text ? JSON.parse(text) as TUnifiApiResponse : {}; + if (parsed.meta?.rc === 'error') { + throw new Error(`UniFi login failed: ${parsed.meta.msg || 'authentication error'}`); + } + this.cookie = response.headers.get('set-cookie') || this.cookie; + this.csrfToken = response.headers.get('x-csrf-token') || this.csrfToken; + } + + private async detectUnifiOs(): Promise { + const response = await this.fetchPath('', { method: 'GET', redirect: 'manual' }).catch(() => undefined); + return response?.status === 200; + } + + private async requestData(pathArg: string, optionsArg: { global?: boolean } = {}): Promise { + const site = this.config.site || unifiDefaultSite; + const prefix = optionsArg.global + ? this.isUnifiOs ? '/proxy/network/api' : '/api' + : this.isUnifiOs ? `/proxy/network/api/s/${encodeURIComponent(site)}` : `/api/s/${encodeURIComponent(site)}`; + const response = await this.fetchPath(`${prefix}${pathArg}`, { + method: 'GET', + headers: this.authHeaders(), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`UniFi request ${pathArg} failed with HTTP ${response.status}: ${text}`); + } + const parsed = text ? JSON.parse(text) as TUnifiApiResponse : {}; + if (parsed.meta?.rc === 'error') { + throw new Error(`UniFi request ${pathArg} failed: ${parsed.meta.msg || 'controller error'}`); + } + return (parsed.data || []) as TData; + } + + private fetchPath(pathArg: string, initArg: RequestInit): Promise { + return globalThis.fetch(`${this.baseUrl()}${pathArg}`, initArg); + } + + private authHeaders(): Record { + const headers: Record = {}; + if (this.cookie) { + headers.cookie = this.cookie; + } + if (this.csrfToken) { + headers['x-csrf-token'] = this.csrfToken; + } + return headers; + } + + private baseUrl(): string { + const protocol = this.config.protocol || 'https'; + const port = this.config.port ?? unifiDefaultPort; + const defaultPort = protocol === 'https' ? 443 : 80; + return `${protocol}://${this.config.host}${port === defaultPort ? '' : `:${port}`}`; + } + + private canUseHttp(): boolean { + return Boolean(this.config.host && this.config.username && this.config.password); + } + + private applyCommandToSnapshot(snapshotArg: IUnifiSnapshot, commandArg: IUnifiCommand): void { + if (commandArg.type === 'blockClient' && commandArg.mac) { + const client = snapshotArg.clients.find((clientArg) => UnifiMapper.normalizeMac(clientArg.mac) === UnifiMapper.normalizeMac(commandArg.mac)); + if (client && typeof commandArg.block === 'boolean') { + client.blocked = commandArg.block; + } + } + if (commandArg.type === 'setWlanEnabled' && commandArg.wlanId) { + const wlan = snapshotArg.wlans.find((wlanArg) => wlanArg.id === commandArg.wlanId || wlanArg._id === commandArg.wlanId || wlanArg.name === commandArg.wlanId); + if (wlan && typeof commandArg.enabled === 'boolean') { + wlan.enabled = commandArg.enabled; + } + } + if (commandArg.type === 'setPoePortEnabled' && commandArg.deviceMac && commandArg.portIdx !== undefined) { + for (const port of snapshotArg.ports) { + if (UnifiMapper.normalizeMac(port.deviceMac || port.device_mac) === UnifiMapper.normalizeMac(commandArg.deviceMac) && String(port.portIdx ?? port.port_idx ?? port.ifname) === String(commandArg.portIdx)) { + port.poeMode = commandArg.enabled ? 'auto' : 'off'; + port.poe_mode = port.poeMode; + } + } + for (const device of snapshotArg.devices) { + for (const port of device.portTable || device.port_table || []) { + if (UnifiMapper.normalizeMac(device.mac) === UnifiMapper.normalizeMac(commandArg.deviceMac) && String(port.portIdx ?? port.port_idx ?? port.ifname) === String(commandArg.portIdx)) { + port.poeMode = commandArg.enabled ? 'auto' : 'off'; + port.poe_mode = port.poeMode; + } + } + } + } + } + + private emit(eventArg: IUnifiEvent): void { + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg ? valueArg : undefined; + } +} diff --git a/ts/integrations/unifi/unifi.classes.configflow.ts b/ts/integrations/unifi/unifi.classes.configflow.ts new file mode 100644 index 0000000..0a6637e --- /dev/null +++ b/ts/integrations/unifi/unifi.classes.configflow.ts @@ -0,0 +1,40 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IUnifiConfig } from './unifi.types.js'; +import { unifiDefaultPort, unifiDefaultSite } from './unifi.types.js'; + +export class UnifiConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect UniFi Network', + description: 'Provide a local UniFi Network controller host and a local controller account. Credentials are only used for setup/runtime and are never added to discovery records.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number', required: true }, + { name: 'site', label: 'Site', type: 'text', required: true }, + { name: 'username', label: 'Username', type: 'text', required: true }, + { name: 'password', label: 'Password', type: 'password', required: true }, + { name: 'verifySsl', label: 'Verify SSL certificate', type: 'boolean' }, + ], + submit: async (valuesArg) => ({ + kind: 'done', + title: 'UniFi Network configured', + config: { + host: String(valuesArg.host || candidateArg.host || ''), + port: Number(valuesArg.port || candidateArg.port || unifiDefaultPort), + site: String(valuesArg.site || candidateArg.metadata?.site || unifiDefaultSite), + username: String(valuesArg.username || ''), + password: String(valuesArg.password || ''), + verifySsl: valuesArg.verifySsl === true, + controller: { + id: candidateArg.id, + host: candidateArg.host, + port: candidateArg.port || unifiDefaultPort, + site: String(valuesArg.site || candidateArg.metadata?.site || unifiDefaultSite), + }, + }, + }), + }; + } +} diff --git a/ts/integrations/unifi/unifi.classes.integration.ts b/ts/integrations/unifi/unifi.classes.integration.ts index 8262262..f99879e 100644 --- a/ts/integrations/unifi/unifi.classes.integration.ts +++ b/ts/integrations/unifi/unifi.classes.integration.ts @@ -1,29 +1,73 @@ -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 { UnifiClient } from './unifi.classes.client.js'; +import { UnifiConfigFlow } from './unifi.classes.configflow.js'; +import { createUnifiDiscoveryDescriptor } from './unifi.discovery.js'; +import { UnifiMapper } from './unifi.mapper.js'; +import type { IUnifiConfig } from './unifi.types.js'; -export class HomeAssistantUnifiIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "unifi", - displayName: "UniFi Network", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/unifi", - "upstreamDomain": "unifi", - "integrationType": "hub", - "iotClass": "local_push", - "qualityScale": "silver", - "requirements": [ - "aiounifi==90" - ], - "dependencies": [ - "unifi_discovery" - ], - "afterDependencies": [], - "codeowners": [ - "@Kane610" - ] -}, - }); +export class UnifiIntegration extends BaseIntegration { + public readonly domain = 'unifi'; + public readonly displayName = 'UniFi Network'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createUnifiDiscoveryDescriptor(); + public readonly configFlow = new UnifiConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/unifi', + upstreamDomain: 'unifi', + integrationType: 'hub', + iotClass: 'local_push', + qualityScale: 'silver', + requirements: ['aiounifi==90'], + dependencies: ['unifi_discovery'], + afterDependencies: [] as string[], + codeowners: ['@Kane610'], + documentation: 'https://www.home-assistant.io/integrations/unifi', + protocolSource: 'aiounifi local controller API: /api/login, /api/s/{site}/stat/sta, /rest/user, /stat/device, /rest/wlanconf', + }; + + public async setup(configArg: IUnifiConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new UnifiRuntime(new UnifiClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantUnifiIntegration extends UnifiIntegration {} + +class UnifiRuntime implements IIntegrationRuntime { + public domain = 'unifi'; + + constructor(private readonly client: UnifiClient) {} + + public async devices(): Promise { + return UnifiMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return UnifiMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(UnifiMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = UnifiMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `UniFi service ${requestArg.domain}.${requestArg.service} has no native mapping for the target.` }; + } + 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/unifi/unifi.discovery.ts b/ts/integrations/unifi/unifi.discovery.ts new file mode 100644 index 0000000..0d8cd69 --- /dev/null +++ b/ts/integrations/unifi/unifi.discovery.ts @@ -0,0 +1,207 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { UnifiMapper } from './unifi.mapper.js'; +import type { IUnifiDiscoveryDeviceRecord, IUnifiManualDiscoveryEntry, IUnifiMdnsRecord, IUnifiSsdpRecord } from './unifi.types.js'; +import { unifiDefaultPort, unifiDefaultSite } from './unifi.types.js'; + +const unifiModels = ['unifi', 'dream machine', 'cloud key', 'udm', 'uck', 'ucg', 'uxg']; + +export class UnifiMdnsMatcher implements IDiscoveryMatcher { + public id = 'unifi-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize UniFi Network controller mDNS advertisements.'; + + public async matches(recordArg: IUnifiMdnsRecord): Promise { + const type = recordArg.type?.toLowerCase() || ''; + const name = recordArg.name?.toLowerCase() || ''; + const model = (recordArg.txt?.model || recordArg.txt?.modelid || '').toLowerCase(); + const matched = type === '_unifi._tcp.local.' || type === '_ubnt._tcp.local.' || name.includes('unifi') || unifiModels.some((modelArg) => model.includes(modelArg)); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a UniFi controller advertisement.' }; + } + const mac = UnifiMapper.normalizeMac(recordArg.txt?.mac || recordArg.txt?.hw_addr); + const id = recordArg.txt?.controller_uuid || recordArg.txt?.uuid || mac || recordArg.name; + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'mDNS record contains UniFi controller metadata.', + normalizedDeviceId: id, + candidate: { + source: 'mdns', + integrationDomain: 'unifi', + id, + host: recordArg.host, + port: recordArg.port || unifiDefaultPort, + name: recordArg.txt?.name || recordArg.name, + manufacturer: 'Ubiquiti Networks', + model: recordArg.txt?.model || recordArg.txt?.modelid || 'UniFi Network', + macAddress: mac, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: recordArg.txt, + }, + }, + }; + } +} + +export class UnifiSsdpMatcher implements IDiscoveryMatcher { + public id = 'unifi-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize UniFi OS consoles from SSDP Ubiquiti metadata.'; + + public async matches(recordArg: IUnifiSsdpRecord): Promise { + const headers = this.lowerHeaders(recordArg.headers || {}); + const manufacturer = (recordArg.manufacturer || headers.manufacturer || '').toLowerCase(); + const modelDescription = (recordArg.modelDescription || headers.modeldescription || headers.model_description || '').toLowerCase(); + const modelName = (recordArg.modelName || headers.modelname || '').toLowerCase(); + const matched = manufacturer.includes('ubiquiti') && unifiModels.some((modelArg) => `${modelDescription} ${modelName}`.includes(modelArg)); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'SSDP record is not a UniFi OS console.' }; + } + return { + matched: true, + confidence: recordArg.usn || recordArg.udn ? 'certain' : 'high', + reason: 'SSDP record matches UniFi OS console metadata.', + normalizedDeviceId: recordArg.usn || recordArg.udn, + candidate: { + source: 'ssdp', + integrationDomain: 'unifi', + id: recordArg.usn || recordArg.udn, + host: recordArg.host || this.hostFromLocation(recordArg.location), + port: recordArg.port || this.portFromLocation(recordArg.location) || unifiDefaultPort, + manufacturer: 'Ubiquiti Networks', + model: recordArg.modelDescription || recordArg.modelName || 'UniFi Network', + metadata: { + location: recordArg.location, + server: recordArg.server, + st: recordArg.st, + nt: recordArg.nt, + }, + }, + }; + } + + private lowerHeaders(headersArg: Record): Record { + return Object.fromEntries(Object.entries(headersArg).map(([keyArg, valueArg]) => [keyArg.toLowerCase(), valueArg])); + } + + private hostFromLocation(locationArg?: string): string | undefined { + if (!locationArg) return undefined; + try { + return new URL(locationArg).hostname; + } catch { + return undefined; + } + } + + private portFromLocation(locationArg?: string): number | undefined { + if (!locationArg) return undefined; + try { + const url = new URL(locationArg); + return url.port ? Number(url.port) : undefined; + } catch { + return undefined; + } + } +} + +export class UnifiManualMatcher implements IDiscoveryMatcher { + public id = 'unifi-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual UniFi Network controller setup entries.'; + + public async matches(inputArg: IUnifiManualDiscoveryEntry): Promise { + const manufacturer = inputArg.manufacturer?.toLowerCase() || ''; + const model = inputArg.model?.toLowerCase() || ''; + const name = inputArg.name?.toLowerCase() || ''; + const matched = Boolean(inputArg.host || manufacturer.includes('ubiquiti') || unifiModels.some((modelArg) => `${model} ${name}`.includes(modelArg)) || inputArg.services?.network || inputArg.metadata?.unifi); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain UniFi setup hints.' }; + } + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start UniFi Network setup.', + normalizedDeviceId: inputArg.id || UnifiMapper.normalizeMac(inputArg.macAddress), + candidate: { + source: 'manual', + integrationDomain: 'unifi', + id: inputArg.id, + host: inputArg.host, + port: inputArg.port || unifiDefaultPort, + name: inputArg.name, + manufacturer: 'Ubiquiti Networks', + model: inputArg.model || 'UniFi Network', + macAddress: UnifiMapper.normalizeMac(inputArg.macAddress), + metadata: { + ...inputArg.metadata, + site: inputArg.site || unifiDefaultSite, + }, + }, + }; + } +} + +export class UnifiDiscoveryDeviceMatcher implements IDiscoveryMatcher { + public id = 'unifi-discovery-device-match'; + public source = 'custom' as const; + public description = 'Recognize records returned by UniFi Discovery scans.'; + + public async matches(recordArg: IUnifiDiscoveryDeviceRecord): Promise { + if (!recordArg.services?.network && !recordArg.source_ip) { + return { matched: false, confidence: 'low', reason: 'Discovery record does not expose the UniFi Network service.' }; + } + const mac = UnifiMapper.normalizeMac(recordArg.hw_addr); + return { + matched: true, + confidence: recordArg.services?.network ? 'certain' : 'medium', + reason: 'UniFi Discovery record exposes the Network service.', + normalizedDeviceId: mac, + candidate: { + source: 'custom', + integrationDomain: 'unifi', + id: mac, + host: recordArg.direct_connect_domain || recordArg.source_ip, + port: unifiDefaultPort, + name: recordArg.name, + manufacturer: 'Ubiquiti Networks', + model: recordArg.model || 'UniFi Network', + macAddress: mac, + metadata: recordArg as Record, + }, + }; + } +} + +export class UnifiCandidateValidator implements IDiscoveryValidator { + public id = 'unifi-candidate-validator'; + public description = 'Validate UniFi Network candidates before setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const manufacturer = candidateArg.manufacturer?.toLowerCase() || ''; + const model = candidateArg.model?.toLowerCase() || ''; + const name = candidateArg.name?.toLowerCase() || ''; + const matched = candidateArg.integrationDomain === 'unifi' || manufacturer.includes('ubiquiti') && unifiModels.some((modelArg) => `${model} ${name}`.includes(modelArg)); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has UniFi Network metadata.' : 'Candidate is not UniFi Network.', + candidate: matched ? { ...candidateArg, port: candidateArg.port || unifiDefaultPort } : undefined, + normalizedDeviceId: candidateArg.id || candidateArg.macAddress, + }; + } +} + +export const createUnifiDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ + integrationDomain: 'unifi', + displayName: 'UniFi Network', + }) + .addMatcher(new UnifiMdnsMatcher()) + .addMatcher(new UnifiSsdpMatcher()) + .addMatcher(new UnifiManualMatcher()) + .addMatcher(new UnifiDiscoveryDeviceMatcher()) + .addValidator(new UnifiCandidateValidator()); +}; diff --git a/ts/integrations/unifi/unifi.mapper.ts b/ts/integrations/unifi/unifi.mapper.ts new file mode 100644 index 0000000..1911d9f --- /dev/null +++ b/ts/integrations/unifi/unifi.mapper.ts @@ -0,0 +1,704 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IUnifiClient, + IUnifiCommand, + IUnifiConfig, + IUnifiDevice, + IUnifiEvent, + IUnifiPort, + IUnifiSnapshot, + IUnifiWlan, +} from './unifi.types.js'; +import { unifiDefaultPort, unifiDefaultSite } from './unifi.types.js'; + +const manufacturer = 'Ubiquiti Networks'; +const connectedDeviceState = 1; + +export class UnifiMapper { + public static toSnapshot(configArg: IUnifiConfig, connectedArg?: boolean, eventsArg: IUnifiEvent[] = []): IUnifiSnapshot { + const source = configArg.snapshot; + const devices = [...(source?.devices || []), ...(configArg.devices || [])]; + const ports = [...(source?.ports || []), ...(configArg.ports || [])]; + + for (const entry of configArg.manualEntries || []) { + if (entry.snapshot) { + devices.push(...entry.snapshot.devices); + ports.push(...entry.snapshot.ports); + } else { + devices.push(...(entry.devices || [])); + ports.push(...(entry.ports || [])); + } + } + + const snapshot: IUnifiSnapshot = { + connected: connectedArg ?? source?.connected ?? Boolean(configArg.host && configArg.username), + host: configArg.host || source?.host, + port: configArg.port || source?.port || unifiDefaultPort, + site: configArg.site || source?.site || unifiDefaultSite, + controller: { + ...source?.controller, + ...configArg.controller, + host: configArg.host || configArg.controller?.host || source?.controller?.host || source?.host, + port: configArg.port || configArg.controller?.port || source?.controller?.port || source?.port || unifiDefaultPort, + site: configArg.site || configArg.controller?.site || source?.controller?.site || source?.site || unifiDefaultSite, + connected: connectedArg ?? source?.controller?.connected ?? source?.connected, + }, + sites: [ + ...(source?.sites || []), + ...(configArg.sites || []), + ...this.manualItems(configArg, 'sites'), + ], + clients: [ + ...(source?.clients || []), + ...(configArg.clients || []), + ...this.manualItems(configArg, 'clients'), + ], + devices, + wlans: [ + ...(source?.wlans || []), + ...(configArg.wlans || []), + ...this.manualItems(configArg, 'wlans'), + ], + ports, + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + }; + + snapshot.ports = this.withDerivedPorts(snapshot); + return snapshot; + } + + public static toDevices(snapshotArg: IUnifiSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = []; + + if (snapshotArg.controller || snapshotArg.host) { + const controller = snapshotArg.controller || {}; + const id = `unifi.controller.${this.slug(controller.id || controller.host || snapshotArg.host || snapshotArg.site || 'default')}`; + devices.push({ + id, + integrationDomain: 'unifi', + name: controller.name || 'UniFi Network', + protocol: 'http', + manufacturer, + model: controller.deviceType || 'UniFi Network Application', + online: snapshotArg.connected, + features: [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'version', capability: 'sensor', name: 'Version', readable: true, writable: false }, + ], + state: [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt }, + { featureId: 'version', value: controller.version || null, updatedAt }, + ], + metadata: { + host: controller.host || snapshotArg.host, + port: controller.port || snapshotArg.port, + site: controller.site || snapshotArg.site, + unifiOs: controller.isUnifiOs, + }, + }); + } + + for (const device of snapshotArg.devices) { + devices.push(this.infrastructureDevice(device, snapshotArg, updatedAt)); + } + + for (const client of snapshotArg.clients) { + devices.push(this.clientDevice(client, updatedAt)); + } + + for (const wlan of snapshotArg.wlans) { + devices.push(this.wlanDevice(wlan, snapshotArg, updatedAt)); + } + + return devices; + } + + public static toEntities(snapshotArg: IUnifiSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + + if (snapshotArg.controller || snapshotArg.host) { + const controller = snapshotArg.controller || {}; + const deviceId = `unifi.controller.${this.slug(controller.id || controller.host || snapshotArg.host || snapshotArg.site || 'default')}`; + entities.push(this.entity('binary_sensor', 'UniFi Network Connected', deviceId, 'unifi_controller_connected', snapshotArg.connected ? 'on' : 'off', usedIds, { + deviceClass: 'connectivity', + host: controller.host || snapshotArg.host, + port: controller.port || snapshotArg.port, + site: controller.site || snapshotArg.site, + }, true)); + if (controller.version) { + entities.push(this.entity('sensor', 'UniFi Network Version', deviceId, 'unifi_controller_version', controller.version, usedIds, undefined, snapshotArg.connected)); + } + } + + for (const client of snapshotArg.clients) { + this.pushClientEntities(entities, client, usedIds); + } + + for (const device of snapshotArg.devices) { + this.pushDeviceEntities(entities, device, snapshotArg, usedIds); + } + + for (const wlan of snapshotArg.wlans) { + this.pushWlanEntities(entities, wlan, snapshotArg, usedIds); + } + + for (const port of this.withDerivedPorts(snapshotArg)) { + this.pushPortEntities(entities, port, snapshotArg, usedIds); + } + + return entities; + } + + public static commandForService(snapshotArg: IUnifiSnapshot, requestArg: IServiceCallRequest): IUnifiCommand | undefined { + if (requestArg.domain === 'unifi') { + const mac = this.stringValue(requestArg.data?.mac) || this.macFromTarget(snapshotArg, requestArg); + if ((requestArg.service === 'block_client' || requestArg.service === 'unblock_client') && mac) { + return { + type: 'blockClient', + service: requestArg.service, + target: requestArg.target, + mac, + block: requestArg.service === 'block_client', + }; + } + if (requestArg.service === 'reconnect_client' && mac) { + return { + type: 'reconnectClient', + service: requestArg.service, + target: requestArg.target, + mac, + }; + } + if ((requestArg.service === 'enable_wlan' || requestArg.service === 'disable_wlan') && this.stringValue(requestArg.data?.wlanId)) { + return { + type: 'setWlanEnabled', + service: requestArg.service, + target: requestArg.target, + wlanId: this.stringValue(requestArg.data?.wlanId), + enabled: requestArg.service === 'enable_wlan', + }; + } + if ((requestArg.service === 'enable_poe' || requestArg.service === 'disable_poe') && this.stringValue(requestArg.data?.deviceMac) && requestArg.data?.portIdx !== undefined) { + return { + type: 'setPoePortEnabled', + service: requestArg.service, + target: requestArg.target, + deviceMac: this.stringValue(requestArg.data.deviceMac), + portIdx: this.stringValue(requestArg.data.portIdx), + enabled: requestArg.service === 'enable_poe', + }; + } + } + + if (requestArg.domain !== 'switch' || !['turn_on', 'turn_off'].includes(requestArg.service)) { + return undefined; + } + + const target = this.findTargetEntity(snapshotArg, requestArg); + if (!target) { + return undefined; + } + const enabled = requestArg.service === 'turn_on'; + + if (target.attributes?.nativeType === 'wlan') { + return { + type: 'setWlanEnabled', + service: requestArg.service, + target: requestArg.target, + wlanId: this.stringValue(target.attributes.wlanId), + enabled, + }; + } + + if (target.attributes?.nativeType === 'poe_port') { + return { + type: 'setPoePortEnabled', + service: requestArg.service, + target: requestArg.target, + deviceMac: this.stringValue(target.attributes.deviceMac), + portIdx: this.stringValue(target.attributes.portIdx), + enabled, + }; + } + + if (target.attributes?.nativeType === 'client_access') { + return { + type: 'blockClient', + service: requestArg.service, + target: requestArg.target, + mac: this.stringValue(target.attributes.mac), + block: !enabled, + }; + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IUnifiEvent): IIntegrationEvent { + return { + type: eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: 'unifi', + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static normalizeMac(valueArg: string | undefined): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.toLowerCase().replace(/[^a-f0-9]/g, ''); + if (compact.length !== 12) { + return valueArg.toLowerCase(); + } + return compact.match(/.{1,2}/g)?.join(':'); + } + + private static infrastructureDevice(deviceArg: IUnifiDevice, snapshotArg: IUnifiSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const deviceId = this.deviceId(deviceArg); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'client_count', capability: 'sensor', name: 'Clients', readable: true, writable: false }, + { id: 'state', capability: 'sensor', name: 'State', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: this.deviceConnected(deviceArg) ? 'online' : 'offline', updatedAt: updatedAtArg }, + { featureId: 'client_count', value: this.clientsForDevice(snapshotArg, deviceArg), updatedAt: updatedAtArg }, + { featureId: 'state', value: this.deviceState(deviceArg), updatedAt: updatedAtArg }, + ]; + const temperature = this.numberValue(deviceArg.generalTemperature, deviceArg.general_temperature); + if (temperature !== undefined || deviceArg.hasTemperature || deviceArg.has_temperature) { + features.push({ id: 'temperature', capability: 'sensor', name: 'Temperature', readable: true, writable: false, unit: 'C' }); + state.push({ featureId: 'temperature', value: temperature ?? null, updatedAt: updatedAtArg }); + } + for (const port of this.portsForDevice(snapshotArg, deviceArg)) { + const portKey = this.portKey(port); + features.push({ id: `port_${portKey}_link`, capability: 'sensor', name: `${this.portName(port)} link`, readable: true, writable: false }); + state.push({ featureId: `port_${portKey}_link`, value: this.booleanValue(port.up) ?? false, updatedAt: updatedAtArg }); + if (this.portHasPoe(port)) { + features.push({ id: `port_${portKey}_poe`, capability: 'switch', name: `${this.portName(port)} PoE`, readable: true, writable: true }); + state.push({ featureId: `port_${portKey}_poe`, value: this.portPoeEnabled(port), updatedAt: updatedAtArg }); + } + } + return { + id: deviceId, + integrationDomain: 'unifi', + name: this.deviceName(deviceArg), + protocol: 'http', + manufacturer, + model: deviceArg.model || deviceArg.type || 'UniFi device', + online: this.deviceConnected(deviceArg), + features, + state, + metadata: { + mac: this.normalizeMac(deviceArg.mac), + ip: deviceArg.ip, + version: deviceArg.version, + type: deviceArg.type, + boardRevision: deviceArg.boardRevision ?? deviceArg.board_rev, + }, + }; + } + + private static clientDevice(clientArg: IUnifiClient, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'presence', capability: 'sensor', name: 'Presence', readable: true, writable: false }, + { id: 'blocked', capability: 'switch', name: 'Network access', readable: true, writable: true }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'presence', value: this.clientConnected(clientArg), updatedAt: updatedAtArg }, + { featureId: 'blocked', value: !this.booleanValue(clientArg.blocked), updatedAt: updatedAtArg }, + ]; + const rx = this.clientRx(clientArg); + const tx = this.clientTx(clientArg); + if (rx !== undefined) { + features.push({ id: 'rx_rate', capability: 'sensor', name: 'RX rate', readable: true, writable: false, unit: 'MB/s' }); + state.push({ featureId: 'rx_rate', value: rx, updatedAt: updatedAtArg }); + } + if (tx !== undefined) { + features.push({ id: 'tx_rate', capability: 'sensor', name: 'TX rate', readable: true, writable: false, unit: 'MB/s' }); + state.push({ featureId: 'tx_rate', value: tx, updatedAt: updatedAtArg }); + } + return { + id: this.clientDeviceId(clientArg), + integrationDomain: 'unifi', + name: this.clientName(clientArg), + protocol: 'unknown', + manufacturer: clientArg.oui || 'Unknown', + model: clientArg.deviceName || clientArg.device_name || 'Network client', + online: this.clientConnected(clientArg), + features, + state, + metadata: { + mac: this.normalizeMac(clientArg.mac), + ip: clientArg.ip, + essid: clientArg.essid, + wired: this.clientWired(clientArg), + guest: this.booleanValue(clientArg.isGuest, clientArg.is_guest), + }, + }; + } + + private static wlanDevice(wlanArg: IUnifiWlan, snapshotArg: IUnifiSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + return { + id: this.wlanDeviceId(wlanArg), + integrationDomain: 'unifi', + name: wlanArg.name, + protocol: 'http', + manufacturer, + model: 'UniFi WLAN', + online: this.booleanValue(wlanArg.enabled) !== false, + features: [ + { id: 'enabled', capability: 'switch', name: 'Enabled', readable: true, writable: true }, + { id: 'clients', capability: 'sensor', name: 'Clients', readable: true, writable: false }, + ], + state: [ + { featureId: 'enabled', value: this.booleanValue(wlanArg.enabled) ?? true, updatedAt: updatedAtArg }, + { featureId: 'clients', value: this.clientsForWlan(snapshotArg, wlanArg), updatedAt: updatedAtArg }, + ], + metadata: { + wlanId: this.wlanId(wlanArg), + siteId: wlanArg.siteId || wlanArg.site_id, + security: wlanArg.security, + guest: this.booleanValue(wlanArg.isGuest, wlanArg.is_guest), + }, + }; + } + + private static pushClientEntities(entitiesArg: IIntegrationEntity[], clientArg: IUnifiClient, usedIdsArg: Map): void { + const name = this.clientName(clientArg); + const deviceId = this.clientDeviceId(clientArg); + const mac = this.normalizeMac(clientArg.mac) || clientArg.mac; + const connected = this.clientConnected(clientArg); + entitiesArg.push(this.entity('binary_sensor', `${name} Connected`, deviceId, `unifi_client_connected_${this.slug(mac)}`, connected ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + mac, + ip: clientArg.ip, + essid: clientArg.essid, + wired: this.clientWired(clientArg), + }, true)); + entitiesArg.push(this.entity('switch', `${name} Network Access`, deviceId, `unifi_client_access_${this.slug(mac)}`, clientArg.blocked ? 'off' : 'on', usedIdsArg, { + nativeType: 'client_access', + mac, + writable: true, + }, true)); + if (clientArg.ip) { + entitiesArg.push(this.entity('sensor', `${name} IP`, deviceId, `unifi_client_ip_${this.slug(mac)}`, clientArg.ip, usedIdsArg, undefined, connected)); + } + this.pushNumericEntity(entitiesArg, 'sensor', `${name} RX Rate`, deviceId, `unifi_client_rx_${this.slug(mac)}`, this.clientRx(clientArg), usedIdsArg, 'MB/s', connected); + this.pushNumericEntity(entitiesArg, 'sensor', `${name} TX Rate`, deviceId, `unifi_client_tx_${this.slug(mac)}`, this.clientTx(clientArg), usedIdsArg, 'MB/s', connected); + this.pushNumericEntity(entitiesArg, 'sensor', `${name} Wired Link Speed`, deviceId, `unifi_client_wired_speed_${this.slug(mac)}`, this.numberValue(clientArg.wiredRateMbps, clientArg.wired_rate_mbps), usedIdsArg, 'Mbit/s', connected && this.clientWired(clientArg)); + this.pushNumericEntity(entitiesArg, 'sensor', `${name} RSSI`, deviceId, `unifi_client_rssi_${this.slug(mac)}`, this.numberValue(clientArg.rssi), usedIdsArg, 'dBm', connected && !this.clientWired(clientArg)); + } + + private static pushDeviceEntities(entitiesArg: IIntegrationEntity[], deviceArg: IUnifiDevice, snapshotArg: IUnifiSnapshot, usedIdsArg: Map): void { + const name = this.deviceName(deviceArg); + const deviceId = this.deviceId(deviceArg); + const mac = this.normalizeMac(deviceArg.mac) || deviceArg.mac; + const connected = this.deviceConnected(deviceArg); + entitiesArg.push(this.entity('binary_sensor', `${name} Connected`, deviceId, `unifi_device_connected_${this.slug(mac)}`, connected ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + mac, + ip: deviceArg.ip, + }, true)); + entitiesArg.push(this.entity('sensor', `${name} State`, deviceId, `unifi_device_state_${this.slug(mac)}`, this.deviceState(deviceArg), usedIdsArg, undefined, true)); + this.pushNumericEntity(entitiesArg, 'sensor', `${name} Clients`, deviceId, `unifi_device_clients_${this.slug(mac)}`, this.clientsForDevice(snapshotArg, deviceArg), usedIdsArg, undefined, connected); + this.pushNumericEntity(entitiesArg, 'sensor', `${name} Uptime`, deviceId, `unifi_device_uptime_${this.slug(mac)}`, this.numberValue(deviceArg.uptime), usedIdsArg, 's', connected); + this.pushNumericEntity(entitiesArg, 'sensor', `${name} Temperature`, deviceId, `unifi_device_temperature_${this.slug(mac)}`, this.numberValue(deviceArg.generalTemperature, deviceArg.general_temperature), usedIdsArg, 'C', connected); + const stats = deviceArg.systemStats || deviceArg['system-stats']; + this.pushNumericEntity(entitiesArg, 'sensor', `${name} CPU Utilization`, deviceId, `unifi_device_cpu_${this.slug(mac)}`, this.numberValue(stats?.cpu), usedIdsArg, '%', connected); + this.pushNumericEntity(entitiesArg, 'sensor', `${name} Memory Utilization`, deviceId, `unifi_device_memory_${this.slug(mac)}`, this.numberValue(stats?.mem), usedIdsArg, '%', connected); + } + + private static pushWlanEntities(entitiesArg: IIntegrationEntity[], wlanArg: IUnifiWlan, snapshotArg: IUnifiSnapshot, usedIdsArg: Map): void { + const name = wlanArg.name; + const wlanId = this.wlanId(wlanArg); + const deviceId = this.wlanDeviceId(wlanArg); + const enabled = this.booleanValue(wlanArg.enabled) !== false; + entitiesArg.push(this.entity('switch', name, deviceId, `unifi_wlan_${this.slug(wlanId)}`, enabled ? 'on' : 'off', usedIdsArg, { + nativeType: 'wlan', + wlanId, + security: wlanArg.security, + hidden: this.booleanValue(wlanArg.hideSsid, wlanArg.hide_ssid), + guest: this.booleanValue(wlanArg.isGuest, wlanArg.is_guest), + writable: true, + }, true)); + this.pushNumericEntity(entitiesArg, 'sensor', `${name} Clients`, deviceId, `unifi_wlan_clients_${this.slug(wlanId)}`, this.clientsForWlan(snapshotArg, wlanArg), usedIdsArg, undefined, enabled); + } + + private static pushPortEntities(entitiesArg: IIntegrationEntity[], portArg: IUnifiPort, snapshotArg: IUnifiSnapshot, usedIdsArg: Map): void { + const device = snapshotArg.devices.find((deviceArg) => this.normalizeMac(deviceArg.mac) === this.normalizeMac(this.portDeviceMac(portArg))); + const parentDeviceId = device ? this.deviceId(device) : `unifi.device.${this.slug(this.portDeviceMac(portArg) || 'unknown')}`; + const parentName = device ? this.deviceName(device) : 'UniFi Device'; + const portName = this.portName(portArg); + const baseName = `${parentName} ${portName}`; + const uniqueBase = `${this.slug(this.portDeviceMac(portArg) || 'unknown')}_${this.slug(String(this.portIdx(portArg) || portArg.ifname || portName))}`; + const available = device ? this.deviceConnected(device) : true; + entitiesArg.push(this.entity('binary_sensor', `${baseName} Link`, parentDeviceId, `unifi_port_link_${uniqueBase}`, portArg.up ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + deviceMac: this.portDeviceMac(portArg), + portIdx: this.portIdx(portArg), + }, available)); + if (this.portHasPoe(portArg)) { + entitiesArg.push(this.entity('switch', `${baseName} PoE`, parentDeviceId, `unifi_port_poe_${uniqueBase}`, this.portPoeEnabled(portArg) ? 'on' : 'off', usedIdsArg, { + nativeType: 'poe_port', + deviceMac: this.portDeviceMac(portArg), + portIdx: this.portIdx(portArg), + poeMode: portArg.poeMode || portArg.poe_mode, + writable: true, + }, available)); + this.pushNumericEntity(entitiesArg, 'sensor', `${baseName} PoE Power`, parentDeviceId, `unifi_port_poe_power_${uniqueBase}`, this.numberValue(portArg.poePower, portArg.poe_power), usedIdsArg, 'W', available); + } + if (typeof this.booleanValue(portArg.enabled, portArg.enable) === 'boolean') { + entitiesArg.push(this.entity('switch', `${baseName} Enabled`, parentDeviceId, `unifi_port_enabled_${uniqueBase}`, this.booleanValue(portArg.enabled, portArg.enable) ? 'on' : 'off', usedIdsArg, { + nativeType: 'port_enabled', + deviceMac: this.portDeviceMac(portArg), + portIdx: this.portIdx(portArg), + }, available)); + } + this.pushNumericEntity(entitiesArg, 'sensor', `${baseName} Link Speed`, parentDeviceId, `unifi_port_speed_${uniqueBase}`, this.numberValue(portArg.speed), usedIdsArg, 'Mbit/s', available); + this.pushNumericEntity(entitiesArg, 'sensor', `${baseName} RX Rate`, parentDeviceId, `unifi_port_rx_${uniqueBase}`, this.numberValue(portArg.rxBytesR, portArg['rx_bytes-r']), usedIdsArg, 'B/s', available); + this.pushNumericEntity(entitiesArg, 'sensor', `${baseName} TX Rate`, parentDeviceId, `unifi_port_tx_${uniqueBase}`, this.numberValue(portArg.txBytesR, portArg['tx_bytes-r']), usedIdsArg, 'B/s', available); + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg?: Record, availableArg = true): IIntegrationEntity { + const baseId = `${platformArg}.${this.slug(nameArg)}`; + const current = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, current + 1); + return { + id: current ? `${baseId}_${current + 1}` : baseId, + uniqueId: uniqueIdArg, + integrationDomain: 'unifi', + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: attributesArg, + available: availableArg, + }; + } + + private static pushNumericEntity(entitiesArg: IIntegrationEntity[], platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, valueArg: number | undefined, usedIdsArg: Map, unitArg?: string, availableArg = true): void { + if (typeof valueArg !== 'number' || Number.isNaN(valueArg)) { + return; + } + entitiesArg.push(this.entity(platformArg, nameArg, deviceIdArg, uniqueIdArg, valueArg, usedIdsArg, unitArg ? { unit: unitArg } : undefined, availableArg)); + } + + private static withDerivedPorts(snapshotArg: IUnifiSnapshot): IUnifiPort[] { + const ports = [...snapshotArg.ports]; + const seen = new Set(ports.map((portArg) => this.portId(portArg))); + for (const device of snapshotArg.devices) { + for (const port of device.portTable || device.port_table || []) { + const withDevice = { ...port, deviceMac: port.deviceMac || port.device_mac || device.mac }; + const id = this.portId(withDevice); + if (!seen.has(id)) { + seen.add(id); + ports.push(withDevice); + } + } + } + return ports; + } + + private static portsForDevice(snapshotArg: IUnifiSnapshot, deviceArg: IUnifiDevice): IUnifiPort[] { + const mac = this.normalizeMac(deviceArg.mac); + return this.withDerivedPorts(snapshotArg).filter((portArg) => this.normalizeMac(this.portDeviceMac(portArg)) === mac); + } + + private static portId(portArg: IUnifiPort): string { + return `${this.normalizeMac(this.portDeviceMac(portArg)) || 'unknown'}_${this.portIdx(portArg) || portArg.ifname || portArg.name || 'port'}`; + } + + private static portDeviceMac(portArg: IUnifiPort): string | undefined { + return portArg.deviceMac || portArg.device_mac; + } + + private static portIdx(portArg: IUnifiPort): number | string | undefined { + return portArg.portIdx ?? portArg.port_idx ?? portArg.ifname; + } + + private static portKey(portArg: IUnifiPort): string { + return this.slug(String(this.portIdx(portArg) || portArg.name || 'port')); + } + + private static portName(portArg: IUnifiPort): string { + const idx = this.portIdx(portArg); + if (portArg.name && portArg.name.trim()) { + return portArg.name; + } + return idx ? `Port ${idx}` : 'Port'; + } + + private static portHasPoe(portArg: IUnifiPort): boolean { + return this.booleanValue(portArg.portPoe, portArg.port_poe, portArg.poeEnable, portArg.poe_enable) === true || (this.numberValue(portArg.poeCaps, portArg.poe_caps) ?? 0) > 0; + } + + private static portPoeEnabled(portArg: IUnifiPort): boolean { + const mode = String(portArg.poeMode || portArg.poe_mode || '').toLowerCase(); + if (mode) { + return mode !== 'off'; + } + return this.booleanValue(portArg.poeEnable, portArg.poe_enable, portArg.portPoe, portArg.port_poe) === true; + } + + private static deviceId(deviceArg: IUnifiDevice): string { + return `unifi.device.${this.slug(this.normalizeMac(deviceArg.mac) || deviceArg.deviceId || deviceArg.device_id || deviceArg.id || 'unknown')}`; + } + + private static clientDeviceId(clientArg: IUnifiClient): string { + return `unifi.client.${this.slug(this.normalizeMac(clientArg.mac) || clientArg.mac)}`; + } + + private static wlanDeviceId(wlanArg: IUnifiWlan): string { + return `unifi.wlan.${this.slug(this.wlanId(wlanArg))}`; + } + + private static wlanId(wlanArg: IUnifiWlan): string { + return wlanArg.id || wlanArg._id || wlanArg.name; + } + + private static deviceName(deviceArg: IUnifiDevice): string { + return deviceArg.name || deviceArg.model || this.normalizeMac(deviceArg.mac) || 'UniFi Device'; + } + + private static clientName(clientArg: IUnifiClient): string { + return clientArg.name || clientArg.hostname || clientArg.deviceName || clientArg.device_name || this.normalizeMac(clientArg.mac) || 'UniFi Client'; + } + + private static deviceConnected(deviceArg: IUnifiDevice): boolean { + if (deviceArg.disabled) { + return false; + } + if (typeof deviceArg.state === 'number') { + return deviceArg.state === connectedDeviceState; + } + if (typeof deviceArg.state === 'string') { + return ['connected', 'online', '1'].includes(deviceArg.state.toLowerCase()); + } + return true; + } + + private static clientConnected(clientArg: IUnifiClient): boolean { + const lastSeen = this.numberValue(clientArg.lastSeen, clientArg.last_seen); + if (lastSeen && Date.now() / 1000 - lastSeen > 3600) { + return false; + } + return true; + } + + private static clientWired(clientArg: IUnifiClient): boolean { + return this.booleanValue(clientArg.isWired, clientArg.is_wired) === true; + } + + private static deviceState(deviceArg: IUnifiDevice): string { + const state = deviceArg.state; + if (state === 0) return 'disconnected'; + if (state === 1) return 'connected'; + if (state === 2) return 'pending'; + if (state === 3) return 'firmware_mismatch'; + if (state === 4) return 'upgrading'; + if (state === 5) return 'provisioning'; + if (state === 6) return 'heartbeat_missed'; + if (state === 7) return 'adopting'; + if (state === 8) return 'deleting'; + if (state === 9) return 'inform_error'; + if (state === 10) return 'adoption_failed'; + if (state === 11) return 'isolated'; + return typeof state === 'string' ? state : 'unknown'; + } + + private static clientsForWlan(snapshotArg: IUnifiSnapshot, wlanArg: IUnifiWlan): number { + return snapshotArg.clients.filter((clientArg) => clientArg.essid === wlanArg.name && this.clientConnected(clientArg)).length; + } + + private static clientsForDevice(snapshotArg: IUnifiSnapshot, deviceArg: IUnifiDevice): number { + const mac = this.normalizeMac(deviceArg.mac); + return snapshotArg.clients.filter((clientArg) => { + const apMac = this.normalizeMac(clientArg.apMac || clientArg.ap_mac); + const switchMac = this.normalizeMac(clientArg.switchMac || clientArg.sw_mac); + return (apMac === mac || switchMac === mac) && this.clientConnected(clientArg); + }).length; + } + + private static clientRx(clientArg: IUnifiClient): number | undefined { + const value = this.clientWired(clientArg) + ? this.numberValue(clientArg.wiredRxBytesR, clientArg['wired-rx_bytes-r']) + : this.numberValue(clientArg.rxBytesR, clientArg['rx_bytes-r']); + return value === undefined ? undefined : value / 1000000; + } + + private static clientTx(clientArg: IUnifiClient): number | undefined { + const value = this.clientWired(clientArg) + ? this.numberValue(clientArg.wiredTxBytesR, clientArg['wired-tx_bytes-r']) + : this.numberValue(clientArg.txBytesR, clientArg['tx_bytes-r']); + return value === undefined ? undefined : value / 1000000; + } + + private static findTargetEntity(snapshotArg: IUnifiSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain); + } + return undefined; + } + + private static macFromTarget(snapshotArg: IUnifiSnapshot, requestArg: IServiceCallRequest): string | undefined { + const target = this.findTargetEntity(snapshotArg, requestArg); + return this.stringValue(target?.attributes?.mac); + } + + private static manualItems(configArg: IUnifiConfig, keyArg: TKey): NonNullable { + return (configArg.manualEntries || []).flatMap((entryArg) => entryArg.snapshot?.[keyArg] || entryArg[keyArg] || []) as NonNullable; + } + + private static booleanValue(...valuesArg: unknown[]): boolean | undefined { + for (const value of valuesArg) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + if (['true', '1', 'yes', 'on'].includes(value.toLowerCase())) return true; + if (['false', '0', 'no', 'off'].includes(value.toLowerCase())) return false; + } + } + return undefined; + } + + private static numberValue(...valuesArg: unknown[]): number | undefined { + for (const value of valuesArg) { + if (typeof value === 'number' && !Number.isNaN(value)) { + return value; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + return undefined; + } + + private static stringValue(valueArg: unknown): string | undefined { + if (typeof valueArg === 'string' && valueArg) { + return valueArg; + } + if (typeof valueArg === 'number') { + return String(valueArg); + } + return undefined; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'unifi'; + } +} diff --git a/ts/integrations/unifi/unifi.types.ts b/ts/integrations/unifi/unifi.types.ts index 031fe36..5858327 100644 --- a/ts/integrations/unifi/unifi.types.ts +++ b/ts/integrations/unifi/unifi.types.ts @@ -1,4 +1,345 @@ -export interface IHomeAssistantUnifiConfig { - // TODO: replace with the TypeScript-native config for unifi. - [key: string]: unknown; +export const unifiDefaultPort = 443; +export const unifiDefaultSite = 'default'; + +export type TUnifiProtocol = 'http' | 'https'; +export type TUnifiCommandType = 'blockClient' | 'reconnectClient' | 'setWlanEnabled' | 'setPoePortEnabled'; +export type TUnifiDiscoveryService = 'network' | 'protect' | 'access' | 'unknown'; + +export interface IUnifiConfig { + host?: string; + port?: number; + protocol?: TUnifiProtocol; + site?: string; + username?: string; + password?: string; + verifySsl?: boolean; + controller?: IUnifiController; + snapshot?: IUnifiSnapshot; + manualEntries?: IUnifiManualEntry[]; + sites?: IUnifiSite[]; + clients?: IUnifiClient[]; + devices?: IUnifiDevice[]; + wlans?: IUnifiWlan[]; + ports?: IUnifiPort[]; + events?: IUnifiEvent[]; + trackClients?: boolean; + trackDevices?: boolean; + trackWiredClients?: boolean; + allowBandwidthSensors?: boolean; + allowUptimeSensors?: boolean; + detectionTimeSeconds?: number; } + +export interface IUnifiController { + id?: string; + name?: string; + host?: string; + port?: number; + site?: string; + version?: string; + deviceType?: string; + isUnifiOs?: boolean; + connected?: boolean; +} + +export interface IUnifiSite { + id?: string; + siteId?: string; + _id?: string; + name?: string; + description?: string; + desc?: string; + role?: string; +} + +export interface IUnifiClient { + id?: string; + _id?: string; + mac: string; + name?: string; + hostname?: string; + deviceName?: string; + device_name?: string; + ip?: string; + oui?: string; + essid?: string; + network?: string; + isWired?: boolean; + is_wired?: boolean; + isGuest?: boolean; + is_guest?: boolean; + blocked?: boolean; + authorized?: boolean; + lastSeen?: number; + last_seen?: number; + firstSeen?: number; + first_seen?: number; + uptime?: number; + apMac?: string; + ap_mac?: string; + switchMac?: string; + sw_mac?: string; + switchPort?: number; + sw_port?: number; + vlan?: number; + radio?: string; + radioName?: string; + radio_name?: string; + radioProto?: string; + radio_proto?: string; + rssi?: number; + rxBytesR?: number; + 'rx_bytes-r'?: number; + txBytesR?: number; + 'tx_bytes-r'?: number; + wiredRxBytesR?: number; + 'wired-rx_bytes-r'?: number; + wiredTxBytesR?: number; + 'wired-tx_bytes-r'?: number; + wiredRateMbps?: number; + wired_rate_mbps?: number; + fixedIp?: string; + fixed_ip?: string; + note?: string; + raw?: Record; +} + +export interface IUnifiDevice { + id?: string; + _id?: string; + deviceId?: string; + device_id?: string; + mac: string; + name?: string; + model?: string; + version?: string; + boardRevision?: number; + board_rev?: number; + ip?: string; + type?: string; + state?: number | string; + disabled?: boolean; + uptime?: number; + numSta?: number; + num_sta?: number; + 'user-num_sta'?: number; + 'guest-num_sta'?: number; + generalTemperature?: number; + general_temperature?: number; + hasTemperature?: boolean; + has_temperature?: boolean; + outletAcPowerBudget?: string | number; + outlet_ac_power_budget?: string | number; + outletAcPowerConsumption?: string | number; + outlet_ac_power_consumption?: string | number; + portTable?: IUnifiPort[]; + port_table?: IUnifiPort[]; + portOverrides?: IUnifiPortOverride[]; + port_overrides?: IUnifiPortOverride[]; + wlanOverrides?: IUnifiWlanOverride[]; + wlan_overrides?: IUnifiWlanOverride[]; + temperatures?: IUnifiTemperature[]; + systemStats?: IUnifiSystemStats; + 'system-stats'?: IUnifiSystemStats; + raw?: Record; +} + +export interface IUnifiSystemStats { + cpu?: string | number; + mem?: string | number; + uptime?: string | number; +} + +export interface IUnifiTemperature { + name?: string; + type?: string; + value?: number; +} + +export interface IUnifiPortOverride { + portIdx?: number; + port_idx?: number; + poeMode?: string; + poe_mode?: string; + portSecurityEnabled?: boolean; + port_security_enabled?: boolean; + portconfId?: string; + portconf_id?: string; +} + +export interface IUnifiPort { + id?: string; + deviceMac?: string; + device_mac?: string; + portIdx?: number | string; + port_idx?: number; + ifname?: string; + name?: string; + media?: string; + enabled?: boolean; + enable?: boolean; + up?: boolean; + portPoe?: boolean; + port_poe?: boolean; + poeEnable?: boolean; + poe_enable?: boolean; + poeMode?: string; + poe_mode?: string; + poeCaps?: number; + poe_caps?: number; + poePower?: string | number; + poe_power?: string | number; + speed?: number; + rxBytesR?: number; + 'rx_bytes-r'?: number; + txBytesR?: number; + 'tx_bytes-r'?: number; + portconfId?: string; + portconf_id?: string; + raw?: Record; +} + +export interface IUnifiWlan { + id?: string; + _id?: string; + name: string; + enabled?: boolean; + siteId?: string; + site_id?: string; + security?: string; + isGuest?: boolean; + is_guest?: boolean; + hideSsid?: boolean; + hide_ssid?: boolean; + nameCombineEnabled?: boolean; + name_combine_enabled?: boolean; + nameCombineSuffix?: string; + name_combine_suffix?: string; + raw?: Record; +} + +export interface IUnifiWlanOverride { + name?: string; + radio?: string; + radioName?: string; + radio_name?: string; + wlanId?: string; + wlan_id?: string; +} + +export interface IUnifiEvent { + type?: string; + key?: string; + mac?: string; + deviceId?: string; + entityId?: string; + data?: unknown; + timestamp?: number; +} + +export interface IUnifiSnapshot { + connected: boolean; + host?: string; + port?: number; + site?: string; + controller?: IUnifiController; + sites: IUnifiSite[]; + clients: IUnifiClient[]; + devices: IUnifiDevice[]; + wlans: IUnifiWlan[]; + ports: IUnifiPort[]; + events: IUnifiEvent[]; +} + +export interface IUnifiManualEntry { + id?: string; + host?: string; + port?: number; + site?: string; + name?: string; + controller?: IUnifiController; + snapshot?: IUnifiSnapshot; + sites?: IUnifiSite[]; + clients?: IUnifiClient[]; + devices?: IUnifiDevice[]; + wlans?: IUnifiWlan[]; + ports?: IUnifiPort[]; + metadata?: Record; +} + +export interface IUnifiCommand { + type: TUnifiCommandType; + service: string; + target: { + entityId?: string; + deviceId?: string; + }; + mac?: string; + wlanId?: string; + deviceMac?: string; + portIdx?: number | string; + enabled?: boolean; + block?: boolean; +} + +export interface IUnifiCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export interface IUnifiMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + txt?: { + mac?: string; + hw_addr?: string; + model?: string; + modelid?: string; + name?: string; + controller_uuid?: string; + uuid?: string; + [key: string]: string | undefined; + }; +} + +export interface IUnifiSsdpRecord { + location?: string; + host?: string; + port?: number; + manufacturer?: string; + modelName?: string; + modelDescription?: string; + server?: string; + usn?: string; + st?: string; + nt?: string; + udn?: string; + headers?: Record; +} + +export interface IUnifiManualDiscoveryEntry { + id?: string; + host?: string; + port?: number; + site?: string; + name?: string; + macAddress?: string; + manufacturer?: string; + model?: string; + services?: Partial>; + metadata?: Record; +} + +export interface IUnifiDiscoveryDeviceRecord { + source_ip?: string; + hw_addr?: string; + direct_connect_domain?: string; + services?: Partial> | Record; + name?: string; + model?: string; +} + +export type IHomeAssistantUnifiConfig = IUnifiConfig;