Add native media and network integrations
This commit is contained in:
+12
@@ -3,17 +3,23 @@ export * from './protocols/index.js';
|
||||
export * from './integrations/index.js';
|
||||
|
||||
import { HueIntegration } from './integrations/hue/index.js';
|
||||
import { AndroidtvIntegration } from './integrations/androidtv/index.js';
|
||||
import { CastIntegration } from './integrations/cast/index.js';
|
||||
import { DeconzIntegration } from './integrations/deconz/index.js';
|
||||
import { DenonavrIntegration } from './integrations/denonavr/index.js';
|
||||
import { EsphomeIntegration } from './integrations/esphome/index.js';
|
||||
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
|
||||
import { KodiIntegration } from './integrations/kodi/index.js';
|
||||
import { MatterIntegration } from './integrations/matter/index.js';
|
||||
import { MqttIntegration } from './integrations/mqtt/index.js';
|
||||
import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
|
||||
import { RokuIntegration } from './integrations/roku/index.js';
|
||||
import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
|
||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||
import { SonosIntegration } from './integrations/sonos/index.js';
|
||||
import { TplinkIntegration } from './integrations/tplink/index.js';
|
||||
import { TradfriIntegration } from './integrations/tradfri/index.js';
|
||||
import { UnifiIntegration } from './integrations/unifi/index.js';
|
||||
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
||||
import { WizIntegration } from './integrations/wiz/index.js';
|
||||
import { XiaomiMiioIntegration } from './integrations/xiaomi_miio/index.js';
|
||||
@@ -24,18 +30,24 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated
|
||||
import { IntegrationRegistry } from './core/index.js';
|
||||
|
||||
export const integrations = [
|
||||
new AndroidtvIntegration(),
|
||||
new CastIntegration(),
|
||||
new DeconzIntegration(),
|
||||
new DenonavrIntegration(),
|
||||
new EsphomeIntegration(),
|
||||
new HomekitControllerIntegration(),
|
||||
new HueIntegration(),
|
||||
new KodiIntegration(),
|
||||
new MatterIntegration(),
|
||||
new MqttIntegration(),
|
||||
new NanoleafIntegration(),
|
||||
new RokuIntegration(),
|
||||
new SamsungtvIntegration(),
|
||||
new ShellyIntegration(),
|
||||
new SonosIntegration(),
|
||||
new TplinkIntegration(),
|
||||
new TradfriIntegration(),
|
||||
new UnifiIntegration(),
|
||||
new WolfSmartsetIntegration(),
|
||||
new WizIntegration(),
|
||||
new XiaomiMiioIntegration(),
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "androidtv",
|
||||
displayName: "Android Debug Bridge",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/androidtv",
|
||||
"upstreamDomain": "androidtv",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"adb-shell[async]==0.4.4",
|
||||
"androidtv[async]==0.0.75"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@JeffLIrion",
|
||||
"@ollo69"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class AndroidtvIntegration extends BaseIntegration<IAndroidtvConfig> {
|
||||
public readonly domain = 'androidtv';
|
||||
public readonly displayName = 'Android Debug Bridge';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createAndroidtvDiscoveryDescriptor();
|
||||
public readonly configFlow = new AndroidtvConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/androidtv',
|
||||
upstreamDomain: 'androidtv',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['adb-shell[async]==0.4.4', 'androidtv[async]==0.0.75'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@JeffLIrion', '@ollo69'],
|
||||
};
|
||||
|
||||
public async setup(configArg: IAndroidtvConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,191 @@
|
||||
export interface IHomeAssistantAndroidtvConfig {
|
||||
// TODO: replace with the TypeScript-native config for androidtv.
|
||||
[key: string]: unknown;
|
||||
export type TAndroidtvDeviceClass = 'auto' | 'androidtv' | 'firetv';
|
||||
|
||||
export type TAndroidtvMediaState = 'off' | 'idle' | 'standby' | 'playing' | 'paused' | 'stopped' | 'unknown';
|
||||
|
||||
export type TAndroidtvPowerState = 'on' | 'off' | 'unknown';
|
||||
|
||||
export type TAndroidtvKeyCommand =
|
||||
| 'BACK'
|
||||
| 'BLUE'
|
||||
| 'CENTER'
|
||||
| 'COMPONENT1'
|
||||
| 'COMPONENT2'
|
||||
| 'COMPOSITE1'
|
||||
| 'COMPOSITE2'
|
||||
| 'DOWN'
|
||||
| 'END'
|
||||
| 'ENTER'
|
||||
| 'ESCAPE'
|
||||
| 'FAST_FORWARD'
|
||||
| 'GREEN'
|
||||
| 'HDMI1'
|
||||
| 'HDMI2'
|
||||
| 'HDMI3'
|
||||
| 'HDMI4'
|
||||
| 'HOME'
|
||||
| 'INPUT'
|
||||
| 'LEFT'
|
||||
| 'MENU'
|
||||
| 'MOVE_HOME'
|
||||
| 'MUTE'
|
||||
| 'PAIRING'
|
||||
| 'POWER'
|
||||
| 'RED'
|
||||
| 'RESUME'
|
||||
| 'REWIND'
|
||||
| 'RIGHT'
|
||||
| 'SAT'
|
||||
| 'SEARCH'
|
||||
| 'SETTINGS'
|
||||
| 'SLEEP'
|
||||
| 'SUSPEND'
|
||||
| 'SYSDOWN'
|
||||
| 'SYSLEFT'
|
||||
| 'SYSRIGHT'
|
||||
| 'SYSUP'
|
||||
| 'TEXT'
|
||||
| 'TOP'
|
||||
| 'UP'
|
||||
| 'VGA'
|
||||
| 'VOLUME_DOWN'
|
||||
| 'VOLUME_UP'
|
||||
| 'WAKEUP'
|
||||
| 'YELLOW';
|
||||
|
||||
export type TAndroidtvCommandAction =
|
||||
| 'turn_on'
|
||||
| 'turn_off'
|
||||
| 'media_play'
|
||||
| 'media_pause'
|
||||
| 'media_play_pause'
|
||||
| 'media_stop'
|
||||
| 'volume_set'
|
||||
| 'volume_step'
|
||||
| 'volume_mute'
|
||||
| 'select_source'
|
||||
| 'remote_send_command'
|
||||
| 'adb_command';
|
||||
|
||||
export interface IAndroidtvConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
deviceClass?: TAndroidtvDeviceClass;
|
||||
deviceName?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
adbKeyPath?: string;
|
||||
adbServerIp?: string;
|
||||
adbServerPort?: number;
|
||||
deviceInfo?: IAndroidtvDeviceInfo;
|
||||
state?: IAndroidtvDeviceState;
|
||||
apps?: IAndroidtvApp[];
|
||||
snapshot?: IAndroidtvSnapshot;
|
||||
}
|
||||
|
||||
export interface IAndroidtvDeviceInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
deviceClass?: TAndroidtvDeviceClass;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
softwareVersion?: string;
|
||||
productId?: string;
|
||||
wifiMac?: string;
|
||||
ethernetMac?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvDeviceState {
|
||||
rawState?: TAndroidtvMediaState | string;
|
||||
powerState?: TAndroidtvPowerState;
|
||||
available?: boolean;
|
||||
currentAppId?: string;
|
||||
currentAppName?: string;
|
||||
runningAppIds?: string[];
|
||||
source?: string;
|
||||
volumeLevel?: number;
|
||||
isVolumeMuted?: boolean;
|
||||
hdmiInput?: string;
|
||||
adbResponse?: string;
|
||||
mediaTitle?: string;
|
||||
mediaArtist?: string;
|
||||
mediaAlbumName?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvApp {
|
||||
id: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
isRunning?: boolean;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export interface IAndroidtvCommand {
|
||||
action: TAndroidtvCommandAction;
|
||||
key?: TAndroidtvKeyCommand | string;
|
||||
keys?: Array<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;
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './androidtv.classes.integration.js';
|
||||
export * from './androidtv.classes.client.js';
|
||||
export * from './androidtv.classes.configflow.js';
|
||||
export * from './androidtv.discovery.js';
|
||||
export * from './androidtv.mapper.js';
|
||||
export * from './androidtv.types.js';
|
||||
|
||||
@@ -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(/'/g, "'").replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "denonavr",
|
||||
displayName: "Denon AVR Network Receivers",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/denonavr",
|
||||
"upstreamDomain": "denonavr",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"denonavr==1.3.2"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@ol-iver",
|
||||
"@starkillerOG"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class DenonavrIntegration extends BaseIntegration<IDenonavrConfig> {
|
||||
public readonly domain = 'denonavr';
|
||||
public readonly displayName = 'Denon AVR Network Receivers';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createDenonavrDiscoveryDescriptor();
|
||||
public readonly configFlow = new DenonavrConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/denonavr',
|
||||
upstreamDomain: 'denonavr',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['denonavr==1.3.2'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@ol-iver', '@starkillerOG'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/denonavr',
|
||||
};
|
||||
|
||||
public async setup(configArg: IDenonavrConfig, contextArg: IIntegrationSetupContext): Promise<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;
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,142 @@
|
||||
export interface IHomeAssistantDenonavrConfig {
|
||||
// TODO: replace with the TypeScript-native config for denonavr.
|
||||
[key: string]: unknown;
|
||||
export type TDenonavrZone = 'Main' | 'Zone2' | 'Zone3';
|
||||
|
||||
export type TDenonavrPowerState = 'ON' | 'STANDBY' | 'OFF' | string;
|
||||
|
||||
export type TDenonavrMediaState = 'on' | 'off' | 'playing' | 'paused' | 'stopped' | 'idle' | string;
|
||||
|
||||
export type TDenonavrReceiverType = 'avr' | 'avr-x' | 'avr-x-2016' | string;
|
||||
|
||||
export type TDenonavrCommand =
|
||||
| 'turn_on'
|
||||
| 'turn_off'
|
||||
| 'volume_up'
|
||||
| 'volume_down'
|
||||
| 'set_volume'
|
||||
| 'mute'
|
||||
| 'select_source'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'play_pause'
|
||||
| 'previous_track'
|
||||
| 'next_track'
|
||||
| 'get_command';
|
||||
|
||||
export interface IDenonavrConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
serialNumber?: string;
|
||||
receiverType?: TDenonavrReceiverType;
|
||||
descriptionPort?: number;
|
||||
showAllSources?: boolean;
|
||||
zone2?: boolean;
|
||||
zone3?: boolean;
|
||||
sourceMap?: Record<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>;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './denonavr.classes.client.js';
|
||||
export * from './denonavr.classes.configflow.js';
|
||||
export * from './denonavr.classes.integration.js';
|
||||
export * from './denonavr.discovery.js';
|
||||
export * from './denonavr.mapper.js';
|
||||
export * from './denonavr.types.js';
|
||||
|
||||
@@ -54,7 +54,6 @@ import { HomeAssistantAmpioIntegration } from '../ampio/index.js';
|
||||
import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js';
|
||||
import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js';
|
||||
import { HomeAssistantAndroidIpWebcamIntegration } from '../android_ip_webcam/index.js';
|
||||
import { HomeAssistantAndroidtvIntegration } from '../androidtv/index.js';
|
||||
import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js';
|
||||
import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js';
|
||||
import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js';
|
||||
@@ -240,7 +239,6 @@ import { HomeAssistantDelugeIntegration } from '../deluge/index.js';
|
||||
import { HomeAssistantDemoIntegration } from '../demo/index.js';
|
||||
import { HomeAssistantDenonIntegration } from '../denon/index.js';
|
||||
import { HomeAssistantDenonRs232Integration } from '../denon_rs232/index.js';
|
||||
import { HomeAssistantDenonavrIntegration } from '../denonavr/index.js';
|
||||
import { HomeAssistantDerivativeIntegration } from '../derivative/index.js';
|
||||
import { HomeAssistantDevialetIntegration } from '../devialet/index.js';
|
||||
import { HomeAssistantDeviceAutomationIntegration } from '../device_automation/index.js';
|
||||
@@ -635,7 +633,6 @@ import { HomeAssistantKiwiIntegration } from '../kiwi/index.js';
|
||||
import { HomeAssistantKmtronicIntegration } from '../kmtronic/index.js';
|
||||
import { HomeAssistantKnockiIntegration } from '../knocki/index.js';
|
||||
import { HomeAssistantKnxIntegration } from '../knx/index.js';
|
||||
import { HomeAssistantKodiIntegration } from '../kodi/index.js';
|
||||
import { HomeAssistantKonnectedIntegration } from '../konnected/index.js';
|
||||
import { HomeAssistantKonnectedEsphomeIntegration } from '../konnected_esphome/index.js';
|
||||
import { HomeAssistantKostalPlenticoreIntegration } from '../kostal_plenticore/index.js';
|
||||
@@ -1062,7 +1059,6 @@ import { HomeAssistantRymproIntegration } from '../rympro/index.js';
|
||||
import { HomeAssistantSabnzbdIntegration } from '../sabnzbd/index.js';
|
||||
import { HomeAssistantSajIntegration } from '../saj/index.js';
|
||||
import { HomeAssistantSamsamIntegration } from '../samsam/index.js';
|
||||
import { HomeAssistantSamsungtvIntegration } from '../samsungtv/index.js';
|
||||
import { HomeAssistantSanixIntegration } from '../sanix/index.js';
|
||||
import { HomeAssistantSatelIntegraIntegration } from '../satel_integra/index.js';
|
||||
import { HomeAssistantSaunumIntegration } from '../saunum/index.js';
|
||||
@@ -1271,7 +1267,6 @@ import { HomeAssistantTorqueIntegration } from '../torque/index.js';
|
||||
import { HomeAssistantTotalconnectIntegration } from '../totalconnect/index.js';
|
||||
import { HomeAssistantTouchlineIntegration } from '../touchline/index.js';
|
||||
import { HomeAssistantTouchlineSlIntegration } from '../touchline_sl/index.js';
|
||||
import { HomeAssistantTplinkIntegration } from '../tplink/index.js';
|
||||
import { HomeAssistantTplinkLteIntegration } from '../tplink_lte/index.js';
|
||||
import { HomeAssistantTplinkOmadaIntegration } from '../tplink_omada/index.js';
|
||||
import { HomeAssistantTplinkTapoIntegration } from '../tplink_tapo/index.js';
|
||||
@@ -1306,7 +1301,6 @@ import { HomeAssistantUhooIntegration } from '../uhoo/index.js';
|
||||
import { HomeAssistantUkTransportIntegration } from '../uk_transport/index.js';
|
||||
import { HomeAssistantUkraineAlarmIntegration } from '../ukraine_alarm/index.js';
|
||||
import { HomeAssistantUltraloqIntegration } from '../ultraloq/index.js';
|
||||
import { HomeAssistantUnifiIntegration } from '../unifi/index.js';
|
||||
import { HomeAssistantUnifiAccessIntegration } from '../unifi_access/index.js';
|
||||
import { HomeAssistantUnifiDirectIntegration } from '../unifi_direct/index.js';
|
||||
import { HomeAssistantUnifiDiscoveryIntegration } from '../unifi_discovery/index.js';
|
||||
@@ -1497,7 +1491,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration())
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidIpWebcamIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration());
|
||||
@@ -1683,7 +1676,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelugeIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDemoIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonRs232Integration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonavrIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDerivativeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevialetIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDeviceAutomationIntegration());
|
||||
@@ -2078,7 +2070,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantKiwiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKmtronicIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnockiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKodiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedEsphomeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKostalPlenticoreIntegration());
|
||||
@@ -2505,7 +2496,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRymproIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSabnzbdIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSajIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSamsamIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSamsungtvIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSanixIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSatelIntegraIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSaunumIntegration());
|
||||
@@ -2714,7 +2704,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantTorqueIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTotalconnectIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTouchlineIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTouchlineSlIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkLteIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkOmadaIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantTplinkTapoIntegration());
|
||||
@@ -2749,7 +2738,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantUhooIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUkTransportIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUkraineAlarmIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUltraloqIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiAccessIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiDirectIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantUnifiDiscoveryIntegration());
|
||||
@@ -2886,20 +2874,26 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1441;
|
||||
export const generatedHomeAssistantPortCount = 1435;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"androidtv",
|
||||
"cast",
|
||||
"deconz",
|
||||
"denonavr",
|
||||
"esphome",
|
||||
"homekit_controller",
|
||||
"hue",
|
||||
"kodi",
|
||||
"matter",
|
||||
"mqtt",
|
||||
"nanoleaf",
|
||||
"roku",
|
||||
"samsungtv",
|
||||
"shelly",
|
||||
"sonos",
|
||||
"tplink",
|
||||
"tradfri",
|
||||
"unifi",
|
||||
"wiz",
|
||||
"xiaomi_miio",
|
||||
"yeelight",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './kodi.classes.client.js';
|
||||
export * from './kodi.classes.configflow.js';
|
||||
export * from './kodi.classes.integration.js';
|
||||
export * from './kodi.discovery.js';
|
||||
export * from './kodi.mapper.js';
|
||||
export * from './kodi.types.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,216 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { KodiClient } from './kodi.classes.client.js';
|
||||
import { KodiConfigFlow } from './kodi.classes.configflow.js';
|
||||
import { createKodiDiscoveryDescriptor } from './kodi.discovery.js';
|
||||
import { KodiMapper } from './kodi.mapper.js';
|
||||
import type { IKodiConfig, TKodiMediaType } from './kodi.types.js';
|
||||
|
||||
export class HomeAssistantKodiIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "kodi",
|
||||
displayName: "Kodi",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/kodi",
|
||||
"upstreamDomain": "kodi",
|
||||
"integrationType": "service",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"pykodi==0.2.7"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [
|
||||
"media_source"
|
||||
],
|
||||
"codeowners": [
|
||||
"@OnFreund"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class KodiIntegration extends BaseIntegration<IKodiConfig> {
|
||||
public readonly domain = 'kodi';
|
||||
public readonly displayName = 'Kodi';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createKodiDiscoveryDescriptor();
|
||||
public readonly configFlow = new KodiConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/kodi',
|
||||
upstreamDomain: 'kodi',
|
||||
integrationType: 'service',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['pykodi==0.2.7'],
|
||||
dependencies: [],
|
||||
afterDependencies: ['media_source'],
|
||||
codeowners: ['@OnFreund'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/kodi',
|
||||
};
|
||||
|
||||
public async setup(configArg: IKodiConfig, contextArg: IIntegrationSetupContext): Promise<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,176 @@
|
||||
export interface IHomeAssistantKodiConfig {
|
||||
// TODO: replace with the TypeScript-native config for kodi.
|
||||
export interface IKodiConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
wsPort?: number;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
deviceInfo?: IKodiDeviceInfo;
|
||||
snapshot?: IKodiSnapshot;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantKodiConfig extends IKodiConfig {}
|
||||
|
||||
export interface IKodiDeviceInfo {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
wsPort?: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface IKodiApplicationVersion {
|
||||
major?: number;
|
||||
minor?: number;
|
||||
revision?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface IKodiApplicationProperties {
|
||||
name?: string;
|
||||
version?: IKodiApplicationVersion;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface IKodiActivePlayer {
|
||||
playerid: number;
|
||||
type?: 'audio' | 'video' | 'picture' | string;
|
||||
playertype?: string;
|
||||
}
|
||||
|
||||
export interface IKodiTime {
|
||||
hours?: number;
|
||||
minutes?: number;
|
||||
seconds?: number;
|
||||
milliseconds?: number;
|
||||
}
|
||||
|
||||
export interface IKodiPlayerProperties {
|
||||
speed?: number;
|
||||
time?: IKodiTime;
|
||||
totaltime?: IKodiTime;
|
||||
live?: boolean;
|
||||
percentage?: number;
|
||||
playlistid?: number;
|
||||
position?: number;
|
||||
repeat?: string;
|
||||
shuffled?: boolean;
|
||||
}
|
||||
|
||||
export interface IKodiMediaItem {
|
||||
id?: number;
|
||||
type?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
file?: string;
|
||||
uniqueid?: string | Record<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;
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './samsungtv.classes.client.js';
|
||||
export * from './samsungtv.classes.configflow.js';
|
||||
export * from './samsungtv.classes.integration.js';
|
||||
export * from './samsungtv.discovery.js';
|
||||
export * from './samsungtv.mapper.js';
|
||||
export * from './samsungtv.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "samsungtv",
|
||||
displayName: "Samsung Smart TV",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/samsungtv",
|
||||
"upstreamDomain": "samsungtv",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "gold",
|
||||
"requirements": [
|
||||
"getmac==0.9.5",
|
||||
"samsungctl[websocket]==0.7.1",
|
||||
"samsungtvws[async,encrypted]==2.7.2",
|
||||
"wakeonlan==3.1.0",
|
||||
"async-upnp-client==0.46.2"
|
||||
],
|
||||
"dependencies": [
|
||||
"ssdp"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@chemelli74",
|
||||
"@epenet"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class SamsungtvIntegration extends BaseIntegration<ISamsungtvConfig> {
|
||||
public readonly domain = 'samsungtv';
|
||||
public readonly displayName = 'Samsung Smart TV';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createSamsungtvDiscoveryDescriptor();
|
||||
public readonly configFlow = new SamsungtvConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/samsungtv',
|
||||
upstreamDomain: 'samsungtv',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'gold',
|
||||
requirements: [
|
||||
'getmac==0.9.5',
|
||||
'samsungctl[websocket]==0.7.1',
|
||||
'samsungtvws[async,encrypted]==2.7.2',
|
||||
'wakeonlan==3.1.0',
|
||||
'async-upnp-client==0.46.2',
|
||||
],
|
||||
dependencies: ['ssdp'],
|
||||
codeowners: ['@chemelli74', '@epenet'],
|
||||
};
|
||||
|
||||
public async setup(configArg: ISamsungtvConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,140 @@
|
||||
export interface IHomeAssistantSamsungtvConfig {
|
||||
// TODO: replace with the TypeScript-native config for samsungtv.
|
||||
export type TSamsungtvProtocolMethod = 'websocket' | 'legacy' | 'encrypted';
|
||||
|
||||
export type TSamsungtvPowerState = 'on' | 'off' | 'unknown';
|
||||
|
||||
export type TSamsungtvRemoteKey = `KEY_${string}` | string;
|
||||
|
||||
export type TSamsungtvCommandAction = 'Click' | 'Press' | 'Release';
|
||||
|
||||
export interface ISamsungtvConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
macAddress?: string;
|
||||
method?: TSamsungtvProtocolMethod;
|
||||
sessionId?: string;
|
||||
deviceInfo?: ISamsungtvDeviceInfoResponse;
|
||||
state?: ISamsungtvState;
|
||||
apps?: ISamsungtvApp[];
|
||||
activeApp?: ISamsungtvApp;
|
||||
snapshot?: ISamsungtvSnapshot;
|
||||
ssdpRenderingControlLocation?: string;
|
||||
ssdpMainTvAgentLocation?: string;
|
||||
websocketName?: string;
|
||||
keyPressDelayMs?: number;
|
||||
connectTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantSamsungtvConfig extends ISamsungtvConfig {}
|
||||
|
||||
export interface ISamsungtvDeviceInfoResponse {
|
||||
id?: string;
|
||||
device?: ISamsungtvDeviceInfo;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISamsungtvDeviceInfo {
|
||||
type?: string;
|
||||
name?: string;
|
||||
modelName?: string;
|
||||
modelNumber?: string;
|
||||
serialNumber?: string;
|
||||
udn?: string;
|
||||
wifiMac?: string;
|
||||
manufacturer?: string;
|
||||
networkType?: string;
|
||||
PowerState?: string;
|
||||
FrameTVSupport?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISamsungtvState {
|
||||
power?: TSamsungtvPowerState;
|
||||
playback?: 'playing' | 'paused' | 'idle' | 'off' | 'unknown';
|
||||
volumeLevel?: number;
|
||||
muted?: boolean;
|
||||
source?: string;
|
||||
appId?: string;
|
||||
appName?: string;
|
||||
mediaTitle?: string;
|
||||
}
|
||||
|
||||
export interface ISamsungtvApp {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
appType?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ISamsungtvSnapshot {
|
||||
deviceInfo: ISamsungtvDeviceInfoResponse;
|
||||
state?: ISamsungtvState;
|
||||
apps: ISamsungtvApp[];
|
||||
activeApp?: ISamsungtvApp;
|
||||
}
|
||||
|
||||
export interface ISamsungtvKeyCommand {
|
||||
type: 'key';
|
||||
key: TSamsungtvRemoteKey;
|
||||
action?: TSamsungtvCommandAction;
|
||||
}
|
||||
|
||||
export interface ISamsungtvLaunchAppCommand {
|
||||
type: 'launch_app';
|
||||
appId: string;
|
||||
appType?: 'DEEP_LINK' | 'NATIVE_LAUNCH' | string;
|
||||
metaTag?: string;
|
||||
}
|
||||
|
||||
export interface ISamsungtvWebsocketCommand {
|
||||
method: string;
|
||||
params: Record<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.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './tplink.classes.client.js';
|
||||
export * from './tplink.classes.configflow.js';
|
||||
export * from './tplink.classes.integration.js';
|
||||
export * from './tplink.discovery.js';
|
||||
export * from './tplink.mapper.js';
|
||||
export * from './tplink.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "tplink",
|
||||
displayName: "TP-Link Smart Home",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/tplink",
|
||||
"upstreamDomain": "tplink",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "platinum",
|
||||
"requirements": [
|
||||
"python-kasa[speedups]==0.10.2"
|
||||
],
|
||||
"dependencies": [
|
||||
"network",
|
||||
"ffmpeg",
|
||||
"stream"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@rytilahti",
|
||||
"@bdraco",
|
||||
"@sdb9696"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class TplinkIntegration extends BaseIntegration<ITplinkConfig> {
|
||||
public readonly domain = 'tplink';
|
||||
public readonly displayName = 'TP-Link Smart Home';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createTplinkDiscoveryDescriptor();
|
||||
public readonly configFlow = new TplinkConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/tplink',
|
||||
upstreamDomain: 'tplink',
|
||||
documentation: 'https://www.home-assistant.io/integrations/tplink',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'platinum',
|
||||
requirements: ['python-kasa[speedups]==0.10.2'],
|
||||
dependencies: ['network', 'ffmpeg', 'stream'],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@rytilahti', '@bdraco', '@sdb9696'],
|
||||
dhcpDiscoveryPorts: [9999, 20002],
|
||||
liveLocalWritesImplemented: false,
|
||||
};
|
||||
|
||||
public async setup(configArg: ITplinkConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new TplinkRuntime(new TplinkClient(configArg));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,300 @@
|
||||
export interface IHomeAssistantTplinkConfig {
|
||||
// TODO: replace with the TypeScript-native config for tplink.
|
||||
import type { IServiceCallResult, TEntityPlatform } from '../../core/types.js';
|
||||
|
||||
export const tplinkDefaultHttpPort = 80;
|
||||
export const tplinkLegacyDiscoveryPort = 9999;
|
||||
export const tplinkSmartDiscoveryPort = 20002;
|
||||
|
||||
export type TTplinkBrand = 'kasa' | 'tapo' | 'tplink' | string;
|
||||
export type TTplinkProtocolFamily = 'iot' | 'smart' | 'tapo' | 'kasa' | 'snapshot' | 'manual' | string;
|
||||
export type TTplinkDeviceKind =
|
||||
| 'plug'
|
||||
| 'strip'
|
||||
| 'switch'
|
||||
| 'dimmer'
|
||||
| 'bulb'
|
||||
| 'light_strip'
|
||||
| 'sensor'
|
||||
| 'hub'
|
||||
| 'camera'
|
||||
| 'fan'
|
||||
| 'thermostat'
|
||||
| 'vacuum'
|
||||
| 'unknown'
|
||||
| string;
|
||||
export type TTplinkFeatureType =
|
||||
| 'switch'
|
||||
| 'sensor'
|
||||
| 'binary_sensor'
|
||||
| 'number'
|
||||
| 'choice'
|
||||
| 'action'
|
||||
| 'light'
|
||||
| 'fan'
|
||||
| 'climate'
|
||||
| string;
|
||||
|
||||
export interface ITplinkCredentials {
|
||||
username?: string;
|
||||
password?: string;
|
||||
credentialsHash?: string;
|
||||
aesKeys?: Record<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.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './unifi.classes.client.js';
|
||||
export * from './unifi.classes.configflow.js';
|
||||
export * from './unifi.classes.integration.js';
|
||||
export * from './unifi.discovery.js';
|
||||
export * from './unifi.mapper.js';
|
||||
export * from './unifi.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "unifi",
|
||||
displayName: "UniFi Network",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/unifi",
|
||||
"upstreamDomain": "unifi",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "silver",
|
||||
"requirements": [
|
||||
"aiounifi==90"
|
||||
],
|
||||
"dependencies": [
|
||||
"unifi_discovery"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@Kane610"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class UnifiIntegration extends BaseIntegration<IUnifiConfig> {
|
||||
public readonly domain = 'unifi';
|
||||
public readonly displayName = 'UniFi Network';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createUnifiDiscoveryDescriptor();
|
||||
public readonly configFlow = new UnifiConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/unifi',
|
||||
upstreamDomain: 'unifi',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'silver',
|
||||
requirements: ['aiounifi==90'],
|
||||
dependencies: ['unifi_discovery'],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@Kane610'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/unifi',
|
||||
protocolSource: 'aiounifi local controller API: /api/login, /api/s/{site}/stat/sta, /rest/user, /stat/device, /rest/wlanconf',
|
||||
};
|
||||
|
||||
public async setup(configArg: IUnifiConfig, contextArg: IIntegrationSetupContext): Promise<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,345 @@
|
||||
export interface IHomeAssistantUnifiConfig {
|
||||
// TODO: replace with the TypeScript-native config for unifi.
|
||||
[key: string]: unknown;
|
||||
export const unifiDefaultPort = 443;
|
||||
export const unifiDefaultSite = 'default';
|
||||
|
||||
export type TUnifiProtocol = 'http' | 'https';
|
||||
export type TUnifiCommandType = 'blockClient' | 'reconnectClient' | 'setWlanEnabled' | 'setPoePortEnabled';
|
||||
export type TUnifiDiscoveryService = 'network' | 'protect' | 'access' | 'unknown';
|
||||
|
||||
export interface IUnifiConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TUnifiProtocol;
|
||||
site?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
verifySsl?: boolean;
|
||||
controller?: IUnifiController;
|
||||
snapshot?: IUnifiSnapshot;
|
||||
manualEntries?: IUnifiManualEntry[];
|
||||
sites?: IUnifiSite[];
|
||||
clients?: IUnifiClient[];
|
||||
devices?: IUnifiDevice[];
|
||||
wlans?: IUnifiWlan[];
|
||||
ports?: IUnifiPort[];
|
||||
events?: IUnifiEvent[];
|
||||
trackClients?: boolean;
|
||||
trackDevices?: boolean;
|
||||
trackWiredClients?: boolean;
|
||||
allowBandwidthSensors?: boolean;
|
||||
allowUptimeSensors?: boolean;
|
||||
detectionTimeSeconds?: number;
|
||||
}
|
||||
|
||||
export interface IUnifiController {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
site?: string;
|
||||
version?: string;
|
||||
deviceType?: string;
|
||||
isUnifiOs?: boolean;
|
||||
connected?: boolean;
|
||||
}
|
||||
|
||||
export interface IUnifiSite {
|
||||
id?: string;
|
||||
siteId?: string;
|
||||
_id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
desc?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface IUnifiClient {
|
||||
id?: string;
|
||||
_id?: string;
|
||||
mac: string;
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
deviceName?: string;
|
||||
device_name?: string;
|
||||
ip?: string;
|
||||
oui?: string;
|
||||
essid?: string;
|
||||
network?: string;
|
||||
isWired?: boolean;
|
||||
is_wired?: boolean;
|
||||
isGuest?: boolean;
|
||||
is_guest?: boolean;
|
||||
blocked?: boolean;
|
||||
authorized?: boolean;
|
||||
lastSeen?: number;
|
||||
last_seen?: number;
|
||||
firstSeen?: number;
|
||||
first_seen?: number;
|
||||
uptime?: number;
|
||||
apMac?: string;
|
||||
ap_mac?: string;
|
||||
switchMac?: string;
|
||||
sw_mac?: string;
|
||||
switchPort?: number;
|
||||
sw_port?: number;
|
||||
vlan?: number;
|
||||
radio?: string;
|
||||
radioName?: string;
|
||||
radio_name?: string;
|
||||
radioProto?: string;
|
||||
radio_proto?: string;
|
||||
rssi?: number;
|
||||
rxBytesR?: number;
|
||||
'rx_bytes-r'?: number;
|
||||
txBytesR?: number;
|
||||
'tx_bytes-r'?: number;
|
||||
wiredRxBytesR?: number;
|
||||
'wired-rx_bytes-r'?: number;
|
||||
wiredTxBytesR?: number;
|
||||
'wired-tx_bytes-r'?: number;
|
||||
wiredRateMbps?: number;
|
||||
wired_rate_mbps?: number;
|
||||
fixedIp?: string;
|
||||
fixed_ip?: string;
|
||||
note?: string;
|
||||
raw?: Record<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;
|
||||
|
||||
Reference in New Issue
Block a user