zigbee2mqtt/lib/extension/homeassistant.js

915 lines
43 KiB
JavaScript
Raw Normal View History

2018-11-16 12:23:11 -07:00
const settings = require('../util/settings');
const logger = require('../util/logger');
const utils = require('../util/utils');
const zigbee2mqttVersion = require('../../package.json').version;
2020-04-11 09:10:56 -07:00
const Extension = require('./extension');
const stringify = require('json-stable-stringify-without-jsonify');
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
const assert = require('assert');
2020-12-28 10:31:34 -07:00
const sensorClick = {
type: 'sensor',
object_id: 'click',
discovery_payload: {
icon: 'mdi:toggle-switch',
value_template: '{{ value_json.click }}',
},
2018-08-04 11:05:34 -07:00
};
const defaultStatusTopic = 'homeassistant/status';
2018-11-16 12:23:11 -07:00
/**
* This extensions handles integration with HomeAssistant
*/
2020-04-11 09:10:56 -07:00
class HomeAssistant extends Extension {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
super(zigbee, mqtt, state, publishEntityState, eventBus);
2018-11-16 12:23:11 -07:00
// A map of all discoverd devices
this.discovered = {};
this.mapping = {};
this.discoveredTriggers = {};
this.legacyApi = settings.get().advanced.legacy_api;
2018-11-16 12:23:11 -07:00
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_rev1', 'WXKG02LM_rev2',
'QBKG04LM', 'QBKG03LM', 'QBKG11LM', 'QBKG21LM', 'QBKG22LM', 'WXKG12LM', 'QBKG12LM',
'E1743'].includes(def.model)) {
// deprecated
2020-12-28 10:31:34 -07:00
this.mapping[def.model].push(sensorClick);
}
if (['ICTC-G-1'].includes(def.model)) {
// deprecated
2020-12-28 10:31:34 -07:00
this.mapping[def.model].push({
type: 'sensor',
object_id: 'brightness',
discovery_payload: {
unit_of_measurement: 'brightness',
icon: 'mdi:brightness-5',
value_template: '{{ value_json.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') {
2020-11-04 14:33:00 -07:00
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',
2020-11-04 14:33:00 -07:00
value_template: `{{ value_json.${state.property} }}`,
command_topic: true,
command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
},
};
2020-11-04 14:33:00 -07:00
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');
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} }}`,
},
};
2020-11-04 14:33:00 -07:00
const mode = expose.features.find((f) => f.name === 'system_mode');
if (mode) {
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 = expose.features.find((f) => f.name === 'running_state');
if (state) {
discoveryEntry.discovery_payload.action_topic = true;
discoveryEntry.discovery_payload.action_template = `{% set values = ` +
`{'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'}` +
` %}{{ values[value_json.${state.property}] }}`;
}
2020-11-04 14:33:00 -07:00
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';
2020-11-04 14:33:00 -07:00
} 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,
2020-10-26 09:25:41 -07:00
...(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' || expose.type === 'composite') {
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];
2020-08-17 11:57:12 -07:00
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);
}
2018-11-16 12:23:11 -07:00
}
async onPublishEntityState(data) {
/**
2020-08-01 01:36:20 -07:00
* 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) {
2020-04-11 11:34:50 -07:00
const endpoint = match[1];
const endpointRegExp = new RegExp(`(.*)_${endpoint}`);
const payload = {};
for (const key of Object.keys(data.payload)) {
2020-04-11 11:34:50 -07:00
const keyMatch = endpointRegExp.exec(key);
if (keyMatch) {
payload[keyMatch[1]] = data.payload[key];
}
}
await this.mqtt.publish(
`${data.entity.name}/${endpoint}`, stringify(payload), {},
);
}
}
}
2020-04-15 13:34:59 -07:00
/**
* 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) {
2020-04-15 13:34:59 -07:00
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
*/
2020-08-23 14:17:15 -07:00
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,
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) {
2020-08-17 11:57:12 -07:00
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);
}
}
2020-04-11 11:34:50 -07:00
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);
}
}
}
Zigbee-herdsman (#1945) * Update zigbee-herdsman and zigbee-shepherd-converters. * Force Aqara S2 Lock endvices (#1764) * Start on zigbee-herdsman controller refactor. * More updates. * Cleanup zapp. * updates. * Propagate adapter disconnected event. * Updates. * Initial refactor to zigbee-herdsman. * Refactor deviceReceive to zigbee-herdsman. * Rename * Refactor deviceConfigure. * Finish bridge config. * Refactor availability. * Active homeassistant extension and more refactors. * Refactor groups. * Enable soft reset. * Activate group membership * Start on tests. * Enable reporting. * Add more controller tests. * Add more tests * Fix linting error. * Data en deviceReceive tests. * Move to zigbee-herdsman-converters. * More device publish tests. * Cleanup dependencies. * Bring device publish coverage to 100. * Bring home assistant test coverage to 100. * Device configure tests. * Attempt to fix tests. * Another attempt. * Another one. * Another one. * Another. * Add wait. * Longer wait. * Debug. * Update dependencies. * Another. * Begin on availability tests. * Improve availability tests. * Complete deviceAvailability tests. * Device bind tests. * More tests. * Begin networkmap refactors. * start on networkmap tests. * Network map tests. * Add utils tests. * Logger tests. * Settings and logger tests. * Ignore some stuff for coverage and add todos. * Add remaining missing tests. * Enforce 100% test coverage. * Start on groups test and refactor entityPublish to resolveEntity * Remove joinPathStorage, not used anymore as group information is stored into zigbee-herdsman database. * Fix linting issues. * Improve tests. * Add groups. * fix group membership. * Group: log names. * Convert MQTT message to string by default. * Fix group name. * Updates. * Revert configuration.yaml. * Add new line. * Fixes. * Updates. * Fix tests. * Ignore soft reset extension.
2019-09-09 10:48:09 -07:00
async onMQTTConnected() {
this.mqtt.subscribe(this.statusTopic);
this.mqtt.subscribe(defaultStatusTopic);
2020-08-17 08:24:57 -07:00
this.mqtt.subscribe(`${this.discoveryTopic}/#`);
2018-11-16 12:23:11 -07:00
// MQTT discovery of all paired devices on startup.
2019-09-23 13:21:27 -07:00
for (const device of this.zigbee.getClients()) {
2020-04-11 11:34:50 -07:00
const resolvedEntity = this.zigbee.resolveEntity(device);
this.discover(resolvedEntity, true);
Zigbee-herdsman (#1945) * Update zigbee-herdsman and zigbee-shepherd-converters. * Force Aqara S2 Lock endvices (#1764) * Start on zigbee-herdsman controller refactor. * More updates. * Cleanup zapp. * updates. * Propagate adapter disconnected event. * Updates. * Initial refactor to zigbee-herdsman. * Refactor deviceReceive to zigbee-herdsman. * Rename * Refactor deviceConfigure. * Finish bridge config. * Refactor availability. * Active homeassistant extension and more refactors. * Refactor groups. * Enable soft reset. * Activate group membership * Start on tests. * Enable reporting. * Add more controller tests. * Add more tests * Fix linting error. * Data en deviceReceive tests. * Move to zigbee-herdsman-converters. * More device publish tests. * Cleanup dependencies. * Bring device publish coverage to 100. * Bring home assistant test coverage to 100. * Device configure tests. * Attempt to fix tests. * Another attempt. * Another one. * Another one. * Another. * Add wait. * Longer wait. * Debug. * Update dependencies. * Another. * Begin on availability tests. * Improve availability tests. * Complete deviceAvailability tests. * Device bind tests. * More tests. * Begin networkmap refactors. * start on networkmap tests. * Network map tests. * Add utils tests. * Logger tests. * Settings and logger tests. * Ignore some stuff for coverage and add todos. * Add remaining missing tests. * Enforce 100% test coverage. * Start on groups test and refactor entityPublish to resolveEntity * Remove joinPathStorage, not used anymore as group information is stored into zigbee-herdsman database. * Fix linting issues. * Improve tests. * Add groups. * fix group membership. * Group: log names. * Convert MQTT message to string by default. * Fix group name. * Updates. * Revert configuration.yaml. * Add new line. * Fixes. * Updates. * Fix tests. * Ignore soft reset extension.
2019-09-09 10:48:09 -07:00
}
2018-11-16 12:23:11 -07:00
}
2020-08-17 11:57:12 -07:00
getConfigs(resolvedEntity) {
if (!resolvedEntity || !resolvedEntity.definition || !this.mapping[resolvedEntity.definition.model]) return [];
2020-08-17 11:57:12 -07:00
let configs = this.mapping[resolvedEntity.definition.model].slice();
2020-08-17 11:57:12 -07:00
if (resolvedEntity.definition.hasOwnProperty('ota')) {
2020-12-28 10:31:34 -07:00
const updateStateSensor = {
type: 'sensor',
object_id: 'update_state',
discovery_payload: {
icon: 'mdi:update',
value_template: `{{ value_json['update']['state'] }}`,
},
};
configs.push(updateStateSensor);
if (this.legacyApi) {
2020-12-28 10:31:34 -07:00
const updateAvailableSensor = {
type: 'binary_sensor',
object_id: 'update_available',
discovery_payload: {
payload_on: true,
payload_off: false,
value_template: '{{ value_json.update_available}}',
},
};
configs.push(updateAvailableSensor);
}
}
2020-08-17 11:57:12 -07:00
if (resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) {
2020-12-28 10:31:34 -07:00
configs = configs.filter((c) => c !== sensorClick);
}
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.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;
}
2020-04-11 11:34:50 -07:00
discover(resolvedEntity, force=false) {
2018-11-16 12:23:11 -07:00
// Check if already discoverd and check if there are configs.
2020-04-11 11:34:50 -07:00
const {device, definition} = resolvedEntity;
const discover = force || !this.discovered[device.ieeeAddr];
if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing ||
2020-08-17 11:57:12 -07:00
(resolvedEntity.settings.hasOwnProperty('homeassistant') && !resolvedEntity.settings.homeassistant)) {
return;
}
2018-11-16 12:23:11 -07:00
2020-08-17 11:57:12 -07:00
const friendlyName = resolvedEntity.settings.friendlyName;
this.getConfigs(resolvedEntity).forEach((config) => {
2018-11-16 12:23:11 -07:00
const payload = {...config.discovery_payload};
2020-08-17 11:57:12 -07:00
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;
Zigbee-herdsman (#1945) * Update zigbee-herdsman and zigbee-shepherd-converters. * Force Aqara S2 Lock endvices (#1764) * Start on zigbee-herdsman controller refactor. * More updates. * Cleanup zapp. * updates. * Propagate adapter disconnected event. * Updates. * Initial refactor to zigbee-herdsman. * Refactor deviceReceive to zigbee-herdsman. * Rename * Refactor deviceConfigure. * Finish bridge config. * Refactor availability. * Active homeassistant extension and more refactors. * Refactor groups. * Enable soft reset. * Activate group membership * Start on tests. * Enable reporting. * Add more controller tests. * Add more tests * Fix linting error. * Data en deviceReceive tests. * Move to zigbee-herdsman-converters. * More device publish tests. * Cleanup dependencies. * Bring device publish coverage to 100. * Bring home assistant test coverage to 100. * Device configure tests. * Attempt to fix tests. * Another attempt. * Another one. * Another one. * Another. * Add wait. * Longer wait. * Debug. * Update dependencies. * Another. * Begin on availability tests. * Improve availability tests. * Complete deviceAvailability tests. * Device bind tests. * More tests. * Begin networkmap refactors. * start on networkmap tests. * Network map tests. * Add utils tests. * Logger tests. * Settings and logger tests. * Ignore some stuff for coverage and add todos. * Add remaining missing tests. * Enforce 100% test coverage. * Start on groups test and refactor entityPublish to resolveEntity * Remove joinPathStorage, not used anymore as group information is stored into zigbee-herdsman database. * Fix linting issues. * Improve tests. * Add groups. * fix group membership. * Group: log names. * Convert MQTT message to string by default. * Fix group name. * Updates. * Revert configuration.yaml. * Add new line. * Fixes. * Updates. * Fix tests. * Ignore soft reset extension.
2019-09-09 10:48:09 -07:00
} else {
/* istanbul ignore else */
if (payload.hasOwnProperty('state_topic')) {
delete payload.state_topic;
}
}
if (payload.position_topic) {
payload.position_topic = stateTopic;
}
2018-11-16 12:23:11 -07:00
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;
if (config.object_id.startsWith(config.type) && config.object_id.includes('_')) {
payload.name += `${nameSeparator}${config.object_id.split(/_(.+)/)[1]}`;
} else if (!config.object_id.startsWith(config.type)) {
payload.name += `${nameSeparator}${config.object_id.replace(/_/g, nameSeparator)}`;
}
2018-11-16 12:23:11 -07:00
// Set unique_id
2020-08-17 11:57:12 -07:00
payload.unique_id = `${resolvedEntity.settings.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
2018-11-16 12:23:11 -07:00
// Attributes for device registry
2020-04-11 11:34:50 -07:00
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`});
}
2018-11-16 12:23:11 -07:00
if (payload.command_topic) {
2020-04-11 11:34:50 -07:00
payload.command_topic = `${settings.get().mqtt.base_topic}/${friendlyName}/`;
2018-04-29 05:09:49 -07:00
2018-11-16 12:23:11 -07:00
if (payload.command_topic_prefix) {
payload.command_topic += `${payload.command_topic_prefix}/`;
delete payload.command_topic_prefix;
2018-11-16 12:23:11 -07:00
}
payload.command_topic += 'set';
if (payload.command_topic_postfix) {
payload.command_topic += `/${payload.command_topic_postfix}`;
delete payload.command_topic_postfix;
}
2018-04-29 05:09:49 -07:00
}
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.
2020-08-17 11:57:12 -07:00
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];
Zigbee-herdsman (#1945) * Update zigbee-herdsman and zigbee-shepherd-converters. * Force Aqara S2 Lock endvices (#1764) * Start on zigbee-herdsman controller refactor. * More updates. * Cleanup zapp. * updates. * Propagate adapter disconnected event. * Updates. * Initial refactor to zigbee-herdsman. * Refactor deviceReceive to zigbee-herdsman. * Rename * Refactor deviceConfigure. * Finish bridge config. * Refactor availability. * Active homeassistant extension and more refactors. * Refactor groups. * Enable soft reset. * Activate group membership * Start on tests. * Enable reporting. * Add more controller tests. * Add more tests * Fix linting error. * Data en deviceReceive tests. * Move to zigbee-herdsman-converters. * More device publish tests. * Cleanup dependencies. * Bring device publish coverage to 100. * Bring home assistant test coverage to 100. * Device configure tests. * Attempt to fix tests. * Another attempt. * Another one. * Another one. * Another. * Add wait. * Longer wait. * Debug. * Update dependencies. * Another. * Begin on availability tests. * Improve availability tests. * Complete deviceAvailability tests. * Device bind tests. * More tests. * Begin networkmap refactors. * start on networkmap tests. * Network map tests. * Add utils tests. * Logger tests. * Settings and logger tests. * Ignore some stuff for coverage and add todos. * Add remaining missing tests. * Enforce 100% test coverage. * Start on groups test and refactor entityPublish to resolveEntity * Remove joinPathStorage, not used anymore as group information is stored into zigbee-herdsman database. * Fix linting issues. * Improve tests. * Add groups. * fix group membership. * Group: log names. * Convert MQTT message to string by default. * Fix group name. * Updates. * Revert configuration.yaml. * Add new line. * Fixes. * Updates. * Fix tests. * Ignore soft reset extension.
2019-09-09 10:48:09 -07:00
} 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];
});
}
});
};
2020-08-17 11:57:12 -07:00
add(resolvedEntity.settings.homeassistant);
2020-08-17 11:57:12 -07:00
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);
2018-11-16 12:23:11 -07:00
});
this.discovered[device.ieeeAddr] = true;
2018-11-16 12:23:11 -07:00
}
onMQTTMessage(topic, message) {
const discoveryRegex = new RegExp(`${this.discoveryTopic}/(.*)/(.*)/(.*)/config`);
2020-08-17 08:24:57 -07:00
const discoveryMatch = topic.match(discoveryRegex);
const isDeviceAutomation = discoveryMatch && discoveryMatch[1] === 'device_automation';
2020-08-17 08:24:57 -07:00
if (discoveryMatch) {
// Clear outdated discovery configs and remember already discoverd device_automations
2020-08-17 08:24:57 -07:00
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))) {
2020-08-17 08:24:57 -07:00
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) {
2020-08-17 08:24:57 -07:00
const type = discoveryMatch[1];
const objectID = discoveryMatch[3];
2020-08-17 11:57:12 -07:00
clear = !this.getConfigs(resolvedEntity).find((c) => c.type === type && c.object_id === objectID);
2020-08-17 08:24:57 -07:00
}
// Device was flagged to be excluded from homeassistant discovery
clear = clear || (resolvedEntity.settings.hasOwnProperty('homeassistant') &&
!resolvedEntity.settings.homeassistant);
2020-08-17 08:24:57 -07:00
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);
2020-08-17 08:24:57 -07:00
}
} else if ((topic === this.statusTopic || topic === defaultStatusTopic) && message.toLowerCase() === 'online') {
Zigbee-herdsman (#1945) * Update zigbee-herdsman and zigbee-shepherd-converters. * Force Aqara S2 Lock endvices (#1764) * Start on zigbee-herdsman controller refactor. * More updates. * Cleanup zapp. * updates. * Propagate adapter disconnected event. * Updates. * Initial refactor to zigbee-herdsman. * Refactor deviceReceive to zigbee-herdsman. * Rename * Refactor deviceConfigure. * Finish bridge config. * Refactor availability. * Active homeassistant extension and more refactors. * Refactor groups. * Enable soft reset. * Activate group membership * Start on tests. * Enable reporting. * Add more controller tests. * Add more tests * Fix linting error. * Data en deviceReceive tests. * Move to zigbee-herdsman-converters. * More device publish tests. * Cleanup dependencies. * Bring device publish coverage to 100. * Bring home assistant test coverage to 100. * Device configure tests. * Attempt to fix tests. * Another attempt. * Another one. * Another one. * Another. * Add wait. * Longer wait. * Debug. * Update dependencies. * Another. * Begin on availability tests. * Improve availability tests. * Complete deviceAvailability tests. * Device bind tests. * More tests. * Begin networkmap refactors. * start on networkmap tests. * Network map tests. * Add utils tests. * Logger tests. * Settings and logger tests. * Ignore some stuff for coverage and add todos. * Add remaining missing tests. * Enforce 100% test coverage. * Start on groups test and refactor entityPublish to resolveEntity * Remove joinPathStorage, not used anymore as group information is stored into zigbee-herdsman database. * Fix linting issues. * Improve tests. * Add groups. * fix group membership. * Group: log names. * Convert MQTT message to string by default. * Fix group name. * Updates. * Revert configuration.yaml. * Add new line. * Fixes. * Updates. * Fix tests. * Ignore soft reset extension.
2019-09-09 10:48:09 -07:00
const timer = setTimeout(async () => {
2018-11-16 12:23:11 -07:00
// Publish all device states.
2019-09-23 13:21:27 -07:00
for (const device of this.zigbee.getClients()) {
if (this.state.exists(device.ieeeAddr)) {
this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
2018-11-16 12:23:11 -07:00
}
Zigbee-herdsman (#1945) * Update zigbee-herdsman and zigbee-shepherd-converters. * Force Aqara S2 Lock endvices (#1764) * Start on zigbee-herdsman controller refactor. * More updates. * Cleanup zapp. * updates. * Propagate adapter disconnected event. * Updates. * Initial refactor to zigbee-herdsman. * Refactor deviceReceive to zigbee-herdsman. * Rename * Refactor deviceConfigure. * Finish bridge config. * Refactor availability. * Active homeassistant extension and more refactors. * Refactor groups. * Enable soft reset. * Activate group membership * Start on tests. * Enable reporting. * Add more controller tests. * Add more tests * Fix linting error. * Data en deviceReceive tests. * Move to zigbee-herdsman-converters. * More device publish tests. * Cleanup dependencies. * Bring device publish coverage to 100. * Bring home assistant test coverage to 100. * Device configure tests. * Attempt to fix tests. * Another attempt. * Another one. * Another one. * Another. * Add wait. * Longer wait. * Debug. * Update dependencies. * Another. * Begin on availability tests. * Improve availability tests. * Complete deviceAvailability tests. * Device bind tests. * More tests. * Begin networkmap refactors. * start on networkmap tests. * Network map tests. * Add utils tests. * Logger tests. * Settings and logger tests. * Ignore some stuff for coverage and add todos. * Add remaining missing tests. * Enforce 100% test coverage. * Start on groups test and refactor entityPublish to resolveEntity * Remove joinPathStorage, not used anymore as group information is stored into zigbee-herdsman database. * Fix linting issues. * Improve tests. * Add groups. * fix group membership. * Group: log names. * Convert MQTT message to string by default. * Fix group name. * Updates. * Revert configuration.yaml. * Add new line. * Fixes. * Updates. * Fix tests. * Ignore soft reset extension.
2019-09-09 10:48:09 -07:00
}
2018-11-16 12:23:11 -07:00
clearTimeout(timer);
}, 30000);
2018-11-16 12:23:11 -07:00
}
}
2020-04-05 06:41:24 -07:00
onZigbeeEvent(type, data, resolvedEntity) {
if (resolvedEntity && type !== 'deviceLeave' && this.mqtt.isConnected()) {
2020-04-11 11:34:50 -07:00
this.discover(resolvedEntity);
2018-11-16 12:23:11 -07:00
}
}
2020-04-11 11:34:50 -07:00
getDevicePayload(resolvedEntity) {
return {
2020-04-11 11:34:50 -07:00
identifiers: [`zigbee2mqtt_${resolvedEntity.settings.ID}`],
name: resolvedEntity.settings.friendlyName,
2020-08-01 01:36:20 -07:00
sw_version: `Zigbee2MQTT ${zigbee2mqttVersion}`,
2020-04-11 11:34:50 -07:00
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;
}
2020-12-28 10:31:34 -07:00
const config = {
type: 'device_automation',
object_id: `${key}_${value}`,
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}/${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);
}
2018-11-16 12:23:11 -07:00
// Only for homeassistant.test.js
_getMapping() {
return this.mapping;
2018-11-16 12:23:11 -07:00
}
_clearDiscoveredTrigger() {
this.discoveredTriggers = new Set();
}
}
2018-11-16 12:23:11 -07:00
module.exports = HomeAssistant;