Files
integrations/ts/integrations/deconz/deconz.mapper.ts
T

526 lines
23 KiB
TypeScript

import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js';
import type { IDeconzGroup, IDeconzLight, IDeconzSensor, IDeconzSnapshot, IDeconzWebsocketEvent } from './deconz.types.js';
interface IDeconzSensorEntityDescription {
key: string;
platform: 'binary_sensor' | 'sensor';
stateKey?: string;
configKey?: string;
nameSuffix?: string;
unit?: string;
deviceClass?: string;
value: (sensorArg: IDeconzSensor) => unknown;
}
const POWER_PLUG_TYPES = new Set([
'on/off light',
'on/off output',
'on/off plug-in unit',
'smart plug',
]);
const COVER_TYPES = new Set([
'level controllable output',
'window covering controller',
'window covering device',
]);
const BINARY_SENSOR_DESCRIPTIONS: IDeconzSensorEntityDescription[] = [
{ key: 'alarm', platform: 'binary_sensor', stateKey: 'alarm', deviceClass: 'safety', value: (sensorArg) => sensorArg.state?.alarm },
{ key: 'carbon_monoxide', platform: 'binary_sensor', stateKey: 'carbonmonoxide', deviceClass: 'carbon_monoxide', value: (sensorArg) => sensorArg.state?.carbonmonoxide },
{ key: 'fire', platform: 'binary_sensor', stateKey: 'fire', deviceClass: 'smoke', value: (sensorArg) => sensorArg.state?.fire },
{ key: 'flag', platform: 'binary_sensor', stateKey: 'flag', value: (sensorArg) => sensorArg.state?.flag },
{ key: 'open', platform: 'binary_sensor', stateKey: 'open', deviceClass: 'opening', value: (sensorArg) => sensorArg.state?.open },
{ key: 'presence', platform: 'binary_sensor', stateKey: 'presence', deviceClass: 'motion', value: (sensorArg) => sensorArg.state?.presence },
{ key: 'vibration', platform: 'binary_sensor', stateKey: 'vibration', deviceClass: 'vibration', value: (sensorArg) => sensorArg.state?.vibration },
{ key: 'water', platform: 'binary_sensor', stateKey: 'water', deviceClass: 'moisture', value: (sensorArg) => sensorArg.state?.water },
{ key: 'tampered', platform: 'binary_sensor', configKey: 'tampered', nameSuffix: 'Tampered', deviceClass: 'tamper', value: (sensorArg) => sensorArg.config?.tampered },
{ key: 'low_battery', platform: 'binary_sensor', configKey: 'lowbattery', nameSuffix: 'Low Battery', deviceClass: 'battery', value: (sensorArg) => sensorArg.config?.lowbattery },
];
const SENSOR_DESCRIPTIONS: IDeconzSensorEntityDescription[] = [
{ key: 'air_quality', platform: 'sensor', stateKey: 'airquality', value: (sensorArg) => sensorArg.state?.airquality },
{ key: 'air_quality_ppb', platform: 'sensor', stateKey: 'airqualityppb', nameSuffix: 'PPB', unit: 'ppb', value: (sensorArg) => sensorArg.state?.airqualityppb },
{ key: 'battery', platform: 'sensor', configKey: 'battery', nameSuffix: 'Battery', unit: '%', deviceClass: 'battery', value: (sensorArg) => sensorArg.config?.battery ?? sensorArg.state?.battery },
{ key: 'button_event', platform: 'sensor', stateKey: 'buttonevent', nameSuffix: 'Button Event', value: (sensorArg) => sensorArg.state?.buttonevent },
{ key: 'consumption', platform: 'sensor', stateKey: 'consumption', unit: 'kWh', deviceClass: 'energy', value: (sensorArg) => DeconzMapper.scaleConsumption(sensorArg.state?.consumption) },
{ key: 'current', platform: 'sensor', stateKey: 'current', unit: 'A', value: (sensorArg) => sensorArg.state?.current },
{ key: 'humidity', platform: 'sensor', stateKey: 'humidity', unit: '%', deviceClass: 'humidity', value: (sensorArg) => DeconzMapper.scaleHundred(sensorArg.state?.humidity) },
{ key: 'light_level', platform: 'sensor', stateKey: 'lightlevel', unit: 'lx', deviceClass: 'illuminance', value: (sensorArg) => DeconzMapper.lightLevel(sensorArg) },
{ key: 'power', platform: 'sensor', stateKey: 'power', unit: 'W', deviceClass: 'power', value: (sensorArg) => sensorArg.state?.power },
{ key: 'pressure', platform: 'sensor', stateKey: 'pressure', unit: 'hPa', deviceClass: 'pressure', value: (sensorArg) => sensorArg.state?.pressure },
{ key: 'status', platform: 'sensor', stateKey: 'status', value: (sensorArg) => sensorArg.state?.status },
{ key: 'temperature', platform: 'sensor', stateKey: 'temperature', unit: 'C', deviceClass: 'temperature', value: (sensorArg) => DeconzMapper.scaleHundred(sensorArg.state?.temperature) },
{ key: 'voltage', platform: 'sensor', stateKey: 'voltage', unit: 'V', deviceClass: 'voltage', value: (sensorArg) => sensorArg.state?.voltage },
];
export class DeconzMapper {
public static toDevices(snapshotArg: IDeconzSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = new Date().toISOString();
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [];
const bridgeId = this.bridgeId(snapshotArg);
devices.push({
id: this.gatewayDeviceId(snapshotArg),
integrationDomain: 'deconz',
name: snapshotArg.config?.name || snapshotArg.config?.devicename || 'deCONZ Gateway',
protocol: 'zigbee',
manufacturer: 'dresden elektronik',
model: snapshotArg.config?.devicename || snapshotArg.config?.modelid || 'deCONZ',
online: snapshotArg.config?.rfconnected !== false,
features: [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
],
state: [
{ featureId: 'connectivity', value: snapshotArg.config?.rfconnected === false ? 'offline' : 'online', updatedAt },
],
metadata: {
bridgeId,
apiVersion: snapshotArg.config?.apiversion,
softwareVersion: snapshotArg.config?.swversion,
websocketPort: snapshotArg.config?.websocketport,
},
});
for (const [lightId, light] of Object.entries(snapshotArg.lights)) {
devices.push(this.lightToDevice(lightId, light, updatedAt));
}
for (const [groupId, group] of Object.entries(snapshotArg.groups)) {
if (!group.lights?.length) {
continue;
}
devices.push(this.groupToDevice(bridgeId, groupId, group, updatedAt));
}
for (const [sensorId, sensor] of Object.entries(snapshotArg.sensors)) {
devices.push(this.sensorToDevice(sensorId, sensor, updatedAt));
}
return devices;
}
public static toEntities(snapshotArg: IDeconzSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const usedIds = new Map<string, number>();
const bridgeId = this.bridgeId(snapshotArg);
for (const [lightId, light] of Object.entries(snapshotArg.lights)) {
entities.push(this.lightToEntity(lightId, light, usedIds));
}
for (const [groupId, group] of Object.entries(snapshotArg.groups)) {
if (!group.lights?.length) {
continue;
}
entities.push(this.groupToEntity(bridgeId, groupId, group, usedIds));
}
for (const [sensorId, sensor] of Object.entries(snapshotArg.sensors)) {
if (this.isThermostat(sensor)) {
entities.push(this.climateToEntity(sensorId, sensor, usedIds));
}
for (const description of [...BINARY_SENSOR_DESCRIPTIONS, ...SENSOR_DESCRIPTIONS]) {
if (this.isThermostat(sensor) && description.key === 'temperature') {
continue;
}
const value = description.value(sensor);
if (value === undefined || value === null) {
continue;
}
entities.push(this.sensorToEntity(sensorId, sensor, description, value, usedIds));
}
}
return entities;
}
public static toIntegrationEvent(eventArg: IDeconzWebsocketEvent): IIntegrationEvent {
return {
type: eventArg.e === 'added' ? 'device_added' : eventArg.e === 'deleted' ? 'device_removed' : 'state_changed',
integrationDomain: 'deconz',
data: eventArg,
timestamp: Date.now(),
};
}
public static scaleHundred(valueArg: unknown): number | undefined {
if (typeof valueArg !== 'number') {
return undefined;
}
return Math.abs(valueArg) > 200 ? valueArg / 100 : valueArg;
}
public static scaleConsumption(valueArg: unknown): number | undefined {
if (typeof valueArg !== 'number') {
return undefined;
}
return Math.abs(valueArg) >= 1000 ? valueArg / 1000 : valueArg;
}
public static lightLevel(sensorArg: IDeconzSensor): number | undefined {
if (typeof sensorArg.state?.lux === 'number') {
return sensorArg.state.lux;
}
if (typeof sensorArg.state?.lightlevel !== 'number') {
return undefined;
}
return Math.round(Math.pow(10, (sensorArg.state.lightlevel - 1) / 10000));
}
private static lightToDevice(lightIdArg: string, lightArg: IDeconzLight, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: this.isLightAvailable(lightArg) ? 'online' : 'offline', updatedAt: updatedAtArg },
];
if (this.isCoverLight(lightArg)) {
features.push({ id: 'position', capability: 'cover', name: 'Position', readable: true, writable: true, unit: '%' });
this.pushDeviceState(state, 'position', this.coverPosition(lightArg), updatedAtArg);
} else if (this.isSwitchLight(lightArg)) {
features.push({ id: 'on', capability: 'switch', name: 'Power', readable: true, writable: true });
this.pushDeviceState(state, 'on', lightArg.state?.on ?? false, updatedAtArg);
} else {
features.push({ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true });
this.pushDeviceState(state, 'on', lightArg.state?.on ?? false, updatedAtArg);
if (typeof lightArg.state?.bri === 'number') {
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' });
this.pushDeviceState(state, 'brightness', Math.round(lightArg.state.bri / 255 * 100), updatedAtArg);
}
}
return {
id: this.lightDeviceId(lightIdArg, lightArg),
integrationDomain: 'deconz',
name: lightArg.name || `deCONZ Light ${lightIdArg}`,
protocol: 'zigbee',
manufacturer: lightArg.manufacturername || 'Unknown',
model: lightArg.modelid || lightArg.type,
online: this.isLightAvailable(lightArg),
features,
state,
metadata: {
deconzId: lightIdArg,
uniqueId: lightArg.uniqueid,
type: lightArg.type,
softwareVersion: lightArg.swversion,
},
};
}
private static groupToDevice(bridgeIdArg: string, groupIdArg: string, groupArg: IDeconzGroup, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: 'online', updatedAt: updatedAtArg },
{ featureId: 'on', value: groupArg.state?.any_on ?? groupArg.action?.on ?? false, updatedAt: updatedAtArg },
];
if (typeof groupArg.action?.bri === 'number') {
state.push({ featureId: 'brightness', value: Math.round(groupArg.action.bri / 255 * 100), updatedAt: updatedAtArg });
}
return {
id: this.groupDeviceId(bridgeIdArg, groupIdArg),
integrationDomain: 'deconz',
name: groupArg.name || `deCONZ Group ${groupIdArg}`,
protocol: 'zigbee',
manufacturer: 'dresden elektronik',
model: 'deCONZ group',
online: true,
features: [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
{ id: 'on', capability: 'light', name: 'Power', readable: true, writable: true },
{ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' },
],
state,
metadata: {
bridgeId: bridgeIdArg,
deconzId: groupIdArg,
lights: groupArg.lights,
type: groupArg.type,
},
};
}
private static sensorToDevice(sensorIdArg: string, sensorArg: IDeconzSensor, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: this.isSensorAvailable(sensorArg) ? 'online' : 'offline', updatedAt: updatedAtArg },
];
if (this.isThermostat(sensorArg)) {
features.push({ id: 'target_temperature', capability: 'climate', name: 'Target Temperature', readable: true, writable: true, unit: 'C' });
features.push({ id: 'current_temperature', capability: 'climate', name: 'Current Temperature', readable: true, writable: false, unit: 'C' });
this.pushDeviceState(state, 'target_temperature', this.targetTemperature(sensorArg), updatedAtArg);
this.pushDeviceState(state, 'current_temperature', this.scaleHundred(sensorArg.state?.temperature), updatedAtArg);
}
for (const description of [...BINARY_SENSOR_DESCRIPTIONS, ...SENSOR_DESCRIPTIONS]) {
if (this.isThermostat(sensorArg) && description.key === 'temperature') {
continue;
}
const value = description.value(sensorArg);
if (value === undefined || value === null) {
continue;
}
features.push({
id: description.key,
capability: 'sensor',
name: this.entityName(sensorArg, description),
readable: true,
writable: Boolean(sensorArg.type?.startsWith('CLIP') && description.stateKey),
unit: description.unit,
});
this.pushDeviceState(state, description.key, description.platform === 'binary_sensor' ? Boolean(value) : value, updatedAtArg);
}
return {
id: this.sensorDeviceId(sensorIdArg, sensorArg),
integrationDomain: 'deconz',
name: sensorArg.name || `deCONZ Sensor ${sensorIdArg}`,
protocol: 'zigbee',
manufacturer: sensorArg.manufacturername || 'Unknown',
model: sensorArg.modelid || sensorArg.type,
online: this.isSensorAvailable(sensorArg),
features,
state,
metadata: {
deconzId: sensorIdArg,
uniqueId: sensorArg.uniqueid,
type: sensorArg.type,
softwareVersion: sensorArg.swversion,
},
};
}
private static lightToEntity(lightIdArg: string, lightArg: IDeconzLight, usedIdsArg: Map<string, number>): IIntegrationEntity {
const platform = this.isCoverLight(lightArg) ? 'cover' : this.isSwitchLight(lightArg) ? 'switch' : 'light';
const resourcePath = `/lights/${lightIdArg}/state`;
return {
id: this.entityId(platform, lightArg.name || `deCONZ Light ${lightIdArg}`, usedIdsArg),
uniqueId: `deconz_light_${this.slug(lightArg.uniqueid || lightIdArg)}`,
integrationDomain: 'deconz',
deviceId: this.lightDeviceId(lightIdArg, lightArg),
platform,
name: lightArg.name || `deCONZ Light ${lightIdArg}`,
state: platform === 'cover' ? this.coverState(lightArg) : lightArg.state?.on ? 'on' : 'off',
attributes: {
deconzResource: 'lights',
deconzId: lightIdArg,
deconzPath: resourcePath,
uniqueId: lightArg.uniqueid,
type: lightArg.type,
brightness: lightArg.state?.bri,
colorMode: lightArg.state?.colormode,
colorTemperature: lightArg.state?.ct,
position: platform === 'cover' ? this.coverPosition(lightArg) : undefined,
reachable: lightArg.state?.reachable,
},
available: this.isLightAvailable(lightArg),
};
}
private static groupToEntity(bridgeIdArg: string, groupIdArg: string, groupArg: IDeconzGroup, usedIdsArg: Map<string, number>): IIntegrationEntity {
return {
id: this.entityId('light', groupArg.name || `deCONZ Group ${groupIdArg}`, usedIdsArg),
uniqueId: `deconz_group_${this.slug(bridgeIdArg)}_${this.slug(groupIdArg)}`,
integrationDomain: 'deconz',
deviceId: this.groupDeviceId(bridgeIdArg, groupIdArg),
platform: 'light',
name: groupArg.name || `deCONZ Group ${groupIdArg}`,
state: groupArg.state?.any_on || groupArg.action?.on ? 'on' : 'off',
attributes: {
deconzResource: 'groups',
deconzId: groupIdArg,
deconzPath: `/groups/${groupIdArg}/action`,
isDeconzGroup: true,
allOn: groupArg.state?.all_on,
anyOn: groupArg.state?.any_on,
brightness: groupArg.action?.bri,
lights: groupArg.lights,
},
available: true,
};
}
private static sensorToEntity(
sensorIdArg: string,
sensorArg: IDeconzSensor,
descriptionArg: IDeconzSensorEntityDescription,
valueArg: unknown,
usedIdsArg: Map<string, number>
): IIntegrationEntity {
const name = this.entityName(sensorArg, descriptionArg);
return {
id: this.entityId(descriptionArg.platform, name, usedIdsArg),
uniqueId: `deconz_sensor_${this.slug(sensorArg.uniqueid || sensorIdArg)}_${descriptionArg.key}`,
integrationDomain: 'deconz',
deviceId: this.sensorDeviceId(sensorIdArg, sensorArg),
platform: descriptionArg.platform,
name,
state: descriptionArg.platform === 'binary_sensor' ? (valueArg ? 'on' : 'off') : valueArg,
attributes: {
deconzResource: 'sensors',
deconzId: sensorIdArg,
deconzPath: descriptionArg.configKey ? `/sensors/${sensorIdArg}/config` : `/sensors/${sensorIdArg}/state`,
deconzStateKey: descriptionArg.stateKey,
deconzConfigKey: descriptionArg.configKey,
deviceClass: descriptionArg.deviceClass,
unit: descriptionArg.unit,
type: sensorArg.type,
uniqueId: sensorArg.uniqueid,
lastUpdated: sensorArg.state?.lastupdated,
},
available: this.isSensorAvailable(sensorArg),
};
}
private static climateToEntity(sensorIdArg: string, sensorArg: IDeconzSensor, usedIdsArg: Map<string, number>): IIntegrationEntity {
return {
id: this.entityId('climate', sensorArg.name || `deCONZ Thermostat ${sensorIdArg}`, usedIdsArg),
uniqueId: `deconz_climate_${this.slug(sensorArg.uniqueid || sensorIdArg)}`,
integrationDomain: 'deconz',
deviceId: this.sensorDeviceId(sensorIdArg, sensorArg),
platform: 'climate',
name: sensorArg.name || `deCONZ Thermostat ${sensorIdArg}`,
state: this.climateMode(sensorArg),
attributes: {
deconzResource: 'sensors',
deconzId: sensorIdArg,
deconzPath: `/sensors/${sensorIdArg}/config`,
currentTemperature: this.scaleHundred(sensorArg.state?.temperature),
targetTemperature: this.targetTemperature(sensorArg),
fanMode: sensorArg.config?.fanmode,
preset: sensorArg.config?.preset,
locked: sensorArg.config?.locked,
valve: sensorArg.config?.valve,
mode: sensorArg.config?.mode,
},
available: this.isSensorAvailable(sensorArg),
};
}
private static pushDeviceState(
stateArg: plugins.shxInterfaces.data.IDeviceState[],
featureIdArg: string,
valueArg: unknown,
updatedAtArg: string
): void {
if (valueArg === undefined) {
return;
}
stateArg.push({ featureId: featureIdArg, value: valueArg as plugins.shxInterfaces.data.TDeviceStateValue, updatedAt: updatedAtArg });
}
private static entityName(sensorArg: IDeconzSensor, descriptionArg: IDeconzSensorEntityDescription): string {
const baseName = sensorArg.name || 'deCONZ Sensor';
return descriptionArg.nameSuffix ? `${baseName} ${descriptionArg.nameSuffix}` : baseName;
}
private static isLightAvailable(lightArg: IDeconzLight): boolean {
return lightArg.state?.reachable !== false;
}
private static isSensorAvailable(sensorArg: IDeconzSensor): boolean {
return sensorArg.config?.reachable !== false && sensorArg.config?.on !== false;
}
private static isSwitchLight(lightArg: IDeconzLight): boolean {
const type = lightArg.type?.toLowerCase() || '';
return POWER_PLUG_TYPES.has(type) || type.includes('plug') || type.includes('outlet');
}
private static isCoverLight(lightArg: IDeconzLight): boolean {
const type = lightArg.type?.toLowerCase() || '';
return COVER_TYPES.has(type) || lightArg.state?.lift !== undefined || lightArg.state?.tilt !== undefined;
}
private static isThermostat(sensorArg: IDeconzSensor): boolean {
const type = sensorArg.type?.toLowerCase() || '';
return type.includes('thermostat')
|| sensorArg.config?.heatsetpoint !== undefined
|| sensorArg.config?.coolsetpoint !== undefined
|| sensorArg.config?.mode !== undefined && sensorArg.state?.temperature !== undefined;
}
private static coverState(lightArg: IDeconzLight): string {
if (lightArg.state?.open === false || lightArg.state?.lift === 100) {
return 'closed';
}
return 'open';
}
private static coverPosition(lightArg: IDeconzLight): number | undefined {
if (typeof lightArg.state?.lift === 'number') {
return this.clamp(100 - lightArg.state.lift, 0, 100);
}
if (typeof lightArg.state?.open === 'boolean') {
return lightArg.state.open ? 100 : 0;
}
return undefined;
}
private static climateMode(sensorArg: IDeconzSensor): string {
if (sensorArg.config?.mode) {
return sensorArg.config.mode;
}
return sensorArg.config?.on === false ? 'off' : 'heat';
}
private static targetTemperature(sensorArg: IDeconzSensor): number | undefined {
if (sensorArg.config?.mode === 'cool' && typeof sensorArg.config.coolsetpoint === 'number') {
return this.scaleHundred(sensorArg.config.coolsetpoint);
}
if (typeof sensorArg.config?.heatsetpoint === 'number') {
return this.scaleHundred(sensorArg.config.heatsetpoint);
}
if (typeof sensorArg.config?.coolsetpoint === 'number') {
return this.scaleHundred(sensorArg.config.coolsetpoint);
}
return undefined;
}
private static gatewayDeviceId(snapshotArg: IDeconzSnapshot): string {
return `deconz.gateway.${this.slug(this.bridgeId(snapshotArg))}`;
}
private static lightDeviceId(lightIdArg: string, lightArg: IDeconzLight): string {
return `deconz.light.${this.slug(this.serialFromUniqueId(lightArg.uniqueid) || lightArg.uniqueid || lightIdArg)}`;
}
private static groupDeviceId(bridgeIdArg: string, groupIdArg: string): string {
return `deconz.group.${this.slug(bridgeIdArg)}_${this.slug(groupIdArg)}`;
}
private static sensorDeviceId(sensorIdArg: string, sensorArg: IDeconzSensor): string {
return `deconz.sensor.${this.slug(this.serialFromUniqueId(sensorArg.uniqueid) || sensorArg.uniqueid || sensorIdArg)}`;
}
private static bridgeId(snapshotArg: IDeconzSnapshot): string {
return snapshotArg.config?.bridgeid || snapshotArg.config?.uuid || 'unknown';
}
private static serialFromUniqueId(uniqueIdArg?: string): string | undefined {
return uniqueIdArg?.split('-')[0];
}
private static entityId(platformArg: string, 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 === 0 ? base : `${base}_${count + 1}`;
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'deconz';
}
private static clamp(valueArg: number, minArg: number, maxArg: number): number {
return Math.min(maxArg, Math.max(minArg, valueArg));
}
}