zigbee2mqtt
Version:
Zigbee to MQTT bridge using Zigbee-herdsman
828 lines • 210 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.HomeAssistant = void 0;
const node_assert_1 = __importDefault(require("node:assert"));
const bind_decorator_1 = __importDefault(require("bind-decorator"));
const json_stable_stringify_without_jsonify_1 = __importDefault(require("json-stable-stringify-without-jsonify"));
const logger_1 = __importDefault(require("../util/logger"));
const settings = __importStar(require("../util/settings"));
const utils_1 = __importStar(require("../util/utils"));
const extension_1 = __importDefault(require("./extension"));
const ACTION_PATTERNS = [
'^(?<button>(?:button_)?[a-z0-9]+)_(?<action>(?:press|hold)(?:_release)?)$',
'^(?<action>recall|scene)_(?<scene>[0-2][0-9]{0,2})$',
'^(?<actionPrefix>region_)(?<region>[1-9]|10)_(?<action>enter|leave|occupied|unoccupied)$',
'^(?<action>dial_rotate)_(?<direction>left|right)_(?<speed>step|slow|fast)$',
'^(?<action>brightness_step)(?:_(?<direction>up|down))?$',
];
const ACCESS_STATE = 0b001;
const ACCESS_SET = 0b010;
const GROUP_SUPPORTED_TYPES = ['light', 'switch', 'lock', 'cover'];
const COVER_OPENING_LOOKUP = ['opening', 'open', 'forward', 'up', 'rising'];
const COVER_CLOSING_LOOKUP = ['closing', 'close', 'backward', 'back', 'reverse', 'down', 'declining'];
const COVER_STOPPED_LOOKUP = ['stopped', 'stop', 'pause', 'paused'];
const SWITCH_DIFFERENT = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode'];
const BINARY_DISCOVERY_LOOKUP = {
activity_led_indicator: { icon: 'mdi:led-on' },
auto_off: { icon: 'mdi:flash-auto' },
battery_low: { entity_category: 'diagnostic', device_class: 'battery' },
button_lock: { entity_category: 'config', icon: 'mdi:lock' },
calibration: { entity_category: 'config', icon: 'mdi:progress-wrench' },
capabilities_configurable_curve: { entity_category: 'diagnostic', icon: 'mdi:tune' },
capabilities_forward_phase_control: { entity_category: 'diagnostic', icon: 'mdi:tune' },
capabilities_overload_detection: { entity_category: 'diagnostic', icon: 'mdi:tune' },
capabilities_reactance_discriminator: { entity_category: 'diagnostic', icon: 'mdi:tune' },
capabilities_reverse_phase_control: { entity_category: 'diagnostic', icon: 'mdi:tune' },
carbon_monoxide: { device_class: 'carbon_monoxide' },
card: { entity_category: 'config', icon: 'mdi:clipboard-check' },
child_lock: { entity_category: 'config', icon: 'mdi:account-lock' },
color_sync: { entity_category: 'config', icon: 'mdi:sync-circle' },
consumer_connected: { device_class: 'plug' },
contact: { device_class: 'door' },
garage_door_contact: { device_class: 'garage_door', payload_on: false, payload_off: true },
eco_mode: { entity_category: 'config', icon: 'mdi:leaf' },
expose_pin: { entity_category: 'config', icon: 'mdi:pin' },
flip_indicator_light: { entity_category: 'config', icon: 'mdi:arrow-left-right' },
gas: { device_class: 'gas' },
indicator_mode: { entity_category: 'config', icon: 'mdi:led-on' },
invert_cover: { entity_category: 'config', icon: 'mdi:arrow-left-right' },
led_disabled_night: { entity_category: 'config', icon: 'mdi:led-off' },
led_indication: { entity_category: 'config', icon: 'mdi:led-on' },
led_enable: { entity_category: 'config', icon: 'mdi:led-on' },
motor_reversal: { entity_category: 'config', icon: 'mdi:arrow-left-right' },
moving: { device_class: 'moving' },
no_position_support: { entity_category: 'config', icon: 'mdi:minus-circle-outline' },
noise_detected: { device_class: 'sound' },
occupancy: { device_class: 'occupancy' },
power_outage_memory: { entity_category: 'config', icon: 'mdi:memory' },
presence: { device_class: 'presence' },
setup: { device_class: 'running' },
smoke: { device_class: 'smoke' },
sos: { device_class: 'safety' },
schedule: { icon: 'mdi:calendar' },
status_capacitive_load: { entity_category: 'diagnostic', icon: 'mdi:tune' },
status_forward_phase_control: { entity_category: 'diagnostic', icon: 'mdi:tune' },
status_inductive_load: { entity_category: 'diagnostic', icon: 'mdi:tune' },
status_overload: { entity_category: 'diagnostic', icon: 'mdi:tune' },
status_reverse_phase_control: { entity_category: 'diagnostic', icon: 'mdi:tune' },
tamper: { device_class: 'tamper' },
temperature_scale: { entity_category: 'config', icon: 'mdi:temperature-celsius' },
test: { entity_category: 'diagnostic', icon: 'mdi:test-tube' },
th_heater: { icon: 'mdi:heat-wave' },
trigger_indicator: { icon: 'mdi:led-on' },
valve_alarm: { device_class: 'problem' },
valve_detection: { icon: 'mdi:pipe-valve' },
valve_state: { device_class: 'opening' },
vibration: { device_class: 'vibration' },
water_leak: { device_class: 'moisture' },
window: { device_class: 'window' },
window_detection: { icon: 'mdi:window-open-variant' },
window_open: { device_class: 'window' },
};
const NUMERIC_DISCOVERY_LOOKUP = {
ac_frequency: { device_class: 'frequency', state_class: 'measurement' },
action_duration: { icon: 'mdi:timer', device_class: 'duration' },
alarm_humidity_max: { device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-plus' },
alarm_humidity_min: { device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-minus' },
alarm_temperature_max: { device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-high' },
alarm_temperature_min: { device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-low' },
angle: { icon: 'angle-acute' },
angle_axis: { icon: 'angle-acute' },
aqi: { device_class: 'aqi', state_class: 'measurement' },
auto_relock_time: { entity_category: 'config', icon: 'mdi:timer' },
away_preset_days: { entity_category: 'config', icon: 'mdi:timer' },
away_preset_temperature: { entity_category: 'config', icon: 'mdi:thermometer' },
ballast_maximum_level: { entity_category: 'config' },
ballast_minimum_level: { entity_category: 'config' },
ballast_physical_maximum_level: { entity_category: 'diagnostic' },
ballast_physical_minimum_level: { entity_category: 'diagnostic' },
battery: { device_class: 'battery', state_class: 'measurement' },
battery2: { device_class: 'battery', entity_category: 'diagnostic', state_class: 'measurement' },
battery_voltage: { device_class: 'voltage', entity_category: 'diagnostic', state_class: 'measurement', enabled_by_default: true },
boost_heating_countdown: { device_class: 'duration' },
boost_heating_countdown_time_set: { entity_category: 'config', icon: 'mdi:timer' },
boost_time: { entity_category: 'config', icon: 'mdi:timer' },
calibration: { entity_category: 'config', icon: 'mdi:wrench-clock' },
calibration_time: { entity_category: 'config', icon: 'mdi:wrench-clock' },
co2: { device_class: 'carbon_dioxide', state_class: 'measurement' },
comfort_temperature: { entity_category: 'config', icon: 'mdi:thermometer' },
cpu_temperature: {
device_class: 'temperature',
entity_category: 'diagnostic',
state_class: 'measurement',
},
cube_side: { icon: 'mdi:cube' },
current: { device_class: 'current', state_class: 'measurement' },
current_phase_b: { device_class: 'current', state_class: 'measurement' },
current_phase_c: { device_class: 'current', state_class: 'measurement' },
deadzone_temperature: { entity_category: 'config', icon: 'mdi:thermometer' },
detection_interval: { icon: 'mdi:timer' },
device_temperature: {
device_class: 'temperature',
entity_category: 'diagnostic',
state_class: 'measurement',
},
distance: { device_class: 'distance', state_class: 'measurement' },
duration: { entity_category: 'config', icon: 'mdi:timer' },
eco2: { device_class: 'carbon_dioxide', state_class: 'measurement' },
eco_temperature: { entity_category: 'config', icon: 'mdi:thermometer' },
energy: { device_class: 'energy', state_class: 'total_increasing' },
external_temperature_input: { device_class: 'temperature', icon: 'mdi:thermometer' },
formaldehyd: { state_class: 'measurement' },
flow: { device_class: 'volume_flow_rate', state_class: 'measurement' },
gas_density: { icon: 'mdi:google-circles-communities', state_class: 'measurement' },
hcho: { icon: 'mdi:air-filter', state_class: 'measurement' },
humidity: { device_class: 'humidity', state_class: 'measurement' },
humidity_calibration: { entity_category: 'config', icon: 'mdi:wrench-clock' },
humidity_max: { entity_category: 'config', icon: 'mdi:water-percent' },
humidity_min: { entity_category: 'config', icon: 'mdi:water-percent' },
illuminance_calibration: { entity_category: 'config', icon: 'mdi:wrench-clock' },
illuminance: { device_class: 'illuminance', state_class: 'measurement' },
internalTemperature: {
device_class: 'temperature',
entity_category: 'diagnostic',
state_class: 'measurement',
},
linkquality: {
enabled_by_default: false,
entity_category: 'diagnostic',
icon: 'mdi:signal',
state_class: 'measurement',
},
local_temperature: { device_class: 'temperature', state_class: 'measurement' },
max_range: { entity_category: 'config', icon: 'mdi:signal-distance-variant' },
max_temperature: { entity_category: 'config', icon: 'mdi:thermometer-high' },
max_temperature_limit: { entity_category: 'config', icon: 'mdi:thermometer-high' },
min_temperature_limit: { entity_category: 'config', icon: 'mdi:thermometer-low' },
min_temperature: { entity_category: 'config', icon: 'mdi:thermometer-low' },
minimum_on_level: { entity_category: 'config' },
measurement_poll_interval: { entity_category: 'config', icon: 'mdi:clock-out' },
motion_sensitivity: { entity_category: 'config', icon: 'mdi:motion-sensor' },
noise: { device_class: 'sound_pressure', state_class: 'measurement' },
noise_detect_level: { icon: 'mdi:volume-equal' },
noise_timeout: { icon: 'mdi:timer' },
occupancy_level: { icon: 'mdi:motion-sensor' },
occupancy_sensitivity: { entity_category: 'config', icon: 'mdi:motion-sensor' },
occupancy_timeout: { entity_category: 'config', icon: 'mdi:timer' },
overload_protection: { icon: 'mdi:flash' },
pm10: { device_class: 'pm10', state_class: 'measurement' },
pm25: { device_class: 'pm25', state_class: 'measurement' },
people: { state_class: 'measurement', icon: 'mdi:account-multiple' },
position: { icon: 'mdi:valve', state_class: 'measurement' },
power: { device_class: 'power', state_class: 'measurement' },
power_phase_b: { device_class: 'power', state_class: 'measurement' },
power_phase_c: { device_class: 'power', state_class: 'measurement' },
power_factor: { device_class: 'power_factor', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement' },
power_outage_count: { icon: 'mdi:counter', enabled_by_default: false },
precision: { entity_category: 'config', icon: 'mdi:decimal-comma-increase' },
pressure: { device_class: 'atmospheric_pressure', state_class: 'measurement' },
presence_timeout: { entity_category: 'config', icon: 'mdi:timer' },
reporting_time: { entity_category: 'config', icon: 'mdi:clock-time-one-outline' },
requested_brightness_level: {
enabled_by_default: false,
entity_category: 'diagnostic',
icon: 'mdi:brightness-5',
},
requested_brightness_percent: {
enabled_by_default: false,
entity_category: 'diagnostic',
icon: 'mdi:brightness-5',
},
smoke_density: { icon: 'mdi:google-circles-communities', state_class: 'measurement' },
soil_moisture: { device_class: 'moisture', state_class: 'measurement' },
temperature: { device_class: 'temperature', state_class: 'measurement' },
temperature_calibration: { entity_category: 'config', icon: 'mdi:wrench-clock' },
temperature_max: { entity_category: 'config', icon: 'mdi:thermometer-plus' },
temperature_min: { entity_category: 'config', icon: 'mdi:thermometer-minus' },
temperature_offset: { icon: 'mdi:thermometer-lines' },
transition: { entity_category: 'config', icon: 'mdi:transition' },
trigger_count: { icon: 'mdi:counter', enabled_by_default: false },
voc: { device_class: 'volatile_organic_compounds', state_class: 'measurement' },
voc_index: { state_class: 'measurement', icon: 'mdi:molecule' },
voc_parts: { device_class: 'volatile_organic_compounds_parts', state_class: 'measurement' },
vibration_timeout: { entity_category: 'config', icon: 'mdi:timer' },
voltage: { device_class: 'voltage', state_class: 'measurement' },
voltage_phase_b: { device_class: 'voltage', state_class: 'measurement' },
voltage_phase_c: { device_class: 'voltage', state_class: 'measurement' },
water_consumed: {
device_class: 'water',
state_class: 'total_increasing',
},
x_axis: { icon: 'mdi:axis-x-arrow' },
y_axis: { icon: 'mdi:axis-y-arrow' },
z_axis: { icon: 'mdi:axis-z-arrow' },
};
const ENUM_DISCOVERY_LOOKUP = {
action: { icon: 'mdi:gesture-double-tap' },
alarm_humidity: { entity_category: 'config', icon: 'mdi:water-percent-alert' },
alarm_temperature: { entity_category: 'config', icon: 'mdi:thermometer-alert' },
backlight_auto_dim: { entity_category: 'config', icon: 'mdi:brightness-auto' },
backlight_mode: { entity_category: 'config', icon: 'mdi:lightbulb' },
calibrate: { icon: 'mdi:tune' },
color_power_on_behavior: { entity_category: 'config', icon: 'mdi:palette' },
control_mode: { entity_category: 'config', icon: 'mdi:tune' },
device_mode: { entity_category: 'config', icon: 'mdi:tune' },
effect: { enabled_by_default: false, icon: 'mdi:palette' },
force: { entity_category: 'config', icon: 'mdi:valve' },
keep_time: { entity_category: 'config', icon: 'mdi:av-timer' },
identify: { device_class: 'identify' },
keypad_lockout: { entity_category: 'config', icon: 'mdi:lock' },
load_detection_mode: { entity_category: 'config', icon: 'mdi:tune' },
load_dimmable: { entity_category: 'config', icon: 'mdi:chart-bell-curve' },
load_type: { entity_category: 'config', icon: 'mdi:led-on' },
melody: { entity_category: 'config', icon: 'mdi:music-note' },
mode_phase_control: { entity_category: 'config', icon: 'mdi:tune' },
mode: { entity_category: 'config', icon: 'mdi:tune' },
mode_switch: { icon: 'mdi:tune' },
motion_sensitivity: { entity_category: 'config', icon: 'mdi:tune' },
operation_mode: { entity_category: 'config', icon: 'mdi:tune' },
power_on_behavior: { entity_category: 'config', icon: 'mdi:power-settings' },
power_outage_memory: { entity_category: 'config', icon: 'mdi:power-settings' },
power_supply_mode: { entity_category: 'config', icon: 'mdi:power-settings' },
power_type: { entity_category: 'config', icon: 'mdi:lightning-bolt-circle' },
restart: { device_class: 'restart' },
sensitivity: { entity_category: 'config', icon: 'mdi:tune' },
sensor: { icon: 'mdi:tune' },
sensors_type: { entity_category: 'config', icon: 'mdi:tune' },
sound_volume: { entity_category: 'config', icon: 'mdi:volume-high' },
status: { icon: 'mdi:state-machine' },
switch_type: { entity_category: 'config', icon: 'mdi:tune' },
temperature_display_mode: { entity_category: 'config', icon: 'mdi:thermometer' },
temperature_sensor_select: { entity_category: 'config', icon: 'mdi:home-thermometer' },
thermostat_unit: { entity_category: 'config', icon: 'mdi:thermometer' },
update: { device_class: 'update' },
volume: { entity_category: 'config', icon: 'mdi: volume-high' },
week: { entity_category: 'config', icon: 'mdi:calendar-clock' },
};
const LIST_DISCOVERY_LOOKUP = {
action: { icon: 'mdi:gesture-double-tap' },
color_options: { icon: 'mdi:palette' },
level_config: { entity_category: 'diagnostic' },
programming_mode: { icon: 'mdi:calendar-clock' },
schedule_settings: { icon: 'mdi:calendar-clock' },
};
const featurePropertyWithoutEndpoint = (feature) => {
if (feature.endpoint) {
return feature.property.slice(0, -1 + -1 * feature.endpoint.length);
}
else {
return feature.property;
}
};
/**
* This class handles the bridge entity configuration for Home Assistant Discovery.
*/
class Bridge {
coordinatorIeeeAddress;
coordinatorType;
coordinatorFirmwareVersion;
discoveryEntries;
options;
get ID() {
return this.coordinatorIeeeAddress;
}
get name() {
return 'bridge';
}
get hardwareVersion() {
return this.coordinatorType;
}
get firmwareVersion() {
return this.coordinatorFirmwareVersion;
}
get configs() {
return this.discoveryEntries;
}
constructor(ieeeAdress, version, discovery) {
this.coordinatorIeeeAddress = ieeeAdress;
this.coordinatorType = version.type;
this.coordinatorFirmwareVersion = version.meta.revision ? `${version.meta.revision}` : /* v8 ignore next */ '';
this.discoveryEntries = discovery;
this.options = {
ID: `bridge_${ieeeAdress}`,
homeassistant: {
name: `Zigbee2MQTT Bridge`,
},
};
}
isDevice() {
return false;
}
isGroup() {
return false;
}
}
/**
* This extensions handles integration with HomeAssistant
*/
class HomeAssistant extends extension_1.default {
discovered = {};
discoveryTopic;
discoveryRegex;
discoveryRegexWoTopic = new RegExp(`(.*)/(.*)/(.*)/config`);
statusTopic;
legacyActionSensor;
experimentalEventEntities;
// @ts-expect-error initialized in `start`
zigbee2MQTTVersion;
// @ts-expect-error initialized in `start`
discoveryOrigin;
// @ts-expect-error initialized in `start`
bridge;
// @ts-expect-error initialized in `start`
bridgeIdentifier;
actionValueTemplate;
constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension) {
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
if (settings.get().advanced.output === 'attribute') {
throw new Error('Home Assistant integration is not possible with attribute output!');
}
const haSettings = settings.get().homeassistant;
(0, node_assert_1.default)(haSettings.enabled, `Home Assistant extension created with setting 'enabled: false'`);
this.discoveryTopic = haSettings.discovery_topic;
this.discoveryRegex = new RegExp(`${haSettings.discovery_topic}/(.*)/(.*)/(.*)/config`);
this.statusTopic = haSettings.status_topic;
this.legacyActionSensor = haSettings.legacy_action_sensor;
this.experimentalEventEntities = haSettings.experimental_event_entities;
if (haSettings.discovery_topic === settings.get().mqtt.base_topic) {
throw new Error(`'homeassistant.discovery_topic' cannot not be equal to the 'mqtt.base_topic' (got '${settings.get().mqtt.base_topic}')`);
}
this.actionValueTemplate = this.getActionValueTemplate();
}
async start() {
if (!settings.get().advanced.cache_state) {
logger_1.default.warning('In order for Home Assistant integration to work properly set `cache_state: true');
}
this.zigbee2MQTTVersion = (await utils_1.default.getZigbee2MQTTVersion(false)).version;
this.discoveryOrigin = { name: 'Zigbee2MQTT', sw: this.zigbee2MQTTVersion, url: 'https://www.zigbee2mqtt.io' };
this.bridge = this.getBridgeEntity(await this.zigbee.getCoordinatorVersion());
this.bridgeIdentifier = this.getDevicePayload(this.bridge).identifiers[0];
this.eventBus.onEntityRemoved(this, this.onEntityRemoved);
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.eventBus.onEntityRenamed(this, this.onEntityRenamed);
this.eventBus.onPublishEntityState(this, this.onPublishEntityState);
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
this.eventBus.onDeviceAnnounce(this, this.onZigbeeEvent);
this.eventBus.onDeviceJoined(this, this.onZigbeeEvent);
this.eventBus.onDeviceInterview(this, this.onZigbeeEvent);
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
this.eventBus.onScenesChanged(this, this.onScenesChanged);
this.eventBus.onEntityOptionsChanged(this, async (data) => await this.discover(data.entity));
this.eventBus.onExposesChanged(this, async (data) => await this.discover(data.device));
await this.mqtt.subscribe(this.statusTopic);
/**
* Prevent unnecessary re-discovery of entities by waiting 5 seconds for retained discovery messages to come in.
* Any received discovery messages will not be published again.
* Unsubscribe from the discoveryTopic to prevent receiving our own messages.
*/
const discoverWait = 5;
// Discover with `published = false`, this will populate `this.discovered` without publishing the discoveries.
// This is needed for clearing outdated entries in `this.onMQTTMessage()`
await this.discover(this.bridge, false);
for (const e of this.zigbee.devicesAndGroupsIterator(utils_1.default.deviceNotCoordinator)) {
await this.discover(e, false);
}
logger_1.default.debug(`Discovering entities to Home Assistant in ${discoverWait}s`);
await this.mqtt.subscribe(`${this.discoveryTopic}/#`);
setTimeout(async () => {
await this.mqtt.unsubscribe(`${this.discoveryTopic}/#`);
logger_1.default.debug(`Discovering entities to Home Assistant`);
await this.discover(this.bridge);
for (const e of this.zigbee.devicesAndGroupsIterator(utils_1.default.deviceNotCoordinator)) {
await this.discover(e);
}
}, utils_1.default.seconds(discoverWait));
}
getDiscovered(entity) {
const ID = typeof entity === 'string' || typeof entity === 'number' ? entity : entity.ID;
if (!(ID in this.discovered)) {
this.discovered[ID] = { messages: {}, triggers: new Set(), mockProperties: new Set(), discovered: false };
}
return this.discovered[ID];
}
exposeToConfig(exposes, entityType, allExposes, definition) {
// For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features
// to use for a bulb (e.g. color_xy/color_temp)
(0, node_assert_1.default)(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed');
const firstExpose = exposes[0];
(0, node_assert_1.default)(entityType === 'device' || GROUP_SUPPORTED_TYPES.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`);
const discoveryEntries = [];
const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined;
const getProperty = (feature) => (entityType === 'group' ? featurePropertyWithoutEndpoint(feature) : feature.property);
switch (firstExpose.type) {
case 'light': {
const hasColorXY = exposes.find((expose) => expose.features.find((e) => e.name === 'color_xy'));
const hasColorHS = exposes.find((expose) => expose.features.find((e) => e.name === 'color_hs'));
const hasBrightness = exposes.find((expose) => expose.features.find((e) => e.name === 'brightness'));
const hasColorTemp = exposes.find((expose) => expose.features.find((e) => e.name === 'color_temp'));
const state = firstExpose.features.find((f) => f.name === 'state');
(0, node_assert_1.default)(state, `Light expose must have a 'state'`);
// Prefer HS over XY when at least one of the lights in the group prefers HS over XY.
// A light prefers HS over XY when HS is earlier in the feature array than HS.
const preferHS = exposes
.map((e) => [e.features.findIndex((ee) => ee.name === 'color_xy'), e.features.findIndex((ee) => ee.name === 'color_hs')])
.filter((d) => d[0] !== -1 && d[1] !== -1 && d[1] < d[0]).length !== 0;
const discoveryEntry = {
type: 'light',
object_id: endpoint ? `light_${endpoint}` : 'light',
mockProperties: [{ property: state.property, value: null }],
discovery_payload: {
name: endpoint ? utils_1.default.capitalize(endpoint) : null,
brightness: !!hasBrightness,
schema: 'json',
command_topic: true,
brightness_scale: 254,
command_topic_prefix: endpoint,
state_topic_postfix: endpoint,
},
};
const colorModes = [
hasColorXY && !preferHS ? 'xy' : null,
(!hasColorXY || preferHS) && hasColorHS ? 'hs' : null,
hasColorTemp ? 'color_temp' : null,
].filter((c) => c);
if (colorModes.length) {
discoveryEntry.discovery_payload.supported_color_modes = colorModes;
}
else {
/**
* All bulbs support brightness, note that `brightness` cannot be combined
* with other color modes.
* https://github.com/Koenkk/zigbee2mqtt/issues/26520#issuecomment-2692432058
*/
discoveryEntry.discovery_payload.supported_color_modes = ['brightness'];
}
if (hasColorTemp) {
const colorTemps = exposes
.map((expose) => expose.features.find((e) => e.name === 'color_temp'))
.filter((e) => e !== undefined && (0, utils_1.isNumericExpose)(e));
const max = Math.min(...colorTemps.map((e) => e.value_max).filter((e) => e !== undefined));
const min = Math.max(...colorTemps.map((e) => e.value_min).filter((e) => e !== undefined));
discoveryEntry.discovery_payload.max_mireds = max;
discoveryEntry.discovery_payload.min_mireds = min;
}
const effects = utils_1.default.arrayUnique(utils_1.default.flatten(allExposes
.filter(utils_1.isEnumExpose)
.filter((e) => e.name === 'effect')
.map((e) => e.values)));
if (effects.length) {
discoveryEntry.discovery_payload.effect = true;
discoveryEntry.discovery_payload.effect_list = effects;
}
discoveryEntries.push(discoveryEntry);
break;
}
case 'switch': {
const state = firstExpose.features.filter(utils_1.isBinaryExpose).find((f) => f.name === 'state');
(0, node_assert_1.default)(state, `Switch expose must have a 'state'`);
const property = getProperty(state);
const discoveryEntry = {
type: 'switch',
object_id: endpoint ? `switch_${endpoint}` : 'switch',
mockProperties: [{ property: property, value: null }],
discovery_payload: {
name: endpoint ? utils_1.default.capitalize(endpoint) : null,
payload_off: state.value_off,
payload_on: state.value_on,
value_template: `{{ value_json.${property} }}`,
command_topic: true,
command_topic_prefix: endpoint,
},
};
if (SWITCH_DIFFERENT.includes(property)) {
discoveryEntry.discovery_payload.name = firstExpose.label;
discoveryEntry.discovery_payload.command_topic_postfix = property;
discoveryEntry.discovery_payload.state_off = state.value_off;
discoveryEntry.discovery_payload.state_on = state.value_on;
discoveryEntry.object_id = property;
if (property === 'window_detection') {
discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant';
}
}
discoveryEntries.push(discoveryEntry);
break;
}
case 'climate': {
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
const setpoint = firstExpose.features.filter(utils_1.isNumericExpose).find((f) => setpointProperties.includes(f.name));
(0, node_assert_1.default)(setpoint && setpoint.value_min !== undefined && setpoint.value_max !== undefined, 'No setpoint found or it is missing value_min/max');
const temperature = firstExpose.features.find((f) => f.name === 'local_temperature');
(0, node_assert_1.default)(temperature, 'No temperature found');
const discoveryEntry = {
type: 'climate',
object_id: endpoint ? `climate_${endpoint}` : 'climate',
mockProperties: [],
discovery_payload: {
name: endpoint ? utils_1.default.capitalize(endpoint) : null,
// Static
state_topic: false,
temperature_unit: 'C',
// Setpoint
temp_step: setpoint.value_step,
min_temp: setpoint.value_min.toString(),
max_temp: setpoint.value_max.toString(),
// Temperature
current_temperature_topic: true,
current_temperature_template: `{{ value_json.${temperature.property} }}`,
command_topic_prefix: endpoint,
},
};
const mode = firstExpose.features.filter(utils_1.isEnumExpose).find((f) => f.name === 'system_mode');
if (mode) {
if (mode.values.includes('sleep')) {
// 'sleep' is not supported by Home Assistant, but is valid according to ZCL
// TRV that support sleep (e.g. Viessmann) will have it removed from here,
// this allows other expose consumers to still use it, e.g. the frontend.
mode.values.splice(mode.values.indexOf('sleep'), 1);
}
discoveryEntry.discovery_payload.mode_state_topic = true;
discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`;
discoveryEntry.discovery_payload.modes = mode.values;
discoveryEntry.discovery_payload.mode_command_topic = true;
}
const state = firstExpose.features.find((f) => f.name === 'running_state');
if (state) {
discoveryEntry.mockProperties.push({ property: state.property, value: null });
discoveryEntry.discovery_payload.action_topic = true;
discoveryEntry.discovery_payload.action_template =
`{% set values = ` +
`{None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'}` +
` %}{{ values[value_json.${state.property}] }}`;
}
const coolingSetpoint = firstExpose.features.find((f) => f.name === 'occupied_cooling_setpoint');
if (coolingSetpoint) {
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
discoveryEntry.discovery_payload.temperature_high_state_template = `{{ value_json.${coolingSetpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
}
else {
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json.${setpoint.property} }}`;
discoveryEntry.discovery_payload.temperature_state_topic = true;
}
const fanMode = firstExpose.features.filter(utils_1.isEnumExpose).find((f) => f.name === 'fan_mode');
if (fanMode) {
discoveryEntry.discovery_payload.fan_modes = fanMode.values;
discoveryEntry.discovery_payload.fan_mode_command_topic = true;
discoveryEntry.discovery_payload.fan_mode_state_template = `{{ value_json.${fanMode.property} }}`;
discoveryEntry.discovery_payload.fan_mode_state_topic = true;
}
const swingMode = firstExpose.features.filter(utils_1.isEnumExpose).find((f) => f.name === 'swing_mode');
if (swingMode) {
discoveryEntry.discovery_payload.swing_modes = swingMode.values;
discoveryEntry.discovery_payload.swing_mode_command_topic = true;
discoveryEntry.discovery_payload.swing_mode_state_template = `{{ value_json.${swingMode.property} }}`;
discoveryEntry.discovery_payload.swing_mode_state_topic = true;
}
const preset = firstExpose.features.filter(utils_1.isEnumExpose).find((f) => f.name === 'preset');
if (preset) {
discoveryEntry.discovery_payload.preset_modes = preset.values;
discoveryEntry.discovery_payload.preset_mode_command_topic = 'preset';
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${preset.property} }}`;
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
}
const tempCalibration = firstExpose.features
.filter(utils_1.isNumericExpose)
.find((f) => f.name === 'local_temperature_calibration');
if (tempCalibration) {
const discoveryEntry = {
type: 'number',
object_id: endpoint ? `${tempCalibration.name}_${endpoint}` : `${tempCalibration.name}`,
mockProperties: [{ property: tempCalibration.property, value: null }],
discovery_payload: {
name: endpoint ? `${tempCalibration.label} ${endpoint}` : tempCalibration.label,
value_template: `{{ value_json.${tempCalibration.property} }}`,
command_topic: true,
command_topic_prefix: endpoint,
command_topic_postfix: tempCalibration.property,
device_class: 'temperature',
entity_category: 'config',
icon: 'mdi:math-compass',
...(tempCalibration.unit && { unit_of_measurement: tempCalibration.unit }),
},
};
if (tempCalibration.value_min != null)
discoveryEntry.discovery_payload.min = tempCalibration.value_min;
if (tempCalibration.value_max != null)
discoveryEntry.discovery_payload.max = tempCalibration.value_max;
if (tempCalibration.value_step != null) {
discoveryEntry.discovery_payload.step = tempCalibration.value_step;
}
discoveryEntries.push(discoveryEntry);
}
const piHeatingDemand = firstExpose.features.filter(utils_1.isNumericExpose).find((f) => f.name === 'pi_heating_demand');
if (piHeatingDemand) {
const discoveryEntry = {
type: 'sensor',
object_id: endpoint ? /* v8 ignore next */ `${piHeatingDemand.name}_${endpoint}` : `${piHeatingDemand.name}`,
mockProperties: [{ property: piHeatingDemand.property, value: null }],
discovery_payload: {
name: endpoint ? /* v8 ignore next */ `${piHeatingDemand.label} ${endpoint}` : piHeatingDemand.label,
value_template: `{{ value_json.${piHeatingDemand.property} }}`,
...(piHeatingDemand.unit && { unit_of_measurement: piHeatingDemand.unit }),
entity_category: 'diagnostic',
icon: 'mdi:radiator',
},
};
discoveryEntries.push(discoveryEntry);
}
discoveryEntries.push(discoveryEntry);
break;
}
case 'lock': {
const state = firstExpose.features.filter(utils_1.isBinaryExpose).find((f) => f.name === 'state');
(0, node_assert_1.default)(state?.name === 'state', "Lock expose must have a 'state'");
const discoveryEntry = {
type: 'lock',
/* v8 ignore next */
object_id: endpoint ? `lock_${endpoint}` : 'lock',
mockProperties: [{ property: state.property, value: null }],
discovery_payload: {
/* v8 ignore next */
name: endpoint ? utils_1.default.capitalize(endpoint) : null,
command_topic_prefix: endpoint,
command_topic: true,
value_template: `{{ value_json.${state.property} }}`,
state_locked: state.value_on,
state_unlocked: state.value_off,
/* v8 ignore next */
command_topic_postfix: endpoint ? state.property : null,
},
};
discoveryEntries.push(discoveryEntry);
break;
}
case 'cover': {
const state = exposes
.find((expose) => expose.features.find((e) => e.name === 'state'))
?.features.find((f) => f.name === 'state');
(0, node_assert_1.default)(state, `Cover expose must have a 'state'`);
const position = exposes
.find((expose) => expose.features.find((e) => e.name === 'position'))
?.features.find((f) => f.name === 'position');
const tilt = exposes
.find((expose) => expose.features.find((e) => e.name === 'tilt'))
?.features.find((f) => f.name === 'tilt');
const motorState = allExposes
?.filter(utils_1.isEnumExpose)
.find((e) => ['motor_state', 'moving'].includes(e.name) && e.access === ACCESS_STATE);
const running = allExposes?.filter(utils_1.isBinaryExpose)?.find((e) => e.name === 'running');
const discoveryEntry = {
type: 'cover',
mockProperties: [{ property: state.property, value: null }],
object_id: endpoint ? `cover_${endpoint}` : 'cover',
discovery_payload: {
name: endpoint ? utils_1.default.capitalize(endpoint) : null,
command_topic_prefix: endpoint,
command_topic: true,
state_topic: true,
state_topic_postfix: endpoint,
},
};
// If curtains have `running` property, use this in discovery.
// The movement direction is calculated (assumed) in this case.
if (running) {
(0, node_assert_1.default)(position, `Cover must have 'position' when it has 'running'`);
discoveryEntry.discovery_payload.value_template =
`{% if "${featurePropertyWithoutEndpoint(running)}" in value_json ` +
`and value_json.${featurePropertyWithoutEndpoint(running)} %} {% if value_json.${featurePropertyWithoutEndpoint(position)} > 0 %} closing ` +
`{% else %} opening {% endif %} {% else %} stopped {% endif %}`;
}
// If curtains have `motor_state` or `moving` property, lookup for possible
// state names to detect movement direction and use this in discovery.
if (motorState) {
const openingState = motorState.values.find((s) => COVER_OPENING_LOOKUP.includes(s.toString().toLowerCase()));
const closingState = motorState.values.find((s) => COVER_CLOSING_LOOKUP.includes(s.toString().toLowerCase()));
const stoppedState = motorState.values.find((s) => COVER_STOPPED_LOOKUP.includes(s.toString().toLowerCase()));
if (openingState && closingState && stoppedState) {
discoveryEntry.discovery_payload.state_opening = openingState;
discoveryEntry.discovery_payload.state_closing = closingState;
discoveryEntry.discovery_payload.state_stopped = stoppedState;
discoveryEntry.discovery_payload.value_template =
`{% if "${featurePropertyWithoutEndpoint(motorState)}" in value_json ` +
`and value_json.${featurePropertyWithoutEndpoint(motorState)} %} {{ value_json.${featurePropertyWithoutEndpoint(motorState)} }} {% else %} ` +
`${stoppedState} {% endif %}`;
}
}
// If curtains do not have `running`, `motor_state` or `moving` properties.
if (!discoveryEntry.discovery_payload.value_template) {
discoveryEntry.discovery_payload.value_template = `{{ value_json.${featurePropertyWithoutEndpoint(state)} }}`;
discoveryEntry.discovery_payload.state_open = 'OPEN';
discoveryEntry.discovery_payload.state_closed = 'CLOSE';
discoveryEntry.discovery_payload.state_stopped = 'STOP';
}
/* v8 ignore start */
if (!position && !tilt) {
discoveryEntry.discovery_payload.optimistic = true;
}
/* v8 ignore stop */
if (position) {
discoveryEntry.discovery_payload = {
...discoveryEntry.discovery_payload,
position_template: `{{ value_json.${featurePropertyWithoutEndpoint(position)} }}`,
set_position_template: `{ "${getProperty(position)}": {{ position }} }`,
set_position_topic: true,
position_topic: true,
};
}
if (tilt) {
discoveryEntry.discovery_payload = {
...discoveryEntry.discovery_payload,
tilt_command_topic: true,
tilt_status_topic: true,
tilt_status_template: `{{ value_json.${featurePropertyWithoutEndpoint(tilt)} }}`,
};
}
discoveryEntries.push(discoveryEntry);
break;
}
case 'fan': {
(0, node_assert_1.default)(!endpoint, `Endpoint not supported for fan type`);
const discoveryEntry = {
type: 'fan',
object_id: 'fan',
mockProperties: [{ property: 'fan_state', value: null }],
discovery_payload: {
name: null,
state_topic: true,
command_topic: true,
},
};
const modeEmulatedSpeed = firstExpose.features.filter(utils_1.isEnumExpose).find((e) => e.name === 'mode');
const nativeSpeed = firstExpose.features.filter(utils_1.isNumericExpose).find((e) => e.name === 'speed');
// Exactly one mode needs to be active (logical xor)
(0, node_assert_1.default)(!modeEmulatedSpeed != !nativeSpeed, 'Fans need to be either mode- or speed-controlled');
if (modeEmulatedSpeed) {
// A fan entity in Home Assistant 2021.3 and above may have a speed,
// controlled by a percentage from 1 to 100, and/or non-speed presets.
// The MQTT Fan integration allows the speed percentage to be mapped
// to a narrower range of speeds (e.g. 1-3), and for these speeds to be
// translated to and from MQTT messages via templates.
//
// For the fixed fan modes in ZCL hvacFanCtrl, we model speeds "low",
// "medium", and "high" as three speeds covering the full percentage
// range as done in Home Assistant's zigpy fan integration, plus
// presets "on", "auto" and "smart" to cover the remaining modes in
// ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is
// always a valid speed.
let speeds = ['off'].concat(['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => modeEmulatedSpeed.values.includes(s)));
let presets = ['on', 'auto', 'smart'].filter((s) => modeEmulatedSpeed.values.includes(s));
if (['99432'].includes(definition.model)) {
// The Hampton Bay 99432 fan implements 4 speeds using the ZCL
// hvacFanCtrl values `low`, `medium`, `high`, and `on`, and
// 1 preset called "Comfort Breeze" using the ZCL value `smart`.
// ZCL value `auto` is unused.
speeds = ['off', 'low', 'medium', 'high', 'on'];
presets = ['smart'];
}
const allowed = [...speeds, ...presets];
modeEmulatedSpeed.values.forEach((s) => (0, node_assert_1.default)(allowed.includes(s.toString())));
const percentValues = speeds.map((s, i) => `'${s}':${i}`).join(', ');
const percentCommands = speeds.map((s, i) => `${i}:'${s}'`).join(', ');
const presetList = presets.map((s) => `'${s}'`).join(', ');
discoveryEntry