zigbee2mqtt
Version:
Zigbee to MQTT bridge using Zigbee-herdsman
825 lines (824 loc) • 216 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: "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.