mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-07-02 01:51:38 +00:00
2404 lines
122 KiB
TypeScript
2404 lines
122 KiB
TypeScript
import assert from "node:assert";
|
|
import bind from "bind-decorator";
|
|
import stringify from "json-stable-stringify-without-jsonify";
|
|
import type * as zhc from "zigbee-herdsman-converters";
|
|
import type {Zh} from "zigbee-herdsman-converters/lib/types";
|
|
import logger from "../util/logger";
|
|
import * as settings from "../util/settings";
|
|
import utils, {assertBinaryExpose, assertEnumExpose, assertNumericExpose, isBinaryExpose, isEnumExpose, isNumericExpose} from "../util/utils";
|
|
import Extension from "./extension";
|
|
|
|
interface MockProperty {
|
|
property: string;
|
|
value: KeyValue | string | null;
|
|
}
|
|
|
|
interface DiscoveryEntry {
|
|
mockProperties: MockProperty[];
|
|
type: string;
|
|
object_id: string;
|
|
discovery_payload: KeyValue;
|
|
endpoint?: Zh.Endpoint;
|
|
}
|
|
|
|
interface Discovered {
|
|
mockProperties: Set<MockProperty>;
|
|
messages: {[s: string]: {payload: string; published: boolean}};
|
|
triggers: Set<string>;
|
|
discovered: boolean;
|
|
}
|
|
|
|
interface ActionData {
|
|
action: string;
|
|
button?: string;
|
|
scene?: string;
|
|
region?: string;
|
|
}
|
|
|
|
const ACTION_PATTERNS: string[] = [
|
|
"^(?<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: ReadonlyArray<string> = ["light", "switch", "lock", "cover"];
|
|
const COVER_OPENING_LOOKUP: ReadonlyArray<string> = ["opening", "open", "forward", "up", "rising"];
|
|
const COVER_CLOSING_LOOKUP: ReadonlyArray<string> = ["closing", "close", "backward", "back", "reverse", "down", "declining"];
|
|
const COVER_STOPPED_LOOKUP: ReadonlyArray<string> = ["stopped", "stop", "pause", "paused"];
|
|
const CONFIG_SWITCH_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|
auto_lock: {entity_category: "config", icon: "mdi:lock"},
|
|
away_mode: {entity_category: "config", icon: "mdi:home-export-outline"},
|
|
comfort_smiley: {entity_category: "config", icon: "mdi:emoticon-happy-outline"},
|
|
enable_display: {entity_category: "config", icon: "mdi:monitor"},
|
|
indicator: {entity_category: "config", icon: "mdi:led-on"},
|
|
tilt_mode: {entity_category: "config", icon: "mdi:angle-acute"},
|
|
valve_detection: {entity_category: "config", icon: "mdi:pipe-valve"},
|
|
window_detection: {entity_category: "config", icon: "mdi:window-open-variant"},
|
|
} as const;
|
|
const SWITCH_DIFFERENT: ReadonlyArray<string> = Object.keys(CONFIG_SWITCH_DISCOVERY_LOOKUP);
|
|
const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|
activity_led_indicator: {icon: "mdi:led-on"},
|
|
area1Occupancy: {device_class: "occupancy"},
|
|
area2Occupancy: {device_class: "occupancy"},
|
|
area3Occupancy: {device_class: "occupancy"},
|
|
area4Occupancy: {device_class: "occupancy"},
|
|
auto_lock: {entity_category: "config", icon: "mdi:lock"},
|
|
auto_off: {icon: "mdi:flash-auto"},
|
|
away_mode: {entity_category: "config", icon: "mdi:home-export-outline"},
|
|
battery_low: {entity_category: "diagnostic", device_class: "battery"},
|
|
button_lock: {entity_category: "config", icon: "mdi:lock"},
|
|
calibration: {entity_category: "config", icon: "mdi:progress-wrench"},
|
|
calibration_left: {entity_category: "config", icon: "mdi:progress-wrench"},
|
|
calibration_right: {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},
|
|
frost_protection: {entity_category: "config", icon: "mdi:snowflake-thermometer"},
|
|
heating_stop: {entity_category: "config", icon: "mdi:radiator-off"},
|
|
eco_mode: {entity_category: "config", icon: "mdi:leaf"},
|
|
enable_display: {entity_category: "config", icon: "mdi:monitor"},
|
|
expose_pin: {entity_category: "config", icon: "mdi:pin"},
|
|
flip_indicator_light: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
|
gas: {device_class: "gas"},
|
|
indicator: {entity_category: "config", icon: "mdi:led-on"},
|
|
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"},
|
|
motor_reversal_left: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
|
motor_reversal_right: {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"},
|
|
rain_status: {device_class: "moisture", icon: "mdi:weather-pouring"},
|
|
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"},
|
|
tilt_mode: {entity_category: "config", icon: "mdi:angle-acute"},
|
|
trigger_indicator: {icon: "mdi:led-on"},
|
|
valve_alarm: {device_class: "problem"},
|
|
valve_detection: {entity_category: "config", icon: "mdi:pipe-valve"},
|
|
valve_state: {device_class: "opening"},
|
|
vibration: {device_class: "vibration"},
|
|
water_leak: {device_class: "moisture"},
|
|
window: {device_class: "window"},
|
|
window_detection: {entity_category: "config", icon: "mdi:window-open-variant"},
|
|
window_open: {device_class: "window"},
|
|
} as const;
|
|
const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|
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"},
|
|
apparent_temperature: {device_class: "temperature", icon: "mdi:thermometer-lines", preserve_name: true, state_class: "measurement"},
|
|
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"},
|
|
calibration_time_left: {entity_category: "config", icon: "mdi:wrench-clock"},
|
|
calibration_time_right: {entity_category: "config", icon: "mdi:wrench-clock"},
|
|
co2: {device_class: "carbon_dioxide", state_class: "measurement"},
|
|
comfort_humidity_max: {device_class: "humidity", entity_category: "config", icon: "mdi:water-percent"},
|
|
comfort_humidity_min: {device_class: "humidity", entity_category: "config", icon: "mdi:water-percent"},
|
|
comfort_temperature: {entity_category: "config", icon: "mdi:thermometer"},
|
|
comfort_temperature_max: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-high"},
|
|
comfort_temperature_min: {device_class: "temperature", entity_category: "config", icon: "mdi:thermometer-low"},
|
|
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_delay: {entity_category: "config", icon: "mdi:timer"},
|
|
detection_interval: {icon: "mdi:timer"},
|
|
device_temperature: {
|
|
device_class: "temperature",
|
|
entity_category: "diagnostic",
|
|
state_class: "measurement",
|
|
},
|
|
dew_point: {device_class: "temperature", icon: "mdi:thermometer-water", preserve_name: true, state_class: "measurement"},
|
|
distance: {device_class: "distance", state_class: "measurement"},
|
|
duration: {entity_category: "config", icon: "mdi:timer"},
|
|
eco2: {device_class: "volatile_organic_compounds_parts", 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", state_class: "measurement"},
|
|
external_humidity: {device_class: "humidity", icon: "mdi:water-percent", state_class: "measurement"},
|
|
fading_time: {entity_category: "config", icon: "mdi:timer"},
|
|
formaldehyd: {state_class: "measurement"},
|
|
flow: {device_class: "volume_flow_rate", state_class: "measurement"},
|
|
gas: {device_class: "gas", state_class: "total_increasing", icon: "mdi:meter-gas"},
|
|
gas_density: {icon: "mdi:google-circles-communities", state_class: "measurement"},
|
|
gust_speed: {device_class: "wind_speed", icon: "mdi:weather-windy-variant", preserve_name: true, state_class: "measurement"},
|
|
hcho: {icon: "mdi:air-filter", state_class: "measurement"},
|
|
heat_stress: {icon: "mdi:weather-sunny-alert", state_class: "measurement"},
|
|
humidex: {device_class: "temperature", icon: "mdi:thermometer-alert", preserve_name: true, 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"},
|
|
illuminance_raw: {state_class: "measurement"},
|
|
interval_time: {entity_category: "config", icon: "mdi:timer"},
|
|
internalTemperature: {
|
|
device_class: "temperature",
|
|
entity_category: "diagnostic",
|
|
state_class: "measurement",
|
|
},
|
|
linkquality: {
|
|
enabled_by_default: false,
|
|
entity_category: "diagnostic",
|
|
icon: "mdi:signal",
|
|
state_class: "measurement",
|
|
},
|
|
load_estimate: {state_class: "measurement"},
|
|
local_temperature: {device_class: "temperature", state_class: "measurement"},
|
|
large_motion_detection_distance: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
|
large_motion_detection_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
|
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"},
|
|
maximum_range: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
|
measurement_interval: {entity_category: "config", icon: "mdi:clock-out"},
|
|
min_temperature_limit: {entity_category: "config", icon: "mdi:thermometer-low"},
|
|
min_temperature: {entity_category: "config", icon: "mdi:thermometer-low"},
|
|
minimum_range: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
|
minimum_on_level: {entity_category: "config"},
|
|
measurement_poll_interval: {entity_category: "config", icon: "mdi:clock-out"},
|
|
medium_motion_detection_distance: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
|
medium_motion_detection_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
|
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", state_class: "measurement"},
|
|
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, state_class: "measurement"},
|
|
precipitation: {device_class: "precipitation", icon: "mdi:weather-rainy", state_class: "total_increasing"},
|
|
precision: {entity_category: "config", icon: "mdi:decimal-comma-increase"},
|
|
pressure: {device_class: "atmospheric_pressure", state_class: "measurement"},
|
|
pressure_trend: {icon: "mdi:trending-up", state_class: "measurement"},
|
|
presence_timeout: {entity_category: "config", icon: "mdi:timer"},
|
|
rain_rate: {device_class: "precipitation_intensity", icon: "mdi:weather-pouring", state_class: "measurement"},
|
|
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"},
|
|
sensitivity: {entity_category: "config", icon: "mdi:tune"},
|
|
small_detection_distance: {entity_category: "config", icon: "mdi:signal-distance-variant"},
|
|
small_detection_sensitivity: {entity_category: "config", icon: "mdi:motion-sensor"},
|
|
soil_calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
|
soil_fertility: {device_class: "conductivity", state_class: "measurement"},
|
|
soil_moisture: {device_class: "moisture", state_class: "measurement"},
|
|
soil_sampling: {entity_category: "config", icon: "mdi:clock-out"},
|
|
soil_warning: {entity_category: "config", icon: "mdi:water-percent-alert"},
|
|
temperature: {device_class: "temperature", state_class: "measurement"},
|
|
temperature_probe: {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"},
|
|
temperature_sampling: {entity_category: "config", icon: "mdi:clock-out"},
|
|
transition: {entity_category: "config", icon: "mdi:transition"},
|
|
trigger_count: {icon: "mdi:counter", enabled_by_default: false, state_class: "measurement"},
|
|
uv_index: {icon: "mdi:white-balance-sunny", state_class: "measurement"},
|
|
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",
|
|
},
|
|
wind_chill: {device_class: "temperature", icon: "mdi:snowflake-thermometer", preserve_name: true, state_class: "measurement"},
|
|
wind_direction: {icon: "mdi:compass-outline", state_class: "measurement"},
|
|
wind_speed: {device_class: "wind_speed", icon: "mdi:weather-windy", state_class: "measurement"},
|
|
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"},
|
|
} as const;
|
|
const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|
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"},
|
|
motor_direction: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
|
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"},
|
|
set_limits: {entity_category: "config", icon: "mdi:ray-start-end"},
|
|
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"},
|
|
temperature_unit: {entity_category: "config", icon: "mdi:temperature-celsius"},
|
|
thermostat_unit: {entity_category: "config", icon: "mdi:thermometer"},
|
|
update: {device_class: "update"},
|
|
volume: {entity_category: "config", icon: "mdi: volume-high"},
|
|
weather_condition: {icon: "mdi:weather-partly-cloudy"},
|
|
week: {entity_category: "config", icon: "mdi:calendar-clock"},
|
|
} as const;
|
|
const LIST_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|
action: {icon: "mdi:gesture-double-tap"},
|
|
color_options: {icon: "mdi:palette"},
|
|
level_config: {entity_category: "diagnostic"},
|
|
programming_mode: {icon: "mdi:calendar-clock"},
|
|
schedule_settings: {entity_category: "config", icon: "mdi:calendar-clock"},
|
|
} as const;
|
|
|
|
const featurePropertyWithoutEndpoint = (feature: zhc.Feature): string => {
|
|
if (feature.endpoint) {
|
|
return feature.property.slice(0, -1 + -1 * feature.endpoint.length);
|
|
}
|
|
|
|
return feature.property;
|
|
};
|
|
|
|
const applyHomeAssistantExposeMetadata = (payload: KeyValue, homeAssistant: zhc.Expose["homeassistant"]): void => {
|
|
const metadata = homeAssistant as KeyValue | undefined;
|
|
if (!metadata) {
|
|
return;
|
|
}
|
|
|
|
if (typeof metadata.entityCategory === "string") {
|
|
payload.entity_category = metadata.entityCategory;
|
|
}
|
|
|
|
if (typeof metadata.deviceClass === "string") {
|
|
payload.device_class = metadata.deviceClass;
|
|
}
|
|
|
|
if (typeof metadata.enabledByDefault === "boolean") {
|
|
payload.enabled_by_default = metadata.enabledByDefault;
|
|
}
|
|
|
|
if (typeof metadata.icon === "string") {
|
|
payload.icon = metadata.icon;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* This class handles the bridge entity configuration for Home Assistant Discovery.
|
|
*/
|
|
class Bridge {
|
|
private coordinatorIeeeAddress: string;
|
|
private coordinatorType: string;
|
|
private coordinatorFirmwareVersion: string;
|
|
private discoveryEntries: DiscoveryEntry[];
|
|
|
|
readonly options: {
|
|
ID?: string;
|
|
homeassistant?: KeyValue;
|
|
};
|
|
|
|
// biome-ignore lint/style/useNamingConvention: API
|
|
get ID(): string {
|
|
return this.coordinatorIeeeAddress;
|
|
}
|
|
get name(): string {
|
|
return "bridge";
|
|
}
|
|
get hardwareVersion(): string {
|
|
return this.coordinatorType;
|
|
}
|
|
get firmwareVersion(): string {
|
|
return this.coordinatorFirmwareVersion;
|
|
}
|
|
get configs(): DiscoveryEntry[] {
|
|
return this.discoveryEntries;
|
|
}
|
|
|
|
constructor(ieeeAdress: string, version: zh.CoordinatorVersion, discovery: DiscoveryEntry[]) {
|
|
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(): this is Device {
|
|
return false;
|
|
}
|
|
isGroup(): this is Group {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This extensions handles integration with HomeAssistant
|
|
*/
|
|
export class HomeAssistant extends Extension {
|
|
private discovered: {[s: string]: Discovered} = {};
|
|
private discoveryTopic: string;
|
|
private discoveryRegex: RegExp;
|
|
private discoveryRegexWoTopic = /(.*)\/(.*)\/(.*)\/config/;
|
|
private groupMemberLookup = new Map<string, string>();
|
|
private statusTopic: string;
|
|
private legacyActionSensor: boolean;
|
|
private experimentalEventEntities: boolean;
|
|
// @ts-expect-error initialized in `start`
|
|
private zigbee2MQTTVersion: string;
|
|
// @ts-expect-error initialized in `start`
|
|
private discoveryOrigin: {name: string; sw: string; url: string};
|
|
// @ts-expect-error initialized in `start`
|
|
private bridge: Bridge;
|
|
// @ts-expect-error initialized in `start`
|
|
private bridgeIdentifier: string;
|
|
private actionValueTemplate: string;
|
|
|
|
constructor(
|
|
zigbee: Zigbee,
|
|
mqtt: Mqtt,
|
|
state: State,
|
|
publishEntityState: PublishEntityState,
|
|
eventBus: EventBus,
|
|
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
|
restartCallback: () => Promise<void>,
|
|
addExtension: (extension: Extension) => Promise<void>,
|
|
) {
|
|
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;
|
|
assert(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();
|
|
}
|
|
|
|
override async start(): Promise<void> {
|
|
if (!settings.get().advanced.cache_state) {
|
|
logger.warning("In order for Home Assistant integration to work properly set `cache_state: true");
|
|
}
|
|
|
|
this.zigbee2MQTTVersion = (await utils.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);
|
|
// TODO: this is triggering for any `data.status`?
|
|
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()`
|
|
// Discover devices before groups to populate `this.groupMemberLookup` for group discovery.
|
|
await this.discover(this.bridge, false);
|
|
|
|
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
|
|
await this.discover(device, false);
|
|
}
|
|
|
|
for (const group of this.zigbee.groupsIterator()) {
|
|
await this.discover(group, false);
|
|
}
|
|
|
|
logger.debug(`Discovering entities to Home Assistant in ${discoverWait}s`);
|
|
await this.mqtt.subscribe(`${this.discoveryTopic}/#`);
|
|
setTimeout(async () => {
|
|
await this.mqtt.unsubscribe(`${this.discoveryTopic}/#`);
|
|
logger.debug("Discovering entities to Home Assistant");
|
|
|
|
await this.discover(this.bridge);
|
|
|
|
for (const e of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) {
|
|
await this.discover(e);
|
|
}
|
|
}, utils.seconds(discoverWait));
|
|
}
|
|
|
|
private getDiscovered(entity: Device | Group | Bridge | string | number): Discovered {
|
|
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];
|
|
}
|
|
|
|
private exposeToConfig(exposes: zhc.Expose[], entity: Device | Group, allExposes: zhc.Expose[]): DiscoveryEntry[] {
|
|
// 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)
|
|
assert(entity.isGroup() || exposes.length === 1, "Multiple exposes for device not allowed");
|
|
const firstExpose = exposes[0];
|
|
assert(entity.isDevice() || GROUP_SUPPORTED_TYPES.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`);
|
|
|
|
const discoveryEntries: DiscoveryEntry[] = [];
|
|
const endpointName = entity.isDevice() ? exposes[0].endpoint : undefined;
|
|
const getProperty = (feature: zhc.Feature): string => (entity.isGroup() ? featurePropertyWithoutEndpoint(feature) : feature.property);
|
|
|
|
switch (firstExpose.type) {
|
|
case "light": {
|
|
const hasColorXY = (exposes as zhc.Light[]).find((expose) => expose.features.find((e) => e.name === "color_xy"));
|
|
const hasColorHS = (exposes as zhc.Light[]).find((expose) => expose.features.find((e) => e.name === "color_hs"));
|
|
const hasBrightness = (exposes as zhc.Light[]).find((expose) => expose.features.find((e) => e.name === "brightness"));
|
|
const hasColorTemp = (exposes as zhc.Light[]).find((expose) => expose.features.find((e) => e.name === "color_temp"));
|
|
const state = (firstExpose as zhc.Light).features.find((f) => f.name === "state");
|
|
assert(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 as zhc.Light[])
|
|
.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: DiscoveryEntry = {
|
|
type: "light",
|
|
object_id: endpointName ? `light_${endpointName}` : "light",
|
|
mockProperties: [{property: state.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? utils.capitalize(endpointName) : null,
|
|
brightness: !!hasBrightness,
|
|
schema: "json",
|
|
command_topic: true,
|
|
brightness_scale: 254,
|
|
command_topic_prefix: endpointName,
|
|
state_topic_postfix: endpointName,
|
|
},
|
|
};
|
|
|
|
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 as zhc.Light[])
|
|
.map((expose) => expose.features.find((e) => e.name === "color_temp"))
|
|
.filter((e) => e !== undefined && 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.arrayUnique(
|
|
utils.flatten(
|
|
allExposes
|
|
.filter(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 as zhc.Switch).features.filter(isBinaryExpose).find((f) => f.name === "state");
|
|
assert(state, `Switch expose must have a 'state'`);
|
|
const property = getProperty(state);
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "switch",
|
|
object_id: endpointName ? `switch_${endpointName}` : "switch",
|
|
mockProperties: [{property: property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? utils.capitalize(endpointName) : null,
|
|
payload_off: state.value_off,
|
|
payload_on: state.value_on,
|
|
value_template: `{{ value_json["${property}"] }}`,
|
|
command_topic: true,
|
|
command_topic_prefix: endpointName,
|
|
},
|
|
};
|
|
|
|
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;
|
|
Object.assign(discoveryEntry.discovery_payload, CONFIG_SWITCH_DISCOVERY_LOOKUP[property]);
|
|
}
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
break;
|
|
}
|
|
case "climate": {
|
|
const heatingSetpoint = (firstExpose as zhc.Climate).features
|
|
.filter(isNumericExpose)
|
|
.find((f) => ["occupied_heating_setpoint", "current_heating_setpoint"].includes(f.name));
|
|
const coolingSetpoint = (firstExpose as zhc.Climate).features
|
|
.filter(isNumericExpose)
|
|
.find((f) => f.name === "occupied_cooling_setpoint");
|
|
const primarySetpoint = heatingSetpoint ?? coolingSetpoint;
|
|
assert(
|
|
primarySetpoint && primarySetpoint.value_min !== undefined && primarySetpoint.value_max !== undefined,
|
|
"No setpoint found or it is missing value_min/max",
|
|
);
|
|
const temperature = (firstExpose as zhc.Climate).features.find((f) => f.name === "local_temperature");
|
|
assert(temperature, "No temperature found");
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "climate",
|
|
object_id: endpointName ? `climate_${endpointName}` : "climate",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: endpointName ? utils.capitalize(endpointName) : null,
|
|
// Static
|
|
state_topic: false,
|
|
temperature_unit: "C",
|
|
// Setpoint
|
|
temp_step: primarySetpoint.value_step,
|
|
min_temp: primarySetpoint.value_min.toString(),
|
|
max_temp: primarySetpoint.value_max.toString(),
|
|
// Temperature
|
|
current_temperature_topic: true,
|
|
current_temperature_template: `{{ value_json["${temperature.property}"] }}`,
|
|
command_topic_prefix: endpointName,
|
|
},
|
|
};
|
|
|
|
const mode = (firstExpose as zhc.Climate).features.filter(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 as zhc.Climate).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}"]] }}`;
|
|
}
|
|
|
|
if (heatingSetpoint && coolingSetpoint) {
|
|
discoveryEntry.discovery_payload.temperature_low_command_topic = heatingSetpoint.name;
|
|
discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json["${heatingSetpoint.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 = primarySetpoint.name;
|
|
discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json["${primarySetpoint.property}"] }}`;
|
|
discoveryEntry.discovery_payload.temperature_state_topic = true;
|
|
}
|
|
|
|
const fanMode = (firstExpose as zhc.Climate).features.filter(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 as zhc.Climate).features.filter(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 as zhc.Climate).features.filter(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 as zhc.Climate).features
|
|
.filter(isNumericExpose)
|
|
.find((f) => f.name === "local_temperature_calibration");
|
|
if (tempCalibration) {
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "number",
|
|
object_id: endpointName ? `${tempCalibration.name}_${endpointName}` : `${tempCalibration.name}`,
|
|
mockProperties: [{property: tempCalibration.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${tempCalibration.label} ${endpointName}` : tempCalibration.label,
|
|
value_template: `{{ value_json["${tempCalibration.property}"] }}`,
|
|
command_topic: true,
|
|
command_topic_prefix: endpointName,
|
|
command_topic_postfix: tempCalibration.property,
|
|
device_class: "temperature_delta",
|
|
entity_category: "config",
|
|
...(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 as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === "pi_heating_demand");
|
|
if (piHeatingDemand) {
|
|
const discoveryEntry: Partial<DiscoveryEntry> = {
|
|
object_id: endpointName ? `${piHeatingDemand.name}_${endpointName}` : `${piHeatingDemand.name}`,
|
|
mockProperties: [{property: piHeatingDemand.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${piHeatingDemand.label} ${endpointName}` : piHeatingDemand.label,
|
|
value_template: `{{ value_json["${piHeatingDemand.property}"] }}`,
|
|
...(piHeatingDemand.unit && {unit_of_measurement: piHeatingDemand.unit}),
|
|
icon: "mdi:radiator",
|
|
},
|
|
};
|
|
|
|
assert(discoveryEntry.discovery_payload);
|
|
|
|
if (piHeatingDemand.access & ACCESS_SET) {
|
|
discoveryEntry.type = "number";
|
|
discoveryEntry.discovery_payload.command_topic = true;
|
|
discoveryEntry.discovery_payload.command_topic_prefix = endpointName;
|
|
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>discoveryEntry);
|
|
}
|
|
|
|
const piCoolingDemand = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === "pi_cooling_demand");
|
|
if (piCoolingDemand) {
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "sensor",
|
|
object_id: endpointName ? /* v8 ignore next */ `${piCoolingDemand.name}_${endpointName}` : `${piCoolingDemand.name}`,
|
|
mockProperties: [{property: piCoolingDemand.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? /* v8 ignore next */ `${piCoolingDemand.label} ${endpointName}` : 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);
|
|
}
|
|
|
|
const localTemperature = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === "local_temperature");
|
|
const temperatureSensor = allExposes?.filter(isNumericExpose).find((e) => e.name === "temperature" && e.access & ACCESS_STATE);
|
|
const localTemperatureSensor = allExposes
|
|
?.filter(isNumericExpose)
|
|
.find((e) => e.name === "local_temperature" && e.access & ACCESS_STATE);
|
|
if (localTemperature && !temperatureSensor && !localTemperatureSensor) {
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "sensor",
|
|
object_id: endpointName ? `${localTemperature.name}_${endpointName}` : `${localTemperature.name}`,
|
|
mockProperties: [{property: localTemperature.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${localTemperature.label} ${endpointName}` : localTemperature.label,
|
|
value_template: `{{ value_json["${localTemperature.property}"] }}`,
|
|
...(localTemperature.unit && {unit_of_measurement: localTemperature.unit}),
|
|
device_class: "temperature",
|
|
state_class: "measurement",
|
|
},
|
|
};
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
}
|
|
|
|
const currentHumidity = allExposes?.filter(isNumericExpose).find((e) => e.name === "humidity" && e.access & ACCESS_STATE);
|
|
if (currentHumidity) {
|
|
discoveryEntry.discovery_payload.current_humidity_template = `{{ value_json["${currentHumidity.property}"] }}`;
|
|
discoveryEntry.discovery_payload.current_humidity_topic = true;
|
|
}
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
break;
|
|
}
|
|
case "lock": {
|
|
const state = (firstExpose as zhc.Lock).features.filter(isBinaryExpose).find((f) => f.name === "state");
|
|
assert(state?.name === "state", "Lock expose must have a 'state'");
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "lock",
|
|
/* v8 ignore next */
|
|
object_id: endpointName ? `lock_${endpointName}` : "lock",
|
|
mockProperties: [{property: state.property, value: null}],
|
|
discovery_payload: {
|
|
/* v8 ignore next */
|
|
name: endpointName ? utils.capitalize(endpointName) : null,
|
|
command_topic_prefix: endpointName,
|
|
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: endpointName ? state.property : null,
|
|
},
|
|
};
|
|
discoveryEntries.push(discoveryEntry);
|
|
break;
|
|
}
|
|
case "cover": {
|
|
const state = (exposes as zhc.Cover[])
|
|
.find((expose) => expose.features.find((e) => e.name === "state"))
|
|
?.features.find((f) => f.name === "state");
|
|
assert(state, `Cover expose must have a 'state'`);
|
|
const position = (exposes as zhc.Cover[])
|
|
.find((expose) => expose.features.find((e) => e.name === "position"))
|
|
?.features.find((f) => f.name === "position");
|
|
const tilt = (exposes as zhc.Cover[])
|
|
.find((expose) => expose.features.find((e) => e.name === "tilt"))
|
|
?.features.find((f) => f.name === "tilt");
|
|
const motorState = allExposes
|
|
?.filter(isEnumExpose)
|
|
.find((e) => ["motor_state", "moving"].includes(e.name) && e.access === ACCESS_STATE);
|
|
const running = allExposes?.filter(isBinaryExpose)?.find((e) => e.name === "running");
|
|
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "cover",
|
|
mockProperties: [{property: state.property, value: null}],
|
|
object_id: endpointName ? `cover_${endpointName}` : "cover",
|
|
discovery_payload: {
|
|
name: endpointName ? utils.capitalize(endpointName) : null,
|
|
command_topic_prefix: endpointName,
|
|
command_topic: true,
|
|
state_topic: true,
|
|
state_topic_postfix: endpointName,
|
|
},
|
|
};
|
|
|
|
// If curtains have `running` property, use this in discovery.
|
|
// The movement direction is calculated (assumed) in this case.
|
|
if (running) {
|
|
assert(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": {
|
|
assert(!endpointName, "Endpoint not supported for fan type");
|
|
const discoveryEntry: 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 as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === "mode");
|
|
const nativeSpeed = (firstExpose as zhc.Fan).features.filter(isNumericExpose).find((e) => e.name === "speed");
|
|
|
|
// Exactly one mode needs to be active (logical xor)
|
|
assert(!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 (entity.isDevice() && entity.definition?.model === "99432") {
|
|
// 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];
|
|
|
|
for (const val of modeEmulatedSpeed.values) {
|
|
assert(allowed.includes(val.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.discovery_payload.percentage_state_topic = true;
|
|
discoveryEntry.discovery_payload.percentage_command_topic = "fan_mode";
|
|
discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json["${modeEmulatedSpeed.property}"]] | default('None') }}`;
|
|
discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`;
|
|
discoveryEntry.discovery_payload.speed_range_min = 1;
|
|
discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1;
|
|
assert(presets.length !== 0);
|
|
discoveryEntry.discovery_payload.preset_mode_state_topic = true;
|
|
discoveryEntry.discovery_payload.preset_mode_command_topic = "fan_mode";
|
|
discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json["${modeEmulatedSpeed.property}"] if value_json["${modeEmulatedSpeed.property}"] in [${presetList}] else 'None' | default('None') }}`;
|
|
discoveryEntry.discovery_payload.preset_modes = presets;
|
|
|
|
// Emulate state based on mode
|
|
discoveryEntry.discovery_payload.state_value_template = "{{ value_json.fan_state }}";
|
|
discoveryEntry.discovery_payload.command_topic_postfix = "fan_state";
|
|
} else if (nativeSpeed) {
|
|
discoveryEntry.discovery_payload.percentage_state_topic = true;
|
|
discoveryEntry.discovery_payload.percentage_command_topic = "speed";
|
|
discoveryEntry.discovery_payload.percentage_value_template = `{{ value_json["${nativeSpeed.property}"] | default('None') }}`;
|
|
discoveryEntry.discovery_payload.percentage_command_template = `{{ value | default('') }}`;
|
|
discoveryEntry.discovery_payload.speed_range_min = nativeSpeed.value_min;
|
|
discoveryEntry.discovery_payload.speed_range_max = nativeSpeed.value_max;
|
|
|
|
// Speed-controlled fans generally have an onOff cluster, use that for state
|
|
discoveryEntry.discovery_payload.state_value_template = "{{ value_json.state }}";
|
|
discoveryEntry.discovery_payload.command_topic_postfix = "state";
|
|
}
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
break;
|
|
}
|
|
case "binary": {
|
|
/**
|
|
* If Z2M binary attribute has SET access then expose it as `switch` in HA
|
|
* There is also a check on the values for typeof boolean to prevent invalid values and commands
|
|
* silently failing - commands work fine but some devices won't reject unexpected values.
|
|
* https://github.com/Koenkk/zigbee2mqtt/issues/7740
|
|
*/
|
|
assertBinaryExpose(firstExpose);
|
|
if (firstExpose.access & ACCESS_SET) {
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "switch",
|
|
mockProperties: [{property: firstExpose.property, value: null}],
|
|
object_id: endpointName ? `switch_${firstExpose.name}_${endpointName}` : `switch_${firstExpose.name}`,
|
|
discovery_payload: {
|
|
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
|
value_template:
|
|
typeof firstExpose.value_on === "boolean"
|
|
? `{% if value_json["${firstExpose.property}"] %}true{% else %}false{% endif %}`
|
|
: `{{ value_json["${firstExpose.property}"] }}`,
|
|
payload_on: firstExpose.value_on.toString(),
|
|
payload_off: firstExpose.value_off.toString(),
|
|
command_topic: true,
|
|
command_topic_prefix: endpointName,
|
|
command_topic_postfix: firstExpose.property,
|
|
...(BINARY_DISCOVERY_LOOKUP[firstExpose.name] || {}),
|
|
},
|
|
};
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
} else {
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "binary_sensor",
|
|
object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`,
|
|
mockProperties: [{property: firstExpose.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
|
value_template: `{{ value_json["${firstExpose.property}"] }}`,
|
|
payload_on: firstExpose.value_on,
|
|
payload_off: firstExpose.value_off,
|
|
...(BINARY_DISCOVERY_LOOKUP[firstExpose.name] || {}),
|
|
},
|
|
};
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
}
|
|
break;
|
|
}
|
|
case "numeric": {
|
|
assertNumericExpose(firstExpose);
|
|
const allowsSet = firstExpose.access & ACCESS_SET;
|
|
|
|
/**
|
|
* If numeric attribute has SET access then expose as SELECT entity.
|
|
*/
|
|
if (allowsSet) {
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "number",
|
|
object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`,
|
|
mockProperties: [{property: firstExpose.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
|
value_template: `{{ value_json["${firstExpose.property}"] }}`,
|
|
command_topic: true,
|
|
command_topic_prefix: endpointName,
|
|
command_topic_postfix: firstExpose.property,
|
|
...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}),
|
|
...(firstExpose.value_step && {step: firstExpose.value_step}),
|
|
...NUMERIC_DISCOVERY_LOOKUP[firstExpose.name],
|
|
},
|
|
};
|
|
|
|
if (NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.device_class === "temperature") {
|
|
discoveryEntry.discovery_payload.device_class = NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.device_class;
|
|
} else {
|
|
delete discoveryEntry.discovery_payload.device_class;
|
|
}
|
|
|
|
if (firstExpose.value_min != null) discoveryEntry.discovery_payload.min = firstExpose.value_min;
|
|
if (firstExpose.value_max != null) discoveryEntry.discovery_payload.max = firstExpose.value_max;
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
break;
|
|
}
|
|
|
|
const extraAttrs = {};
|
|
|
|
// If a variable includes Wh, mark it as energy
|
|
if (firstExpose.unit && ["Wh", "kWh"].includes(firstExpose.unit)) {
|
|
Object.assign(extraAttrs, {device_class: "energy", state_class: "total_increasing"});
|
|
}
|
|
// If a variable includes A or mA, mark it as current
|
|
else if (firstExpose.unit && ["A", "mA"].includes(firstExpose.unit)) {
|
|
Object.assign(extraAttrs, {device_class: "current", state_class: "measurement"});
|
|
}
|
|
// If a variable includes mW, W, kW mark it as power
|
|
else if (firstExpose.unit && ["mW", "W", "kW"].includes(firstExpose.unit)) {
|
|
Object.assign(extraAttrs, {device_class: "power", state_class: "measurement"});
|
|
}
|
|
|
|
let key = firstExpose.name;
|
|
|
|
// Home Assistant uses a different voc device_class for µg/m³ versus ppb or ppm.
|
|
if (firstExpose.name === "voc" && firstExpose.unit && ["ppb", "ppm"].includes(firstExpose.unit)) {
|
|
key = "voc_parts";
|
|
}
|
|
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "sensor",
|
|
object_id: endpointName ? `${firstExpose.name}_${endpointName}` : `${firstExpose.name}`,
|
|
mockProperties: [{property: firstExpose.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
|
value_template: `{{ value_json["${firstExpose.property}"] }}`,
|
|
enabled_by_default: !allowsSet,
|
|
...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}),
|
|
...NUMERIC_DISCOVERY_LOOKUP[key],
|
|
...extraAttrs,
|
|
},
|
|
};
|
|
|
|
// When a device_class is set, unit_of_measurement must be set, otherwise warnings are generated.
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/15958#issuecomment-1377483202
|
|
if (discoveryEntry.discovery_payload.device_class && !discoveryEntry.discovery_payload.unit_of_measurement) {
|
|
delete discoveryEntry.discovery_payload.device_class;
|
|
}
|
|
|
|
// entity_category config is not allowed for sensors
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/20252
|
|
if (discoveryEntry.discovery_payload.entity_category === "config") {
|
|
discoveryEntry.discovery_payload.entity_category = "diagnostic";
|
|
}
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
break;
|
|
}
|
|
case "enum": {
|
|
assertEnumExpose(firstExpose);
|
|
/**
|
|
* If enum attribute does not have SET access and is named 'action', then expose
|
|
* as EVENT entity. Wildcard actions like `recall_*` are currently not supported.
|
|
*/
|
|
if (firstExpose.property === "action") {
|
|
if (
|
|
this.experimentalEventEntities &&
|
|
firstExpose.access & ACCESS_STATE &&
|
|
!(firstExpose.access & ACCESS_SET) &&
|
|
firstExpose.property === "action"
|
|
) {
|
|
discoveryEntries.push({
|
|
type: "event",
|
|
object_id: firstExpose.property,
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
|
state_topic: true,
|
|
event_types: this.prepareActionEventTypes(firstExpose.values),
|
|
value_template: this.actionValueTemplate,
|
|
...ENUM_DISCOVERY_LOOKUP[firstExpose.name],
|
|
},
|
|
});
|
|
}
|
|
if (!this.legacyActionSensor) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json["${firstExpose.property}"] }}` : undefined;
|
|
|
|
/**
|
|
* If enum has only one item and has SET access then expose as BUTTON entity.
|
|
*/
|
|
if (firstExpose.access & ACCESS_SET && firstExpose.values.length === 1) {
|
|
discoveryEntries.push({
|
|
type: "button",
|
|
object_id: firstExpose.property,
|
|
mockProperties: [{property: firstExpose.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? /* v8 ignore next */ `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
|
state_topic: false,
|
|
command_topic_prefix: endpointName,
|
|
command_topic: true,
|
|
command_topic_postfix: firstExpose.property,
|
|
payload_press: firstExpose.values[0].toString(),
|
|
...ENUM_DISCOVERY_LOOKUP[firstExpose.name],
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
|
|
/**
|
|
* If enum attribute has SET access then expose as SELECT entity.
|
|
*/
|
|
if (firstExpose.access & ACCESS_SET) {
|
|
discoveryEntries.push({
|
|
type: "select",
|
|
object_id: firstExpose.property,
|
|
mockProperties: [{property: firstExpose.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
|
value_template: valueTemplate,
|
|
state_topic: !!(firstExpose.access & ACCESS_STATE),
|
|
command_topic_prefix: endpointName,
|
|
command_topic: true,
|
|
command_topic_postfix: firstExpose.property,
|
|
options: firstExpose.values.map((v) => v.toString()),
|
|
...ENUM_DISCOVERY_LOOKUP[firstExpose.name],
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
|
|
/**
|
|
* Otherwise expose as SENSOR entity.
|
|
*/
|
|
if (firstExpose.access & ACCESS_STATE) {
|
|
discoveryEntries.push({
|
|
type: "sensor",
|
|
object_id: firstExpose.property,
|
|
mockProperties: [{property: firstExpose.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${firstExpose.label} ${endpointName}` : firstExpose.label,
|
|
value_template: valueTemplate,
|
|
...(firstExpose.property === "action" ? {entity_category: "diagnostic"} : {}),
|
|
...ENUM_DISCOVERY_LOOKUP[firstExpose.name],
|
|
},
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "text":
|
|
case "composite":
|
|
case "list": {
|
|
const firstExposeTyped = firstExpose as zhc.Text | zhc.Composite | zhc.List;
|
|
|
|
// Warning composite → HA siren entity
|
|
if (firstExposeTyped.type === "composite" && firstExposeTyped.name === "warning" && firstExposeTyped.access & ACCESS_SET) {
|
|
const warningExpose = firstExpose as zhc.Composite;
|
|
const modeFeature = warningExpose.features.filter(isEnumExpose).find((f) => f.name === "mode");
|
|
const levelFeature = warningExpose.features.filter(isEnumExpose).find((f) => f.name === "level");
|
|
const durationFeature = warningExpose.features.filter(isNumericExpose).find((f) => f.name === "duration");
|
|
|
|
const discoveryEntry: DiscoveryEntry = {
|
|
type: "siren",
|
|
object_id: endpointName ? /* v8 ignore next */ `siren_${endpointName}` : "siren",
|
|
mockProperties: [{property: warningExpose.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? /* v8 ignore next */ utils.capitalize(endpointName) : null,
|
|
command_topic: true,
|
|
command_topic_prefix: endpointName,
|
|
state_topic: false,
|
|
optimistic: true,
|
|
},
|
|
};
|
|
|
|
if (modeFeature) {
|
|
const tones = modeFeature.values.filter((v) => v !== "stop");
|
|
if (tones.length) {
|
|
discoveryEntry.discovery_payload.available_tones = tones;
|
|
}
|
|
}
|
|
|
|
if (levelFeature) {
|
|
discoveryEntry.discovery_payload.support_volume_set = true;
|
|
}
|
|
|
|
if (durationFeature) {
|
|
discoveryEntry.discovery_payload.support_duration = true;
|
|
}
|
|
|
|
const levelTemplate =
|
|
"{% if volume_level is defined %}" +
|
|
"{% if volume_level | float <= 0.25 %}low" +
|
|
"{% elif volume_level | float <= 0.5 %}medium" +
|
|
"{% elif volume_level | float <= 0.75 %}high" +
|
|
"{% else %}very_high{% endif %}" +
|
|
"{% else %}medium{% endif %}";
|
|
|
|
discoveryEntry.discovery_payload.command_template =
|
|
`{"warning": {"mode": "{{ tone | default('emergency') }}", ` +
|
|
`"level": "${levelTemplate}", ` +
|
|
`"duration": {{ duration | default(10) }}}}`;
|
|
|
|
discoveryEntry.discovery_payload.command_off_template = '{"warning": {"mode": "stop"}}';
|
|
|
|
discoveryEntries.push(discoveryEntry);
|
|
break;
|
|
}
|
|
|
|
if (firstExposeTyped.type === "text" && firstExposeTyped.access & ACCESS_SET) {
|
|
discoveryEntries.push({
|
|
type: "text",
|
|
object_id: firstExposeTyped.property,
|
|
mockProperties: [{property: firstExposeTyped.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${firstExposeTyped.label} ${endpointName}` : firstExposeTyped.label,
|
|
state_topic: firstExposeTyped.access & ACCESS_STATE,
|
|
value_template: `{{ value_json["${firstExposeTyped.property}"] }}`,
|
|
command_topic_prefix: endpointName,
|
|
command_topic: true,
|
|
command_topic_postfix: firstExposeTyped.property,
|
|
...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name],
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
if (firstExposeTyped.access & ACCESS_STATE) {
|
|
discoveryEntries.push({
|
|
type: "sensor",
|
|
object_id: firstExposeTyped.property,
|
|
mockProperties: [{property: firstExposeTyped.property, value: null}],
|
|
discovery_payload: {
|
|
name: endpointName ? `${firstExposeTyped.label} ${endpointName}` : firstExposeTyped.label,
|
|
// Truncate text if it's too long
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/23199
|
|
value_template: `{{ value_json["${firstExposeTyped.property}"] | default('',True) | string | truncate(254, True, '', 0) }}`,
|
|
...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name],
|
|
},
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Exposes with category 'config' or 'diagnostic' are always added to the respective category.
|
|
// This takes precedence over definitions in this file.
|
|
if (firstExpose.category === "config" || firstExpose.category === "diagnostic") {
|
|
for (const entry of discoveryEntries) {
|
|
entry.discovery_payload.entity_category = firstExpose.category;
|
|
}
|
|
}
|
|
|
|
for (const entry of discoveryEntries) {
|
|
applyHomeAssistantExposeMetadata(entry.discovery_payload, firstExpose.homeassistant);
|
|
|
|
// If a sensor has entity category `config`, then change
|
|
// it to `diagnostic`. Sensors have no input, so can't be configured.
|
|
// https://github.com/Koenkk/zigbee2mqtt/pull/19474
|
|
if (["binary_sensor", "sensor"].includes(entry.type) && entry.discovery_payload.entity_category === "config") {
|
|
entry.discovery_payload.entity_category = "diagnostic";
|
|
}
|
|
|
|
// Event entities cannot have an entity_category set.
|
|
if (entry.type === "event" && entry.discovery_payload.entity_category) {
|
|
delete entry.discovery_payload.entity_category;
|
|
}
|
|
|
|
// Let Home Assistant generate entity name when device_class is present.
|
|
// preserve_name allows device_class and explicit name to coexist (e.g. derived sensors).
|
|
if (entry.discovery_payload.device_class && !NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.preserve_name) {
|
|
delete entry.discovery_payload.name;
|
|
}
|
|
|
|
entry.endpoint = entity.isDevice() ? entity.endpoint(endpointName) : undefined;
|
|
}
|
|
|
|
return discoveryEntries;
|
|
}
|
|
|
|
@bind async onEntityRemoved(data: eventdata.EntityRemoved): Promise<void> {
|
|
logger.debug(`Clearing Home Assistant discovery for '${data.name}'`);
|
|
const discovered = this.getDiscovered(data.entity.ID);
|
|
|
|
for (const topic of Object.keys(discovered.messages)) {
|
|
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
|
|
}
|
|
|
|
delete this.discovered[data.entity.ID];
|
|
}
|
|
|
|
@bind async onGroupMembersChanged(data: eventdata.GroupMembersChanged): Promise<void> {
|
|
await this.discover(data.group);
|
|
}
|
|
|
|
@bind async onPublishEntityState(data: eventdata.PublishEntityState): Promise<void> {
|
|
/**
|
|
* In case we deal with a lightEndpoint configuration Zigbee2MQTT publishes
|
|
* e.g. {state_l1: ON, brightness_l1: 250} to zigbee2mqtt/mydevice.
|
|
* As the Home Assistant MQTT JSON light cannot be configured to use state_l1/brightness_l1
|
|
* as the state variables, the state topic is set to zigbee2mqtt/mydevice/l1.
|
|
* Here we retrieve all the attributes with the _l1 values and republish them on
|
|
* zigbee2mqtt/mydevice/l1.
|
|
*/
|
|
// biome-ignore lint/style/noNonNullAssertion: TODO: biome migration: should this be validated instead?
|
|
const entity = this.zigbee.resolveEntity(data.entity.name)!;
|
|
|
|
if (entity.isDevice()) {
|
|
for (const topic in this.getDiscovered(entity).messages) {
|
|
const topicMatch = topic.match(this.discoveryRegexWoTopic);
|
|
|
|
/* v8 ignore start */
|
|
if (!topicMatch) {
|
|
continue;
|
|
}
|
|
/* v8 ignore stop */
|
|
|
|
const objectID = topicMatch[3];
|
|
const lightMatch = /^light_(.*)/.exec(objectID);
|
|
const coverMatch = /^cover_(.*)/.exec(objectID);
|
|
|
|
const match = lightMatch || coverMatch;
|
|
|
|
if (match) {
|
|
const endpoint = match[1];
|
|
const endpointRegExp = new RegExp(`(.*)_${endpoint}`);
|
|
const payload: KeyValue = {};
|
|
for (const key of Object.keys(data.message)) {
|
|
const keyMatch = endpointRegExp.exec(key);
|
|
if (keyMatch) {
|
|
payload[keyMatch[1]] = data.message[key];
|
|
}
|
|
}
|
|
|
|
await this.mqtt.publish(`${data.entity.name}/${endpoint}`, stringify(payload), {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publish an empty value for click and action payload, in this way Home Assistant
|
|
* can use Home Assistant entities in automations.
|
|
* https://github.com/Koenkk/zigbee2mqtt/issues/959#issuecomment-480341347
|
|
*/
|
|
if (this.legacyActionSensor && data.message.action) {
|
|
await this.publishEntityState(data.entity, {action: ""});
|
|
}
|
|
|
|
/**
|
|
* Implements the MQTT device trigger (https://www.home-assistant.io/integrations/device_trigger.mqtt/)
|
|
* The MQTT device trigger does not support JSON parsing, so it cannot listen to zigbee2mqtt/my_device
|
|
* Whenever a device publish an {action: *} we discover an MQTT device trigger sensor
|
|
* and republish it to zigbee2mqtt/my_device/action
|
|
*/
|
|
if (settings.get().advanced.output === "json" && entity.isDevice() && entity.definition && data.message.action) {
|
|
const value = data.message.action.toString();
|
|
await this.publishDeviceTriggerDiscover(entity, "action", value);
|
|
await this.mqtt.publish(`${data.entity.name}/action`, value, {});
|
|
}
|
|
}
|
|
|
|
@bind async onEntityRenamed(data: eventdata.EntityRenamed): Promise<void> {
|
|
logger.debug(`Refreshing Home Assistant discovery topic for '${data.entity.name}'`);
|
|
|
|
// Clear before rename so Home Assistant uses new friendly_name
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/4096#issuecomment-674044916
|
|
if (data.homeAssisantRename) {
|
|
const discovered = this.getDiscovered(data.entity);
|
|
for (const topic of Object.keys(discovered.messages)) {
|
|
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
|
|
}
|
|
discovered.messages = {};
|
|
|
|
// Make sure Home Assistant deletes the old entity first otherwise another one (_2) is created
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/12610
|
|
await utils.sleep(2);
|
|
}
|
|
|
|
await this.discover(data.entity);
|
|
|
|
if (data.entity.isDevice()) {
|
|
for (const config of this.getDiscovered(data.entity).triggers) {
|
|
const key = config.substring(0, config.indexOf("_"));
|
|
const value = config.substring(config.indexOf("_") + 1);
|
|
await this.publishDeviceTriggerDiscover(data.entity, key, value, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
private getConfigs(entity: Device | Group | Bridge): DiscoveryEntry[] {
|
|
const isDevice = entity.isDevice();
|
|
const isGroup = entity.isGroup();
|
|
|
|
/* v8 ignore next */
|
|
if (!entity || (isDevice && !entity.definition)) return [];
|
|
|
|
let configs: DiscoveryEntry[] = [];
|
|
if (isDevice) {
|
|
const exposes = entity.exposes(); // avoid calling it hundred of times/s
|
|
for (const expose of exposes) {
|
|
configs.push(...this.exposeToConfig([expose], entity, exposes));
|
|
}
|
|
} else if (isGroup) {
|
|
// group
|
|
const exposesByType: {[s: string]: zhc.Expose[]} = {};
|
|
const allExposes: zhc.Expose[] = [];
|
|
|
|
for (const member of entity.zh.members) {
|
|
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
|
|
if (device.definition) {
|
|
const exposes = device.exposes();
|
|
allExposes.push(...exposes);
|
|
for (const expose of exposes.filter((e) => GROUP_SUPPORTED_TYPES.includes(e.type))) {
|
|
let key = expose.type;
|
|
if (["switch", "lock", "cover"].includes(expose.type) && expose.endpoint) {
|
|
// A device can have multiple of these types which have to discovered separately.
|
|
// e.g. switch with property state and valve_detection.
|
|
const state = (expose as zhc.Switch | zhc.Lock | zhc.Cover).features.find((f) => f.name === "state");
|
|
assert(state, `'switch', 'lock' or 'cover' is missing state`);
|
|
key += featurePropertyWithoutEndpoint(state);
|
|
}
|
|
|
|
if (!exposesByType[key]) exposesByType[key] = [];
|
|
exposesByType[key].push(expose);
|
|
}
|
|
}
|
|
}
|
|
|
|
configs = ([] as DiscoveryEntry[]).concat(
|
|
...Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, entity, allExposes)),
|
|
);
|
|
} else {
|
|
// Discover bridge config.
|
|
configs.push(...entity.configs);
|
|
}
|
|
|
|
if (isDevice && settings.get().advanced.last_seen !== "disable") {
|
|
const config: DiscoveryEntry = {
|
|
type: "sensor",
|
|
object_id: "last_seen",
|
|
mockProperties: [{property: "last_seen", value: null}],
|
|
discovery_payload: {
|
|
name: "Last seen",
|
|
value_template: "{{ value_json.last_seen }}",
|
|
icon: "mdi:clock",
|
|
enabled_by_default: false,
|
|
entity_category: "diagnostic",
|
|
},
|
|
};
|
|
|
|
if (settings.get().advanced.last_seen.startsWith("ISO_8601")) {
|
|
config.discovery_payload.device_class = "timestamp";
|
|
}
|
|
|
|
configs.push(config);
|
|
}
|
|
|
|
if (isDevice && entity.definition?.ota) {
|
|
const updateSensor: DiscoveryEntry = {
|
|
type: "update",
|
|
object_id: "update",
|
|
mockProperties: [{property: "update", value: {state: null}}],
|
|
discovery_payload: {
|
|
name: null,
|
|
entity_picture: "https://github.com/Koenkk/zigbee2mqtt/raw/master/images/logo.png",
|
|
state_topic: true,
|
|
device_class: "firmware",
|
|
entity_category: "config",
|
|
command_topic: `${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/update`,
|
|
payload_install: `{"id": "${entity.ieeeAddr}"}`,
|
|
value_template: `{"latest_version":"{{ value_json['update']['latest_version'] }}","installed_version":"{{ value_json['update']['installed_version'] }}","update_percentage":{{ value_json['update'].get('progress', 'null') }},"in_progress":{{ (value_json['update']['state'] == 'updating')|lower }}}`,
|
|
},
|
|
};
|
|
configs.push(updateSensor);
|
|
}
|
|
|
|
// Discover scenes.
|
|
for (const endpointOrGroup of isDevice ? entity.zh.endpoints : isGroup ? [entity.zh] : []) {
|
|
for (const scene of utils.getScenes(endpointOrGroup)) {
|
|
const sceneEntry: DiscoveryEntry = {
|
|
type: "scene",
|
|
object_id: `scene_${scene.id}`,
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: `${scene.name}`,
|
|
state_topic: false,
|
|
command_topic: true,
|
|
payload_on: `{ "scene_recall": ${scene.id} }`,
|
|
object_id_postfix: `_${scene.name.replace(/\s+/g, "_").toLowerCase()}`,
|
|
},
|
|
};
|
|
|
|
configs.push(sceneEntry);
|
|
}
|
|
}
|
|
|
|
// deep clone of the config objects
|
|
configs = JSON.parse(JSON.stringify(configs));
|
|
|
|
if (entity.options.homeassistant) {
|
|
const s = entity.options.homeassistant;
|
|
configs = configs.filter((config) => s[config.object_id] === undefined || s[config.object_id] != null);
|
|
|
|
for (const config of configs) {
|
|
const configOverride = s[config.object_id];
|
|
if (configOverride) {
|
|
config.object_id = configOverride.object_id || config.object_id;
|
|
config.type = configOverride.type || config.type;
|
|
}
|
|
}
|
|
}
|
|
|
|
return configs;
|
|
}
|
|
|
|
private async discover(entity: Device | Group | Bridge, publish = true): Promise<void> {
|
|
// Handle type differences.
|
|
const isDevice = entity.isDevice();
|
|
const isGroup = entity.isGroup();
|
|
|
|
if (isGroup && entity.zh.members.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
isDevice &&
|
|
(!entity.definition || !entity.interviewed || (entity.options.homeassistant !== undefined && !entity.options.homeassistant))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const discovered = this.getDiscovered(entity);
|
|
discovered.discovered = true;
|
|
const lastDiscoveredTopics = Object.keys(discovered.messages);
|
|
const newDiscoveredTopics = new Set<string>();
|
|
|
|
for (const config of this.getConfigs(entity)) {
|
|
const payload = {...config.discovery_payload};
|
|
const baseTopic = `${settings.get().mqtt.base_topic}/${entity.name}`;
|
|
let stateTopic = baseTopic;
|
|
if (payload.state_topic_postfix) {
|
|
stateTopic += `/${payload.state_topic_postfix}`;
|
|
delete payload.state_topic_postfix;
|
|
}
|
|
|
|
if (payload.state_topic === undefined || payload.state_topic) {
|
|
payload.state_topic = stateTopic;
|
|
} else {
|
|
if (payload.state_topic !== undefined) {
|
|
delete payload.state_topic;
|
|
}
|
|
}
|
|
|
|
if (payload.position_topic) {
|
|
payload.position_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.tilt_status_topic) {
|
|
payload.tilt_status_topic = stateTopic;
|
|
}
|
|
|
|
const devicePayload = this.getDevicePayload(entity);
|
|
|
|
// Suggest object_id (entity_id) for entity
|
|
payload.object_id = devicePayload.name.replace(/\s+/g, "_").toLowerCase();
|
|
if (config.object_id.startsWith(config.type) && config.object_id.includes("_")) {
|
|
payload.object_id += `_${config.object_id.split(/_(.+)/)[1]}`;
|
|
} else if (!config.object_id.startsWith(config.type)) {
|
|
payload.object_id += `_${config.object_id}`;
|
|
}
|
|
|
|
// Allow customization of the `payload.object_id` without touching the other uses of `config.object_id`
|
|
// (e.g. for setting the `payload.unique_id` and as an internal key).
|
|
payload.object_id = `${payload.object_id}${payload.object_id_postfix ?? ""}`;
|
|
delete payload.object_id_postfix;
|
|
|
|
// Set `default_entity_id`, as of HA 2025.10 this replaces the `object_id`.
|
|
// For migration purposes we set both for now.
|
|
// https://github.com/home-assistant/core/pull/151775
|
|
payload.default_entity_id = `${config.type}.${payload.object_id}`;
|
|
|
|
// Set unique_id
|
|
const uniqueId = `${entity.options.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
|
|
payload.unique_id = uniqueId;
|
|
|
|
if (config.endpoint) {
|
|
assert(entity.isDevice());
|
|
const memberKey = `${config.endpoint.deviceIeeeAddress}_${config.endpoint.ID}_${config.type}`;
|
|
this.groupMemberLookup.set(memberKey, uniqueId);
|
|
}
|
|
|
|
// Add group member unique_ids for Home Assistant entity grouping
|
|
// This assumes that `discover()` already has been called for all devices, since it depends on the
|
|
// `groupMemberLookup` being populated.
|
|
// https://www.home-assistant.io/integrations/mqtt#grouping-entities
|
|
if (isGroup) {
|
|
const memberIds: string[] = [];
|
|
for (const endpoint of entity.zh.members) {
|
|
const memberKey = `${endpoint.deviceIeeeAddress}_${endpoint.ID}_${config.type}`;
|
|
const memberUniqueId = this.groupMemberLookup.get(memberKey);
|
|
if (memberUniqueId) {
|
|
memberIds.push(memberUniqueId);
|
|
}
|
|
}
|
|
|
|
if (memberIds.length > 0) {
|
|
payload.group = memberIds;
|
|
}
|
|
}
|
|
|
|
// Attributes for device registry and origin
|
|
payload.device = devicePayload;
|
|
payload.origin = this.discoveryOrigin;
|
|
|
|
// Availability payload (can be disabled by setting `payload.availability = false`).
|
|
if (payload.availability === undefined || payload.availability) {
|
|
payload.availability = [{topic: `${settings.get().mqtt.base_topic}/bridge/state`}];
|
|
|
|
if (isDevice || isGroup) {
|
|
if (utils.isAvailabilityEnabledForEntity(entity, settings.get())) {
|
|
payload.availability_mode = "all";
|
|
payload.availability.push({topic: `${baseTopic}/availability`});
|
|
}
|
|
} else {
|
|
// Bridge availability is different.
|
|
payload.availability_mode = "all";
|
|
}
|
|
|
|
if (isDevice && entity.options.disabled) {
|
|
// Mark disabled device always as unavailable
|
|
for (const entry of payload.availability) {
|
|
entry.value_template = '{{ "offline" }}';
|
|
}
|
|
} else {
|
|
for (const entry of payload.availability) {
|
|
entry.value_template = "{{ value_json.state }}";
|
|
}
|
|
}
|
|
} else {
|
|
delete payload.availability;
|
|
}
|
|
|
|
const commandTopicPrefix = payload.command_topic_prefix ? `${payload.command_topic_prefix}/` : "";
|
|
delete payload.command_topic_prefix;
|
|
const commandTopicPostfix = payload.command_topic_postfix ? `/${payload.command_topic_postfix}` : "";
|
|
delete payload.command_topic_postfix;
|
|
const commandTopic = `${baseTopic}/${commandTopicPrefix}set${commandTopicPostfix}`;
|
|
|
|
if (payload.command_topic && typeof payload.command_topic !== "string") {
|
|
payload.command_topic = commandTopic;
|
|
}
|
|
|
|
if (payload.set_position_topic) {
|
|
payload.set_position_topic = commandTopic;
|
|
}
|
|
|
|
if (payload.tilt_command_topic) {
|
|
payload.tilt_command_topic = `${baseTopic}/${commandTopicPrefix}set/tilt`;
|
|
}
|
|
|
|
if (payload.mode_state_topic) {
|
|
payload.mode_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.mode_command_topic) {
|
|
payload.mode_command_topic = `${baseTopic}/${commandTopicPrefix}set/system_mode`;
|
|
}
|
|
|
|
if (payload.current_temperature_topic) {
|
|
payload.current_temperature_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.temperature_state_topic) {
|
|
payload.temperature_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.temperature_low_state_topic) {
|
|
payload.temperature_low_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.temperature_high_state_topic) {
|
|
payload.temperature_high_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.temperature_command_topic) {
|
|
payload.temperature_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_command_topic}`;
|
|
}
|
|
|
|
if (payload.temperature_low_command_topic) {
|
|
payload.temperature_low_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_low_command_topic}`;
|
|
}
|
|
|
|
if (payload.temperature_high_command_topic) {
|
|
payload.temperature_high_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.temperature_high_command_topic}`;
|
|
}
|
|
|
|
if (payload.fan_mode_state_topic) {
|
|
payload.fan_mode_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.fan_mode_command_topic) {
|
|
payload.fan_mode_command_topic = `${baseTopic}/${commandTopicPrefix}set/fan_mode`;
|
|
}
|
|
|
|
if (payload.swing_mode_state_topic) {
|
|
payload.swing_mode_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.swing_mode_command_topic) {
|
|
payload.swing_mode_command_topic = `${baseTopic}/${commandTopicPrefix}set/swing_mode`;
|
|
}
|
|
|
|
if (payload.percentage_state_topic) {
|
|
payload.percentage_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.percentage_command_topic) {
|
|
payload.percentage_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.percentage_command_topic}`;
|
|
}
|
|
|
|
if (payload.preset_mode_state_topic) {
|
|
payload.preset_mode_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.preset_mode_command_topic) {
|
|
payload.preset_mode_command_topic = `${baseTopic}/${commandTopicPrefix}set/${payload.preset_mode_command_topic}`;
|
|
}
|
|
|
|
if (payload.action_topic) {
|
|
payload.action_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.current_humidity_topic) {
|
|
payload.current_humidity_topic = stateTopic;
|
|
}
|
|
|
|
if (entity.isDevice()) {
|
|
try {
|
|
entity.definition?.meta?.overrideHaDiscoveryPayload?.(payload, entity.options);
|
|
} catch (error) {
|
|
logger.error(`Failed to override HA discovery payload (${(error as Error).stack})`);
|
|
}
|
|
}
|
|
|
|
// Override configuration with user settings after converter compatibility
|
|
// mappings, so per-device configuration remains the final authority.
|
|
if (entity.options.homeassistant != null) {
|
|
const add = (obj: KeyValue, ignoreName: boolean): void => {
|
|
for (const key in obj) {
|
|
if (key === "type" || key === "object_id") {
|
|
continue;
|
|
}
|
|
|
|
if (ignoreName && key === "name") {
|
|
continue;
|
|
}
|
|
|
|
if (["number", "string", "boolean"].includes(typeof obj[key]) || Array.isArray(obj[key])) {
|
|
payload[key] = obj[key];
|
|
} else if (obj[key] === null) {
|
|
delete payload[key];
|
|
} else if (key === "device" && typeof obj[key] === "object") {
|
|
for (const devKey in obj.device) {
|
|
payload.device[devKey] = obj.device[devKey];
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
add(entity.options.homeassistant, true);
|
|
|
|
if (entity.options.homeassistant[config.object_id] != null) {
|
|
add(entity.options.homeassistant[config.object_id], false);
|
|
}
|
|
}
|
|
|
|
const topic = this.getDiscoveryTopic(config, entity);
|
|
const payloadStr = stringify(payload);
|
|
newDiscoveredTopics.add(topic);
|
|
|
|
// Only discover when not discovered yet
|
|
const discoveredMessage = discovered.messages[topic];
|
|
if (!discoveredMessage || discoveredMessage.payload !== payloadStr || !discoveredMessage.published) {
|
|
discovered.messages[topic] = {payload: payloadStr, published: publish};
|
|
if (publish) {
|
|
await this.mqtt.publish(topic, payloadStr, {
|
|
clientOptions: {retain: true, qos: 1},
|
|
baseTopic: this.discoveryTopic,
|
|
skipReceive: false,
|
|
});
|
|
}
|
|
} else {
|
|
logger.debug(`Skipping discovery of '${topic}', already discovered`);
|
|
}
|
|
|
|
if (config.mockProperties) {
|
|
for (const mockProperty of config.mockProperties) {
|
|
discovered.mockProperties.add(mockProperty);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const topic of lastDiscoveredTopics) {
|
|
const isDeviceAutomation = topic.match(this.discoveryRegexWoTopic)?.[1] === "device_automation";
|
|
if (!newDiscoveredTopics.has(topic) && !isDeviceAutomation) {
|
|
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
|
|
}
|
|
}
|
|
}
|
|
|
|
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
|
const discoveryMatch = data.topic.match(this.discoveryRegex);
|
|
const isDeviceAutomation = discoveryMatch && discoveryMatch[1] === "device_automation";
|
|
if (discoveryMatch) {
|
|
// Clear outdated discovery configs and remember already discovered device_automations
|
|
let message: KeyValue;
|
|
|
|
try {
|
|
message = JSON.parse(data.message);
|
|
const baseTopic = `${settings.get().mqtt.base_topic}/`;
|
|
if (isDeviceAutomation && !message.topic?.startsWith(baseTopic)) {
|
|
return;
|
|
}
|
|
|
|
if (!isDeviceAutomation && !message.availability?.[0].topic.startsWith(baseTopic)) {
|
|
return;
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
// Group discovery topic uses "ENCODEDBASETOPIC_GROUPID", device use ieeeAddr
|
|
const ID = discoveryMatch[2].includes("_") ? discoveryMatch[2].split("_")[1] : discoveryMatch[2];
|
|
const entity = ID === this.bridge.ID ? this.bridge : this.zigbee.resolveEntity(ID);
|
|
let clear = !entity || (entity.isDevice() && !entity.definition);
|
|
|
|
// Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml
|
|
if (entity) {
|
|
const key = `${discoveryMatch[3].substring(0, discoveryMatch[3].indexOf("_"))}`;
|
|
const triggerTopic = `${settings.get().mqtt.base_topic}/${entity.name}/${key}`;
|
|
if (isDeviceAutomation && message.topic === triggerTopic) {
|
|
this.getDiscovered(ID).triggers.add(discoveryMatch[3]);
|
|
}
|
|
}
|
|
|
|
const topic = data.topic.substring(this.discoveryTopic.length + 1);
|
|
if (!clear && !isDeviceAutomation && entity && !(topic in this.getDiscovered(entity).messages)) {
|
|
clear = true;
|
|
}
|
|
|
|
// Device was flagged to be excluded from homeassistant discovery
|
|
clear = clear || Boolean(entity && entity.options.homeassistant !== undefined && !entity.options.homeassistant);
|
|
|
|
if (clear) {
|
|
logger.debug(`Clearing outdated Home Assistant config '${data.topic}'`);
|
|
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
|
|
} else if (entity) {
|
|
this.getDiscovered(entity).messages[topic] = {payload: stringify(message), published: true};
|
|
}
|
|
} else if (data.topic === this.statusTopic && data.message.toLowerCase() === "online") {
|
|
const timer = setTimeout(async () => {
|
|
// Re-publish bridge state so HA marks all entities as available before receiving cached device states.
|
|
await this.mqtt.publish("bridge/state", stringify({state: "online"}), {clientOptions: {retain: true, qos: 1}});
|
|
|
|
// Publish all device states.
|
|
for (const entity of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) {
|
|
if (this.state.exists(entity)) {
|
|
await this.publishEntityState(entity, this.state.get(entity), "publishCached");
|
|
}
|
|
}
|
|
|
|
clearTimeout(timer);
|
|
}, 30000);
|
|
}
|
|
}
|
|
|
|
@bind async onZigbeeEvent(data: {device: Device}): Promise<void> {
|
|
if (!this.getDiscovered(data.device).discovered) {
|
|
await this.discover(data.device);
|
|
}
|
|
}
|
|
|
|
@bind async onScenesChanged(data: eventdata.ScenesChanged): Promise<void> {
|
|
// Re-trigger MQTT discovery of changed devices and groups, similar to bridge.ts
|
|
|
|
// First, clear existing scene discovery topics
|
|
logger.debug(`Clearing Home Assistant scene discovery for '${data.entity.name}'`);
|
|
const discovered = this.getDiscovered(data.entity);
|
|
|
|
for (const topic of Object.keys(discovered.messages)) {
|
|
if (topic.startsWith("scene")) {
|
|
await this.mqtt.publish(topic, "", {clientOptions: {retain: true, qos: 1}, baseTopic: this.discoveryTopic, skipReceive: false});
|
|
delete discovered.messages[topic];
|
|
}
|
|
}
|
|
|
|
// Make sure Home Assistant deletes the old entity first otherwise another one (_2) is created
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/12610
|
|
logger.debug("Finished clearing scene discovery topics, waiting for Home Assistant.");
|
|
await utils.sleep(2);
|
|
|
|
// Re-discover entity (including any new scenes).
|
|
logger.debug("Re-discovering entities with their scenes.");
|
|
await this.discover(data.entity);
|
|
}
|
|
|
|
private getDevicePayload(entity: Device | Group | Bridge): KeyValue {
|
|
const identifierPostfix = entity.isGroup() ? `zigbee2mqtt_${this.getEncodedBaseTopic()}` : "zigbee2mqtt";
|
|
|
|
// Allow device name to be overridden by homeassistant config
|
|
let deviceName = entity.name;
|
|
if (typeof entity.options.homeassistant?.name === "string") {
|
|
deviceName = entity.options.homeassistant.name;
|
|
}
|
|
|
|
const payload: KeyValue = {
|
|
identifiers: [`${identifierPostfix}_${entity.options.ID}`],
|
|
name: deviceName,
|
|
sw_version: `Zigbee2MQTT ${this.zigbee2MQTTVersion}`,
|
|
};
|
|
|
|
const url = settings.get().frontend?.url ?? "";
|
|
// Since zigbee2mqtt-windfront support multiple instances the configuration URL contains the
|
|
// instance ID. Since we don't know which instance it is we always point to 0.
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/28936
|
|
const urlEntityPostfix = settings.get().frontend.package === "zigbee2mqtt-windfront" ? "0/" : "";
|
|
if (entity.isDevice()) {
|
|
assert(entity.definition, `Cannot 'getDevicePayload' for unsupported device`);
|
|
payload.model = entity.definition.description;
|
|
payload.model_id = entity.definition.model;
|
|
payload.manufacturer = entity.definition.vendor;
|
|
payload.sw_version = entity.zh.softwareBuildID;
|
|
payload.hw_version = entity.zh.hardwareVersion;
|
|
payload.configuration_url = `${url}/#/device/${urlEntityPostfix}${entity.ieeeAddr}/info`;
|
|
} else if (entity.isGroup()) {
|
|
payload.model = "Group";
|
|
payload.manufacturer = "Zigbee2MQTT";
|
|
payload.configuration_url = `${url}/#/group/${urlEntityPostfix}${entity.ID}`;
|
|
} else {
|
|
payload.model = "Bridge";
|
|
payload.manufacturer = "Zigbee2MQTT";
|
|
payload.hw_version = `${entity.hardwareVersion} ${entity.firmwareVersion}`;
|
|
payload.sw_version = this.zigbee2MQTTVersion;
|
|
payload.configuration_url = `${url}/#/settings`;
|
|
}
|
|
|
|
if (!url) {
|
|
delete payload.configuration_url;
|
|
}
|
|
|
|
// Link devices & groups to bridge.
|
|
if (entity !== this.bridge) {
|
|
payload.via_device = this.bridgeIdentifier;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
override adjustMessageBeforePublish(entity: Device | Group | Bridge, message: KeyValue): void {
|
|
for (const mockProperty of this.getDiscovered(entity).mockProperties) {
|
|
if (message[mockProperty.property] === undefined) {
|
|
message[mockProperty.property] = mockProperty.value;
|
|
}
|
|
}
|
|
|
|
// Copy hue -> h, saturation -> s to make homeassistant happy
|
|
if (message.color !== undefined) {
|
|
if (message.color.hue !== undefined) {
|
|
message.color.h = message.color.hue;
|
|
}
|
|
if (message.color.saturation !== undefined) {
|
|
message.color.s = message.color.saturation;
|
|
}
|
|
}
|
|
|
|
if (entity.isDevice() && entity.definition?.ota && message.update?.latest_version == null) {
|
|
message.update = {...message.update, installed_version: -1, latest_version: -1};
|
|
}
|
|
}
|
|
|
|
private getEncodedBaseTopic(): string {
|
|
return settings
|
|
.get()
|
|
.mqtt.base_topic.split("")
|
|
.map((s) => s.charCodeAt(0).toString())
|
|
.join("");
|
|
}
|
|
|
|
private getDiscoveryTopic(config: DiscoveryEntry, entity: Device | Group | Bridge): string {
|
|
const key = entity.isDevice() ? entity.ieeeAddr : `${this.getEncodedBaseTopic()}_${entity.ID}`;
|
|
return `${config.type}/${key}/${config.object_id}/config`;
|
|
}
|
|
|
|
private async publishDeviceTriggerDiscover(device: Device, key: string, value: string, force = false): Promise<void> {
|
|
const haConfig = device.options.homeassistant;
|
|
if (
|
|
device.options.homeassistant !== undefined &&
|
|
(haConfig == null || (haConfig.device_automation !== undefined && typeof haConfig === "object" && haConfig.device_automation == null))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const discovered = this.getDiscovered(device);
|
|
const discoveredKey = `${key}_${value}`;
|
|
if (discovered.triggers.has(discoveredKey) && !force) {
|
|
return;
|
|
}
|
|
|
|
const config: DiscoveryEntry = {
|
|
type: "device_automation",
|
|
object_id: `${key}_${value}`,
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
automation_type: "trigger",
|
|
type: key,
|
|
},
|
|
};
|
|
|
|
const topic = this.getDiscoveryTopic(config, device);
|
|
const payload = {
|
|
...config.discovery_payload,
|
|
subtype: value,
|
|
payload: value,
|
|
topic: `${settings.get().mqtt.base_topic}/${device.name}/${key}`,
|
|
device: this.getDevicePayload(device),
|
|
origin: this.discoveryOrigin,
|
|
};
|
|
|
|
await this.mqtt.publish(topic, stringify(payload), {
|
|
clientOptions: {retain: true, qos: 1},
|
|
baseTopic: this.discoveryTopic,
|
|
skipReceive: false,
|
|
});
|
|
discovered.triggers.add(discoveredKey);
|
|
}
|
|
|
|
private getBridgeEntity(coordinatorVersion: zh.CoordinatorVersion): Bridge {
|
|
const coordinatorIeeeAddress = this.zigbee.firstCoordinatorEndpoint().deviceIeeeAddress;
|
|
const discovery: DiscoveryEntry[] = [];
|
|
const bridge = new Bridge(coordinatorIeeeAddress, coordinatorVersion, discovery);
|
|
const baseTopic = `${settings.get().mqtt.base_topic}/${bridge.name}`;
|
|
|
|
discovery.push(
|
|
// Binary sensors.
|
|
{
|
|
type: "binary_sensor",
|
|
object_id: "connection_state",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: "Connection state",
|
|
device_class: "connectivity",
|
|
entity_category: "diagnostic",
|
|
state_topic: true,
|
|
state_topic_postfix: "state",
|
|
value_template: "{{ value_json.state }}",
|
|
payload_on: "online",
|
|
payload_off: "offline",
|
|
availability: false,
|
|
},
|
|
},
|
|
{
|
|
type: "binary_sensor",
|
|
object_id: "restart_required",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: "Restart required",
|
|
device_class: "problem",
|
|
entity_category: "diagnostic",
|
|
enabled_by_default: false,
|
|
state_topic: true,
|
|
state_topic_postfix: "info",
|
|
value_template: "{{ value_json.restart_required }}",
|
|
payload_on: true,
|
|
payload_off: false,
|
|
},
|
|
},
|
|
|
|
// Buttons.
|
|
{
|
|
type: "button",
|
|
object_id: "restart",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: "Restart",
|
|
device_class: "restart",
|
|
state_topic: false,
|
|
command_topic: `${baseTopic}/request/restart`,
|
|
payload_press: "",
|
|
},
|
|
},
|
|
|
|
// Selects.
|
|
{
|
|
type: "select",
|
|
object_id: "log_level",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: "Log level",
|
|
entity_category: "config",
|
|
state_topic: true,
|
|
state_topic_postfix: "info",
|
|
value_template: "{{ value_json.log_level | lower }}",
|
|
command_topic: `${baseTopic}/request/options`,
|
|
command_template: '{"options": {"advanced": {"log_level": "{{ value }}" } } }',
|
|
options: settings.LOG_LEVELS,
|
|
},
|
|
},
|
|
// Sensors:
|
|
{
|
|
type: "sensor",
|
|
object_id: "version",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: "Version",
|
|
icon: "mdi:zigbee",
|
|
entity_category: "diagnostic",
|
|
state_topic: true,
|
|
state_topic_postfix: "info",
|
|
value_template: "{{ value_json.version }}",
|
|
},
|
|
},
|
|
{
|
|
type: "sensor",
|
|
object_id: "coordinator_version",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: "Coordinator version",
|
|
icon: "mdi:chip",
|
|
entity_category: "diagnostic",
|
|
enabled_by_default: false,
|
|
state_topic: true,
|
|
state_topic_postfix: "info",
|
|
value_template: "{{ value_json.coordinator.meta.revision }}",
|
|
},
|
|
},
|
|
{
|
|
type: "sensor",
|
|
object_id: "network_map",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: "Network map",
|
|
entity_category: "diagnostic",
|
|
enabled_by_default: false,
|
|
state_topic: true,
|
|
state_topic_postfix: "response/networkmap",
|
|
value_template: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}",
|
|
json_attributes_topic: `${baseTopic}/response/networkmap`,
|
|
json_attributes_template: "{{ value_json.data.value | tojson }}",
|
|
},
|
|
},
|
|
|
|
// Switches.
|
|
{
|
|
type: "switch",
|
|
object_id: "permit_join",
|
|
mockProperties: [],
|
|
discovery_payload: {
|
|
name: "Permit join",
|
|
icon: "mdi:human-greeting-proximity",
|
|
state_topic: true,
|
|
state_topic_postfix: "info",
|
|
value_template: "{{ value_json.permit_join | lower }}",
|
|
command_topic: `${baseTopic}/request/permit_join`,
|
|
state_on: "true",
|
|
state_off: "false",
|
|
payload_on: '{"time": 254}',
|
|
payload_off: '{"time": 0}',
|
|
},
|
|
},
|
|
);
|
|
|
|
return bridge;
|
|
}
|
|
|
|
parseActionValue(action: string): ActionData {
|
|
// Handle standard actions.
|
|
for (const p of ACTION_PATTERNS) {
|
|
const m = action.match(p);
|
|
if (m?.groups?.action) {
|
|
return this.buildAction(m.groups);
|
|
}
|
|
}
|
|
|
|
// Handle wildcard actions.
|
|
let m = action.match(/^(?<action>recall|scene)_\*(?:_(?<endpoint>e1|e2|s1|s2))?$/);
|
|
if (m?.groups?.action) {
|
|
logger.debug(`Found scene wildcard action ${m.groups.action}`);
|
|
return this.buildAction(m.groups, {scene: "wildcard"});
|
|
}
|
|
|
|
m = action.match(/^(?<actionPrefix>region_)\*_(?<action>enter|leave|occupied|unoccupied)$/);
|
|
if (m?.groups?.action) {
|
|
logger.debug(`Found region wildcard action ${m.groups.action}`);
|
|
return this.buildAction(m.groups, {region: "wildcard"});
|
|
}
|
|
|
|
// If nothing matches, keep the plain action value.
|
|
return {action};
|
|
}
|
|
|
|
private buildAction(groups: {[key: string]: string}, props: {[key: string]: string} = {}): ActionData {
|
|
utils.removeNullPropertiesFromObject(groups);
|
|
|
|
let a: string = groups.action;
|
|
if (groups?.actionPrefix) {
|
|
a = groups.actionPrefix + a;
|
|
delete groups.actionPrefix;
|
|
}
|
|
return {...groups, action: a, ...props};
|
|
}
|
|
|
|
private prepareActionEventTypes(values: zhc.Enum["values"]): string[] {
|
|
return utils.arrayUnique(values.map((v) => this.parseActionValue(v.toString()).action).filter((v) => !v.includes("*")));
|
|
}
|
|
|
|
private parseGroupsFromRegex(pattern: string): string[] {
|
|
return [...pattern.matchAll(/\(\?<([a-zA-Z]+)>/g)].map((v) => v[1]);
|
|
}
|
|
|
|
private getActionValueTemplate(): string {
|
|
// TODO: Implement parsing for all event types.
|
|
const patterns = ACTION_PATTERNS.map((v) => {
|
|
return `{"pattern": '${v.replaceAll(/\?<([a-zA-Z]+)>/g, "?P<$1>")}', "groups": [${this.parseGroupsFromRegex(v)
|
|
.map((g) => `"${g}"`)
|
|
.join(", ")}]}`;
|
|
}).join(",\n");
|
|
|
|
const value_template = `{% set patterns = [\n${patterns}\n] %}
|
|
{% set action_value = value_json.action|default('') %}
|
|
{% set ns = namespace(r=[('action', action_value)]) %}
|
|
{% for p in patterns %}
|
|
{% set m = action_value|regex_findall(p.pattern) %}
|
|
{% if m[0] is undefined %}{% continue %}{% endif %}
|
|
{% for key, value in zip(p.groups, m[0]) %}
|
|
{% set ns.r = ns.r|rejectattr(0, 'eq', key)|list + [(key, value)] %}
|
|
{% endfor %}
|
|
{% endfor %}
|
|
{% if (ns.r|selectattr(0, 'eq', 'actionPrefix')|first) is defined %}
|
|
{% set ns.r = ns.r|rejectattr(0, 'eq', 'action')|list + [('action', ns.r|selectattr(0, 'eq', 'actionPrefix')|map(attribute=1)|first + ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}
|
|
{% endif %}
|
|
{% set ns.r = ns.r + [('event_type', ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}
|
|
{{dict.from_keys(ns.r|rejectattr(0, 'in', ('action', 'actionPrefix'))|reject('eq', ('event_type', None))|reject('eq', ('event_type', '')))|to_json}}`;
|
|
|
|
return value_template;
|
|
}
|
|
}
|
|
|
|
export default HomeAssistant;
|