888 lines
41 KiB
TypeScript
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));
|
|
}
|
|
}
|