Skip to content

Commit

Permalink
fix: Home Assistant event entities, part 2 (#24717)
Browse files Browse the repository at this point in the history
* Add Philips Hue Tap dial rotation

* Add support for Hue Tap buttons

* Refactor action parsing
  • Loading branch information
mundschenk-at authored Nov 11, 2024
1 parent 32e2637 commit 22e13c5
Show file tree
Hide file tree
Showing 2 changed files with 75 additions and 43 deletions.
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}}`;

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

0 comments on commit 22e13c5

Please sign in to comment.