UNPKG

zigbee2mqtt

Version:

Zigbee to MQTT bridge using Zigbee-herdsman

825 lines (824 loc) 216 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: "occupancy" }, 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" }, external_temperature: { device_class: "temperature", icon: "mdi:thermometer" }, external_humidity: { device_class: "humidity", icon: "mdi:water-percent" }, 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: { icon: "mdi:axis-x-arrow", state_class: "measurement" }, x_axis: { icon: "mdi:axis-x-arrow", state_class: "measurement" }, y: { icon: "mdi:axis-y-arrow", state_class: "measurement" }, y_axis: { icon: "mdi:axis-y-arrow", state_class: "measurement" }, z: { icon: "mdi:axis-z-arrow", state_class: "measurement" }, z_axis: { icon: "mdi:axis-z-arrow", state_class: "measurement" }, }; 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); } return feature.property; }; /** * This class handles the bridge entity configuration for Home Assistant Discovery. */ class Bridge { coordinatorIeeeAddress; coordinatorType; coordinatorFirmwareVersion; discoveryEntries; options; // biome-ignore lint/style/useNamingConvention: API 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 = /(.*)\/(.*)\/(.*)\/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 = { object_id: endpoint ? `${piHeatingDemand.name}_${endpoint}` : `${piHeatingDemand.name}`, mockProperties: [{ property: piHeatingDemand.property, value: null }], discovery_payload: { name: endpoint ? `${piHeatingDemand.label} ${endpoint}` : piHeatingDemand.label, value_template: `{{ value_json.${piHeatingDemand.property} }}`, ...(piHeatingDemand.unit && { unit_of_measurement: piHeatingDemand.unit }), icon: "mdi:radiator", }, }; (0, node_assert_1.default)(discoveryEntry.discovery_payload); if (piHeatingDemand.access & ACCESS_SET) { discoveryEntry.type = "number"; discoveryEntry.discovery_payload.command_topic = true; discoveryEntry.discovery_payload.command_topic_prefix = endpoint; discoveryEntry.discovery_payload.command_topic_postfix = piHeatingDemand.property; discoveryEntry.discovery_payload.min = piHeatingDemand.value_min; discoveryEntry.discovery_payload.max = piHeatingDemand.value_max; } else { discoveryEntry.type = "sensor"; discoveryEntry.discovery_payload.entity_category = "diagnostic"; } discoveryEntries.push(discoveryEntry); } const piCoolingDemand = firstExpose.features.filter(utils_1.isNumericExpose).find((f) => f.name === "pi_cooling_demand"); if (piCoolingDemand) { const discoveryEntry = { type: "sensor", object_id: endpoint ? /* v8 ignore next */ `${piCoolingDemand.name}_${endpoint}` : `${piCoolingDemand.name}`, mockProperties: [{ property: piCoolingDemand.property, value: null }], discovery_payload: { name: endpoint ? /* v8 ignore next */ `${piCoolingDemand.label} ${endpoint}` : piCoolingDemand.label, value_template: `{{ value_json.${piCoolingDemand.property} }}`, ...(piCoolingDemand.unit && { unit_of_measurement: piCoolingDemand.unit }), entity_category: "diagnostic", icon: "mdi:air-conditioner", }, }; 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.