Files
integrations/ts/integrations/tplink/tplink.mapper.ts
T

888 lines
41 KiB
TypeScript

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));
}
}