mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2024-11-17 10:58:31 -07:00
931 lines
43 KiB
JavaScript
931 lines
43 KiB
JavaScript
const settings = require('../util/settings');
|
|
const logger = require('../util/logger');
|
|
const utils = require('../util/utils');
|
|
const zigbee2mqttVersion = require('../../package.json').version;
|
|
const Extension = require('./extension');
|
|
const stringify = require('json-stable-stringify-without-jsonify');
|
|
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
|
const assert = require('assert');
|
|
|
|
const cfg = {
|
|
// Binary sensor
|
|
'binary_sensor_update_available': {
|
|
type: 'binary_sensor',
|
|
object_id: 'update_available',
|
|
discovery_payload: {
|
|
payload_on: true,
|
|
payload_off: false,
|
|
value_template: '{{ value_json.update_available}}',
|
|
},
|
|
},
|
|
|
|
// Sensor
|
|
'sensor_update_state': {
|
|
type: 'sensor',
|
|
object_id: 'update_state',
|
|
discovery_payload: {
|
|
icon: 'mdi:update',
|
|
value_template: `{{ value_json['update']['state'] }}`,
|
|
},
|
|
},
|
|
'sensor_click': {
|
|
type: 'sensor',
|
|
object_id: 'click',
|
|
discovery_payload: {
|
|
icon: 'mdi:toggle-switch',
|
|
value_template: '{{ value_json.click }}',
|
|
},
|
|
},
|
|
'sensor_brightness': {
|
|
type: 'sensor',
|
|
object_id: 'brightness',
|
|
discovery_payload: {
|
|
unit_of_measurement: 'brightness',
|
|
icon: 'mdi:brightness-5',
|
|
value_template: '{{ value_json.brightness }}',
|
|
},
|
|
},
|
|
'sensor_linkquality': {
|
|
type: 'sensor',
|
|
object_id: 'linkquality',
|
|
discovery_payload: {
|
|
icon: 'mdi:signal',
|
|
unit_of_measurement: 'lqi',
|
|
value_template: '{{ value_json.linkquality }}',
|
|
},
|
|
},
|
|
|
|
// Trigger
|
|
'trigger_action': {
|
|
type: 'device_automation',
|
|
discovery_payload: {
|
|
automation_type: 'trigger',
|
|
type: 'action',
|
|
},
|
|
},
|
|
'trigger_click': {
|
|
type: 'device_automation',
|
|
discovery_payload: {
|
|
automation_type: 'trigger',
|
|
type: 'click',
|
|
},
|
|
},
|
|
};
|
|
|
|
const defaultStatusTopic = 'homeassistant/status';
|
|
|
|
/**
|
|
* This extensions handles integration with HomeAssistant
|
|
*/
|
|
class HomeAssistant extends Extension {
|
|
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
|
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
|
|
|
// A map of all discoverd devices
|
|
this.discovered = {};
|
|
this.mapping = {};
|
|
this.discoveredTriggers = {};
|
|
this.legacyApi = settings.get().advanced.legacy_api;
|
|
this.newApi = settings.get().experimental.new_api;
|
|
|
|
if (!settings.get().advanced.cache_state) {
|
|
logger.warn('In order for HomeAssistant integration to work properly set `cache_state: true');
|
|
}
|
|
|
|
if (settings.get().experimental.output === 'attribute') {
|
|
throw new Error('Home Assitant integration is not possible with attribute output!');
|
|
}
|
|
|
|
this.discoveryTopic = settings.get().advanced.homeassistant_discovery_topic;
|
|
this.statusTopic = settings.get().advanced.homeassistant_status_topic;
|
|
|
|
this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.resolvedEntity), this.constructor.name);
|
|
this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data), this.constructor.name);
|
|
this.eventBus.on('deviceRenamed', (data) =>
|
|
this.onDeviceRenamed(data.device, data.homeAssisantRename), this.constructor.name,
|
|
);
|
|
|
|
this.populateMapping();
|
|
}
|
|
|
|
populateMapping() {
|
|
for (const def of zigbeeHerdsmanConverters.definitions) {
|
|
if (def.hasOwnProperty('exposes')) {
|
|
this.mapping[def.model] = [];
|
|
|
|
if (['WXKG01LM', 'HS1EB/HS1EB-E', 'ICZB-KPD14S', 'TERNCY-SD01', 'TERNCY-PP01', 'ICZB-KPD18S',
|
|
'E1766', 'ZWallRemote0', 'ptvo.switch', '2AJZ4KPKEY', 'ZGRC-KEY-013', 'HGZB-02S', 'HGZB-045',
|
|
'HGZB-1S', 'AV2010/34', 'IM6001-BTP01', 'WXKG11LM', 'WXKG03LM', 'WXKG02LM', 'QBKG04LM', 'QBKG03LM',
|
|
'QBKG11LM', 'QBKG21LM', 'QBKG22LM', 'WXKG12LM', 'QBKG12LM', 'E1743'].includes(def.model)) {
|
|
// deprecated
|
|
this.mapping[def.model].push(cfg.sensor_click);
|
|
}
|
|
|
|
if (['ICTC-G-1'].includes(def.model)) {
|
|
// deprecated
|
|
this.mapping[def.model].push(cfg.sensor_brightness);
|
|
}
|
|
|
|
for (const expose of def.exposes) {
|
|
let discoveryEntry = null;
|
|
/* istanbul ignore else */
|
|
if (expose.type === 'light') {
|
|
discoveryEntry = {
|
|
type: 'light',
|
|
object_id: expose.endpoint ? `light_${expose.endpoint}` : 'light',
|
|
discovery_payload: {
|
|
brightness: !!expose.features.find((e) => e.name === 'brightness'),
|
|
color_temp: !!expose.features.find((e) => e.name === 'color_temp'),
|
|
xy: !!expose.features.find((e) => e.name === 'color_xy'),
|
|
hs: !!expose.features.find((e) => e.name === 'color_hs'),
|
|
schema: 'json',
|
|
command_topic: true,
|
|
brightness_scale: 254,
|
|
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
|
|
state_topic_postfix: expose.endpoint ? expose.endpoint : undefined,
|
|
},
|
|
};
|
|
|
|
const effect = def.exposes.find((e) => e.type === 'enum' && e.name === 'effect');
|
|
if (effect) {
|
|
discoveryEntry.discovery_payload.effect = true;
|
|
discoveryEntry.discovery_payload.effect_list = effect.values;
|
|
}
|
|
} else if (expose.type === 'switch') {
|
|
const state = expose.features.find((f) => f.name === 'state');
|
|
discoveryEntry = {
|
|
type: 'switch',
|
|
object_id: expose.endpoint ? `switch_${expose.endpoint}` : 'switch',
|
|
discovery_payload: {
|
|
payload_off: 'OFF',
|
|
payload_on: 'ON',
|
|
value_template: `{{ value_json.${state.property} }}`,
|
|
command_topic: true,
|
|
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
|
|
},
|
|
};
|
|
|
|
if (state.property === 'valve_detection' || state.property === 'window_detection') {
|
|
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
|
|
discoveryEntry.discovery_payload.state_off = 'OFF';
|
|
discoveryEntry.discovery_payload.state_on = 'ON';
|
|
discoveryEntry.discovery_payload.state_topic = true;
|
|
discoveryEntry.object_id = state.property;
|
|
|
|
if (state.property === 'window_detection') {
|
|
discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant';
|
|
}
|
|
}
|
|
} else if (expose.type === 'climate') {
|
|
const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
|
|
const setpoint = expose.features.find((f) => setpointProperties.includes(f.name));
|
|
assert(setpoint, 'No setpoint found');
|
|
const temperature = expose.features.find((f) => f.name === 'local_temperature');
|
|
assert(temperature, 'No temperature found');
|
|
const mode = expose.features.find((f) => f.name === 'system_mode');
|
|
assert(mode, 'No mode found');
|
|
const state = expose.features.find((f) => f.name === 'running_state');
|
|
assert(state, 'No state found');
|
|
|
|
discoveryEntry = {
|
|
type: 'climate',
|
|
object_id: expose.endpoint ? `climate_${expose.endpoint}` : 'climate',
|
|
discovery_payload: {
|
|
// Static
|
|
state_topic: false,
|
|
temperature_unit: 'C',
|
|
// Setpoint
|
|
temp_step: setpoint.value_step,
|
|
min_temp: setpoint.value_min.toString(),
|
|
max_temp: setpoint.value_max.toString(),
|
|
// Temperature
|
|
current_temperature_topic: true,
|
|
current_temperature_template: `{{ value_json.${temperature.property} }}`,
|
|
// Mode
|
|
mode_state_topic: true,
|
|
mode_state_template: `{{ value_json.${mode.property} }}`,
|
|
modes: mode.values,
|
|
mode_command_topic: true,
|
|
// State
|
|
action_topic: true,
|
|
action_template: `{% set values = ` +
|
|
`{'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'}` +
|
|
` %}{{ values[value_json.${state.property}] }}`,
|
|
},
|
|
};
|
|
|
|
const coolingSetpoint = expose.features.find((f) => f.name === 'occupied_cooling_setpoint');
|
|
if (coolingSetpoint) {
|
|
discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
|
|
discoveryEntry.discovery_payload.temperature_low_state_template =
|
|
`{{ value_json.${setpoint.property} }}`;
|
|
discoveryEntry.discovery_payload.temperature_low_state_topic = true;
|
|
discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
|
|
discoveryEntry.discovery_payload.temperature_high_state_template =
|
|
`{{ value_json.${coolingSetpoint.property} }}`;
|
|
discoveryEntry.discovery_payload.temperature_high_state_topic = true;
|
|
} else {
|
|
discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
|
|
discoveryEntry.discovery_payload.temperature_state_template =
|
|
`{{ value_json.${setpoint.property} }}`;
|
|
discoveryEntry.discovery_payload.temperature_state_topic = true;
|
|
}
|
|
|
|
const fanMode = expose.features.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 preset = expose.features.find((f) => f.name === 'preset');
|
|
if (preset) {
|
|
discoveryEntry.discovery_payload.hold_modes = preset.values;
|
|
discoveryEntry.discovery_payload.hold_command_topic = true;
|
|
discoveryEntry.discovery_payload.hold_state_template =
|
|
`{{ value_json.${preset.property} }}`;
|
|
discoveryEntry.discovery_payload.hold_state_topic = true;
|
|
}
|
|
|
|
const awayMode = expose.features.find((f) => f.name === 'away_mode');
|
|
if (awayMode) {
|
|
discoveryEntry.discovery_payload.away_mode_command_topic = true;
|
|
discoveryEntry.discovery_payload.away_mode_state_topic = true;
|
|
discoveryEntry.discovery_payload.away_mode_state_template =
|
|
`{{ value_json.${awayMode.property} }}`;
|
|
}
|
|
|
|
if (expose.endpoint) {
|
|
discoveryEntry.discovery_payload.state_topic_postfix = expose.endpoint;
|
|
}
|
|
} else if (expose.type === 'lock') {
|
|
assert(!expose.endpoint, `Endpoint not supported for lock type`);
|
|
const state = expose.features.find((f) => f.name === 'state');
|
|
assert(state, 'No state found');
|
|
discoveryEntry = {
|
|
type: 'lock',
|
|
object_id: 'lock',
|
|
discovery_payload: {
|
|
command_topic: true,
|
|
value_template: `{{ value_json.${state.property} }}`,
|
|
},
|
|
};
|
|
|
|
if (state.property === 'keypad_lockout') {
|
|
// deprecated: keypad_lockout is messy, but changing is breaking
|
|
discoveryEntry.discovery_payload.payload_lock = state.value_on;
|
|
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
|
|
discoveryEntry.discovery_payload.state_topic = true;
|
|
discoveryEntry.object_id = 'keypad_lock';
|
|
} else if (state.property === 'child_lock') {
|
|
// deprecated: child_lock is messy, but changing is breaking
|
|
discoveryEntry.discovery_payload.payload_lock = state.value_on;
|
|
discoveryEntry.discovery_payload.payload_unlock = state.value_off;
|
|
discoveryEntry.discovery_payload.state_locked = 'LOCKED';
|
|
discoveryEntry.discovery_payload.state_unlocked = 'UNLOCKED';
|
|
discoveryEntry.discovery_payload.state_topic = true;
|
|
discoveryEntry.object_id = 'child_lock';
|
|
} else {
|
|
discoveryEntry.discovery_payload.state_locked = state.value_on;
|
|
discoveryEntry.discovery_payload.state_unlocked = state.value_off;
|
|
}
|
|
|
|
if (state.property !== 'state') {
|
|
discoveryEntry.discovery_payload.command_topic_postfix = state.property;
|
|
}
|
|
} else if (expose.type === 'cover') {
|
|
assert(!expose.endpoint, `Endpoint not supported for cover type`);
|
|
const hasPosition = expose.features.find((e) => e.name === 'position');
|
|
const hasTilt = expose.features.find((e) => e.name === 'tilt');
|
|
|
|
discoveryEntry = {
|
|
type: 'cover',
|
|
object_id: 'cover',
|
|
discovery_payload: {
|
|
command_topic: true,
|
|
state_topic: !hasPosition,
|
|
},
|
|
};
|
|
|
|
if (!hasPosition && !hasTilt) {
|
|
discoveryEntry.discovery_payload.optimistic = true;
|
|
}
|
|
|
|
if (hasPosition) {
|
|
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
|
|
value_template: '{{ value_json.position }}',
|
|
set_position_template: '{ "position": {{ position }} }',
|
|
set_position_topic: true,
|
|
position_topic: true,
|
|
};
|
|
}
|
|
|
|
if (hasTilt) {
|
|
discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
|
|
tilt_command_topic: true,
|
|
tilt_status_topic: true,
|
|
tilt_status_template: '{{ value_json.tilt }}',
|
|
};
|
|
}
|
|
} else if (expose.type === 'fan') {
|
|
assert(!expose.endpoint, `Endpoint not supported for fan type`);
|
|
discoveryEntry = {
|
|
type: 'fan',
|
|
object_id: 'fan',
|
|
discovery_payload: {
|
|
state_topic: true,
|
|
state_value_template: '{{ value_json.fan_state }}',
|
|
command_topic: true,
|
|
command_topic_postfix: 'fan_state',
|
|
},
|
|
};
|
|
|
|
const speed = expose.features.find((e) => e.name === 'mode');
|
|
if (speed) {
|
|
discoveryEntry.discovery_payload.speed_state_topic = true;
|
|
discoveryEntry.discovery_payload.speed_command_topic = true;
|
|
discoveryEntry.discovery_payload.speed_value_template = '{{ value_json.fan_mode }}';
|
|
discoveryEntry.discovery_payload.speeds = speed.values;
|
|
}
|
|
} else if (expose.type === 'binary') {
|
|
const lookup = {
|
|
occupancy: {device_class: 'motion'},
|
|
battery_low: {device_class: 'battery'},
|
|
water_leak: {device_class: 'moisture'},
|
|
vibration: {device_class: 'vibration'},
|
|
contact: {device_class: 'door'},
|
|
smoke: {device_class: 'smoke'},
|
|
gas: {device_class: 'gas'},
|
|
carbon_monoxide: {device_class: 'safety'},
|
|
presence: {device_class: 'presence'},
|
|
};
|
|
|
|
assert(!expose.endpoint, `Endpoint not supported for binary type`);
|
|
|
|
discoveryEntry = {
|
|
type: 'binary_sensor',
|
|
object_id: expose.name,
|
|
discovery_payload: {
|
|
value_template: `{{ value_json.${expose.property} }}`,
|
|
payload_on: expose.value_on,
|
|
payload_off: expose.value_off,
|
|
...(lookup[expose.name] || {}),
|
|
},
|
|
};
|
|
} else if (expose.type === 'numeric') {
|
|
const lookup = {
|
|
battery: {device_class: 'battery'},
|
|
temperature: {device_class: 'temperature'},
|
|
humidity: {device_class: 'humidity'},
|
|
illuminance_lux: {device_class: 'illuminance'},
|
|
illuminance: {device_class: 'illuminance'},
|
|
soil_moisture: {icon: 'mdi:water-percent'},
|
|
position: {icon: 'mdi:valve'},
|
|
pressure: {device_class: 'pressure'},
|
|
power: {icon: 'mdi:flash'},
|
|
linkquality: {icon: 'mdi:signal'},
|
|
current: {icon: 'mdi:current-ac'},
|
|
voltage: {icon: 'mdi:alpha-v'},
|
|
current_phase_b: {icon: 'mdi:current-ac'},
|
|
voltage_phase_b: {icon: 'mdi:alpha-v'},
|
|
current_phase_c: {icon: 'mdi:current-ac'},
|
|
voltage_phase_c: {icon: 'mdi:alpha-v'},
|
|
energy: {icon: 'mdi:power-plug'},
|
|
smoke_density: {icon: 'mdi:google-circles-communities'},
|
|
gas_density: {icon: 'mdi:google-circles-communities'},
|
|
pm25: {icon: 'mdi:air-filter'},
|
|
pm10: {icon: 'mdi:air-filter'},
|
|
voc: {icon: 'mdi:air-filter'},
|
|
aqi: {icon: 'mdi:air-filter'},
|
|
hcho: {icon: 'mdi:air-filter'},
|
|
requested_brightness_level: {icon: 'mdi:brightness-5'},
|
|
requested_brightness_percent: {icon: 'mdi:brightness-5'},
|
|
eco2: {icon: 'mdi:molecule-co2'},
|
|
co2: {icon: 'mdi:molecule-co2'},
|
|
local_temperature: {device_class: 'temperature'},
|
|
};
|
|
|
|
assert(!expose.endpoint, `Endpoint not supported for numeric type`);
|
|
|
|
discoveryEntry = {
|
|
type: 'sensor',
|
|
object_id: expose.name,
|
|
discovery_payload: {
|
|
unit_of_measurement: expose.unit ? expose.unit : '-',
|
|
value_template: `{{ value_json.${expose.property} }}`,
|
|
...lookup[expose.name],
|
|
},
|
|
};
|
|
} else if (expose.type === 'enum' || expose.type === 'text') {
|
|
const ACCESS_STATE = 1;
|
|
if (expose.access & ACCESS_STATE) {
|
|
const lookup = {
|
|
action: {icon: 'mdi:gesture-double-tap'},
|
|
};
|
|
|
|
discoveryEntry = {
|
|
type: 'sensor',
|
|
object_id: expose.property,
|
|
discovery_payload: {
|
|
value_template: `{{ value_json.${expose.property} }}`,
|
|
...lookup[expose.name],
|
|
},
|
|
};
|
|
}
|
|
} else {
|
|
throw new Error(`Unsupported exposes type: '${expose.type}'`);
|
|
}
|
|
|
|
if (discoveryEntry) {
|
|
this.mapping[def.model].push(discoveryEntry);
|
|
}
|
|
}
|
|
} else {
|
|
logger.warn(`Supported device '${def.model}' has no Home Assistant mapping`);
|
|
}
|
|
}
|
|
|
|
for (const definition of utils.getExternalConvertersDefinitions(settings)) {
|
|
if (definition.hasOwnProperty('homeassistant')) {
|
|
this.mapping[definition.model] = definition.homeassistant;
|
|
}
|
|
}
|
|
}
|
|
|
|
onDeviceRemoved(resolvedEntity) {
|
|
logger.debug(`Clearing Home Assistant discovery topic for '${resolvedEntity.name}'`);
|
|
delete this.discovered[resolvedEntity.device.ieeeAddr];
|
|
for (const config of this.getConfigs(resolvedEntity)) {
|
|
const topic = this.getDiscoveryTopic(config, resolvedEntity.device);
|
|
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
|
}
|
|
}
|
|
|
|
async onPublishEntityState(data) {
|
|
/**
|
|
* 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.
|
|
*/
|
|
if (data.entity.definition && this.mapping[data.entity.definition.model]) {
|
|
for (const config of this.mapping[data.entity.definition.model]) {
|
|
const match = /light_(.*)/.exec(config['object_id']);
|
|
if (match) {
|
|
const endpoint = match[1];
|
|
const endpointRegExp = new RegExp(`(.*)_${endpoint}`);
|
|
const payload = {};
|
|
for (const key of Object.keys(data.payload)) {
|
|
const keyMatch = endpointRegExp.exec(key);
|
|
if (keyMatch) {
|
|
payload[keyMatch[1]] = data.payload[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 (settings.get().advanced.homeassistant_legacy_triggers) {
|
|
const keys = ['action', 'click'].filter((k) => data.payload.hasOwnProperty(k) && data.payload[k] !== '');
|
|
for (const key of keys) {
|
|
this.publishEntityState(data.entity.device.ieeeAddr, {[key]: ''});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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_devic/action
|
|
*/
|
|
if (data.entity.definition) {
|
|
const keys = ['action', 'click'].filter((k) => data.payload[k] && data.payload[k] !== '');
|
|
for (const key of keys) {
|
|
const value = data.payload[key].toString();
|
|
await this.publishDeviceTriggerDiscover(data.entity, key, value);
|
|
await this.mqtt.publish(`${data.entity.name}/${key}`, value, {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publish a value for update_available (if not there yet) to prevent Home Assistant generating warnings of
|
|
* this value not being available.
|
|
*/
|
|
const supportsOTA = data.entity.definition && data.entity.definition.hasOwnProperty('ota');
|
|
const mockedValues = [
|
|
{
|
|
property: 'update_available',
|
|
condition: supportsOTA && this.legacyApi,
|
|
value: false,
|
|
},
|
|
{
|
|
property: 'update',
|
|
condition: supportsOTA && this.newApi,
|
|
value: {state: 'idle'},
|
|
},
|
|
{
|
|
property: 'water_leak',
|
|
condition: data.entity.device && data.entity.definition && this.mapping[data.entity.definition.model] &&
|
|
this.mapping[data.entity.definition.model].filter((c) => c.object_id === 'water_leak').length === 1,
|
|
value: false,
|
|
},
|
|
];
|
|
|
|
for (const entry of mockedValues) {
|
|
if (entry.condition && !data.payload.hasOwnProperty(entry.property)) {
|
|
logger.debug(`Mocking '${entry.property}' value for Home Assistant`);
|
|
this.publishEntityState(data.entity.device.ieeeAddr, {[entry.property]: entry.value});
|
|
}
|
|
}
|
|
}
|
|
|
|
onDeviceRenamed(device, homeAssisantRename) {
|
|
logger.debug(`Refreshing Home Assistant discovery topic for '${device.ieeeAddr}'`);
|
|
const resolvedEntity = this.zigbee.resolveEntity(device);
|
|
|
|
// Clear before rename so Home Assistant uses new friendly_name
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/4096#issuecomment-674044916
|
|
if (homeAssisantRename) {
|
|
for (const config of this.getConfigs(resolvedEntity)) {
|
|
const topic = this.getDiscoveryTopic(config, device);
|
|
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
|
}
|
|
}
|
|
|
|
this.discover(resolvedEntity, true);
|
|
|
|
if (this.discoveredTriggers[device.ieeeAddr]) {
|
|
for (const config of this.discoveredTriggers[device.ieeeAddr]) {
|
|
const key = config.substring(0, config.indexOf('_'));
|
|
const value = config.substring(config.indexOf('_') + 1);
|
|
this.publishDeviceTriggerDiscover(resolvedEntity, key, value, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
async onMQTTConnected() {
|
|
this.mqtt.subscribe(this.statusTopic);
|
|
this.mqtt.subscribe(defaultStatusTopic);
|
|
this.mqtt.subscribe(`${this.discoveryTopic}/#`);
|
|
|
|
// MQTT discovery of all paired devices on startup.
|
|
for (const device of this.zigbee.getClients()) {
|
|
const resolvedEntity = this.zigbee.resolveEntity(device);
|
|
this.discover(resolvedEntity, true);
|
|
}
|
|
}
|
|
|
|
getConfigs(resolvedEntity) {
|
|
if (!resolvedEntity || !resolvedEntity.definition || !this.mapping[resolvedEntity.definition.model]) return [];
|
|
|
|
let configs = this.mapping[resolvedEntity.definition.model].slice();
|
|
if (resolvedEntity.definition.hasOwnProperty('ota')) {
|
|
if (this.legacyApi) {
|
|
configs.push(cfg.binary_sensor_update_available);
|
|
}
|
|
|
|
if (this.newApi) {
|
|
configs.push(cfg.sensor_update_state);
|
|
}
|
|
}
|
|
|
|
if (resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) {
|
|
configs = configs.filter((c) => c !== cfg.sensor_click);
|
|
}
|
|
|
|
if (!settings.get().advanced.homeassistant_legacy_triggers) {
|
|
configs = configs.filter((c) => c.object_id !== 'action' && c.object_id !== 'click');
|
|
}
|
|
|
|
// deep clone of the config objects
|
|
configs = JSON.parse(JSON.stringify(configs));
|
|
|
|
if (resolvedEntity.settings.hasOwnProperty('homeassistant')) {
|
|
configs.forEach((config) => {
|
|
const configOverride = resolvedEntity.settings.homeassistant[config.object_id];
|
|
if (configOverride) {
|
|
config.object_id = configOverride.object_id || config.object_id;
|
|
config.type = configOverride.type || config.type;
|
|
}
|
|
});
|
|
}
|
|
|
|
return configs;
|
|
}
|
|
|
|
discover(resolvedEntity, force=false) {
|
|
// Check if already discoverd and check if there are configs.
|
|
const {device, definition} = resolvedEntity;
|
|
const discover = force || !this.discovered[device.ieeeAddr];
|
|
if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing ||
|
|
(resolvedEntity.settings.hasOwnProperty('homeassistant') && !resolvedEntity.settings.homeassistant)) {
|
|
return;
|
|
}
|
|
|
|
const friendlyName = resolvedEntity.settings.friendlyName;
|
|
this.getConfigs(resolvedEntity).forEach((config) => {
|
|
const payload = {...config.discovery_payload};
|
|
let stateTopic = `${settings.get().mqtt.base_topic}/${friendlyName}`;
|
|
if (payload.state_topic_postfix) {
|
|
stateTopic += `/${payload.state_topic_postfix}`;
|
|
delete payload.state_topic_postfix;
|
|
}
|
|
|
|
if (!payload.hasOwnProperty('state_topic') || payload.state_topic) {
|
|
payload.state_topic = stateTopic;
|
|
} else {
|
|
/* istanbul ignore else */
|
|
if (payload.hasOwnProperty('state_topic')) {
|
|
delete payload.state_topic;
|
|
}
|
|
}
|
|
|
|
if (payload.position_topic) {
|
|
payload.position_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.tilt_status_topic) {
|
|
payload.tilt_status_topic = stateTopic;
|
|
}
|
|
|
|
payload.json_attributes_topic = stateTopic;
|
|
|
|
// Set (unique) name, separate by space if friendlyName contains space.
|
|
const nameSeparator = friendlyName.includes(' ') ? ' ' : '_';
|
|
payload.name = `${friendlyName}${nameSeparator}${config.object_id}`;
|
|
|
|
// Set unique_id
|
|
payload.unique_id = `${resolvedEntity.settings.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
|
|
|
|
// Attributes for device registry
|
|
payload.device = this.getDevicePayload(resolvedEntity);
|
|
|
|
// Availability payload
|
|
payload.availability = [{topic: `${settings.get().mqtt.base_topic}/bridge/state`}];
|
|
if (settings.get().advanced.availability_timeout) {
|
|
payload.availability.push({topic: `${settings.get().mqtt.base_topic}/${friendlyName}/availability`});
|
|
}
|
|
|
|
if (payload.command_topic) {
|
|
payload.command_topic = `${settings.get().mqtt.base_topic}/${friendlyName}/`;
|
|
|
|
if (payload.command_topic_prefix) {
|
|
payload.command_topic += `${payload.command_topic_prefix}/`;
|
|
delete payload.command_topic_prefix;
|
|
}
|
|
|
|
payload.command_topic += 'set';
|
|
|
|
if (payload.command_topic_postfix) {
|
|
payload.command_topic += `/${payload.command_topic_postfix}`;
|
|
delete payload.command_topic_postfix;
|
|
}
|
|
}
|
|
|
|
if (payload.set_position_topic && payload.command_topic) {
|
|
payload.set_position_topic = payload.command_topic;
|
|
}
|
|
|
|
if (payload.tilt_command_topic && payload.command_topic) {
|
|
// Home Assistant does not support templates to set tilt (as of 2019-08-17),
|
|
// so we (have to) use a subtopic.
|
|
payload.tilt_command_topic = payload.command_topic + '/tilt';
|
|
}
|
|
|
|
if (payload.mode_state_topic) {
|
|
payload.mode_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.mode_command_topic) {
|
|
payload.mode_command_topic = `${stateTopic}/set/system_mode`;
|
|
}
|
|
|
|
if (payload.hold_command_topic) {
|
|
payload.hold_command_topic = `${stateTopic}/set/preset`;
|
|
}
|
|
|
|
if (payload.hold_state_topic) {
|
|
payload.hold_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.away_mode_state_topic) {
|
|
payload.away_mode_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.away_mode_command_topic) {
|
|
payload.away_mode_command_topic = `${stateTopic}/set/away_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.speed_state_topic) {
|
|
payload.speed_state_topic = stateTopic;
|
|
}
|
|
|
|
if (payload.temperature_command_topic) {
|
|
payload.temperature_command_topic = `${stateTopic}/set/${payload.temperature_command_topic}`;
|
|
}
|
|
|
|
if (payload.temperature_low_command_topic) {
|
|
payload.temperature_low_command_topic = `${stateTopic}/set/${payload.temperature_low_command_topic}`;
|
|
}
|
|
|
|
if (payload.temperature_high_command_topic) {
|
|
payload.temperature_high_command_topic = `${stateTopic}/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 = `${stateTopic}/set/fan_mode`;
|
|
}
|
|
|
|
if (payload.speed_command_topic) {
|
|
payload.speed_command_topic = `${stateTopic}/set/fan_mode`;
|
|
}
|
|
|
|
if (payload.action_topic) {
|
|
payload.action_topic = stateTopic;
|
|
}
|
|
|
|
// Override configuration with user settings.
|
|
if (resolvedEntity.settings.hasOwnProperty('homeassistant')) {
|
|
const add = (obj) => {
|
|
Object.keys(obj).forEach((key) => {
|
|
if (['type', 'object_id'].includes(key)) {
|
|
return;
|
|
} else if (['number', 'string', 'boolean'].includes(typeof obj[key])) {
|
|
payload[key] = obj[key];
|
|
} else if (obj[key] === null) {
|
|
delete payload[key];
|
|
} else if (key === 'device' && typeof obj[key] === 'object') {
|
|
Object.keys(obj['device']).forEach((key) => {
|
|
payload['device'][key] = obj['device'][key];
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
add(resolvedEntity.settings.homeassistant);
|
|
|
|
if (resolvedEntity.settings.homeassistant.hasOwnProperty(config.object_id)) {
|
|
add(resolvedEntity.settings.homeassistant[config.object_id]);
|
|
}
|
|
}
|
|
|
|
const topic = this.getDiscoveryTopic(config, device);
|
|
this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
|
});
|
|
|
|
this.discovered[device.ieeeAddr] = true;
|
|
}
|
|
|
|
onMQTTMessage(topic, message) {
|
|
const discoveryRegex = new RegExp(`${this.discoveryTopic}/(.*)/(.*)/(.*)/config`);
|
|
const discoveryMatch = topic.match(discoveryRegex);
|
|
const isDeviceAutomation = discoveryMatch && discoveryMatch[1] === 'device_automation';
|
|
if (discoveryMatch) {
|
|
// Clear outdated discovery configs and remember already discoverd device_automations
|
|
try {
|
|
message = JSON.parse(message);
|
|
const baseTopic = settings.get().mqtt.base_topic + '/';
|
|
if (isDeviceAutomation && (!message.topic || !message.topic.startsWith(baseTopic))) {
|
|
return;
|
|
}
|
|
|
|
if (!isDeviceAutomation &&
|
|
(!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
|
|
const ieeeAddr = discoveryMatch[2];
|
|
const resolvedEntity = this.zigbee.resolveEntity(ieeeAddr);
|
|
let clear = !resolvedEntity || !resolvedEntity.definition;
|
|
|
|
// Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml
|
|
if (resolvedEntity) {
|
|
const key = `${discoveryMatch[3].substring(0, discoveryMatch[3].indexOf('_'))}`;
|
|
const triggerTopic = `${settings.get().mqtt.base_topic}/${resolvedEntity.name}/${key}`;
|
|
if (isDeviceAutomation && message.topic === triggerTopic) {
|
|
if (!this.discoveredTriggers[ieeeAddr]) {
|
|
this.discoveredTriggers[ieeeAddr] = new Set();
|
|
}
|
|
this.discoveredTriggers[ieeeAddr].add(discoveryMatch[3]);
|
|
}
|
|
}
|
|
|
|
if (!clear && !isDeviceAutomation) {
|
|
const type = discoveryMatch[1];
|
|
const objectID = discoveryMatch[3];
|
|
clear = !this.getConfigs(resolvedEntity).find((c) => c.type === type && c.object_id === objectID);
|
|
}
|
|
|
|
if (clear) {
|
|
logger.debug(`Clearing Home Assistant config '${topic}'`);
|
|
topic = topic.substring(this.discoveryTopic.length + 1);
|
|
this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
|
}
|
|
} else if ((topic === this.statusTopic || topic === defaultStatusTopic) && message.toLowerCase() === 'online') {
|
|
const timer = setTimeout(async () => {
|
|
// Publish all device states.
|
|
for (const device of this.zigbee.getClients()) {
|
|
if (this.state.exists(device.ieeeAddr)) {
|
|
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
|
|
}
|
|
}
|
|
|
|
clearTimeout(timer);
|
|
}, 30000);
|
|
}
|
|
}
|
|
|
|
onZigbeeEvent(type, data, resolvedEntity) {
|
|
if (resolvedEntity && type !== 'deviceLeave' && this.mqtt.isConnected()) {
|
|
this.discover(resolvedEntity);
|
|
}
|
|
}
|
|
|
|
getDevicePayload(resolvedEntity) {
|
|
return {
|
|
identifiers: [`zigbee2mqtt_${resolvedEntity.settings.ID}`],
|
|
name: resolvedEntity.settings.friendlyName,
|
|
sw_version: `Zigbee2MQTT ${zigbee2mqttVersion}`,
|
|
model: `${resolvedEntity.definition.description} (${resolvedEntity.definition.model})`,
|
|
manufacturer: resolvedEntity.definition.vendor,
|
|
};
|
|
}
|
|
|
|
getDiscoveryTopic(config, device) {
|
|
return `${config.type}/${device.ieeeAddr}/${config.object_id}/config`;
|
|
}
|
|
|
|
async publishDeviceTriggerDiscover(entity, key, value, force=false) {
|
|
const device = entity.device;
|
|
if (!this.discoveredTriggers[device.ieeeAddr]) {
|
|
this.discoveredTriggers[device.ieeeAddr] = new Set();
|
|
}
|
|
|
|
const discoveredKey = `${key}_${value}`;
|
|
if (this.discoveredTriggers[device.ieeeAddr].has(discoveredKey) && !force) {
|
|
return;
|
|
}
|
|
|
|
const config = cfg[`trigger_${key}`];
|
|
config.object_id = `${key}_${value}`;
|
|
const topic = this.getDiscoveryTopic(config, device);
|
|
const payload = {
|
|
...config.discovery_payload,
|
|
subtype: value,
|
|
payload: value,
|
|
topic: `${settings.get().mqtt.base_topic}/${entity.name}/${key}`,
|
|
device: this.getDevicePayload(entity),
|
|
};
|
|
|
|
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false);
|
|
this.discoveredTriggers[device.ieeeAddr].add(discoveredKey);
|
|
}
|
|
|
|
// Only for homeassistant.test.js
|
|
_getMapping() {
|
|
return this.mapping;
|
|
}
|
|
|
|
_clearDiscoveredTrigger() {
|
|
this.discoveredTriggers = new Set();
|
|
}
|
|
}
|
|
|
|
module.exports = HomeAssistant;
|