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

Home Assistant event entities, part 2 #24717

Merged
merged 3 commits into from
Nov 11, 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
109 changes: 68 additions & 41 deletions lib/extension/homeassistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ interface ActionData {
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 ACTION_PATTERNS: string[] = [
'^(?<button>(?:button_)?[a-z0-9]+)_(?<action>(?:press|hold)(?:_release)?)$',
'^(?<action>recall|scene)_(?<scene>[0-2][0-9]{0,2})$',
'^(?<actionPrefix>region_)(?<region>[1-9]|10)_(?<action>enter|leave|occupied|unoccupied)$',
'^(?<action>dial_rotate)_(?<direction>left|right)_(?<speed>step|slow|fast)$',
'^(?<action>brightness_step)(?:_(?<direction>up|down))?$',
];

const SENSOR_CLICK: Readonly<DiscoveryEntry> = {
type: 'sensor',
Expand Down Expand Up @@ -455,6 +459,7 @@ export default class HomeAssistant extends Extension {
private bridge: Bridge;
// @ts-expect-error initialized in `start`
private bridgeIdentifier: string;
private actionValueTemplate: string;

constructor(
zigbee: Zigbee,
Expand Down Expand Up @@ -482,6 +487,8 @@ export default class HomeAssistant extends Extension {
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}')`);
}

this.actionValueTemplate = this.getActionValueTemplate();
}

override async start(): Promise<void> {
Expand Down Expand Up @@ -1177,22 +1184,7 @@ export default class HomeAssistant extends Extension {
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}}`,
value_template: this.actionValueTemplate,
...ENUM_DISCOVERY_LOOKUP[firstExpose.name],
},
});
Expand Down Expand Up @@ -2224,39 +2216,74 @@ export default class HomeAssistant extends Extension {
}

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};
// Handle standard actions.
for (const p of ACTION_PATTERNS) {
const m = action.match(p);
if (m?.groups?.action) {
return this.buildAction(m.groups);
}
}

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};
// Handle wildcard actions.
let m = action.match(/^(?<action>recall|scene)_\*(?:_(?<endpoint>e1|e2|s1|s2))?$/);
if (m?.groups?.action) {
logger.debug('Found scene wildcard action ' + m.groups.action);
return this.buildAction(m.groups, {scene: 'wildcard'});
}

const regions = action.match(ACTION_REGION_PATTERN);
if (regions?.groups?.action) {
return {...regions.groups, action: 'region_' + regions.groups.action};
m = action.match(/^(?<actionPrefix>region_)\*_(?<action>enter|leave|occupied|unoccupied)$/);
if (m?.groups?.action) {
logger.debug('Found region wildcard action ' + m.groups.action);
return this.buildAction(m.groups, {region: 'wildcard'});
}

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'};
}
// If nothing matches, keep the plain action value.
return {action};
}

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'};
}
private buildAction(groups: {[key: string]: string}, props: {[key: string]: string} = {}): ActionData {
utils.removeNullPropertiesFromObject(groups);

return {action};
let a: string = groups.action;
if (groups?.actionPrefix) {
a = groups.actionPrefix + a;
delete groups.actionPrefix;
}
return {...groups, action: a, ...props};
}

private prepareActionEventTypes(values: zhc.Enum['values']): string[] {
return utils.arrayUnique(values.map((v) => this.parseActionValue(v.toString()).action).filter((v) => !v.includes('*')));
}

private parseGroupsFromRegex(pattern: string): string[] {
return [...pattern.matchAll(/\(\?<([a-zA-Z]+)>/g)].map((v) => v[1]);
}

private getActionValueTemplate(): string {
// TODO: Implement parsing for all event types.
const patterns = ACTION_PATTERNS.map((v) => {
return `{"pattern": '${v.replaceAll(/\?<([a-zA-Z]+)>/g, '?P<$1>')}', "groups": [${this.parseGroupsFromRegex(v)
.map((g) => `"${g}"`)
.join(', ')}]}`;
}).join(',\n');

const value_template =
`{% set patterns = [\n${patterns}\n] %}\n` +
`{% set ns = namespace(r=[('event_type', value_json.action)]) %}\n` +
`{% for p in patterns %}\n` +
` {% set m = value_json.action|regex_findall(p.pattern) %}\n` +
` {% if m[0] is undefined %}{% continue %}{% endif %}\n` +
` {% for key, value in zip(p.groups, m[0]) %}\n` +
` {% set ns.r = ns.r + [(key, value)] %}\n` +
` {% endfor %}\n` +
`{% endfor %}\n` +
`{% if ns.r|selectattr(0, 'eq', 'actionPrefix')|first is defined %}\n` +
` {% set ns.r = ns.r|rejectattr(0, 'eq', 'action')|list + [('action', ns.r|selectattr(0, 'eq', 'actionPrefix')|map(attribute=1)|first + ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n` +
`{% endif %}\n` +
`{% set ns.r = ns.r + [('event_type', ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n` +
`{{dict.from_keys(ns.r|rejectattr(0, 'in', 'action, actionPrefix'))|to_json}}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mundschenk-at: I'm working on making openHAB compatible with event entities from z2m. I've worked through several constructs Jinjava/openHAB didn't previously support, but now I'm working on dict.from_keys. AFAICT, this is not the Python built in dict.fromkeys method (that method would set the value of all entries to None, and testing this template in Home Assistant does not have that issue), but I can't find it explicitly defined in Home Assistant core's template extensions nor in Jinja itself. Do you know where it comes from, and can point me to source code or documentation on it? Or at least a high level description? My guess is that it takes a list of lists (of two items), and returns a new dict with each of those as key and value entries?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ccutrer HA uses Jinja2, so I assume it would be a builtin (builtin-globals.dict)? I've had a look at the source on GitHub and could not really make heads or tails of it. I just got the usage from the HA forum. However, I've no experimented and it appears the from_keys is unnecessary, you can simple use the dict() constructor to create the dictionary from the list of tuples. I'll simplify the template, once we have decided how to continue with event.

{{dict(ns.r|rejectattr(0, 'in', 'action, actionPrefix'))|to_json}}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh that would be good. dict.from_keys doesn't exist in the base python3/jinja2 install on my Ubuntu box, but it does work within Home Assistant running on a pi.


return value_template;
}
}
9 changes: 7 additions & 2 deletions test/homeassistant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ describe('HomeAssistant extension', () => {
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}}',
'{% set patterns = [\n{"pattern": \'^(?P<button>(?:button_)?[a-z0-9]+)_(?P<action>(?:press|hold)(?:_release)?)$\', "groups": ["button", "action"]},\n{"pattern": \'^(?P<action>recall|scene)_(?P<scene>[0-2][0-9]{0,2})$\', "groups": ["action", "scene"]},\n{"pattern": \'^(?P<actionPrefix>region_)(?P<region>[1-9]|10)_(?P<action>enter|leave|occupied|unoccupied)$\', "groups": ["actionPrefix", "region", "action"]},\n{"pattern": \'^(?P<action>dial_rotate)_(?P<direction>left|right)_(?P<speed>step|slow|fast)$\', "groups": ["action", "direction", "speed"]},\n{"pattern": \'^(?P<action>brightness_step)(?:_(?P<direction>up|down))?$\', "groups": ["action", "direction"]}\n] %}\n{% set ns = namespace(r=[(\'event_type\', value_json.action)]) %}\n{% for p in patterns %}\n {% set m = value_json.action|regex_findall(p.pattern) %}\n {% if m[0] is undefined %}{% continue %}{% endif %}\n {% for key, value in zip(p.groups, m[0]) %}\n {% set ns.r = ns.r + [(key, value)] %}\n {% endfor %}\n{% endfor %}\n{% if ns.r|selectattr(0, \'eq\', \'actionPrefix\')|first is defined %}\n {% set ns.r = ns.r|rejectattr(0, \'eq\', \'action\')|list + [(\'action\', ns.r|selectattr(0, \'eq\', \'actionPrefix\')|map(attribute=1)|first + ns.r|selectattr(0, \'eq\', \'action\')|map(attribute=1)|first)] %}\n{% endif %}\n{% set ns.r = ns.r + [(\'event_type\', ns.r|selectattr(0, \'eq\', \'action\')|map(attribute=1)|first)] %}\n{{dict.from_keys(ns.r|rejectattr(0, \'in\', \'action, actionPrefix\'))|to_json}}',
};

expect(MQTT.publish).toHaveBeenCalledWith(
Expand All @@ -451,6 +451,11 @@ describe('HomeAssistant extension', () => {
['left_press_release', {action: 'press_release', button: 'left'}],
['right_hold', {action: 'hold', button: 'right'}],
['right_hold_release', {action: 'hold_release', button: 'right'}],
['button_4_hold_release', {action: 'hold_release', button: 'button_4'}],
['dial_rotate_left_step', {action: 'dial_rotate', direction: 'left', speed: 'step'}],
['dial_rotate_right_fast', {action: 'dial_rotate', direction: 'right', speed: 'fast'}],
['brightness_step_up', {action: 'brightness_step', direction: 'up'}],
['brightness_stop', {action: 'brightness_stop'}],
])('Should parse action names correctly', (action, expected) => {
expect(extension.parseActionValue(action)).toStrictEqual(expected);
});
Expand Down Expand Up @@ -1993,7 +1998,7 @@ describe('HomeAssistant extension', () => {
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}}',
'{% set patterns = [\n{"pattern": \'^(?P<button>(?:button_)?[a-z0-9]+)_(?P<action>(?:press|hold)(?:_release)?)$\', "groups": ["button", "action"]},\n{"pattern": \'^(?P<action>recall|scene)_(?P<scene>[0-2][0-9]{0,2})$\', "groups": ["action", "scene"]},\n{"pattern": \'^(?P<actionPrefix>region_)(?P<region>[1-9]|10)_(?P<action>enter|leave|occupied|unoccupied)$\', "groups": ["actionPrefix", "region", "action"]},\n{"pattern": \'^(?P<action>dial_rotate)_(?P<direction>left|right)_(?P<speed>step|slow|fast)$\', "groups": ["action", "direction", "speed"]},\n{"pattern": \'^(?P<action>brightness_step)(?:_(?P<direction>up|down))?$\', "groups": ["action", "direction"]}\n] %}\n{% set ns = namespace(r=[(\'event_type\', value_json.action)]) %}\n{% for p in patterns %}\n {% set m = value_json.action|regex_findall(p.pattern) %}\n {% if m[0] is undefined %}{% continue %}{% endif %}\n {% for key, value in zip(p.groups, m[0]) %}\n {% set ns.r = ns.r + [(key, value)] %}\n {% endfor %}\n{% endfor %}\n{% if ns.r|selectattr(0, \'eq\', \'actionPrefix\')|first is defined %}\n {% set ns.r = ns.r|rejectattr(0, \'eq\', \'action\')|list + [(\'action\', ns.r|selectattr(0, \'eq\', \'actionPrefix\')|map(attribute=1)|first + ns.r|selectattr(0, \'eq\', \'action\')|map(attribute=1)|first)] %}\n{% endif %}\n{% set ns.r = ns.r + [(\'event_type\', ns.r|selectattr(0, \'eq\', \'action\')|map(attribute=1)|first)] %}\n{{dict.from_keys(ns.r|rejectattr(0, \'in\', \'action, actionPrefix\'))|to_json}}',
};

expect(MQTT.publish).toHaveBeenCalledWith(
Expand Down