UNPKG

zigbee2mqtt

Version:

Zigbee to MQTT bridge using Zigbee-herdsman

828 lines 210 kB
"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