mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-29 07:08:56 +00:00
feat: Add experimental support for Home Assistant event entities (#24233)
* Expose new event entity for actions * Unify exposed actions for HA * Fix event tests * Only add event entities wen homeassistant/experimental_event_entities is true --------- Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
This commit is contained in:
co-authored by
Koen Kanters
parent
c078ccb136
commit
848f250dbb
@@ -29,6 +29,17 @@ interface Discovered {
|
||||
discovered: boolean;
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
action: string;
|
||||
button?: string;
|
||||
scene?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
const ACTION_BUTTON_PATTERN: string = '^(?<button>[a-z]+)_(?<action>(?:press|hold)(?:_release)?)$';
|
||||
const ACTION_SCENE_PATTERN: string = '^(?<action>recall|scene)_(?<scene>[0-2][0-9]{0,2})$';
|
||||
const ACTION_REGION_PATTERN: string = '^region_(?<region>[1-9]|10)_(?<action>enter|leave|occupied|unoccupied)$';
|
||||
|
||||
const SENSOR_CLICK: Readonly<DiscoveryEntry> = {
|
||||
type: 'sensor',
|
||||
object_id: 'click',
|
||||
@@ -435,6 +446,7 @@ export default class HomeAssistant extends Extension {
|
||||
private statusTopic: string;
|
||||
private entityAttributes: boolean;
|
||||
private legacyTrigger: boolean;
|
||||
private experimentalEventEntities: boolean;
|
||||
// @ts-expect-error initialized in `start`
|
||||
private zigbee2MQTTVersion: string;
|
||||
// @ts-expect-error initialized in `start`
|
||||
@@ -466,6 +478,7 @@ export default class HomeAssistant extends Extension {
|
||||
this.statusTopic = haSettings.status_topic;
|
||||
this.entityAttributes = haSettings.legacy_entity_attributes;
|
||||
this.legacyTrigger = haSettings.legacy_triggers;
|
||||
this.experimentalEventEntities = haSettings.experimental_event_entities;
|
||||
if (haSettings.discovery_topic === settings.get().mqtt.base_topic) {
|
||||
throw new Error(`'homeassistant.discovery_topic' cannot not be equal to the 'mqtt.base_topic' (got '${settings.get().mqtt.base_topic}')`);
|
||||
}
|
||||
@@ -1146,6 +1159,45 @@ export default class HomeAssistant extends Extension {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If enum attribute does not have SET access and is named 'action', then expose
|
||||
* as EVENT entity. Wildcard actions like `recall_*` are currently not supported.
|
||||
*/
|
||||
if (
|
||||
this.experimentalEventEntities &&
|
||||
firstExpose.access & ACCESS_STATE &&
|
||||
!(firstExpose.access & ACCESS_SET) &&
|
||||
firstExpose.property == 'action'
|
||||
) {
|
||||
discoveryEntries.push({
|
||||
type: 'event',
|
||||
object_id: firstExpose.property,
|
||||
mockProperties: [{property: firstExpose.property, value: null}],
|
||||
discovery_payload: {
|
||||
name: endpoint ? /* istanbul ignore next */ `${firstExpose.label} ${endpoint}` : firstExpose.label,
|
||||
state_topic: true,
|
||||
event_types: this.prepareActionEventTypes(firstExpose.values),
|
||||
|
||||
// TODO: Implement parsing for all event types.
|
||||
value_template:
|
||||
`{%- set buttons = value_json.action|regex_findall_index(${ACTION_BUTTON_PATTERN.replaceAll(/\?<([a-z]+)>/g, '?P<$1>')}) -%}` +
|
||||
`{%- set scenes = value_json.action|regex_findall_index(${ACTION_SCENE_PATTERN.replaceAll(/\?<([a-z]+)>/g, '?P<$1>')}) -%}` +
|
||||
`{%- set regions = value_json.action|regex_findall_index(${ACTION_REGION_PATTERN.replaceAll(/\?<([a-z]+)>/g, '?P<$1>')}) -%}` +
|
||||
`{%- if buttons -%}\n` +
|
||||
` {%- set d = dict(event_type = "{{buttons[1]}}", button = "{{buttons[0]}}_button" -%}\n` +
|
||||
`{%- elif scenes -%}\n` +
|
||||
` {%- set d = dict(event_type = "{{scenes[0]}}", scene = "{{scenes[1]}}" -%}\n` +
|
||||
`{%- elif regions -%}\n` +
|
||||
` {%- set d = dict(event_type = "region_{{regions[1]}}", region = "{{regions[0]}}" -%}\n` +
|
||||
`{%- else -%}\n` +
|
||||
` {%- set d = dict(event_type = "{{value_json.action}}" ) -%}\n` +
|
||||
`{%- endif -%}\n` +
|
||||
`{{d|to_json}}`,
|
||||
...ENUM_DISCOVERY_LOOKUP[firstExpose.name],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If enum attribute has SET access then expose as SELECT entity too.
|
||||
* Note: currently both sensor and select are discovered, this is to avoid
|
||||
@@ -1253,9 +1305,12 @@ export default class HomeAssistant extends Extension {
|
||||
if (['binary_sensor', 'sensor'].includes(d.type) && d.discovery_payload.entity_category === 'config') {
|
||||
d.discovery_payload.entity_category = 'diagnostic';
|
||||
}
|
||||
});
|
||||
|
||||
discoveryEntries.forEach((d) => {
|
||||
// Event entities cannot have an entity_category set.
|
||||
if (d.type === 'event' && d.discovery_payload.entity_category) {
|
||||
delete d.discovery_payload.entity_category;
|
||||
}
|
||||
|
||||
// Let Home Assistant generate entity name when device_class is present
|
||||
if (d.discovery_payload.device_class) {
|
||||
delete d.discovery_payload.name;
|
||||
@@ -1537,7 +1592,7 @@ export default class HomeAssistant extends Extension {
|
||||
}
|
||||
|
||||
if (!this.legacyTrigger) {
|
||||
configs = configs.filter((c) => c.object_id !== 'action' && c.object_id !== 'click');
|
||||
configs = configs.filter((c) => (c.object_id !== 'action' && c.object_id !== 'click') || c.type == 'event');
|
||||
}
|
||||
|
||||
// deep clone of the config objects
|
||||
@@ -2167,4 +2222,41 @@ export default class HomeAssistant extends Extension {
|
||||
|
||||
return bridge;
|
||||
}
|
||||
|
||||
private parseActionValue(action: string): ActionData {
|
||||
const buttons = action.match(ACTION_BUTTON_PATTERN);
|
||||
if (buttons?.groups?.action) {
|
||||
//console.log('Recognized button actions', buttons.groups);
|
||||
return {...buttons.groups, action: buttons.groups.action};
|
||||
}
|
||||
|
||||
const scenes = action.match(ACTION_SCENE_PATTERN);
|
||||
if (scenes?.groups?.action) {
|
||||
//console.log('Recognized scene actions', scenes.groups);
|
||||
return {...scenes.groups, action: scenes.groups.action};
|
||||
}
|
||||
|
||||
const regions = action.match(ACTION_REGION_PATTERN);
|
||||
if (regions?.groups?.action) {
|
||||
return {...regions.groups, action: 'region_' + regions.groups.action};
|
||||
}
|
||||
|
||||
const sceneWildcard = action.match(/^(?<action>recall|scene)_\*$/);
|
||||
if (sceneWildcard?.groups?.action) {
|
||||
logger.debug('Found scene wildcard action ' + sceneWildcard.groups.action);
|
||||
return {action: sceneWildcard.groups.action, scene: 'wildcard'};
|
||||
}
|
||||
|
||||
const regionWildcard = action.match(/^region_\*_(?<action>enter|leave|occupied|unoccupied)$/);
|
||||
if (regionWildcard?.groups?.action) {
|
||||
logger.debug('Found region wildcard action ' + regionWildcard.groups.action);
|
||||
return {action: 'region_' + regionWildcard.groups.action, region: 'wildcard'};
|
||||
}
|
||||
|
||||
return {action};
|
||||
}
|
||||
|
||||
private prepareActionEventTypes(values: zhc.Enum['values']): string[] {
|
||||
return utils.arrayUnique(values.map((v) => this.parseActionValue(v.toString()).action).filter((v) => !v.includes('*')));
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -118,6 +118,7 @@ declare global {
|
||||
status_topic: string;
|
||||
legacy_entity_attributes: boolean;
|
||||
legacy_triggers: boolean;
|
||||
experimental_event_entities: boolean;
|
||||
};
|
||||
permit_join: boolean;
|
||||
availability?: {
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
"description": "Home Assistant status topic",
|
||||
"requiresRestart": true,
|
||||
"examples": ["homeassistant/status"]
|
||||
},
|
||||
"experimental_event_entities": {
|
||||
"type": "boolean",
|
||||
"title": "Home Assistant experimental event entities",
|
||||
"description": "Home Assistant experimental event entities, when enabled Zigbee2MQTT will add event entities for exposed actions. The events and attributes are currently deemed experimental and subject to change.",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,13 @@ function loadSettingsWithDefaults(): void {
|
||||
}
|
||||
|
||||
if (_settingsWithDefaults.homeassistant) {
|
||||
const defaults = {discovery_topic: 'homeassistant', status_topic: 'hass/status', legacy_entity_attributes: true, legacy_triggers: true};
|
||||
const defaults = {
|
||||
discovery_topic: 'homeassistant',
|
||||
status_topic: 'hass/status',
|
||||
legacy_entity_attributes: true,
|
||||
legacy_triggers: true,
|
||||
experimental_event_entities: false,
|
||||
};
|
||||
const sLegacy = {};
|
||||
if (_settingsWithDefaults.advanced) {
|
||||
for (const key of [
|
||||
|
||||
@@ -89,6 +89,9 @@ describe('HomeAssistant extension', () => {
|
||||
});
|
||||
|
||||
it('Should discover devices and groups', async () => {
|
||||
settings.set(['homeassistant'], {experimental_event_entities: true});
|
||||
await resetExtension();
|
||||
|
||||
let payload;
|
||||
|
||||
payload = {
|
||||
@@ -404,6 +407,52 @@ describe('HomeAssistant extension', () => {
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
payload = {
|
||||
availability: [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
device: {
|
||||
identifiers: ['zigbee2mqtt_0x0017880104e45520'],
|
||||
manufacturer: 'Aqara',
|
||||
model: 'Wireless mini switch (WXKG11LM)',
|
||||
name: 'button',
|
||||
sw_version: null,
|
||||
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
|
||||
},
|
||||
event_types: ['single', 'double', 'triple', 'quadruple', 'hold', 'release'],
|
||||
icon: 'mdi:gesture-double-tap',
|
||||
json_attributes_topic: 'zigbee2mqtt/button',
|
||||
name: 'Action',
|
||||
object_id: 'button_action',
|
||||
origin: origin,
|
||||
state_topic: 'zigbee2mqtt/button',
|
||||
unique_id: '0x0017880104e45520_action_zigbee2mqtt',
|
||||
// Needs to be updated whenever one of the ACTION_*_PATTERN constants changes.
|
||||
value_template:
|
||||
'{%- set buttons = value_json.action|regex_findall_index(^(?P<button>[a-z]+)_(?P<action>(?:press|hold)(?:_release)?)$) -%}{%- set scenes = value_json.action|regex_findall_index(^(?P<action>recall|scene)_(?P<scene>[0-2][0-9]{0,2})$) -%}{%- set regions = value_json.action|regex_findall_index(^region_(?P<region>[1-9]|10)_(?P<action>enter|leave|occupied|unoccupied)$) -%}{%- if buttons -%}\n {%- set d = dict(event_type = "{{buttons[1]}}", button = "{{buttons[0]}}_button" -%}\n{%- elif scenes -%}\n {%- set d = dict(event_type = "{{scenes[0]}}", scene = "{{scenes[1]}}" -%}\n{%- elif regions -%}\n {%- set d = dict(event_type = "region_{{regions[1]}}", region = "{{regions[0]}}" -%}\n{%- else -%}\n {%- set d = dict(event_type = "{{value_json.action}}" ) -%}\n{%- endif -%}\n{{d|to_json}}',
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/event/0x0017880104e45520/action/config',
|
||||
stringify(payload),
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['recall_1', {action: 'recall', scene: '1'}],
|
||||
['recall_*', {action: 'recall', scene: 'wildcard'}],
|
||||
['on', {action: 'on'}],
|
||||
['on_1', {action: 'on_1'}],
|
||||
['release_left', {action: 'release_left'}],
|
||||
['region_1_enter', {action: 'region_enter', region: '1'}],
|
||||
['region_*_leave', {action: 'region_leave', region: 'wildcard'}],
|
||||
['left_press', {action: 'press', button: 'left'}],
|
||||
['left_press_release', {action: 'press_release', button: 'left'}],
|
||||
['right_hold', {action: 'hold', button: 'right'}],
|
||||
['right_hold_release', {action: 'hold_release', button: 'right'}],
|
||||
])('Should parse action names correctly', (action, expected) => {
|
||||
expect(extension.parseActionValue(action)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('Should not discovery devices which are already discovered', async () => {
|
||||
@@ -1915,6 +1964,46 @@ describe('HomeAssistant extension', () => {
|
||||
expect(MQTT.publish).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Should enable experimental event entities', async () => {
|
||||
settings.set(['homeassistant'], {experimental_event_entities: true});
|
||||
settings.set(['devices', '0x0017880104e45520'], {
|
||||
legacy: false,
|
||||
friendly_name: 'button',
|
||||
retain: false,
|
||||
});
|
||||
await resetExtension();
|
||||
|
||||
const payload = {
|
||||
availability: [{topic: 'zigbee2mqtt/bridge/state'}],
|
||||
device: {
|
||||
identifiers: ['zigbee2mqtt_0x0017880104e45520'],
|
||||
manufacturer: 'Aqara',
|
||||
model: 'Wireless mini switch (WXKG11LM)',
|
||||
name: 'button',
|
||||
sw_version: null,
|
||||
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
|
||||
},
|
||||
event_types: ['single', 'double', 'triple', 'quadruple', 'hold', 'release'],
|
||||
icon: 'mdi:gesture-double-tap',
|
||||
json_attributes_topic: 'zigbee2mqtt/button',
|
||||
name: 'Action',
|
||||
object_id: 'button_action',
|
||||
origin: origin,
|
||||
state_topic: 'zigbee2mqtt/button',
|
||||
unique_id: '0x0017880104e45520_action_zigbee2mqtt',
|
||||
// Needs to be updated whenever one of the ACTION_*_PATTERN constants changes.
|
||||
value_template:
|
||||
'{%- set buttons = value_json.action|regex_findall_index(^(?P<button>[a-z]+)_(?P<action>(?:press|hold)(?:_release)?)$) -%}{%- set scenes = value_json.action|regex_findall_index(^(?P<action>recall|scene)_(?P<scene>[0-2][0-9]{0,2})$) -%}{%- set regions = value_json.action|regex_findall_index(^region_(?P<region>[1-9]|10)_(?P<action>enter|leave|occupied|unoccupied)$) -%}{%- if buttons -%}\n {%- set d = dict(event_type = "{{buttons[1]}}", button = "{{buttons[0]}}_button" -%}\n{%- elif scenes -%}\n {%- set d = dict(event_type = "{{scenes[0]}}", scene = "{{scenes[1]}}" -%}\n{%- elif regions -%}\n {%- set d = dict(event_type = "region_{{regions[1]}}", region = "{{regions[0]}}" -%}\n{%- else -%}\n {%- set d = dict(event_type = "{{value_json.action}}" ) -%}\n{%- endif -%}\n{{d|to_json}}',
|
||||
};
|
||||
|
||||
expect(MQTT.publish).toHaveBeenCalledWith(
|
||||
'homeassistant/event/0x0017880104e45520/action/config',
|
||||
stringify(payload),
|
||||
{retain: true, qos: 1},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should republish payload to postfix topic with lightWithPostfix config', async () => {
|
||||
MQTT.publish.mockClear();
|
||||
|
||||
|
||||
@@ -1026,6 +1026,7 @@ describe('Settings', () => {
|
||||
settings.reRead();
|
||||
expect(settings.get().homeassistant).toStrictEqual({
|
||||
discovery_topic: 'new',
|
||||
experimental_event_entities: false,
|
||||
legacy_entity_attributes: true,
|
||||
legacy_triggers: true,
|
||||
status_topic: 'olds',
|
||||
|
||||
Reference in New Issue
Block a user