Advertisement
Guest User

homeassistant.js

a guest
Apr 7th, 2021
156
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 44.20 KB | None | 0 0
  1. const settings = require('../util/settings');
  2. const logger = require('../util/logger');
  3. const utils = require('../util/utils');
  4. const zigbee2mqttVersion = require('../../package.json').version;
  5. const Extension = require('./extension');
  6. const stringify = require('json-stable-stringify-without-jsonify');
  7. const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
  8. const assert = require('assert');
  9. const safeDefault = "| default ('')";
  10.  
  11. const sensorClick = {
  12. type: 'sensor',
  13. object_id: 'click',
  14. discovery_payload: {
  15. icon: 'mdi:toggle-switch',
  16. value_template: '{{ value_json.click }}',
  17. },
  18. };
  19.  
  20. const defaultStatusTopic = 'homeassistant/status';
  21.  
  22. /**
  23. * This extensions handles integration with HomeAssistant
  24. */
  25. class HomeAssistant extends Extension {
  26. constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
  27. super(zigbee, mqtt, state, publishEntityState, eventBus);
  28.  
  29. // A map of all discoverd devices
  30. this.discovered = {};
  31. this.mapping = {};
  32. this.discoveredTriggers = {};
  33. this.legacyApi = settings.get().advanced.legacy_api;
  34.  
  35. if (!settings.get().advanced.cache_state) {
  36. logger.warn('In order for HomeAssistant integration to work properly set `cache_state: true');
  37. }
  38.  
  39. if (settings.get().experimental.output === 'attribute') {
  40. throw new Error('Home Assitant integration is not possible with attribute output!');
  41. }
  42.  
  43. this.discoveryTopic = settings.get().advanced.homeassistant_discovery_topic;
  44. this.statusTopic = settings.get().advanced.homeassistant_status_topic;
  45.  
  46. this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.resolvedEntity), this.constructor.name);
  47. this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data), this.constructor.name);
  48. this.eventBus.on('deviceRenamed', (data) =>
  49. this.onDeviceRenamed(data.device, data.homeAssisantRename), this.constructor.name,
  50. );
  51.  
  52. this.populateMapping();
  53. }
  54.  
  55. populateMapping() {
  56. for (const def of zigbeeHerdsmanConverters.definitions) {
  57. this.mapping[def.model] = [];
  58.  
  59. if (['WXKG01LM', 'HS1EB/HS1EB-E', 'ICZB-KPD14S', 'TERNCY-SD01', 'TERNCY-PP01', 'ICZB-KPD18S',
  60. 'E1766', 'ZWallRemote0', 'ptvo.switch', '2AJZ4KPKEY', 'ZGRC-KEY-013', 'HGZB-02S', 'HGZB-045',
  61. 'HGZB-1S', 'AV2010/34', 'IM6001-BTP01', 'WXKG11LM', 'WXKG03LM', 'WXKG02LM_rev1', 'WXKG02LM_rev2',
  62. 'QBKG04LM', 'QBKG03LM', 'QBKG11LM', 'QBKG21LM', 'QBKG22LM', 'WXKG12LM', 'QBKG12LM',
  63. 'E1743'].includes(def.model)) {
  64. // deprecated
  65. this.mapping[def.model].push(sensorClick);
  66. }
  67.  
  68. if (['ICTC-G-1'].includes(def.model)) {
  69. // deprecated
  70. this.mapping[def.model].push({
  71. type: 'sensor',
  72. object_id: 'brightness',
  73. discovery_payload: {
  74. unit_of_measurement: 'brightness',
  75. icon: 'mdi:brightness-5',
  76. value_template: '{{ value_json.brightness }}',
  77. },
  78. });
  79. }
  80.  
  81. for (const expose of def.exposes) {
  82. let discoveryEntry = null;
  83. /* istanbul ignore else */
  84. if (expose.type === 'light') {
  85. const supportsXY = !!expose.features.find((e) => e.name === 'color_xy');
  86. const supportsHS = !!expose.features.find((e) => e.name === 'color_hs');
  87. const colorTemp = expose.features.find((e) => e.name === 'color_temp');
  88. discoveryEntry = {
  89. type: 'light',
  90. object_id: expose.endpoint ? `light_${expose.endpoint}` : 'light',
  91. discovery_payload: {
  92. brightness: !!expose.features.find((e) => e.name === 'brightness'),
  93. color_temp: !!colorTemp,
  94. xy: supportsXY,
  95. hs: !supportsXY && supportsHS,
  96. schema: 'json',
  97. command_topic: true,
  98. brightness_scale: 254,
  99. command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
  100. state_topic_postfix: expose.endpoint ? expose.endpoint : undefined,
  101. },
  102. };
  103.  
  104. if (colorTemp) {
  105. discoveryEntry.discovery_payload.max_mireds = colorTemp.value_max;
  106. discoveryEntry.discovery_payload.min_mireds = colorTemp.value_min;
  107. }
  108.  
  109. const effect = def.exposes.find((e) => e.type === 'enum' && e.name === 'effect');
  110. if (effect) {
  111. discoveryEntry.discovery_payload.effect = true;
  112. discoveryEntry.discovery_payload.effect_list = effect.values;
  113. }
  114. } else if (expose.type === 'switch') {
  115. const state = expose.features.find((f) => f.name === 'state');
  116. discoveryEntry = {
  117. type: 'switch',
  118. object_id: expose.endpoint ? `switch_${expose.endpoint}` : 'switch',
  119. discovery_payload: {
  120. payload_off: state.value_off,
  121. payload_on: state.value_on,
  122. value_template: `{{ value_json.${state.property} }}`,
  123. command_topic: true,
  124. command_topic_prefix: expose.endpoint ? expose.endpoint : undefined,
  125. },
  126. };
  127.  
  128. const different = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode'];
  129. if (different.includes(state.property)) {
  130. discoveryEntry.discovery_payload.command_topic_postfix = state.property;
  131. discoveryEntry.discovery_payload.state_off = state.value_off;
  132. discoveryEntry.discovery_payload.state_on = state.value_on;
  133. discoveryEntry.discovery_payload.state_topic = true;
  134. discoveryEntry.object_id = state.property;
  135.  
  136. if (state.property === 'window_detection') {
  137. discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant';
  138. }
  139. }
  140. } else if (expose.type === 'climate') {
  141. const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint'];
  142. const setpoint = expose.features.find((f) => setpointProperties.includes(f.name));
  143. assert(setpoint, 'No setpoint found');
  144. const temperature = expose.features.find((f) => f.name === 'local_temperature');
  145. assert(temperature, 'No temperature found');
  146.  
  147. discoveryEntry = {
  148. type: 'climate',
  149. object_id: expose.endpoint ? `climate_${expose.endpoint}` : 'climate',
  150. discovery_payload: {
  151. // Static
  152. state_topic: false,
  153. temperature_unit: 'C',
  154. // Setpoint
  155. temp_step: setpoint.value_step,
  156. min_temp: setpoint.value_min.toString(),
  157. max_temp: setpoint.value_max.toString(),
  158. // Temperature
  159. current_temperature_topic: true,
  160. current_temperature_template: `{{ value_json.${temperature.property} }}`,
  161. },
  162. };
  163.  
  164. const mode = expose.features.find((f) => f.name === 'system_mode');
  165. if (mode) {
  166. if (mode.values.includes('sleep')) {
  167. // 'sleep' is not supported by homeassistent, but is valid according to ZCL
  168. // TRV that support sleep (e.g. Viessmann) will have it removed from here,
  169. // this allows other expose consumers to still use it, e.g. the frontend.
  170. mode.values.splice(mode.values.indexOf('sleep'), 1);
  171. }
  172. discoveryEntry.discovery_payload.mode_state_topic = true;
  173. discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`;
  174. discoveryEntry.discovery_payload.modes = mode.values;
  175. discoveryEntry.discovery_payload.mode_command_topic = true;
  176. }
  177.  
  178. const state = expose.features.find((f) => f.name === 'running_state');
  179. if (state) {
  180. discoveryEntry.discovery_payload.action_topic = true;
  181. discoveryEntry.discovery_payload.action_template = `{% set values = ` +
  182. `{'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'}` +
  183. ` %}{{ values[value_json.${state.property}] }}`;
  184. }
  185.  
  186. const coolingSetpoint = expose.features.find((f) => f.name === 'occupied_cooling_setpoint');
  187. if (coolingSetpoint) {
  188. discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name;
  189. discoveryEntry.discovery_payload.temperature_low_state_template =
  190. `{{ value_json.${setpoint.property} }}`;
  191. discoveryEntry.discovery_payload.temperature_low_state_topic = true;
  192. discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name;
  193. discoveryEntry.discovery_payload.temperature_high_state_template =
  194. `{{ value_json.${coolingSetpoint.property} }}`;
  195. discoveryEntry.discovery_payload.temperature_high_state_topic = true;
  196. } else {
  197. discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name;
  198. discoveryEntry.discovery_payload.temperature_state_template =
  199. `{{ value_json.${setpoint.property} }}`;
  200. discoveryEntry.discovery_payload.temperature_state_topic = true;
  201. }
  202.  
  203. const fanMode = expose.features.find((f) => f.name === 'fan_mode');
  204. if (fanMode) {
  205. discoveryEntry.discovery_payload.fan_modes = fanMode.values;
  206. discoveryEntry.discovery_payload.fan_mode_command_topic = true;
  207. discoveryEntry.discovery_payload.fan_mode_state_template =
  208. `{{ value_json.${fanMode.property} }}`;
  209. discoveryEntry.discovery_payload.fan_mode_state_topic = true;
  210. }
  211.  
  212. const preset = expose.features.find((f) => f.name === 'preset');
  213. if (preset) {
  214. discoveryEntry.discovery_payload.hold_modes = preset.values;
  215. discoveryEntry.discovery_payload.hold_command_topic = true;
  216. discoveryEntry.discovery_payload.hold_state_template =
  217. `{{ value_json.${preset.property} ${safeDefault} }}`;
  218. discoveryEntry.discovery_payload.hold_state_topic = true;
  219. }
  220.  
  221. const awayMode = expose.features.find((f) => f.name === 'away_mode');
  222. if (awayMode) {
  223. discoveryEntry.discovery_payload.away_mode_command_topic = true;
  224. discoveryEntry.discovery_payload.away_mode_state_topic = true;
  225. discoveryEntry.discovery_payload.away_mode_state_template =
  226. `{{ value_json.${awayMode.property} }}`;
  227. }
  228.  
  229. if (expose.endpoint) {
  230. discoveryEntry.discovery_payload.state_topic_postfix = expose.endpoint;
  231. }
  232. } else if (expose.type === 'lock') {
  233. assert(!expose.endpoint, `Endpoint not supported for lock type`);
  234. const state = expose.features.find((f) => f.name === 'state');
  235. assert(state, 'No state found');
  236. discoveryEntry = {
  237. type: 'lock',
  238. object_id: 'lock',
  239. discovery_payload: {
  240. command_topic: true,
  241. value_template: `{{ value_json.${state.property} }}`,
  242. },
  243. };
  244.  
  245. if (state.property === 'keypad_lockout') {
  246. // deprecated: keypad_lockout is messy, but changing is breaking
  247. discoveryEntry.discovery_payload.payload_lock = state.value_on;
  248. discoveryEntry.discovery_payload.payload_unlock = state.value_off;
  249. discoveryEntry.discovery_payload.state_topic = true;
  250. discoveryEntry.object_id = 'keypad_lock';
  251. } else if (state.property === 'child_lock') {
  252. // deprecated: child_lock is messy, but changing is breaking
  253. discoveryEntry.discovery_payload.payload_lock = state.value_on;
  254. discoveryEntry.discovery_payload.payload_unlock = state.value_off;
  255. discoveryEntry.discovery_payload.state_locked = 'LOCK';
  256. discoveryEntry.discovery_payload.state_unlocked = 'UNLOCK';
  257. discoveryEntry.discovery_payload.state_topic = true;
  258. discoveryEntry.object_id = 'child_lock';
  259. } else {
  260. discoveryEntry.discovery_payload.state_locked = state.value_on;
  261. discoveryEntry.discovery_payload.state_unlocked = state.value_off;
  262. }
  263.  
  264. if (state.property !== 'state') {
  265. discoveryEntry.discovery_payload.command_topic_postfix = state.property;
  266. }
  267. } else if (expose.type === 'cover') {
  268. assert(!expose.endpoint, `Endpoint not supported for cover type`);
  269. const hasPosition = expose.features.find((e) => e.name === 'position');
  270. const hasTilt = expose.features.find((e) => e.name === 'tilt');
  271.  
  272. discoveryEntry = {
  273. type: 'cover',
  274. object_id: 'cover',
  275. discovery_payload: {
  276. command_topic: true,
  277. state_topic: !hasPosition,
  278. },
  279. };
  280.  
  281. if (!hasPosition && !hasTilt) {
  282. discoveryEntry.discovery_payload.optimistic = true;
  283. }
  284.  
  285. if (hasPosition) {
  286. discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
  287. position_template: '{{ value_json.position }}',
  288. set_position_template: '{ "position": {{ position }} }',
  289. set_position_topic: true,
  290. position_topic: true,
  291. };
  292. }
  293.  
  294. if (hasTilt) {
  295. discoveryEntry.discovery_payload = {...discoveryEntry.discovery_payload,
  296. tilt_command_topic: true,
  297. tilt_status_topic: true,
  298. tilt_status_template: '{{ value_json.tilt }}',
  299. };
  300. }
  301. } else if (expose.type === 'fan') {
  302. assert(!expose.endpoint, `Endpoint not supported for fan type`);
  303. discoveryEntry = {
  304. type: 'fan',
  305. object_id: 'fan',
  306. discovery_payload: {
  307. state_topic: true,
  308. state_value_template: '{{ value_json.fan_state }}',
  309. command_topic: true,
  310. command_topic_postfix: 'fan_state',
  311. },
  312. };
  313.  
  314. const speed = expose.features.find((e) => e.name === 'mode');
  315. if (speed) {
  316. discoveryEntry.discovery_payload.speed_state_topic = true;
  317. discoveryEntry.discovery_payload.speed_command_topic = true;
  318. discoveryEntry.discovery_payload.speed_value_template = '{{ value_json.fan_mode }}';
  319. discoveryEntry.discovery_payload.speeds = speed.values;
  320. }
  321. } else if (expose.type === 'binary') {
  322. const lookup = {
  323. occupancy: {device_class: 'motion'},
  324. battery_low: {device_class: 'battery'},
  325. water_leak: {device_class: 'moisture'},
  326. vibration: {device_class: 'vibration'},
  327. contact: {device_class: 'door'},
  328. smoke: {device_class: 'smoke'},
  329. gas: {device_class: 'gas'},
  330. carbon_monoxide: {device_class: 'safety'},
  331. presence: {device_class: 'presence'},
  332. };
  333.  
  334. discoveryEntry = {
  335. type: 'binary_sensor',
  336. object_id: expose.endpoint ? `${expose.name}_${expose.endpoint}` : `${expose.name}`,
  337. discovery_payload: {
  338. value_template: `{{ value_json.${expose.property} ${safeDefault} }}`,
  339. payload_on: expose.value_on,
  340. payload_off: expose.value_off,
  341. ...(lookup[expose.name] || {}),
  342. },
  343. };
  344. } else if (expose.type === 'numeric') {
  345. const lookup = {
  346. battery: {device_class: 'battery'},
  347. temperature: {device_class: 'temperature'},
  348. humidity: {device_class: 'humidity'},
  349. illuminance_lux: {device_class: 'illuminance'},
  350. illuminance: {device_class: 'illuminance'},
  351. soil_moisture: {icon: 'mdi:water-percent'},
  352. position: {icon: 'mdi:valve'},
  353. pressure: {device_class: 'pressure'},
  354. power: {device_class: 'power'},
  355. linkquality: {icon: 'mdi:signal'},
  356. current: {device_class: 'current'},
  357. voltage: {device_class: 'voltage'},
  358. current_phase_b: {device_class: 'current'},
  359. voltage_phase_b: {device_class: 'voltage'},
  360. current_phase_c: {device_class: 'current'},
  361. voltage_phase_c: {device_class: 'voltage'},
  362. energy: {device_class: 'energy'},
  363. smoke_density: {icon: 'mdi:google-circles-communities'},
  364. gas_density: {icon: 'mdi:google-circles-communities'},
  365. pm25: {icon: 'mdi:air-filter'},
  366. pm10: {icon: 'mdi:air-filter'},
  367. voc: {icon: 'mdi:air-filter'},
  368. aqi: {icon: 'mdi:air-filter'},
  369. hcho: {icon: 'mdi:air-filter'},
  370. requested_brightness_level: {icon: 'mdi:brightness-5'},
  371. requested_brightness_percent: {icon: 'mdi:brightness-5'},
  372. eco2: {icon: 'mdi:molecule-co2'},
  373. co2: {icon: 'mdi:molecule-co2'},
  374. local_temperature: {device_class: 'temperature'},
  375. x_axis: {icon: 'mdi:axis-x-arrow'},
  376. y_axis: {icon: 'mdi:axis-y-arrow'},
  377. z_axis: {icon: 'mdi:axis-z-arrow'},
  378. };
  379.  
  380. discoveryEntry = {
  381. type: 'sensor',
  382. object_id: expose.endpoint ? `${expose.name}_${expose.endpoint}` : `${expose.name}`,
  383. discovery_payload: {
  384. value_template: `{{ value_json.${expose.property} ${safeDefault} }}`,
  385. ...(expose.unit && {unit_of_measurement: expose.unit}),
  386. ...lookup[expose.name],
  387. },
  388. };
  389. } else if (expose.type === 'enum' || expose.type === 'text' || expose.type === 'composite') {
  390. const ACCESS_STATE = 1;
  391. if (expose.access & ACCESS_STATE) {
  392. const lookup = {
  393. action: {icon: 'mdi:gesture-double-tap'},
  394. };
  395.  
  396. discoveryEntry = {
  397. type: 'sensor',
  398. object_id: expose.property,
  399. discovery_payload: {
  400. value_template: `{{ value_json.${expose.property} ${safeDefault} }}`,
  401. ...lookup[expose.name],
  402. },
  403. };
  404. }
  405. } else {
  406. throw new Error(`Unsupported exposes type: '${expose.type}'`);
  407. }
  408.  
  409. if (discoveryEntry) {
  410. this.mapping[def.model].push(discoveryEntry);
  411. }
  412. }
  413. }
  414.  
  415. for (const definition of utils.getExternalConvertersDefinitions(settings)) {
  416. if (definition.hasOwnProperty('homeassistant')) {
  417. this.mapping[definition.model] = definition.homeassistant;
  418. }
  419. }
  420. }
  421.  
  422. onDeviceRemoved(resolvedEntity) {
  423. logger.debug(`Clearing Home Assistant discovery topic for '${resolvedEntity.name}'`);
  424. delete this.discovered[resolvedEntity.device.ieeeAddr];
  425. for (const config of this.getConfigs(resolvedEntity)) {
  426. const topic = this.getDiscoveryTopic(config, resolvedEntity.device);
  427. this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
  428. }
  429. }
  430.  
  431. async onPublishEntityState(data) {
  432. /**
  433. * In case we deal with a lightEndpoint configuration Zigbee2MQTT publishes
  434. * e.g. {state_l1: ON, brightness_l1: 250} to zigbee2mqtt/mydevice.
  435. * As the Home Assistant MQTT JSON light cannot be configured to use state_l1/brightness_l1
  436. * as the state variables, the state topic is set to zigbee2mqtt/mydevice/l1.
  437. * Here we retrieve all the attributes with the _l1 values and republish them on
  438. * zigbee2mqtt/mydevice/l1.
  439. */
  440. if (data.entity.definition && this.mapping[data.entity.definition.model]) {
  441. for (const config of this.mapping[data.entity.definition.model]) {
  442. const match = /light_(.*)/.exec(config['object_id']);
  443. if (match) {
  444. const endpoint = match[1];
  445. const endpointRegExp = new RegExp(`(.*)_${endpoint}`);
  446. const payload = {};
  447. for (const key of Object.keys(data.messagePayload)) {
  448. const keyMatch = endpointRegExp.exec(key);
  449. if (keyMatch) {
  450. payload[keyMatch[1]] = data.messagePayload[key];
  451. }
  452. }
  453.  
  454. await this.mqtt.publish(
  455. `${data.entity.name}/${endpoint}`, stringify(payload), {},
  456. );
  457. }
  458. }
  459. }
  460.  
  461. /**
  462. * Publish an empty value for click and action payload, in this way Home Assistant
  463. * can use Home Assistant entities in automations.
  464. * https://github.com/Koenkk/zigbee2mqtt/issues/959#issuecomment-480341347
  465. */
  466. if (settings.get().advanced.homeassistant_legacy_triggers) {
  467. const keys = ['action', 'click'].filter((k) =>
  468. data.messagePayload.hasOwnProperty(k) && data.messagePayload[k] !== '');
  469. for (const key of keys) {
  470. this.publishEntityState(data.entity.device.ieeeAddr, {[key]: ''});
  471. }
  472. }
  473.  
  474. /**
  475. * Implements the MQTT device trigger (https://www.home-assistant.io/integrations/device_trigger.mqtt/)
  476. * The MQTT device trigger does not support JSON parsing, so it cannot listen to zigbee2mqtt/my_device
  477. * Whenever a device publish an {action: *} we discover an MQTT device trigger sensor
  478. * and republish it to zigbee2mqtt/my_devic/action
  479. */
  480. if (data.entity.definition) {
  481. const keys = ['action', 'click'].filter((k) => data.messagePayload[k] && data.messagePayload[k] !== '');
  482. for (const key of keys) {
  483. const value = data.messagePayload[key].toString();
  484. await this.publishDeviceTriggerDiscover(data.entity, key, value);
  485. await this.mqtt.publish(`${data.entity.name}/${key}`, value, {});
  486. }
  487. }
  488.  
  489. /**
  490. * Publish a value for update_available (if not there yet) to prevent Home Assistant generating warnings of
  491. * this value not being available.
  492. */
  493. const supportsOTA = data.entity.definition && data.entity.definition.hasOwnProperty('ota');
  494. const mockedValues = [
  495. {
  496. property: 'update_available',
  497. condition: supportsOTA && this.legacyApi,
  498. value: false,
  499. },
  500. {
  501. property: 'update',
  502. condition: supportsOTA,
  503. value: {state: 'idle'},
  504. },
  505. {
  506. property: 'water_leak',
  507. condition: data.entity.device && data.entity.definition && this.mapping[data.entity.definition.model] &&
  508. this.mapping[data.entity.definition.model].filter((c) => c.object_id === 'water_leak').length === 1,
  509. value: false,
  510. },
  511. ];
  512.  
  513. for (const entry of mockedValues) {
  514. if (entry.condition && !data.messagePayload.hasOwnProperty(entry.property)) {
  515. logger.debug(`Mocking '${entry.property}' value for Home Assistant`);
  516. this.publishEntityState(data.entity.device.ieeeAddr, {[entry.property]: entry.value});
  517. }
  518. }
  519. }
  520.  
  521. onDeviceRenamed(device, homeAssisantRename) {
  522. logger.debug(`Refreshing Home Assistant discovery topic for '${device.ieeeAddr}'`);
  523. const resolvedEntity = this.zigbee.resolveEntity(device);
  524.  
  525. // Clear before rename so Home Assistant uses new friendly_name
  526. // https://github.com/Koenkk/zigbee2mqtt/issues/4096#issuecomment-674044916
  527. if (homeAssisantRename) {
  528. for (const config of this.getConfigs(resolvedEntity)) {
  529. const topic = this.getDiscoveryTopic(config, device);
  530. this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
  531. }
  532. }
  533.  
  534. this.discover(resolvedEntity, true);
  535.  
  536. if (this.discoveredTriggers[device.ieeeAddr]) {
  537. for (const config of this.discoveredTriggers[device.ieeeAddr]) {
  538. const key = config.substring(0, config.indexOf('_'));
  539. const value = config.substring(config.indexOf('_') + 1);
  540. this.publishDeviceTriggerDiscover(resolvedEntity, key, value, true);
  541. }
  542. }
  543. }
  544.  
  545. async onMQTTConnected() {
  546. this.mqtt.subscribe(this.statusTopic);
  547. this.mqtt.subscribe(defaultStatusTopic);
  548. this.mqtt.subscribe(`${this.discoveryTopic}/#`);
  549.  
  550. // MQTT discovery of all paired devices on startup.
  551. for (const device of this.zigbee.getClients()) {
  552. const resolvedEntity = this.zigbee.resolveEntity(device);
  553. this.discover(resolvedEntity, true);
  554. }
  555. }
  556.  
  557. getConfigs(resolvedEntity) {
  558. if (!resolvedEntity || !resolvedEntity.definition || !this.mapping[resolvedEntity.definition.model]) return [];
  559.  
  560. let configs = this.mapping[resolvedEntity.definition.model].slice();
  561. if (resolvedEntity.definition.hasOwnProperty('ota')) {
  562. const updateStateSensor = {
  563. type: 'sensor',
  564. object_id: 'update_state',
  565. discovery_payload: {
  566. icon: 'mdi:update',
  567. value_template: `{{ value_json['update']['state'] }}`,
  568. },
  569. };
  570.  
  571. configs.push(updateStateSensor);
  572. if (this.legacyApi) {
  573. const updateAvailableSensor = {
  574. type: 'binary_sensor',
  575. object_id: 'update_available',
  576. discovery_payload: {
  577. payload_on: true,
  578. payload_off: false,
  579. value_template: '{{ value_json.update_available}}',
  580. },
  581. };
  582. configs.push(updateAvailableSensor);
  583. }
  584. }
  585.  
  586. if (resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) {
  587. configs = configs.filter((c) => c !== sensorClick);
  588. }
  589.  
  590. if (!settings.get().advanced.homeassistant_legacy_triggers) {
  591. configs = configs.filter((c) => c.object_id !== 'action' && c.object_id !== 'click');
  592. }
  593.  
  594. // deep clone of the config objects
  595. configs = JSON.parse(JSON.stringify(configs));
  596.  
  597. if (resolvedEntity.settings.homeassistant) {
  598. const s = resolvedEntity.settings.homeassistant;
  599. configs = configs.filter((config) => !s.hasOwnProperty(config.object_id) || s[config.object_id] != null);
  600. configs.forEach((config) => {
  601. const configOverride = s[config.object_id];
  602. if (configOverride) {
  603. config.object_id = configOverride.object_id || config.object_id;
  604. config.type = configOverride.type || config.type;
  605. }
  606. });
  607. }
  608.  
  609. return configs;
  610. }
  611.  
  612. discover(resolvedEntity, force=false) {
  613. // Check if already discoverd and check if there are configs.
  614. const {device, definition} = resolvedEntity;
  615. const discover = force || !this.discovered[device.ieeeAddr];
  616. if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing ||
  617. (resolvedEntity.settings.hasOwnProperty('homeassistant') && !resolvedEntity.settings.homeassistant)) {
  618. return;
  619. }
  620.  
  621. const friendlyName = resolvedEntity.settings.friendlyName;
  622. this.getConfigs(resolvedEntity).forEach((config) => {
  623. const payload = {...config.discovery_payload};
  624. let stateTopic = `${settings.get().mqtt.base_topic}/${friendlyName}`;
  625. if (payload.state_topic_postfix) {
  626. stateTopic += `/${payload.state_topic_postfix}`;
  627. delete payload.state_topic_postfix;
  628. }
  629.  
  630. if (!payload.hasOwnProperty('state_topic') || payload.state_topic) {
  631. payload.state_topic = stateTopic;
  632. } else {
  633. /* istanbul ignore else */
  634. if (payload.hasOwnProperty('state_topic')) {
  635. delete payload.state_topic;
  636. }
  637. }
  638.  
  639. if (payload.position_topic) {
  640. payload.position_topic = stateTopic;
  641. }
  642.  
  643. if (payload.tilt_status_topic) {
  644. payload.tilt_status_topic = stateTopic;
  645. }
  646.  
  647. payload.json_attributes_topic = stateTopic;
  648.  
  649. // Set (unique) name, separate by space if friendlyName contains space.
  650. const nameSeparator = friendlyName.includes('_') ? '_' : ' ';
  651. payload.name = friendlyName;
  652. if (config.object_id.startsWith(config.type) && config.object_id.includes('_')) {
  653. payload.name += `${nameSeparator}${config.object_id.split(/_(.+)/)[1]}`;
  654. } else if (!config.object_id.startsWith(config.type)) {
  655. payload.name += `${nameSeparator}${config.object_id.replace(/_/g, nameSeparator)}`;
  656. }
  657.  
  658. // Set unique_id
  659. payload.unique_id = `${resolvedEntity.settings.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
  660.  
  661. // Attributes for device registry
  662. payload.device = this.getDevicePayload(resolvedEntity);
  663.  
  664. // Availability payload
  665. payload.availability = [{topic: `${settings.get().mqtt.base_topic}/bridge/state`}];
  666. if (settings.get().advanced.availability_timeout) {
  667. payload.availability.push({topic: `${settings.get().mqtt.base_topic}/${friendlyName}/availability`});
  668. }
  669.  
  670. if (payload.command_topic) {
  671. payload.command_topic = `${settings.get().mqtt.base_topic}/${friendlyName}/`;
  672.  
  673. if (payload.command_topic_prefix) {
  674. payload.command_topic += `${payload.command_topic_prefix}/`;
  675. delete payload.command_topic_prefix;
  676. }
  677.  
  678. payload.command_topic += 'set';
  679.  
  680. if (payload.command_topic_postfix) {
  681. payload.command_topic += `/${payload.command_topic_postfix}`;
  682. delete payload.command_topic_postfix;
  683. }
  684. }
  685.  
  686. if (payload.set_position_topic && payload.command_topic) {
  687. payload.set_position_topic = payload.command_topic;
  688. }
  689.  
  690. if (payload.tilt_command_topic && payload.command_topic) {
  691. // Home Assistant does not support templates to set tilt (as of 2019-08-17),
  692. // so we (have to) use a subtopic.
  693. payload.tilt_command_topic = payload.command_topic + '/tilt';
  694. }
  695.  
  696. if (payload.mode_state_topic) {
  697. payload.mode_state_topic = stateTopic;
  698. }
  699.  
  700. if (payload.mode_command_topic) {
  701. payload.mode_command_topic = `${stateTopic}/set/system_mode`;
  702. }
  703.  
  704. if (payload.hold_command_topic) {
  705. payload.hold_command_topic = `${stateTopic}/set/preset`;
  706. }
  707.  
  708. if (payload.hold_state_topic) {
  709. payload.hold_state_topic = stateTopic;
  710. }
  711.  
  712. if (payload.away_mode_state_topic) {
  713. payload.away_mode_state_topic = stateTopic;
  714. }
  715.  
  716. if (payload.away_mode_command_topic) {
  717. payload.away_mode_command_topic = `${stateTopic}/set/away_mode`;
  718. }
  719.  
  720. if (payload.current_temperature_topic) {
  721. payload.current_temperature_topic = stateTopic;
  722. }
  723.  
  724. if (payload.temperature_state_topic) {
  725. payload.temperature_state_topic = stateTopic;
  726. }
  727.  
  728. if (payload.temperature_low_state_topic) {
  729. payload.temperature_low_state_topic = stateTopic;
  730. }
  731.  
  732. if (payload.temperature_high_state_topic) {
  733. payload.temperature_high_state_topic = stateTopic;
  734. }
  735.  
  736. if (payload.speed_state_topic) {
  737. payload.speed_state_topic = stateTopic;
  738. }
  739.  
  740. if (payload.temperature_command_topic) {
  741. payload.temperature_command_topic = `${stateTopic}/set/${payload.temperature_command_topic}`;
  742. }
  743.  
  744. if (payload.temperature_low_command_topic) {
  745. payload.temperature_low_command_topic = `${stateTopic}/set/${payload.temperature_low_command_topic}`;
  746. }
  747.  
  748. if (payload.temperature_high_command_topic) {
  749. payload.temperature_high_command_topic = `${stateTopic}/set/${payload.temperature_high_command_topic}`;
  750. }
  751.  
  752. if (payload.fan_mode_state_topic) {
  753. payload.fan_mode_state_topic = stateTopic;
  754. }
  755.  
  756. if (payload.fan_mode_command_topic) {
  757. payload.fan_mode_command_topic = `${stateTopic}/set/fan_mode`;
  758. }
  759.  
  760. if (payload.speed_command_topic) {
  761. payload.speed_command_topic = `${stateTopic}/set/fan_mode`;
  762. }
  763.  
  764. if (payload.action_topic) {
  765. payload.action_topic = stateTopic;
  766. }
  767.  
  768. // Override configuration with user settings.
  769. if (resolvedEntity.settings.hasOwnProperty('homeassistant')) {
  770. const add = (obj) => {
  771. Object.keys(obj).forEach((key) => {
  772. if (['type', 'object_id'].includes(key)) {
  773. return;
  774. } else if (['number', 'string', 'boolean'].includes(typeof obj[key])) {
  775. payload[key] = obj[key];
  776. } else if (obj[key] === null) {
  777. delete payload[key];
  778. } else if (key === 'device' && typeof obj[key] === 'object') {
  779. Object.keys(obj['device']).forEach((key) => {
  780. payload['device'][key] = obj['device'][key];
  781. });
  782. }
  783. });
  784. };
  785.  
  786. add(resolvedEntity.settings.homeassistant);
  787.  
  788. if (resolvedEntity.settings.homeassistant.hasOwnProperty(config.object_id)) {
  789. add(resolvedEntity.settings.homeassistant[config.object_id]);
  790. }
  791. }
  792.  
  793. const topic = this.getDiscoveryTopic(config, device);
  794. this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false);
  795. });
  796.  
  797. this.discovered[device.ieeeAddr] = true;
  798. }
  799.  
  800. onMQTTMessage(topic, message) {
  801. const discoveryRegex = new RegExp(`${this.discoveryTopic}/(.*)/(.*)/(.*)/config`);
  802. const discoveryMatch = topic.match(discoveryRegex);
  803. const isDeviceAutomation = discoveryMatch && discoveryMatch[1] === 'device_automation';
  804. if (discoveryMatch) {
  805. // Clear outdated discovery configs and remember already discoverd device_automations
  806. try {
  807. message = JSON.parse(message);
  808. const baseTopic = settings.get().mqtt.base_topic + '/';
  809. if (isDeviceAutomation && (!message.topic || !message.topic.startsWith(baseTopic))) {
  810. return;
  811. }
  812.  
  813. if (!isDeviceAutomation &&
  814. (!message.availability || !message.availability[0].topic.startsWith(baseTopic))) {
  815. return;
  816. }
  817. } catch (e) {
  818. return;
  819. }
  820.  
  821. const ieeeAddr = discoveryMatch[2];
  822. const resolvedEntity = this.zigbee.resolveEntity(ieeeAddr);
  823. let clear = !resolvedEntity || !resolvedEntity.definition;
  824.  
  825. // Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml
  826. if (resolvedEntity) {
  827. const key = `${discoveryMatch[3].substring(0, discoveryMatch[3].indexOf('_'))}`;
  828. const triggerTopic = `${settings.get().mqtt.base_topic}/${resolvedEntity.name}/${key}`;
  829. if (isDeviceAutomation && message.topic === triggerTopic) {
  830. if (!this.discoveredTriggers[ieeeAddr]) {
  831. this.discoveredTriggers[ieeeAddr] = new Set();
  832. }
  833. this.discoveredTriggers[ieeeAddr].add(discoveryMatch[3]);
  834. }
  835. }
  836.  
  837. if (!clear && !isDeviceAutomation) {
  838. const type = discoveryMatch[1];
  839. const objectID = discoveryMatch[3];
  840. clear = !this.getConfigs(resolvedEntity).find((c) => c.type === type && c.object_id === objectID);
  841. }
  842. // Device was flagged to be excluded from homeassistant discovery
  843. clear = clear || (resolvedEntity.settings.hasOwnProperty('homeassistant') &&
  844. !resolvedEntity.settings.homeassistant);
  845.  
  846. if (clear) {
  847. logger.debug(`Clearing Home Assistant config '${topic}'`);
  848. topic = topic.substring(this.discoveryTopic.length + 1);
  849. this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false);
  850. }
  851. } else if ((topic === this.statusTopic || topic === defaultStatusTopic) && message.toLowerCase() === 'online') {
  852. const timer = setTimeout(async () => {
  853. // Publish all device states.
  854. for (const device of this.zigbee.getClients()) {
  855. if (this.state.exists(device.ieeeAddr)) {
  856. this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr));
  857. }
  858. }
  859.  
  860. clearTimeout(timer);
  861. }, 30000);
  862. }
  863. }
  864.  
  865. onZigbeeEvent(type, data, resolvedEntity) {
  866. if (resolvedEntity && type !== 'deviceLeave' && this.mqtt.isConnected()) {
  867. this.discover(resolvedEntity);
  868. }
  869. }
  870.  
  871. getDevicePayload(resolvedEntity) {
  872. return {
  873. identifiers: [`zigbee2mqtt_${resolvedEntity.settings.ID}`],
  874. name: resolvedEntity.settings.friendlyName,
  875. sw_version: `Zigbee2MQTT ${zigbee2mqttVersion}`,
  876. model: `${resolvedEntity.definition.description} (${resolvedEntity.definition.model})`,
  877. manufacturer: resolvedEntity.definition.vendor,
  878. };
  879. }
  880.  
  881. getDiscoveryTopic(config, device) {
  882. return `${config.type}/${device.ieeeAddr}/${config.object_id}/config`;
  883. }
  884.  
  885. async publishDeviceTriggerDiscover(entity, key, value, force=false) {
  886. if (entity.settings.hasOwnProperty('homeassistant') &&
  887. (entity.settings.homeassistant == null || entity.settings.homeassistant.device_automation == null)) {
  888. return;
  889. }
  890.  
  891. const device = entity.device;
  892. if (!this.discoveredTriggers[device.ieeeAddr]) {
  893. this.discoveredTriggers[device.ieeeAddr] = new Set();
  894. }
  895.  
  896. const discoveredKey = `${key}_${value}`;
  897. if (this.discoveredTriggers[device.ieeeAddr].has(discoveredKey) && !force) {
  898. return;
  899. }
  900.  
  901. const config = {
  902. type: 'device_automation',
  903. object_id: `${key}_${value}`,
  904. discovery_payload: {
  905. automation_type: 'trigger',
  906. type: key,
  907. },
  908. };
  909.  
  910. const topic = this.getDiscoveryTopic(config, device);
  911. const payload = {
  912. ...config.discovery_payload,
  913. subtype: value,
  914. payload: value,
  915. topic: `${settings.get().mqtt.base_topic}/${entity.name}/${key}`,
  916. device: this.getDevicePayload(entity),
  917. };
  918.  
  919. await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false);
  920. this.discoveredTriggers[device.ieeeAddr].add(discoveredKey);
  921. }
  922.  
  923. // Only for homeassistant.test.js
  924. _getMapping() {
  925. return this.mapping;
  926. }
  927.  
  928. _clearDiscoveredTrigger() {
  929. this.discoveredTriggers = new Set();
  930. }
  931. }
  932.  
  933. module.exports = HomeAssistant;
  934.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement