Skip to content

Commit

Permalink
feat: Add experimental support for Home Assistant event entities (#…
Browse files Browse the repository at this point in the history
…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 <[email protected]>
  • Loading branch information
mundschenk-at and Koenkk authored Nov 3, 2024
1 parent c078ccb commit 848f250
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 4 deletions.
98 changes: 95 additions & 3 deletions lib/extension/homeassistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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}')`);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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('*')));
}
}
1 change: 1 addition & 0 deletions lib/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ declare global {
status_topic: string;
legacy_entity_attributes: boolean;
legacy_triggers: boolean;
experimental_event_entities: boolean;
};
permit_join: boolean;
availability?: {
Expand Down
6 changes: 6 additions & 0 deletions lib/util/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion lib/util/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
89 changes: 89 additions & 0 deletions test/homeassistant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions test/settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit 848f250

Please sign in to comment.