Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] Add support for Home Assistant event entities #24233

Merged
merged 4 commits into from
Nov 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -430,6 +441,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 @@ -461,6 +473,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 @@ -1141,6 +1154,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,
Koenkk marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -1248,9 +1300,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 @@ -1532,7 +1587,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 @@ -2162,4 +2217,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