Add native media and network integrations

This commit is contained in:
2026-05-05 16:20:10 +00:00
parent 1eebd71e7d
commit 489d9d5243
63 changed files with 8605 additions and 195 deletions
@@ -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();
@@ -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();
@@ -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();
@@ -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();
+48
View File
@@ -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();
+58
View File
@@ -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();
@@ -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();
@@ -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();
+59
View File
@@ -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();
+97
View File
@@ -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();
+59
View File
@@ -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();
+113
View File
@@ -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();
+12
View File
@@ -3,17 +3,23 @@ export * from './protocols/index.js';
export * from './integrations/index.js'; export * from './integrations/index.js';
import { HueIntegration } from './integrations/hue/index.js'; import { HueIntegration } from './integrations/hue/index.js';
import { AndroidtvIntegration } from './integrations/androidtv/index.js';
import { CastIntegration } from './integrations/cast/index.js'; import { CastIntegration } from './integrations/cast/index.js';
import { DeconzIntegration } from './integrations/deconz/index.js'; import { DeconzIntegration } from './integrations/deconz/index.js';
import { DenonavrIntegration } from './integrations/denonavr/index.js';
import { EsphomeIntegration } from './integrations/esphome/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js';
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js'; import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
import { KodiIntegration } from './integrations/kodi/index.js';
import { MatterIntegration } from './integrations/matter/index.js'; import { MatterIntegration } from './integrations/matter/index.js';
import { MqttIntegration } from './integrations/mqtt/index.js'; import { MqttIntegration } from './integrations/mqtt/index.js';
import { NanoleafIntegration } from './integrations/nanoleaf/index.js'; import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
import { RokuIntegration } from './integrations/roku/index.js'; import { RokuIntegration } from './integrations/roku/index.js';
import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
import { ShellyIntegration } from './integrations/shelly/index.js'; import { ShellyIntegration } from './integrations/shelly/index.js';
import { SonosIntegration } from './integrations/sonos/index.js'; import { SonosIntegration } from './integrations/sonos/index.js';
import { TplinkIntegration } from './integrations/tplink/index.js';
import { TradfriIntegration } from './integrations/tradfri/index.js'; import { TradfriIntegration } from './integrations/tradfri/index.js';
import { UnifiIntegration } from './integrations/unifi/index.js';
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js'; import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
import { WizIntegration } from './integrations/wiz/index.js'; import { WizIntegration } from './integrations/wiz/index.js';
import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/index.js'; import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/index.js';
@@ -24,18 +30,24 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated
import { IntegrationRegistry } from './core/index.js'; import { IntegrationRegistry } from './core/index.js';
export const integrations = [ export const integrations = [
new AndroidtvIntegration(),
new CastIntegration(), new CastIntegration(),
new DeconzIntegration(), new DeconzIntegration(),
new DenonavrIntegration(),
new EsphomeIntegration(), new EsphomeIntegration(),
new HomekitControllerIntegration(), new HomekitControllerIntegration(),
new HueIntegration(), new HueIntegration(),
new KodiIntegration(),
new MatterIntegration(), new MatterIntegration(),
new MqttIntegration(), new MqttIntegration(),
new NanoleafIntegration(), new NanoleafIntegration(),
new RokuIntegration(), new RokuIntegration(),
new SamsungtvIntegration(),
new ShellyIntegration(), new ShellyIntegration(),
new SonosIntegration(), new SonosIntegration(),
new TplinkIntegration(),
new TradfriIntegration(), new TradfriIntegration(),
new UnifiIntegration(),
new WolfSmartsetIntegration(), new WolfSmartsetIntegration(),
new WizIntegration(), new WizIntegration(),
new XiaomiMiioIntegration(), new XiaomiMiioIntegration(),
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,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<IAndroidtvSnapshot> {
return this.normalizeSnapshot(this.snapshot || this.snapshotFromManualConfig());
}
public async turnOn(): Promise<void> {
return this.unsupported({ action: 'turn_on' });
}
public async turnOff(): Promise<void> {
return this.unsupported({ action: 'turn_off' });
}
public async mediaPlay(): Promise<void> {
return this.unsupported({ action: 'media_play' });
}
public async mediaPause(): Promise<void> {
return this.unsupported({ action: 'media_pause' });
}
public async mediaPlayPause(): Promise<void> {
return this.unsupported({ action: 'media_play_pause' });
}
public async mediaStop(): Promise<void> {
return this.unsupported({ action: 'media_stop' });
}
public async setVolumeLevel(volumeLevelArg: number): Promise<void> {
return this.unsupported({ action: 'volume_set', volumeLevel: volumeLevelArg });
}
public async stepVolume(volumeStepArg: number): Promise<void> {
return this.unsupported({ action: 'volume_step', volumeStep: volumeStepArg });
}
public async muteVolume(mutedArg: boolean): Promise<void> {
return this.unsupported({ action: 'volume_mute', muted: mutedArg });
}
public async selectSource(sourceArg: string): Promise<void> {
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<void> {
return this.unsupported({ action: 'remote_send_command', keys: commandsArg, repeats: repeatsArg });
}
public async adbCommand(commandArg: string): Promise<void> {
return this.unsupported({ action: 'adb_command', shell: commandArg });
}
public async destroy(): Promise<void> {}
private async unsupported(commandArg: IAndroidtvCommand): Promise<never> {
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,
};
}
}
@@ -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<IAndroidtvConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAndroidtvConfig>> {
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';
}
}
@@ -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 { export class AndroidtvIntegration extends BaseIntegration<IAndroidtvConfig> {
constructor() { public readonly domain = 'androidtv';
super({ public readonly displayName = 'Android Debug Bridge';
domain: "androidtv", public readonly status = 'control-runtime' as const;
displayName: "Android Debug Bridge", public readonly discoveryDescriptor = createAndroidtvDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new AndroidtvConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/androidtv", upstreamPath: 'homeassistant/components/androidtv',
"upstreamDomain": "androidtv", upstreamDomain: 'androidtv',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['adb-shell[async]==0.4.4', 'androidtv[async]==0.0.75'],
"adb-shell[async]==0.4.4", dependencies: [],
"androidtv[async]==0.0.75" afterDependencies: [],
], codeowners: ['@JeffLIrion', '@ollo69'],
"dependencies": [], };
"afterDependencies": [],
"codeowners": [ public async setup(configArg: IAndroidtvConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
"@JeffLIrion", void contextArg;
"@ollo69" return new AndroidtvRuntime(new AndroidtvClient(configArg));
] }
},
}); public async destroy(): Promise<void> {}
}
export class HomeAssistantAndroidtvIntegration extends AndroidtvIntegration {}
class AndroidtvRuntime implements IIntegrationRuntime {
public domain = 'androidtv';
constructor(private readonly client: AndroidtvClient) {}
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
return AndroidtvMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AndroidtvMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'remote') {
return await this.callRemoteService(requestArg);
}
if (requestArg.domain === '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<void> {
await this.client.destroy();
}
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<IServiceCallResult> {
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<IServiceCallResult> {
if (requestArg.service === 'turn_on') {
await this.client.turnOn();
return { success: true };
}
if (requestArg.service === 'turn_off') {
await this.client.turnOff();
return { success: true };
}
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
await this.client.mediaPlay();
return { success: true };
}
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
await this.client.mediaPause();
return { success: true };
}
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
await this.client.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}` };
} }
} }
@@ -0,0 +1,76 @@
import type { TAndroidtvKeyCommand } from './androidtv.types.js';
export const androidtvDefaultPort = 5555;
export const androidtvDefaultAdbServerPort = 5037;
export const androidtvKeyCodes: Record<TAndroidtvKeyCommand, number> = {
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<string, string> = {
'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',
};
@@ -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<IAndroidtvMdnsRecord> {
public id = 'androidtv-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Android TV mDNS setup hints.';
public async matches(recordArg: IAndroidtvMdnsRecord): Promise<IDiscoveryMatch> {
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<IAndroidtvManualEntry> {
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<IDiscoveryMatch> {
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<string | undefined>): 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<IAndroidtvAdbHostRecord> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
@@ -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<string>();
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';
}
}
+190 -3
View File
@@ -1,4 +1,191 @@
export interface IHomeAssistantAndroidtvConfig { export type TAndroidtvDeviceClass = 'auto' | 'androidtv' | 'firetv';
// TODO: replace with the TypeScript-native config for androidtv.
[key: string]: unknown; 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<TAndroidtvKeyCommand | string>;
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<string, unknown>;
}
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<string, unknown>;
}
export interface IAndroidtvMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
txt?: Record<string, string | undefined>;
metadata?: Record<string, unknown>;
}
export type TAndroidtvDiscoveryRecord = IAndroidtvManualEntry | IAndroidtvAdbHostRecord | IAndroidtvMdnsRecord;
export type IHomeAssistantAndroidtvConfig = IAndroidtvConfig;
+4
View File
@@ -1,2 +1,6 @@
export * from './androidtv.classes.integration.js'; 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'; export * from './androidtv.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,448 @@
import type { IDenonavrConfig, IDenonavrReceiverInfo, IDenonavrSnapshot, IDenonavrZoneState, TDenonavrCommand, TDenonavrZone } from './denonavr.types.js';
const zoneNumbers: Record<TDenonavrZone, number> = { Main: 1, Zone2: 2, Zone3: 3 };
const statusPaths: Record<TDenonavrZone, string> = {
Main: '/goform/formMainZone_MainZoneXmlStatus.xml',
Zone2: '/goform/formZone2_Zone2XmlStatus.xml',
Zone3: '/goform/formZone3_Zone3XmlStatus.xml',
};
const sourcePrefixes: Record<TDenonavrZone, string> = { Main: 'SI', Zone2: 'Z2', Zone3: 'Z3' };
const volumeUpCommands: Record<TDenonavrZone, string> = { Main: 'MVUP', Zone2: 'Z2UP', Zone3: 'Z3UP' };
const volumeDownCommands: Record<TDenonavrZone, string> = { Main: 'MVDOWN', Zone2: 'Z2DOWN', Zone3: 'Z3DOWN' };
const defaultSourceMap: Record<string, string> = {
'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<IDenonavrSnapshot> {
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<IDenonavrReceiverInfo> {
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<IDenonavrZoneState[]> {
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<string | undefined> {
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<void> {}
private async getZoneState(zoneArg: TDenonavrZone): Promise<IDenonavrZoneState> {
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<IDenonavrReceiverInfo | undefined> {
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<string>();
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<IDenonavrReceiverInfo | undefined> {
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<string> {
return this.fetchText(pathArg.startsWith('/') ? pathArg : `/${pathArg}`);
}
private async direct(commandArg: string): Promise<string> {
return this.command(`/goform/formiPhoneAppDirect.xml?${encodeURIComponent(commandArg).replace(/%2F/g, '/').replace(/%20/g, '%20')}`);
}
private async netaudioCommand(commandArg: 'CurUp' | 'CurDown'): Promise<void> {
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<string> {
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<number, string> {
const lines: Record<number, string> = {};
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(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
}
}
@@ -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<IDenonavrConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDenonavrConfig>> {
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;
};
@@ -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 { export class DenonavrIntegration extends BaseIntegration<IDenonavrConfig> {
constructor() { public readonly domain = 'denonavr';
super({ public readonly displayName = 'Denon AVR Network Receivers';
domain: "denonavr", public readonly status = 'control-runtime' as const;
displayName: "Denon AVR Network Receivers", public readonly discoveryDescriptor = createDenonavrDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new DenonavrConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/denonavr", upstreamPath: 'homeassistant/components/denonavr',
"upstreamDomain": "denonavr", upstreamDomain: 'denonavr',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_push", iotClass: 'local_push',
"requirements": [ requirements: ['denonavr==1.3.2'],
"denonavr==1.3.2" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@ol-iver', '@starkillerOG'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/denonavr',
"@ol-iver", };
"@starkillerOG"
] public async setup(configArg: IDenonavrConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
}, void contextArg;
}); return new DenonavrRuntime(new DenonavrClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantDenonavrIntegration extends DenonavrIntegration {}
class DenonavrRuntime implements IIntegrationRuntime {
public domain = 'denonavr';
constructor(private readonly client: DenonavrClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DenonavrMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DenonavrMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
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;
} }
} }
@@ -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<IDenonavrSsdpRecord> {
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<IDiscoveryMatch> {
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<IDenonavrMdnsRecord> {
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<IDiscoveryMatch> {
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<IDenonavrManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const 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;
};
+193
View File
@@ -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';
}
}
+141 -3
View File
@@ -1,4 +1,142 @@
export interface IHomeAssistantDenonavrConfig { export type TDenonavrZone = 'Main' | 'Zone2' | 'Zone3';
// TODO: replace with the TypeScript-native config for denonavr.
[key: string]: unknown; 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<string, string>;
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<string, string>;
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<string, string | undefined>;
upnp?: Record<string, string | undefined>;
}
export interface IDenonavrMdnsRecord {
name?: string;
type?: string;
host?: string;
port?: number;
txt?: Record<string, string | undefined>;
}
export interface IDenonavrManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
metadata?: Record<string, unknown>;
} }
+4
View File
@@ -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.classes.integration.js';
export * from './denonavr.discovery.js';
export * from './denonavr.mapper.js';
export * from './denonavr.types.js'; export * from './denonavr.types.js';
+7 -13
View File
@@ -54,7 +54,6 @@ import { HomeAssistantAmpioIntegration } from '../ampio/index.js';
import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js'; import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js';
import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js'; import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js';
import { HomeAssistantAndroidIpWebcamIntegration } from '../android_ip_webcam/index.js'; import { HomeAssistantAndroidIpWebcamIntegration } from '../android_ip_webcam/index.js';
import { HomeAssistantAndroidtvIntegration } from '../androidtv/index.js';
import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js'; import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js';
import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js'; import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js';
import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/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 { HomeAssistantDemoIntegration } from '../demo/index.js';
import { HomeAssistantDenonIntegration } from '../denon/index.js'; import { HomeAssistantDenonIntegration } from '../denon/index.js';
import { HomeAssistantDenonRs232Integration } from '../denon_rs232/index.js'; import { HomeAssistantDenonRs232Integration } from '../denon_rs232/index.js';
import { HomeAssistantDenonavrIntegration } from '../denonavr/index.js';
import { HomeAssistantDerivativeIntegration } from '../derivative/index.js'; import { HomeAssistantDerivativeIntegration } from '../derivative/index.js';
import { HomeAssistantDevialetIntegration } from '../devialet/index.js'; import { HomeAssistantDevialetIntegration } from '../devialet/index.js';
import { HomeAssistantDeviceAutomationIntegration } from '../device_automation/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 { HomeAssistantKmtronicIntegration } from '../kmtronic/index.js';
import { HomeAssistantKnockiIntegration } from '../knocki/index.js'; import { HomeAssistantKnockiIntegration } from '../knocki/index.js';
import { HomeAssistantKnxIntegration } from '../knx/index.js'; import { HomeAssistantKnxIntegration } from '../knx/index.js';
import { HomeAssistantKodiIntegration } from '../kodi/index.js';
import { HomeAssistantKonnectedIntegration } from '../konnected/index.js'; import { HomeAssistantKonnectedIntegration } from '../konnected/index.js';
import { HomeAssistantKonnectedEsphomeIntegration } from '../konnected_esphome/index.js'; import { HomeAssistantKonnectedEsphomeIntegration } from '../konnected_esphome/index.js';
import { HomeAssistantKostalPlenticoreIntegration } from '../kostal_plenticore/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 { HomeAssistantSabnzbdIntegration } from '../sabnzbd/index.js';
import { HomeAssistantSajIntegration } from '../saj/index.js'; import { HomeAssistantSajIntegration } from '../saj/index.js';
import { HomeAssistantSamsamIntegration } from '../samsam/index.js'; import { HomeAssistantSamsamIntegration } from '../samsam/index.js';
import { HomeAssistantSamsungtvIntegration } from '../samsungtv/index.js';
import { HomeAssistantSanixIntegration } from '../sanix/index.js'; import { HomeAssistantSanixIntegration } from '../sanix/index.js';
import { HomeAssistantSatelIntegraIntegration } from '../satel_integra/index.js'; import { HomeAssistantSatelIntegraIntegration } from '../satel_integra/index.js';
import { HomeAssistantSaunumIntegration } from '../saunum/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 { HomeAssistantTotalconnectIntegration } from '../totalconnect/index.js';
import { HomeAssistantTouchlineIntegration } from '../touchline/index.js'; import { HomeAssistantTouchlineIntegration } from '../touchline/index.js';
import { HomeAssistantTouchlineSlIntegration } from '../touchline_sl/index.js'; import { HomeAssistantTouchlineSlIntegration } from '../touchline_sl/index.js';
import { HomeAssistantTplinkIntegration } from '../tplink/index.js';
import { HomeAssistantTplinkLteIntegration } from '../tplink_lte/index.js'; import { HomeAssistantTplinkLteIntegration } from '../tplink_lte/index.js';
import { HomeAssistantTplinkOmadaIntegration } from '../tplink_omada/index.js'; import { HomeAssistantTplinkOmadaIntegration } from '../tplink_omada/index.js';
import { HomeAssistantTplinkTapoIntegration } from '../tplink_tapo/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 { HomeAssistantUkTransportIntegration } from '../uk_transport/index.js';
import { HomeAssistantUkraineAlarmIntegration } from '../ukraine_alarm/index.js'; import { HomeAssistantUkraineAlarmIntegration } from '../ukraine_alarm/index.js';
import { HomeAssistantUltraloqIntegration } from '../ultraloq/index.js'; import { HomeAssistantUltraloqIntegration } from '../ultraloq/index.js';
import { HomeAssistantUnifiIntegration } from '../unifi/index.js';
import { HomeAssistantUnifiAccessIntegration } from '../unifi_access/index.js'; import { HomeAssistantUnifiAccessIntegration } from '../unifi_access/index.js';
import { HomeAssistantUnifiDirectIntegration } from '../unifi_direct/index.js'; import { HomeAssistantUnifiDirectIntegration } from '../unifi_direct/index.js';
import { HomeAssistantUnifiDiscoveryIntegration } from '../unifi_discovery/index.js'; import { HomeAssistantUnifiDiscoveryIntegration } from '../unifi_discovery/index.js';
@@ -1497,7 +1491,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration())
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidIpWebcamIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidIpWebcamIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration());
@@ -1683,7 +1676,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelugeIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDemoIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDemoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonRs232Integration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonRs232Integration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonavrIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDerivativeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDerivativeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevialetIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevialetIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceAutomationIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceAutomationIntegration());
@@ -2078,7 +2070,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantKiwiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKmtronicIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKmtronicIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnockiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnockiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKodiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedEsphomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedEsphomeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKostalPlenticoreIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKostalPlenticoreIntegration());
@@ -2505,7 +2496,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRymproIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSabnzbdIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSabnzbdIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSajIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSajIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSamsamIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSamsamIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSamsungtvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSanixIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSanixIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSatelIntegraIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSatelIntegraIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSaunumIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantSaunumIntegration());
@@ -2714,7 +2704,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantTorqueIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTotalconnectIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTotalconnectIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTouchlineIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTouchlineIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTouchlineSlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTouchlineSlIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkLteIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkLteIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkOmadaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkOmadaIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkTapoIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkTapoIntegration());
@@ -2749,7 +2738,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantUhooIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUkTransportIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUkTransportIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUkraineAlarmIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUkraineAlarmIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUltraloqIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUltraloqIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiAccessIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiAccessIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiDirectIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiDirectIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiDiscoveryIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiDiscoveryIntegration());
@@ -2886,20 +2874,26 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1441; export const generatedHomeAssistantPortCount = 1435;
export const handwrittenHomeAssistantPortDomains = [ export const handwrittenHomeAssistantPortDomains = [
"androidtv",
"cast", "cast",
"deconz", "deconz",
"denonavr",
"esphome", "esphome",
"homekit_controller", "homekit_controller",
"hue", "hue",
"kodi",
"matter", "matter",
"mqtt", "mqtt",
"nanoleaf", "nanoleaf",
"roku", "roku",
"samsungtv",
"shelly", "shelly",
"sonos", "sonos",
"tplink",
"tradfri", "tradfri",
"unifi",
"wiz", "wiz",
"xiaomi_miio", "xiaomi_miio",
"yeelight", "yeelight",
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './kodi.classes.client.js';
export * from './kodi.classes.configflow.js';
export * from './kodi.classes.integration.js'; export * from './kodi.classes.integration.js';
export * from './kodi.discovery.js';
export * from './kodi.mapper.js';
export * from './kodi.types.js'; export * from './kodi.types.js';
+372
View File
@@ -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<IKodiSnapshot> {
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<boolean> {
const result = await this.callMethod<string>('JSONRPC.Ping');
return result === 'pong';
}
public async getApplicationProperties(): Promise<IKodiApplicationProperties> {
return this.callMethod<IKodiApplicationProperties>('Application.GetProperties', {
properties: ['name', 'version', 'volume', 'muted'],
});
}
public async getActivePlayers(): Promise<IKodiActivePlayer[]> {
return this.callMethod<IKodiActivePlayer[]>('Player.GetActivePlayers');
}
public async getPlayerProperties(playerIdArg: number): Promise<IKodiPlayerProperties> {
return this.callMethod<IKodiPlayerProperties>('Player.GetProperties', {
playerid: playerIdArg,
properties: playerProperties,
});
}
public async getPlayerItem(playerIdArg: number): Promise<IKodiMediaItem | undefined> {
const result = await this.callMethod<{ item?: IKodiMediaItem }>('Player.GetItem', {
playerid: playerIdArg,
properties: itemProperties,
});
return result.item;
}
public async setVolumeLevel(volumeLevelArg: number): Promise<number> {
return this.callMethod<number>('Application.SetVolume', {
volume: Math.max(0, Math.min(100, Math.round(volumeLevelArg * 100))),
});
}
public async stepVolume(directionArg: 'increment' | 'decrement'): Promise<number> {
return this.callMethod<number>('Application.SetVolume', { volume: directionArg });
}
public async setMuted(mutedArg: boolean): Promise<boolean> {
return this.callMethod<boolean>('Application.SetMute', { mute: mutedArg });
}
public async playPause(): Promise<unknown> {
return this.playState('toggle');
}
public async play(): Promise<unknown> {
return this.playState(true);
}
public async pause(): Promise<unknown> {
return this.playState(false);
}
public async stop(): Promise<string> {
return this.callMethod<string>('Player.Stop', { playerid: await this.activePlayerId() });
}
public async nextTrack(): Promise<string> {
return this.callMethod<string>('Player.GoTo', { playerid: await this.activePlayerId(), to: 'next' });
}
public async previousTrack(): Promise<string> {
return this.callMethod<string>('Player.GoTo', { playerid: await this.activePlayerId(), to: 'previous' });
}
public async seek(positionSecondsArg: number): Promise<unknown> {
return this.callMethod('Player.Seek', {
playerid: await this.activePlayerId(),
value: { time: this.secondsToTime(positionSecondsArg) },
});
}
public async playMedia(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number): Promise<string> {
return this.callMethod<string>('Player.Open', { item: this.mediaItem(mediaTypeArg, mediaIdArg) });
}
public async clearPlaylist(playlistIdArg = 0): Promise<string> {
return this.callMethod<string>('Playlist.Clear', { playlistid: playlistIdArg });
}
public async addToPlaylist(mediaTypeArg: TKodiMediaType, mediaIdArg: string | number, playlistIdArg = 0): Promise<string> {
return this.callMethod<string>('Playlist.Add', {
playlistid: playlistIdArg,
item: this.playlistItem(mediaTypeArg, mediaIdArg),
});
}
public async showNotification(titleArg: string, messageArg: string, iconArg = 'info', displayTimeMsArg = 10000): Promise<string> {
return this.callMethod<string>('GUI.ShowNotification', {
title: titleArg,
message: messageArg,
image: iconArg,
displaytime: Math.max(1500, Math.round(displayTimeMsArg)),
});
}
public async input(commandArg: TKodiInputCommand): Promise<string | unknown> {
const method = this.inputMethod(commandArg);
return this.callMethod(method);
}
public async quit(): Promise<string> {
return this.callMethod<string>('Application.Quit');
}
public async callMethod<T = unknown>(methodArg: string, paramsArg?: TKodiJsonRpcParams): Promise<T> {
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<T>;
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<void> {}
private async playState(playArg: boolean | 'toggle'): Promise<unknown> {
return this.callMethod('Player.PlayPause', {
playerid: await this.activePlayerId(),
play: playArg,
});
}
private async activePlayerId(): Promise<number> {
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<string, unknown> {
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<string, unknown> {
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<string, string> = {
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<string, string> {
const headers: Record<string, string> = { '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;
}
}
@@ -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<IKodiConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IKodiConfig>> {
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;
}
}
+213 -25
View File
@@ -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 { export class KodiIntegration extends BaseIntegration<IKodiConfig> {
constructor() { public readonly domain = 'kodi';
super({ public readonly displayName = 'Kodi';
domain: "kodi", public readonly status = 'control-runtime' as const;
displayName: "Kodi", public readonly discoveryDescriptor = createKodiDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new KodiConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/kodi", upstreamPath: 'homeassistant/components/kodi',
"upstreamDomain": "kodi", upstreamDomain: 'kodi',
"integrationType": "service", integrationType: 'service',
"iotClass": "local_push", iotClass: 'local_push',
"requirements": [ requirements: ['pykodi==0.2.7'],
"pykodi==0.2.7" dependencies: [],
], afterDependencies: ['media_source'],
"dependencies": [], codeowners: ['@OnFreund'],
"afterDependencies": [ configFlow: true,
"media_source" documentation: 'https://www.home-assistant.io/integrations/kodi',
], };
"codeowners": [
"@OnFreund" public async setup(configArg: IKodiConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
] void contextArg;
}, return new KodiRuntime(new KodiClient(configArg));
}); }
public async destroy(): Promise<void> {}
}
export class HomeAssistantKodiIntegration extends KodiIntegration {}
class KodiRuntime implements IIntegrationRuntime {
public domain = 'kodi';
constructor(private readonly client: KodiClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return KodiMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return KodiMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === '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<void> {
await this.client.destroy();
}
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<IServiceCallResult> {
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<IServiceCallResult> {
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<IServiceCallResult> {
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<string, unknown> | unknown[] | undefined {
const explicitParams = requestArg.data?.params;
if (Array.isArray(explicitParams)) {
return explicitParams;
}
if (explicitParams && typeof explicitParams === 'object') {
return explicitParams as Record<string, unknown>;
}
const params: Record<string, unknown> = {};
for (const [key, value] of Object.entries(requestArg.data || {})) {
if (key !== 'method') {
params[key] = value;
}
}
return Object.keys(params).length ? params : undefined;
} }
} }
+135
View File
@@ -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<IKodiMdnsRecord> {
public id = 'kodi-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Kodi JSON-RPC mDNS advertisements.';
public async matches(recordArg: IKodiMdnsRecord): Promise<IDiscoveryMatch> {
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<IKodiManualEntry> {
public id = 'kodi-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Kodi setup entries.';
public async matches(inputArg: IKodiManualEntry): Promise<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const cleanName = (valueArg: string | undefined): string | undefined => {
return valueArg?.replace(/\._xbmc-jsonrpc-h\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
};
+169
View File
@@ -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<string, string> = {
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';
}
}
+174 -2
View File
@@ -1,4 +1,176 @@
export interface IHomeAssistantKodiConfig { export interface IKodiConfig {
// TODO: replace with the TypeScript-native config for kodi. 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<string, string | number | undefined>;
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<Record<string, unknown>>;
};
[key: string]: unknown; [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<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface IKodiManualEntry {
host?: string;
port?: number;
wsPort?: number;
ssl?: boolean;
id?: string;
uuid?: string;
name?: string;
model?: string;
manufacturer?: string;
metadata?: Record<string, unknown>;
}
export type TKodiJsonRpcParams = Record<string, unknown> | unknown[] | undefined;
export interface IKodiJsonRpcRequest {
jsonrpc: '2.0';
method: string;
params?: TKodiJsonRpcParams;
id: number;
}
export interface IKodiJsonRpcResponse<T = unknown> {
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;
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './samsungtv.classes.client.js';
export * from './samsungtv.classes.configflow.js';
export * from './samsungtv.classes.integration.js'; export * from './samsungtv.classes.integration.js';
export * from './samsungtv.discovery.js';
export * from './samsungtv.mapper.js';
export * from './samsungtv.types.js'; export * from './samsungtv.types.js';
@@ -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<TWebSocketLike>();
private token?: string;
constructor(private readonly config: ISamsungtvConfig) {
this.token = config.token;
}
public async getSnapshot(): Promise<ISamsungtvSnapshot> {
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<ISamsungtvDeviceInfoResponse> {
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<ISamsungtvApp[]> {
return this.config.snapshot?.apps ?? this.config.apps ?? [];
}
public async sendKeys(keysArg: Array<TSamsungtvRemoteKey | string>, actionArg: TSamsungtvCommandAction = 'Click'): Promise<void> {
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<void> {
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<void> {
await this.sendKeys(['KEY_POWERON']);
this.updateLocalState({ power: 'on' });
}
public async turnOff(): Promise<void> {
await this.sendKeys(['KEY_POWEROFF']);
this.updateLocalState({ power: 'off', playback: 'off' });
}
public async play(): Promise<void> {
await this.sendKeys(['KEY_PLAY']);
this.updateLocalState({ playback: 'playing' });
}
public async pause(): Promise<void> {
await this.sendKeys(['KEY_PAUSE']);
this.updateLocalState({ playback: 'paused' });
}
public async playPause(): Promise<void> {
await this.sendKeys(['KEY_PLAYPAUSE']);
}
public async setVolumeLevel(volumeLevelArg: number): Promise<void> {
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<void> {
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<void> {
const source = sourceArg.trim();
if (!source) {
throw new Error('Samsung TV select_source requires a source name.');
}
const sourceKeys: Record<string, string> = {
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<void> {
for (const socket of this.sockets) {
socket.close();
}
this.sockets.clear();
}
private async sendWebsocketCommands(commandsArg: ISamsungtvWebsocketCommand[]): Promise<void> {
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<void>): Promise<void> {
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<void> {
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<ISamsungtvDeviceInfoResponse> {
return this.requestJson<ISamsungtvDeviceInfoResponse>('');
}
private async requestJson<TResult>(routeArg: string): Promise<TResult> {
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<string, string> = {
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<string, unknown>;
const data = typeof parsed.data === 'object' && parsed.data !== null ? parsed.data as Record<string, unknown> : 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<ISamsungtvState>): 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<void> {
await new Promise((resolve) => setTimeout(resolve, msArg));
}
}
@@ -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<ISamsungtvConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISamsungtvConfig>> {
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,
},
};
},
};
}
}
@@ -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 { export class SamsungtvIntegration extends BaseIntegration<ISamsungtvConfig> {
constructor() { public readonly domain = 'samsungtv';
super({ public readonly displayName = 'Samsung Smart TV';
domain: "samsungtv", public readonly status = 'control-runtime' as const;
displayName: "Samsung Smart TV", public readonly discoveryDescriptor = createSamsungtvDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new SamsungtvConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/samsungtv", upstreamPath: 'homeassistant/components/samsungtv',
"upstreamDomain": "samsungtv", upstreamDomain: 'samsungtv',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_push", iotClass: 'local_push',
"qualityScale": "gold", qualityScale: 'gold',
"requirements": [ requirements: [
"getmac==0.9.5", 'getmac==0.9.5',
"samsungctl[websocket]==0.7.1", 'samsungctl[websocket]==0.7.1',
"samsungtvws[async,encrypted]==2.7.2", 'samsungtvws[async,encrypted]==2.7.2',
"wakeonlan==3.1.0", 'wakeonlan==3.1.0',
"async-upnp-client==0.46.2" 'async-upnp-client==0.46.2',
], ],
"dependencies": [ dependencies: ['ssdp'],
"ssdp" codeowners: ['@chemelli74', '@epenet'],
], };
"afterDependencies": [],
"codeowners": [ public async setup(configArg: ISamsungtvConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
"@chemelli74", void contextArg;
"@epenet" return new SamsungtvRuntime(new SamsungtvClient(configArg));
] }
},
}); public async destroy(): Promise<void> {}
}
export class HomeAssistantSamsungtvIntegration extends SamsungtvIntegration {}
class SamsungtvRuntime implements IIntegrationRuntime {
public domain = 'samsungtv';
constructor(private readonly client: SamsungtvClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return SamsungtvMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return SamsungtvMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
return await this.callServiceUnsafe(requestArg);
} catch (error) {
return { success: false, error: this.errorMessage(error) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
private async callServiceUnsafe(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<IServiceCallResult> {
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);
} }
} }
@@ -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<ISamsungtvSsdpRecord> {
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<IDiscoveryMatch> {
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<string, unknown> = { 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<ISamsungtvMdnsRecord> {
public id = 'samsungtv-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Samsung TV mDNS AirPlay advertisements.';
public async matches(recordArg: ISamsungtvMdnsRecord): Promise<IDiscoveryMatch> {
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<ISamsungtvManualEntry> {
public id = 'samsungtv-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Samsung TV setup entries.';
public async matches(inputArg: ISamsungtvManualEntry): Promise<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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<string, string | undefined>].filter(Boolean) as Array<Record<string, string | undefined>>;
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;
};
@@ -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<Pick<ISamsungtvState, 'power' | 'playback'>> & 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<Pick<ISamsungtvState, 'power' | 'playback'>> & 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';
}
}
+138 -2
View File
@@ -1,4 +1,140 @@
export interface IHomeAssistantSamsungtvConfig { export type TSamsungtvProtocolMethod = 'websocket' | 'legacy' | 'encrypted';
// TODO: replace with the TypeScript-native config for samsungtv.
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; [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<string, unknown>;
}
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<string, string | undefined>;
upnp?: Record<string, string | undefined>;
ssdp_st?: string;
ssdp_usn?: string;
ssdp_location?: string;
}
export interface ISamsungtvMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface ISamsungtvManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
model?: string;
manufacturer?: string;
macAddress?: string;
token?: string;
metadata?: Record<string, unknown>;
}
export type TSamsungtvDiscoveryRecord = ISamsungtvSsdpRecord | ISamsungtvMdnsRecord | ISamsungtvManualEntry;
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './tplink.classes.client.js';
export * from './tplink.classes.configflow.js';
export * from './tplink.classes.integration.js'; export * from './tplink.classes.integration.js';
export * from './tplink.discovery.js';
export * from './tplink.mapper.js';
export * from './tplink.types.js'; export * from './tplink.types.js';
@@ -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<TTplinkEventHandler>();
constructor(private readonly config: ITplinkConfig) {}
public async getSnapshot(): Promise<ITplinkSnapshot> {
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<ITplinkCommandResult> {
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<void> {
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.';
}
}
@@ -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<ITplinkConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ITplinkConfig>> {
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<string, unknown>): Promise<IConfigFlowStep<ITplinkConfig>> {
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<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -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 { export class TplinkIntegration extends BaseIntegration<ITplinkConfig> {
constructor() { public readonly domain = 'tplink';
super({ public readonly displayName = 'TP-Link Smart Home';
domain: "tplink", public readonly status = 'control-runtime' as const;
displayName: "TP-Link Smart Home", public readonly discoveryDescriptor = createTplinkDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new TplinkConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/tplink", upstreamPath: 'homeassistant/components/tplink',
"upstreamDomain": "tplink", upstreamDomain: 'tplink',
"integrationType": "device", documentation: 'https://www.home-assistant.io/integrations/tplink',
"iotClass": "local_polling", integrationType: 'device',
"qualityScale": "platinum", iotClass: 'local_polling',
"requirements": [ qualityScale: 'platinum',
"python-kasa[speedups]==0.10.2" requirements: ['python-kasa[speedups]==0.10.2'],
], dependencies: ['network', 'ffmpeg', 'stream'],
"dependencies": [ afterDependencies: [] as string[],
"network", codeowners: ['@rytilahti', '@bdraco', '@sdb9696'],
"ffmpeg", dhcpDiscoveryPorts: [9999, 20002],
"stream" liveLocalWritesImplemented: false,
], };
"afterDependencies": [],
"codeowners": [ public async setup(configArg: ITplinkConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
"@rytilahti", void contextArg;
"@bdraco", return new TplinkRuntime(new TplinkClient(configArg));
"@sdb9696" }
]
}, public async destroy(): Promise<void> {}
}); }
export class HomeAssistantTplinkIntegration extends TplinkIntegration {}
class TplinkRuntime implements IIntegrationRuntime {
public domain = 'tplink';
constructor(private readonly client: TplinkClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return TplinkMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return TplinkMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(TplinkMapper.toIntegrationEvent(eventArg)));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
+270
View File
@@ -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<ITplinkMdnsRecord> {
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<IDiscoveryMatch> {
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<string, string | undefined>, keyArg: string): string | undefined {
return txtArg[keyArg] || txtArg[keyArg.toUpperCase()];
}
}
export class TplinkDhcpMatcher implements IDiscoveryMatcher<ITplinkDhcpRecord> {
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<IDiscoveryMatch> {
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<ITplinkManualDiscoveryRecord> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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));
};
+887
View File
@@ -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<string, string> = {
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<string, number>();
const seen = new Set<string>();
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<string, unknown>);
}
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<string, unknown> = { 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<typeof TplinkMapper.controlState>, usedIdsArg: Map<string, number>): 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<typeof TplinkMapper.controlState>, usedIdsArg: Map<string, number>): 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<string, number>): 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<string, number>): 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<string, unknown>, 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<string, unknown>, 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<ITplinkFeature & { key: string }> {
const properties: Array<ITplinkFeature & { key: string }> = [];
const state = this.normalizedState(deviceArg);
const existing = new Set<string>();
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<string, number>, attributesArg: Record<string, unknown>, 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<string, ITplinkDevice>();
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<string, plugins.shxInterfaces.data.IDeviceFeature>();
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<string, unknown> {
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, number>): 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined, keysArg: string[]): unknown {
for (const key of keysArg) {
if (dataArg && key in dataArg) {
return dataArg[key];
}
}
return undefined;
}
private static numberFromData(dataArg: Record<string, unknown> | 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<string, unknown> {
return this.isRecord(valueArg) ? valueArg : {};
}
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
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));
}
}
+298 -2
View File
@@ -1,4 +1,300 @@
export interface IHomeAssistantTplinkConfig { import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for tplink.
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<string, unknown>;
[key: string]: unknown; [key: string]: unknown;
} }
export interface ITplinkConnectionParameters {
deviceFamily?: string;
encryptionType?: string;
loginVersion?: number;
usesHttp?: boolean;
httpPort?: number;
[key: string]: unknown;
}
export type ITplinkStateRecord = Record<string, unknown>;
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<string, unknown>;
[key: string]: unknown;
}
export interface ITplinkModuleState {
name?: string;
features?: Record<string, ITplinkFeature> | 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<string, unknown>;
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<string, ITplinkFeature> | ITplinkFeature[];
modules?: Record<string, ITplinkModuleState>;
state?: ITplinkStateRecord;
sysInfo?: ITplinkStateRecord;
systemInfo?: ITplinkStateRecord;
lightState?: ITplinkStateRecord;
emeter?: ITplinkStateRecord;
sensors?: ITplinkFeature[];
entities?: ITplinkEntityDescriptor[];
available?: boolean;
online?: boolean;
updatedAt?: string;
metadata?: Record<string, unknown>;
[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<string, unknown>;
}
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<string, ITplinkFeature> | ITplinkFeature[];
device?: ITplinkDevice;
devices?: ITplinkDevice[];
snapshot?: ITplinkSnapshot;
credentials?: ITplinkCredentials;
metadata?: Record<string, unknown>;
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<string, unknown>;
connectionParameters?: ITplinkConnectionParameters;
usesHttp?: boolean;
snapshot?: ITplinkSnapshot;
device?: ITplinkDevice;
devices?: ITplinkDevice[];
manualEntries?: ITplinkManualEntry[];
state?: ITplinkStateRecord;
features?: Record<string, ITplinkFeature> | ITplinkFeature[];
modules?: Record<string, ITplinkModuleState>;
children?: ITplinkChildDevice[];
entities?: ITplinkEntityDescriptor[];
events?: ITplinkEvent[];
timeoutMs?: number;
commandExecutor?: TTplinkCommandExecutor;
metadata?: Record<string, unknown>;
[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<string, unknown>;
}
export interface ITplinkCommandResult extends IServiceCallResult {}
export type TTplinkCommandExecutor = (
commandArg: ITplinkClientCommand
) => Promise<ITplinkCommandResult | unknown> | ITplinkCommandResult | unknown;
export interface ITplinkMdnsRecord {
type?: string;
serviceType?: string;
name?: string;
host?: string;
hostname?: string;
addresses?: string[];
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
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<string, unknown>;
[key: string]: unknown;
}
export interface ITplinkManualDiscoveryRecord extends ITplinkManualEntry {}
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './unifi.classes.client.js';
export * from './unifi.classes.configflow.js';
export * from './unifi.classes.integration.js'; export * from './unifi.classes.integration.js';
export * from './unifi.discovery.js';
export * from './unifi.mapper.js';
export * from './unifi.types.js'; export * from './unifi.types.js';
@@ -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<TData> = {
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<IUnifiSnapshot> {
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<IUnifiCommandResult> {
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<void> {
this.eventHandlers.clear();
}
private async fetchSnapshot(): Promise<IUnifiSnapshot> {
await this.login();
const site = this.config.site || unifiDefaultSite;
const [sites, clients, allClients, devices, wlans, systemInfo] = await Promise.all([
this.requestData<Record<string, unknown>[]>('/self/sites', { global: true }).catch(() => []),
this.requestData<Record<string, unknown>[]>('/stat/sta').catch(() => []),
this.requestData<Record<string, unknown>[]>('/rest/user').catch(() => []),
this.requestData<Record<string, unknown>[]>('/stat/device').catch(() => []),
this.requestData<Record<string, unknown>[]>('/rest/wlanconf').catch(() => []),
this.requestData<Record<string, unknown>[]>('/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<void> {
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<unknown> : {};
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<boolean> {
const response = await this.fetchPath('', { method: 'GET', redirect: 'manual' }).catch(() => undefined);
return response?.status === 200;
}
private async requestData<TData>(pathArg: string, optionsArg: { global?: boolean } = {}): Promise<TData> {
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<TData> : {};
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<Response> {
return globalThis.fetch(`${this.baseUrl()}${pathArg}`, initArg);
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
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;
}
}
@@ -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<IUnifiConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IUnifiConfig>> {
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),
},
},
}),
};
}
}
@@ -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 { export class UnifiIntegration extends BaseIntegration<IUnifiConfig> {
constructor() { public readonly domain = 'unifi';
super({ public readonly displayName = 'UniFi Network';
domain: "unifi", public readonly status = 'control-runtime' as const;
displayName: "UniFi Network", public readonly discoveryDescriptor = createUnifiDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new UnifiConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/unifi", upstreamPath: 'homeassistant/components/unifi',
"upstreamDomain": "unifi", upstreamDomain: 'unifi',
"integrationType": "hub", integrationType: 'hub',
"iotClass": "local_push", iotClass: 'local_push',
"qualityScale": "silver", qualityScale: 'silver',
"requirements": [ requirements: ['aiounifi==90'],
"aiounifi==90" dependencies: ['unifi_discovery'],
], afterDependencies: [] as string[],
"dependencies": [ codeowners: ['@Kane610'],
"unifi_discovery" 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',
"afterDependencies": [], };
"codeowners": [
"@Kane610" public async setup(configArg: IUnifiConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
] void contextArg;
}, return new UnifiRuntime(new UnifiClient(configArg));
}); }
public async destroy(): Promise<void> {}
}
export class HomeAssistantUnifiIntegration extends UnifiIntegration {}
class UnifiRuntime implements IIntegrationRuntime {
public domain = 'unifi';
constructor(private readonly client: UnifiClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return UnifiMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return UnifiMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(UnifiMapper.toIntegrationEvent(eventArg)));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
+207
View File
@@ -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<IUnifiMdnsRecord> {
public id = 'unifi-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize UniFi Network controller mDNS advertisements.';
public async matches(recordArg: IUnifiMdnsRecord): Promise<IDiscoveryMatch> {
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<IUnifiSsdpRecord> {
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<IDiscoveryMatch> {
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<string, string | undefined>): Record<string, string | undefined> {
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<IUnifiManualDiscoveryEntry> {
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<IDiscoveryMatch> {
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<IUnifiDiscoveryDeviceRecord> {
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<IDiscoveryMatch> {
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<string, unknown>,
},
};
}
}
export class UnifiCandidateValidator implements IDiscoveryValidator {
public id = 'unifi-candidate-validator';
public description = 'Validate UniFi Network candidates before setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
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());
};
+704
View File
@@ -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<string, number>();
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<string, number>): 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<string, number>): 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<string, number>): 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<string, number>): 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<string, number>, attributesArg?: Record<string, unknown>, 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<string, number>, 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<TKey extends 'sites' | 'clients' | 'wlans'>(configArg: IUnifiConfig, keyArg: TKey): NonNullable<IUnifiConfig[TKey]> {
return (configArg.manualEntries || []).flatMap((entryArg) => entryArg.snapshot?.[keyArg] || entryArg[keyArg] || []) as NonNullable<IUnifiConfig[TKey]>;
}
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';
}
}
+344 -3
View File
@@ -1,4 +1,345 @@
export interface IHomeAssistantUnifiConfig { export const unifiDefaultPort = 443;
// TODO: replace with the TypeScript-native config for unifi. export const unifiDefaultSite = 'default';
[key: string]: unknown;
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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
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<string, string | undefined>;
}
export interface IUnifiManualDiscoveryEntry {
id?: string;
host?: string;
port?: number;
site?: string;
name?: string;
macAddress?: string;
manufacturer?: string;
model?: string;
services?: Partial<Record<TUnifiDiscoveryService, boolean>>;
metadata?: Record<string, unknown>;
}
export interface IUnifiDiscoveryDeviceRecord {
source_ip?: string;
hw_addr?: string;
direct_connect_domain?: string;
services?: Partial<Record<TUnifiDiscoveryService, boolean>> | Record<string, boolean | undefined>;
name?: string;
model?: string;
}
export type IHomeAssistantUnifiConfig = IUnifiConfig;