From 3bf21db6432d19f710dea575c2822ac865a1f03b Mon Sep 17 00:00:00 2001 From: kennyboy55 Date: Sat, 16 May 2026 13:58:52 +0200 Subject: [PATCH] Implement button events --- README.md | 4 +- src/buttonplus_generator/constants.py | 57 ++- src/buttonplus_generator/generator.py | 28 +- src/buttonplus_generator/merger.py | 77 +++- src/buttonplus_generator/snippets/bars.py | 2 + src/buttonplus_generator/snippets/display.py | 2 + src/buttonplus_generator/snippets/events.py | 398 +++++++++++++++++++ src/buttonplus_generator/utils.py | 46 +++ 8 files changed, 596 insertions(+), 18 deletions(-) create mode 100644 src/buttonplus_generator/snippets/events.py diff --git a/README.md b/README.md index b95b170..25e143d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,5 @@ The configuration (handmade) on which this generator is based, can be found in t - Allow adding of font and icons easily ## Missing config -- Event generation (triggers and stuff, see events.yaml) - - configurable timing and button events - - Always set display to left and right page - Event lambda's (see config.yaml) +- Allow turn off click-confirm \ No newline at end of file diff --git a/src/buttonplus_generator/constants.py b/src/buttonplus_generator/constants.py index bac5c7b..7ef4b76 100644 --- a/src/buttonplus_generator/constants.py +++ b/src/buttonplus_generator/constants.py @@ -18,11 +18,60 @@ class BarSide(StrEnum): LEFT = "left" RIGHT = "right" -class ClickTypes(StrEnum): - SINGLE = "single" - DOUBLE = "double" - TRIPLE = "triple" +class ClickType(StrEnum): + SINGLE = "click" + DOUBLE = "double_click" + TRIPLE = "triple_click" HOLD = "hold" + +class Color(): + + def __init__(self, hex: str): + self.hex = hex.lstrip("#") + + offset = 0 + + if(len(self.hex) == 6): + offset = 2 + elif(len(self.hex) == 3): + offset = 1 + else: + raise ValueError("HEX must be either 3 or 6 characters long") + + self.r = int(self.hex[0:offset], 16) + self.g = int(self.hex[(offset):(offset*2)], 16) + self.b = int(self.hex[(offset*2):(offset*3)], 16) + + def hex(self) -> str: + return "#" + self.hex + + + +TIMING_SINGLE_CLICK = [ + "ON for at most 300ms", + "OFF for at least 0.2s" +] + +TIMING_DOUBLE_CLICK = [ + "ON for at most 300ms", + "OFF for at most 0.2s", + "ON for at most 300ms", + "OFF for at least 0.2s" +] + +TIMING_TRIPLE_CLICK = [ + "ON for at most 300ms", + "OFF for at most 0.2s", + "ON for at most 300ms", + "OFF for at most 0.2s", + "ON for at most 300ms", + "OFF for at least 0.2s" +] + +TIMING_HOLD_CLICK = [ + "ON for at least 1s", + "OFF for at least 0.2s" +] class YAMLExtend: def __init__(self, value): self.value = value diff --git a/src/buttonplus_generator/generator.py b/src/buttonplus_generator/generator.py index fa86835..ca5bdfe 100644 --- a/src/buttonplus_generator/generator.py +++ b/src/buttonplus_generator/generator.py @@ -10,22 +10,23 @@ from typing import Any, Dict, List import yaml -from buttonplus_generator.constants import YAMLExtend, YAMLLambda, YAMLSecret +from buttonplus_generator.constants import YAMLExtend, YAMLLambda, YAMLSecret, ClickType from buttonplus_generator.snippets.common import config_common from buttonplus_generator.snippets.bars import config_bars from buttonplus_generator.snippets.display import config_display -#from buttonplus_generator.snippets.events import config_events +from buttonplus_generator.snippets.events import config_events from buttonplus_generator.merger import deep_merge +from buttonplus_generator.utils import list_to_click_type -def compose_config(device_name: str, version: int, num_bars: int, pages: List[str], click_types: List[str]) -> Dict[str, Any]: +def compose_config(device_name: str, version: int, num_bars: int, pages: List[str], click_types: List[ClickType], click_confirm: bool) -> Dict[str, Any]: config = config_common(device_name=device_name, version=version, page_names=pages, num_bars=num_bars) if(num_bars < 4): # Always assume display if less than 4 bars config = deep_merge(config, config_display(version=version, num_bars=num_bars, page_names=pages)) config = deep_merge(config, config_bars(version=version, num_bars=num_bars, page_names=pages)) - #config = deep_merge(config, config_events(version=version, num_bars=num_bars, page_names=pages, click_types=click_types)) + config = deep_merge(config, config_events(version=version, num_bars=num_bars, page_names=pages, click_types=click_types, click_confirm=click_confirm)) return config @@ -47,14 +48,18 @@ def yaml_dumper(config: Dict[str, Any], fh) -> str: return dumper.represent_scalar('!lambda', data.value, style='"') def represent_secret(dumper, data): - return dumper.represent_scalar('!secret', data.value, style="'") + return dumper.represent_scalar('!secret', data.value, style='') yaml.add_representer(str, represent_multistr) yaml.add_representer(YAMLExtend, represent_extend) yaml.add_representer(YAMLLambda, represent_lambda) yaml.add_representer(YAMLSecret, represent_secret) - yaml.dump(config, fh, sort_keys=False, encoding="utf-8", default_flow_style=False) + class NoAliasDumper(yaml.Dumper): + def ignore_aliases(self, data): + return True + + yaml.dump(config, fh, sort_keys=False, encoding="utf-8", default_flow_style=False, Dumper=NoAliasDumper) def main(argv=None) -> int: parser = argparse.ArgumentParser( @@ -100,6 +105,12 @@ def main(argv=None) -> int: help="List click types buttons, choices are [single|double|triple|hold].", metavar="TYPE" ) + parser.add_argument( + "--no-click-confirm", + default=True, + action="store_false", + help="Dont' flash the LED as a confirmation of the click event." + ) parser.add_argument( "--output", "-o", default="buttonplus.yaml", help="Output file path" ) @@ -109,10 +120,11 @@ def main(argv=None) -> int: version: int = args.version num_bars: int = args.bars pages: List[str] = args.pages - click_types: List[str] = args.click_types + click_types: List[ClickType] = list_to_click_type(args.click_types) + click_confirm: bool = args.click_confirm output: str = args.output - config = compose_config(device_name, version, num_bars, pages, click_types) + config = compose_config(device_name, version, num_bars, pages, click_types, click_confirm) with open(output, "w", encoding="utf-8") as fh: fh.writelines( [ diff --git a/src/buttonplus_generator/merger.py b/src/buttonplus_generator/merger.py index 3d1522f..6713ac3 100644 --- a/src/buttonplus_generator/merger.py +++ b/src/buttonplus_generator/merger.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, List def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: @@ -8,13 +8,84 @@ def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: """ result = dict(a) for k, v in b.items(): + # Key from b also in a if k in result: + # a[k] and b[k] are both a dictionary: recursive deep_merge if isinstance(result[k], dict) and isinstance(v, dict): result[k] = deep_merge(result[k], v) + # a[k] and b[k] are both a list: list_merge elif isinstance(result[k], list) and isinstance(v, list): - result[k] = result[k] + v + result[k] = list_merge(result[k], v) else: result[k] = v else: result[k] = v - return result \ No newline at end of file + return result + +def list_merge(a: List[Any], b: List[Any]) -> List[Any]: + """ + Merge two list together based on id's. This is similar to !extend in YAML. + + Steps: + 1. Check if both lists contain Dicts with an `id` key in them + 1a. Otherwise just return a + b (extend) + 2. For every entry in a: + 3. Store id in a list for later + 4. Find the same id in b, if not found add to result without merge + 5. Perform a deep_merge on the dictionary, add to result list + 6. Ensure there are no id's missing from b, by checking every entry agains the list from step 3 + 7. Return result + """ + + # If either list is empty just concatenate + if not a or not b: + return list(a) + list(b) + + # Detect whether both lists contain dicts with an 'id' key + a_has_id = any(isinstance(x, dict) and "id" in x for x in a) + b_has_id = any(isinstance(x, dict) and "id" in x for x in b) + + # If there are no id-based dicts in either list, just extend + if not (a_has_id and b_has_id): + return list(a) + list(b) + + result: List[Any] = [] + seen_ids = set() + + # Merge entries from `a`, matching by `id` in `b` when present + for item in a: + if isinstance(item, dict) and "id" in item: + id_val = item.get("id") + # find matching entry in b + match = None + for bi in b: + if isinstance(bi, dict) and bi.get("id") == id_val: + match = bi + break + + if match is not None: + # deep_merge returns a new dict + merged = deep_merge(item, match) + result.append(merged) + else: + result.append(item) + + if id_val is not None: + seen_ids.add(id_val) + else: + # Not an id'd dict, keep as-is + result.append(item) + + # Add remaining entries from b that were not merged or that don't have ids + for item in b: + if isinstance(item, dict) and "id" in item: + id_val = item.get("id") + if id_val is None: + result.append(item) + elif id_val not in seen_ids: + result.append(item) + else: + result.append(item) + + return result + \ No newline at end of file diff --git a/src/buttonplus_generator/snippets/bars.py b/src/buttonplus_generator/snippets/bars.py index a19be77..0021f90 100644 --- a/src/buttonplus_generator/snippets/bars.py +++ b/src/buttonplus_generator/snippets/bars.py @@ -147,6 +147,7 @@ def _bar_button_entry(bar_nr: int, base_id: str) -> List[Dict[str, Any]]: "platform": "gpio", "id": f"bar{bar_nr}_btn_left", "name": f"BAR {bar_nr} button Left", + "internal": True, "pin": { "mcp23xxx": base_id, "number": 6, @@ -158,6 +159,7 @@ def _bar_button_entry(bar_nr: int, base_id: str) -> List[Dict[str, Any]]: "platform": "gpio", "id": f"bar{bar_nr}_btn_right", "name": f"BAR {bar_nr} button Right", + "internal": True, "pin": { "mcp23xxx": base_id, "number": 2, diff --git a/src/buttonplus_generator/snippets/display.py b/src/buttonplus_generator/snippets/display.py index 1bb9a89..fed7fcb 100644 --- a/src/buttonplus_generator/snippets/display.py +++ b/src/buttonplus_generator/snippets/display.py @@ -88,6 +88,7 @@ def display_buttons(num_bars: int) -> Dict[str, Any]: "platform": "gpio", "id": "main_display_btn_left", "name": "Main Display button Left", + "internal": True, "pin": { "mcp23xxx": get_display_id(num_bars), "number": 6, @@ -99,6 +100,7 @@ def display_buttons(num_bars: int) -> Dict[str, Any]: "platform": "gpio", "id": "main_display_btn_right", "name": "Main Display button Right", + "internal": True, "pin": { "mcp23xxx": get_display_id(num_bars), "number": 2, diff --git a/src/buttonplus_generator/snippets/events.py b/src/buttonplus_generator/snippets/events.py new file mode 100644 index 0000000..19123b2 --- /dev/null +++ b/src/buttonplus_generator/snippets/events.py @@ -0,0 +1,398 @@ +from typing import Any, Dict, List + +from buttonplus_generator.constants import * +from buttonplus_generator.merger import deep_merge +from buttonplus_generator.utils import click_type_color, click_type_timing + + +def config_events( + version: int, + num_bars: int, + page_names: List[str], + click_types: List[ClickType], + click_confirm: bool, +) -> Dict[str, Any]: + config = events(num_bars=num_bars, page_names=page_names, click_types=click_types) + config = deep_merge(config, events_timing( + version=version, + num_bars=num_bars, + click_types=click_types, + click_confirm=click_confirm, + )) + + if click_confirm: + config = deep_merge(config, event_confirm_script()) + + return config + + +def events( + num_bars: int, page_names: List[str], click_types: List[ClickType] +) -> Dict[str, Any]: + event_dict = { + "substitutions": { + "button_events": _generate_event_names(page_names, click_types) + } + } + + event_dict["event"] = [] + + if num_bars < 4: + event_dict["event"].extend(_event_display_entry(BarSide.LEFT)) + event_dict["event"].extend(_event_display_entry(BarSide.RIGHT)) + + if num_bars > 0: + event_dict["event"].extend(_event_bar_entry(1, BarSide.LEFT)) + event_dict["event"].extend(_event_bar_entry(1, BarSide.RIGHT)) + + if num_bars > 1: + event_dict["event"].extend(_event_bar_entry(2, BarSide.LEFT)) + event_dict["event"].extend(_event_bar_entry(2, BarSide.RIGHT)) + + if num_bars > 2: + event_dict["event"].extend(_event_bar_entry(3, BarSide.LEFT)) + event_dict["event"].extend(_event_bar_entry(3, BarSide.RIGHT)) + + if num_bars > 3: + event_dict["event"].extend(_event_bar_entry(4, BarSide.LEFT)) + event_dict["event"].extend(_event_bar_entry(4, BarSide.RIGHT)) + + return event_dict + + +def _event_display_entry(display_side: BarSide) -> List[Dict[str, Any]]: + return [ + { + "platform": "template", + "id": f"display_btn_{display_side}_event", + "name": f"DISPLAY Button {str(display_side).capitalize()}", + "device_class": "button", + "event_types": r"${button_events}", + } + ] + + +def _event_bar_entry(bar_nr: int, bar_side: BarSide) -> List[Dict[str, Any]]: + return [ + { + "platform": "template", + "id": f"bar{bar_nr}_btn_{bar_side}_event", + "name": f"BAR {bar_nr} Button {str(bar_side).capitalize()}", + "device_class": "button", + "event_types": r"${button_events}", + } + ] + + +def _generate_event_names( + page_names: List[str], click_types: List[ClickType] +) -> List[str]: + return [ + f"{str(ct)} page {page_num}" + for page_num in range(0, len(page_names)) + for ct in click_types + ] + + +def event_confirm_script() -> Dict[str, Any]: + generated_cpp = ( + "event_id->trigger(event_msg);\n" + "\n" + "if(led_id != nullptr){\n" + " auto call = led_id->make_call();\n" + " call.set_state(true);\n" + " call.set_rgb(r, g, b);\n" + " call.set_brightness(1.0);\n" + " call.set_flash_length(200); // Returns to previous state automatically\n" + " call.perform();\n" + "}" + ) + + return { + "script": [ + { + "id": "event_trigger_feedback", + "parameters": { + "event_id": "esphome::template_::TemplateEvent*", + "event_msg": "std::string", + "led_id": "light::LightState*", + "r": "float", + "g": "float", + "b": "float", + }, + "then": [{"lambda": generated_cpp}], + } + ] + } + + +def events_timing( + version: int, num_bars: int, click_types: List[ClickType], click_confirm: bool +) -> Dict[str, Any]: + event_dict = {"binary_sensor": []} + + if num_bars < 4: + event_dict["binary_sensor"].extend( + _display_button_entry(version, BarSide.LEFT, click_types, click_confirm) + ) + event_dict["binary_sensor"].extend( + _display_button_entry(version, BarSide.RIGHT, click_types, click_confirm) + ) + + if num_bars > 0: + event_dict["binary_sensor"].extend( + _bar_button_entry(1, BarSide.LEFT, click_types, click_confirm) + ) + event_dict["binary_sensor"].extend( + _bar_button_entry(1, BarSide.RIGHT, click_types, click_confirm) + ) + + if num_bars > 1: + event_dict["binary_sensor"].extend( + _bar_button_entry(2, BarSide.LEFT, click_types, click_confirm) + ) + event_dict["binary_sensor"].extend( + _bar_button_entry(2, BarSide.RIGHT, click_types, click_confirm) + ) + + if num_bars > 2: + event_dict["binary_sensor"].extend( + _bar_button_entry(3, BarSide.LEFT, click_types, click_confirm) + ) + event_dict["binary_sensor"].extend( + _bar_button_entry(3, BarSide.RIGHT, click_types, click_confirm) + ) + + if num_bars > 3: + event_dict["binary_sensor"].extend( + _bar_button_entry(3, BarSide.LEFT, click_types, click_confirm) + ) + event_dict["binary_sensor"].extend( + _bar_button_entry(3, BarSide.RIGHT, click_types, click_confirm) + ) + + return event_dict + + +def _bar_button_entry( + bar_nr: int, bar_side: BarSide, click_types: List[ClickType], click_confirm: bool +) -> List[Dict[str, Any]]: + multi_click_list = [] + + # TODO click_confirm implement + + for ct in click_types: + multi_click_list.extend( + _timing_entry( + event_id=f"bar{bar_nr}_btn_{bar_side}_event", + led_id=f"bar{bar_nr}_{bar_side}_rgb_front", + event_type=ct, + timing=click_type_timing(ct), + color=click_type_color(ct), + ) + ) + + return [ + { + "id": f"bar{bar_nr}_btn_{bar_side}", + "filters": [{"delayed_off": "10ms"}], + "on_multi_click": multi_click_list, + } + ] + + +def _display_button_entry( + version: int, + display_side: BarSide, + click_types: List[ClickType], + click_confirm: bool, +) -> List[Dict[str, Any]]: + multi_click_list = [] + + # TODO click_confirm implement + + led_id = f"main_display_{display_side}_rgb_front" + + if version == 1: # No leds + led_id = YAMLLambda("return nullptr;") + + # Add special page changer events + script = "prev_page" if display_side == BarSide.LEFT else "next_page" + multi_click_list.append( + { + "timing": click_type_timing(ClickType.SINGLE), + "then": [{"script.execute": script}], + } + ) + + # The other clicks + other_click_types: List[ClickType] = click_types[:] + other_click_types.remove(ClickType.SINGLE) + + for ct in other_click_types: + multi_click_list.extend( + _timing_entry( + event_id=f"display_btn_{display_side}_event", + led_id=led_id, + event_type=ct, + timing=click_type_timing(ct), + color=click_type_color(ct), + ) + ) + + return [ + { + "id": f"main_display_btn_{display_side}", + "filters": [{"delayed_off": "10ms"}], + "on_multi_click": multi_click_list, + } + ] + + +def _timing_entry( + event_id: str, led_id: str, event_type: str, timing: List[str], color: Color +) -> List[Dict[str, Any]]: + return [ + { + "timing": timing, + "then": [ + { + "script.execute": { + "id": "event_trigger_feedback", + "event_id": event_id, + "led_id": led_id, + "r": color.r, + "g": color.g, + "b": color.b, + "event_msg": YAMLLambda( + 'return "' + + event_type + + ' page " + std::to_string(id(active_page_nr));' + ), + } + } + ], + } + ] + + +def old(): + # 1. Define event lists for substitutions + base_types = ["click", "double_click", "triple_click", "hold"] + display_types = [] + (base_types[1:] if INCLUDE_DISPLAY_EXTRA else []) + + display_events = [] + button_events = [] + for p in range(NR_OF_PAGES): + display_events.extend([f"{t} page {p}" for t in display_types]) + button_events.extend([f"{t} page {p}" for t in base_types]) + + # 2. Event Entities + buttons = [ + ("DISPLAY Button Left", "display_btn_left_event", "${display_events}"), + ("DISPLAY Button Right", "display_btn_right_event", "${display_events}"), + ("BAR1 Button Left", "bar1_btn_left_event", "${button_events}"), + ("BAR1 Button Right", "bar1_btn_right_event", "${button_events}"), + ("BAR2 Button Left", "bar2_btn_left_event", "${button_events}"), + ("BAR2 Button Right", "bar2_btn_right_event", "${button_events}"), + ("BAR3 Button Left", "bar3_btn_left_event", "${button_events}"), + ("BAR3 Button Right", "bar3_btn_right_event", "${button_events}"), + ] + for name, eid, types in buttons: + data["event"].append( + { + "platform": "template", + "name": name, + "id": eid, + "device_class": "button", + "event_types": types, + } + ) + + # 3. Binary Sensor Extensions + data["binary_sensor"] = [] + triggers = { + "click": (0.0, 1.0, 0.0), # Green + "double_click": (0.0, 0.0, 1.0), # Blue + "triple_click": (1.0, 0.0, 1.0), # Purple + "hold": (1.0, 0.5, 0.0), # Orange + } + + btn_configs = [ + ("main_display_btn_left", "display_btn_left_event", None, "prev_page"), + ("main_display_btn_right", "display_btn_right_event", None, "next_page"), + ("bar1_btn_left", "bar1_btn_left_event", "bar1_left_rgb_front", None), + ("bar1_btn_right", "bar1_btn_right_event", "bar1_right_rgb_front", None), + ("bar2_btn_left", "bar2_btn_left_event", "bar2_left_rgb_front", None), + ("bar2_btn_right", "bar2_btn_right_event", "bar2_right_rgb_front", None), + ("bar3_btn_left", "bar3_btn_left_event", "bar3_left_rgb_front", None), + ("bar3_btn_right", "bar3_btn_right_event", "bar3_right_rgb_front", None), + ] + + for btn_id, event_id, led_id, page_script in btn_configs: + sensor = { + "id": ESPHomeExtend(btn_id), + "filters": [{"delayed_off": "10ms"}], + "internal": True, + "on_multi_click": [], + } + + is_display = "main_display" in btn_id + for t_name, color in triggers.items(): + if is_display and t_name != "click" and not INCLUDE_DISPLAY_EXTRA: + continue + + multi_click = {"timing": []} + if t_name == "click": + multi_click["timing"] = [ + "ON for at most 300ms", + "OFF for at least 0.2s", + ] + elif t_name == "double_click": + multi_click["timing"] = [ + "ON for at most 300ms", + "OFF for at most 0.2s", + "ON for at most 300ms", + "OFF for at least 0.2s", + ] + elif t_name == "triple_click": + multi_click["timing"] = [ + "ON for at most 300ms", + "OFF for at most 0.2s", + "ON for at most 300ms", + "OFF for at most 0.2s", + "ON for at most 300ms", + "OFF for at least 0.2s", + ] + elif t_name == "hold": + multi_click["timing"] = ["ON for at least 1s", "OFF for at least 0.2s"] + + actions = [] + if t_name == "click" and page_script: + actions.append({"script.execute": page_script}) + else: + # The Feedback call with proper ESPHome formatting + actions.append( + { + "script.execute": { + "id": "trigger_with_feedback", + "event_id": event_id, + "event_msg": ESPHomeLambda( + f'return "{t_name} page " + std::to_string(id(active_page_nr));' + ), + "led_id": ( + led_id + if (led_id and LED_CONFIRMATION) + else ESPHomeLambda("return nullptr;") + ), + "r": color[0], + "g": color[1], + "b": color[2], + } + } + ) + multi_click["then"] = actions + sensor["on_multi_click"].append(multi_click) + data["binary_sensor"].append(sensor) + + return data diff --git a/src/buttonplus_generator/utils.py b/src/buttonplus_generator/utils.py index 6f5873c..2248b91 100644 --- a/src/buttonplus_generator/utils.py +++ b/src/buttonplus_generator/utils.py @@ -2,6 +2,8 @@ import re from typing import List +from buttonplus_generator.constants import * + def slugify(input : str) -> str: s = input.lower() @@ -29,3 +31,47 @@ def get_display_address(num_bars : int) -> str: raise ValueError("Can't have a display with this number of bars") return 0x23 - num_bars + +def string_to_click_type(ct:str) -> ClickType: + if ct == "single": + return ClickType.SINGLE + elif ct == "double": + return ClickType.DOUBLE + elif ct == "triple": + return ClickType.TRIPLE + elif ct == "hold": + return ClickType.HOLD + +def list_to_click_type(str_list:List[str]) -> List[ClickType]: + ct_list: List[ClickType] = [] + + for ct in str_list: + ct_list.append(string_to_click_type(ct)) + + return ct_list + +def click_type_color(ct: ClickType) -> Color: + + if(ct is ClickType.SINGLE): + return Color("#00FF00") + elif(ct is ClickType.DOUBLE): + return Color("#0000FF") + elif(ct is ClickType.TRIPLE): + return Color("#FF0000") + elif(ct is ClickType.HOLD): + return Color("#FF00FF") + + return Color("#FFFFFF") + +def click_type_timing(ct: ClickType) -> List[str]: + + if(ct is ClickType.SINGLE): + return TIMING_SINGLE_CLICK + elif(ct is ClickType.DOUBLE): + return TIMING_DOUBLE_CLICK + elif(ct is ClickType.TRIPLE): + return TIMING_TRIPLE_CLICK + elif(ct is ClickType.HOLD): + return TIMING_HOLD_CLICK + + return TIMING_SINGLE_CLICK \ No newline at end of file