From fa36a8e4aaf6f518617ca41b07bec269c9164b62 Mon Sep 17 00:00:00 2001 From: kennyboy55 Date: Mon, 18 May 2026 13:17:24 +0200 Subject: [PATCH] Enhance configuration generation and merging --- README.md | 24 ++-- src/buttonplus_generator/generator.py | 14 ++- src/buttonplus_generator/merger.py | 54 ++++++++- src/buttonplus_generator/snippets/bars.py | 30 +++-- src/buttonplus_generator/snippets/common.py | 112 ++++++++++++++---- src/buttonplus_generator/snippets/display.py | 16 +-- src/buttonplus_generator/snippets/events.py | 6 +- src/buttonplus_generator/snippets/services.py | 84 ++++++++++--- src/buttonplus_generator/utils.py | 18 +-- 9 files changed, 273 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 0db7214..371632d 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,34 @@ A python script that generates a full ESPHome config for the Button+ V2 -## Project uses pipenv +## Features +- Notification Service + - And toasts! +- Generate pages and all buttons and events belonging to each page + +## Setup +### Project uses pipenv run `pipenv install` to setup the project the first time. + run `pipenv shell` after that to open the virtual environment. -You can then use `python + +You can then use `python src/main.py --help` to run the generator ## Config example The configuration (handmade) on which this generator is based, can be found in the `esphome` folder. -# Todo +## Todo -## Ideas -- Notification service: until dismissed, or with timeout -- Toast service, see above +### Ideas - SHow image service - LED effects: flash, breathe - Top level config only for drawing, everything else hidden - back to using packages or includes probably - Allow adding of font and icons easily +- LED set per page! -## Missing config +### Missing config - Event lambda's (see config.yaml) - Implement --no-click-confirm -- Add notification LED partition, in order to update multiple at the same time - Fix LED effects: Blink and Breathe -- Dismiss notification button by changing page? \ No newline at end of file + - And make effects optional (--no-effects) \ No newline at end of file diff --git a/src/buttonplus_generator/generator.py b/src/buttonplus_generator/generator.py index 76b700e..ef3e115 100644 --- a/src/buttonplus_generator/generator.py +++ b/src/buttonplus_generator/generator.py @@ -28,6 +28,7 @@ def compose_config( click_types: List[ClickType], click_confirm: bool, services: bool, + effects: bool ) -> Dict[str, Any]: # Base config @@ -38,7 +39,7 @@ def compose_config( # Display, if present if num_bars < 4: config = deep_merge( - config, config_display(version=version, num_bars=num_bars, page_names=pages) + config, config_display(version=version, num_bars=num_bars, page_names=pages, add_effects=effects) ) # Services, only available with display @@ -47,7 +48,7 @@ def compose_config( # Bars config = deep_merge( - config, config_bars(version=version, num_bars=num_bars, page_names=pages) + config, config_bars(version=version, num_bars=num_bars, page_names=pages, add_effects=effects) ) # Events @@ -160,6 +161,12 @@ def main(argv=None) -> int: action="store_true", help="Don't add any of the toast, notification or image services.", ) + parser.add_argument( + "--no-effects", + default=False, + action="store_true", + help="Don't add any effects to the LEDs.", + ) parser.add_argument( "--output", "-o", default="buttonplus.yaml", help="Output file path" ) @@ -171,11 +178,12 @@ def main(argv=None) -> int: pages: List[str] = args.pages click_types: List[ClickType] = list_to_click_type(args.click_types) click_confirm: bool = not args.no_click_confirm + effects: bool = not args.no_effects services: bool = not args.no_services output: str = args.output config = compose_config( - device_name, version, num_bars, pages, click_types, click_confirm, services + device_name, version, num_bars, pages, click_types, click_confirm, services, effects ) 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 d6ed508..2c4df7c 100644 --- a/src/buttonplus_generator/merger.py +++ b/src/buttonplus_generator/merger.py @@ -28,7 +28,7 @@ def list_merge(a: List[Any], b: List[Any]) -> List[Any]: Steps: 1. Check if both lists contain Dicts with an `id` key in them - 1a. Otherwise just return a + b (extend) + 1a. Otherwise do a pre-append list merge (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 @@ -47,44 +47,92 @@ def list_merge(a: List[Any], b: List[Any]) -> List[Any]: # 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) + return pre_append_list_merge(a, b) result: List[Any] = [] seen_ids = set() # Merge entries from `a`, matching by `id` in `b` when present for item in a: + # There is an `id`` field if isinstance(item, dict) and "id" in item: id_val = item.get("id") + # find matching entry in b match = None for bi in b: + # check if b also had `id` field with the same value if isinstance(bi, dict) and bi.get("id") == id_val: match = bi break + # Both a and b have a matching `id` entry, so deep merge the dict if match is not None: # deep_merge returns a new dict merged = deep_merge(item, match) result.append(merged) + # No match, just copy entry from `a` else: result.append(item) + # Store all the id's that we have seen if id_val is not None: seen_ids.add(id_val) + + # No `id` field, just add to the list 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: + # There is an `id`` field (we only copy missing id's) if isinstance(item, dict) and "id" in item: id_val = item.get("id") + + # Empty id, just add it if id_val is None: result.append(item) + + # Add the entry if is an unknown id (not already merged with an `a` entry) elif id_val not in seen_ids: result.append(item) + # No id field, just add it else: result.append(item) + # Return the merged list return result + +def pre_append_list_merge(a: List[Any], b: List[Any]) -> List[Any]: + + # Detect whether both lists contain dicts with an 'prepend' key + a_has_prepend = any(isinstance(x, dict) and "prepend" in x for x in a) + b_has_prepend = any(isinstance(x, dict) and "prepend" in x for x in b) + + # If there are no `prepend` keys in either list, just extend + if not a_has_prepend and not b_has_prepend: + return list(a) + list(b) + + prepend_list = [] + append_list = [] + + def do_pre_or_append(entry: Dict[str, Any]) -> None: + # There is a `prepend` field + if isinstance(entry, dict) and "prepend" in entry: + prepend = entry.pop("prepend") + + if(prepend): + # Append, so that the prepend order stays the same as how they are inserted + prepend_list.append(entry) + else: + append_list.append(entry) + else: + append_list.append(entry) + + for item in a: + do_pre_or_append(item) + + for item in b: + do_pre_or_append(item) + + return list(prepend_list) + list(append_list) \ No newline at end of file diff --git a/src/buttonplus_generator/snippets/bars.py b/src/buttonplus_generator/snippets/bars.py index e9cdad8..5aeae97 100644 --- a/src/buttonplus_generator/snippets/bars.py +++ b/src/buttonplus_generator/snippets/bars.py @@ -4,14 +4,14 @@ from buttonplus_generator.merger import deep_merge from buttonplus_generator.utils import get_light_effects -def config_bars(version: int, num_bars: int, page_names: List[str]) -> Dict[str, Any]: +def config_bars(version: int, num_bars: int, page_names: List[str], add_effects: bool) -> Dict[str, Any]: config = bars_backlight() config = deep_merge(config, bars_setup(num_bars=num_bars)) config = deep_merge(config, bars_buttons(num_bars=num_bars)) config = deep_merge( config, bars_lights( - num_bars=num_bars, display_has_leds=(version == 2 and num_bars < 4) + num_bars=num_bars, display_has_leds=(version == 2 and num_bars < 4), add_effects=add_effects ), ) config = deep_merge(config, bars_display_output(num_bars=num_bars)) @@ -171,7 +171,7 @@ def _bar_button_entry(bar_nr: int, base_id: str) -> List[Dict[str, Any]]: ] -def bars_lights(num_bars: int, display_has_leds: bool = True) -> Dict[str, Any]: +def bars_lights(num_bars: int, display_has_leds: bool, add_effects: bool) -> Dict[str, Any]: bar_dict = {"light": []} if num_bars < 1: @@ -180,49 +180,52 @@ def bars_lights(num_bars: int, display_has_leds: bool = True) -> Dict[str, Any]: display_offset = 4 if display_has_leds else 0 if num_bars > 0: - bar_dict["light"].extend(_bar_lights_entry(1, 0 + display_offset)) + bar_dict["light"].extend(_bar_lights_entry(1, 0 + display_offset, add_effects)) if num_bars > 1: - bar_dict["light"].extend(_bar_lights_entry(2, 4 + display_offset)) + bar_dict["light"].extend(_bar_lights_entry(2, 4 + display_offset, add_effects)) if num_bars > 2: - bar_dict["light"].extend(_bar_lights_entry(3, 8 + display_offset)) + bar_dict["light"].extend(_bar_lights_entry(3, 8 + display_offset, add_effects)) if num_bars > 3: - bar_dict["light"].extend(_bar_lights_entry(4, 12)) # no display + bar_dict["light"].extend(_bar_lights_entry(4, 12, add_effects)) # no display return bar_dict -def _bar_lights_entry(bar_nr: int, offset: int) -> List[Dict[str, Any]]: +def _bar_lights_entry(bar_nr: int, offset: int, add_effects: bool) -> List[Dict[str, Any]]: + + light_effects = get_light_effects(add_effects) + return [ { "platform": "partition", "name": f"BAR {bar_nr} Left RGB Front", "id": f"bar{bar_nr}_left_rgb_front", "segments": [{"id": "neopixels", "from": offset + 0, "to": offset + 0}], - "effects": get_light_effects() + "effects": light_effects }, { "platform": "partition", "name": f"BAR {bar_nr} Left RGB Back", "id": f"bar{bar_nr}_left_rgb_back", "segments": [{"id": "neopixels", "from": offset + 1, "to": offset + 1}], - "effects": get_light_effects() + "effects": light_effects }, { "platform": "partition", "name": f"BAR {bar_nr} Right RGB Front", "id": f"bar{bar_nr}_right_rgb_front", "segments": [{"id": "neopixels", "from": offset + 2, "to": offset + 2}], - "effects": get_light_effects() + "effects": light_effects }, { "platform": "partition", "name": f"BAR {bar_nr} Right RGB Back", "id": f"bar{bar_nr}_right_rgb_back", "segments": [{"id": "neopixels", "from": offset + 3, "to": offset + 3}], - "effects": get_light_effects() + "effects": light_effects }, ] @@ -359,6 +362,7 @@ def _bars_script_update_entry( generated_cpp = "" + # Loop through both lists at the same time for bar, state in zip(bars_ids, bars_state): generated_cpp += f"id({bar}).turn_{state}();\n" @@ -437,7 +441,7 @@ def _bars_script_draw_entry( f" // Page {page_num} - {page_names[page_num]}\n" f" case {page_num}:\n" f' id(bar_display).printf(80, 0, id(font_arial20), id(accent), TextAlign::TOP_CENTER , "Bar {bar_nr} {bar_side}");\n' - f' id(bar_display).printf(80, 80, id(font_arial20), id(white), TextAlign::BOTTOM_CENTER , "Page {page_num}");\n' + f' id(bar_display).printf(80, 80, id(font_arial20), id(white), TextAlign::BOTTOM_CENTER , "{page_names[page_num]}");\n' f" break;\n" ) diff --git a/src/buttonplus_generator/snippets/common.py b/src/buttonplus_generator/snippets/common.py index 0df4838..68ae390 100644 --- a/src/buttonplus_generator/snippets/common.py +++ b/src/buttonplus_generator/snippets/common.py @@ -29,7 +29,7 @@ def config_common( if num_bars > 2: num_leds = 13 + display_add if num_bars > 3: - num_leds = 17 #no display + num_leds = 17 # no display config = deep_merge(config, neopixels(num_leds=num_leds)) config = deep_merge(config, update_scripts(num_bars=num_bars)) @@ -44,7 +44,10 @@ def base_config(device_name: str) -> Dict[str, Any]: "friendly_name": device_name, "on_boot": { "priority": -100, - "then": [{"script.execute": "setup_bar_displays"}], + "then": [ + {"script.execute": "setup_bar_displays"}, + {"script.execute": "test_all_leds"}, + ], }, }, "esp32": { @@ -145,22 +148,32 @@ def pages(nr_of_pages: int, page_names: List[str]) -> Dict[str, Any]: } ] + next_page_cpp = ( + "id(active_page_nr) = id(active_page_nr) + 1;\n" + "if(id(active_page_nr) >= id(nr_of_pages)) {\n" + " id(active_page_nr) = 0;\n" + "}" + ) + + prev_page_cpp = ( + "id(active_page_nr) = id(active_page_nr) - 1;\n" + "if(id(active_page_nr) < 0) {\n" + " id(active_page_nr) = id(nr_of_pages) - 1;\n" + "}" + ) + page_dict["script"] = [ { "id": "next_page", "then": [ - { - "lambda": "id(active_page_nr) = id(active_page_nr) + 1;\nif(id(active_page_nr) >= id(nr_of_pages)) {\n id(active_page_nr) = 0;\n}" - }, + {"lambda": next_page_cpp}, {"script.execute": "update_page"}, ], }, { "id": "prev_page", "then": [ - { - "lambda": "id(active_page_nr) = id(active_page_nr) - 1;\nif(id(active_page_nr) < 0) {\n id(active_page_nr) = id(nr_of_pages) - 1;\n}" - }, + {"lambda": prev_page_cpp}, {"script.execute": "update_page"}, ], }, @@ -248,6 +261,43 @@ def spi() -> Dict[str, Any]: def neopixels(num_leds: int) -> Dict[str, Any]: return { + "script": [ + { + "id": "test_all_leds", + "then": [ + {"delay": "1000ms"}, + { + "light.turn_on": { + "id": "internal_all_rgb", + "red": "100%", + "green": "0%", + "blue": "0%", + "flash_length": "100ms", + } + }, + {"delay": "200ms"}, + { + "light.turn_on": { + "id": "internal_all_rgb", + "red": "0%", + "green": "100%", + "blue": "0%", + "flash_length": "100ms", + } + }, + {"delay": "200ms"}, + { + "light.turn_on": { + "id": "internal_all_rgb", + "red": "0%", + "green": "0%", + "blue": "100%", + "flash_length": "100ms", + } + }, + ], + } + ], "light": [ { "platform": "esp32_rmt_led_strip", @@ -256,8 +306,21 @@ def neopixels(num_leds: int) -> Dict[str, Any]: "chipset": "ws2812", "pin": GPIO_NEOPIXELS, "num_leds": num_leds, + "internal": True, }, - ] + { + "platform": "partition", + "id": "internal_all_rgb", + "internal": True, + "segments": [ + { + "id": "neopixels", + "from": 0, + "to": num_leds - 1, + }, + ], + }, + ], } @@ -328,24 +391,29 @@ def sensors(ambient_gain: str, extra_ambient_sensors: bool) -> Dict[str, Any]: return sensors_dict + def update_scripts(num_bars: int) -> Dict[str, Any]: common_dict = {"script": []} - + update_list = [ { - "script.execute": "update_bars", + "script.execute": "update_bars", }, {"delay": "0.1s"}, ] - + if num_bars < 4: - update_list.extend([ - { - "script.execute": "update_main", - }, - {"delay": "0.1s"}, - ]) - - common_dict["script"].append({"id": "update_all", "then": update_list[:-1]}) - - return common_dict \ No newline at end of file + update_list.extend( + [ + { + "script.execute": "update_main", + }, + {"delay": "0.1s"}, + ] + ) + + common_dict["script"].append( + {"id": "update_all", "mode": "restart", "then": update_list[:-1]} + ) + + return common_dict diff --git a/src/buttonplus_generator/snippets/display.py b/src/buttonplus_generator/snippets/display.py index b72d1f0..21adf21 100644 --- a/src/buttonplus_generator/snippets/display.py +++ b/src/buttonplus_generator/snippets/display.py @@ -4,12 +4,12 @@ from buttonplus_generator.utils import get_display_id, get_display_address, get_ from buttonplus_generator.merger import deep_merge -def config_display(version: int, num_bars: int, page_names: List[str]) -> Dict[str, Any]: +def config_display(version: int, num_bars: int, page_names: List[str], add_effects: bool) -> Dict[str, Any]: config = display_backlight(num_bars=num_bars) config = deep_merge(config, display_setup(num_bars=num_bars)) config = deep_merge(config, display_buttons(num_bars=num_bars)) config = deep_merge( - config, display_lights(num_bars=num_bars, display_has_leds=(version == 2)) + config, display_lights(num_bars=num_bars, display_has_leds=(version == 2), add_effects=add_effects) ) config = deep_merge(config, update_scripts(page_names=page_names)) @@ -112,13 +112,15 @@ def display_buttons(num_bars: int) -> Dict[str, Any]: } -def display_lights(num_bars: int, display_has_leds: bool = True) -> Dict[str, Any]: +def display_lights(num_bars: int, display_has_leds: bool, add_effects: bool) -> Dict[str, Any]: if not display_has_leds: return {} if num_bars > 3: raise ValueError("At most three bars must be connected to use a display!") + light_effects = get_light_effects(add_effects) + return { "light": [ { @@ -126,28 +128,28 @@ def display_lights(num_bars: int, display_has_leds: bool = True) -> Dict[str, An "name": "Main Display Left RGB Front", "id": "main_display_left_rgb_front", "segments": [{"id": "neopixels", "from": 0, "to": 0}], - "effects": get_light_effects() + "effects": light_effects }, { "platform": "partition", "name": "Main Display Left RGB Back", "id": "main_display_left_rgb_back", "segments": [{"id": "neopixels", "from": 1, "to": 1}], - "effects": get_light_effects() + "effects": light_effects }, { "platform": "partition", "name": "Main Display Right RGB Front", "id": "main_display_right_rgb_front", "segments": [{"id": "neopixels", "from": 2, "to": 2}], - "effects": get_light_effects() + "effects": light_effects }, { "platform": "partition", "name": "Main Display Right RGB Back", "id": "main_display_right_rgb_back", "segments": [{"id": "neopixels", "from": 3, "to": 3}], - "effects": get_light_effects() + "effects": light_effects }, ] } diff --git a/src/buttonplus_generator/snippets/events.py b/src/buttonplus_generator/snippets/events.py index 8ed7724..1da091a 100644 --- a/src/buttonplus_generator/snippets/events.py +++ b/src/buttonplus_generator/snippets/events.py @@ -88,8 +88,8 @@ 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)) + f"{str(ct)} {page}" + for page in page_names for ct in click_types ] @@ -267,7 +267,7 @@ def _timing_entry( "event_msg": YAMLLambda( 'return "' + event_type - + ' page " + std::to_string(id(active_page_nr));' + + ' " + id(active_page_name);' ), } } diff --git a/src/buttonplus_generator/snippets/services.py b/src/buttonplus_generator/snippets/services.py index 0440820..eb18878 100644 --- a/src/buttonplus_generator/snippets/services.py +++ b/src/buttonplus_generator/snippets/services.py @@ -66,15 +66,19 @@ def notification_service(version: int) -> Dict[str, Any]: "}\n" ) - led_left = "main_display_left_rgb_front" - led_right = "main_display_right_rgb_front" - + notification_led_partition_left = 0 + notification_led_partition_right = 2 + notification_led_left = "main_display_left_rgb_front" + notification_led_right = "main_display_right_rgb_front" + # No display leds, switch to BAR 1 wall leds if version == 1: - led_left = "bar1_left_rgb_back" - led_right = "bar1_right_rgb_back" + notification_led_partition_left = 1 + notification_led_partition_right = 3 + notification_led_left = "bar1_left_rgb_back" + notification_led_right = "bar1_right_rgb_back" - return { + service_dict = { "globals": [ {"id": "internal_notify", "type": "std::string", "initial_value": ""} ], @@ -100,17 +104,7 @@ def notification_service(version: int) -> Dict[str, Any]: "then": [ { "light.turn_on": { - "id": led_left, - "brightness": "100%", - "red": "100%", - "green": "100%", - "blue": "100%", - "flash_length": "1s", - } - }, - { - "light.turn_on": { - "id": led_right, + "id": "internal_notification_rgb", "brightness": "100%", "red": "100%", "green": "100%", @@ -122,6 +116,15 @@ def notification_service(version: int) -> Dict[str, Any]: ], } }, + { + "wait_until": { + "condition": { + "lambda": "return id(internal_notify).size() == 0;" + } + } + }, + {"light.control": {"id": notification_led_left}}, + {"light.control": {"id": notification_led_right}}, ], }, { @@ -138,4 +141,51 @@ def notification_service(version: int) -> Dict[str, Any]: }, ] }, + "light": [ + { + "platform": "partition", + "id": "internal_notification_rgb", + "internal": True, + "segments": [ + { + "id": "neopixels", + "from": notification_led_partition_left, + "to": notification_led_partition_left, + }, + { + "id": "neopixels", + "from": notification_led_partition_right, + "to": notification_led_partition_right, + }, + ], + } + ], } + + dismiss_notification = { + "prepend": True, + "if": { + "condition": [ + {"lambda": YAMLLambda("return id(internal_notify).size() > 0;")} + ], + "then": [ + { + "globals.set": { + "id": "internal_notify", + "value": YAMLLambda("return std::string();"), + }, + }, + {"script.execute": "update_main"}, + {"script.stop": "next_page"}, + ], + }, + } + + service_dict["script"].extend( + [ + {"id": "next_page", "then": [dismiss_notification]}, + {"id": "prev_page", "then": [dismiss_notification]}, + ] + ) + + return service_dict diff --git a/src/buttonplus_generator/utils.py b/src/buttonplus_generator/utils.py index f804189..f696915 100644 --- a/src/buttonplus_generator/utils.py +++ b/src/buttonplus_generator/utils.py @@ -32,23 +32,25 @@ def get_display_address(num_bars : int) -> str: return 0x23 - num_bars -def get_light_effects() -> List[Dict[str, Any]]: +def get_light_effects(enabled: bool) -> List[Dict[str, Any]]: + if not enabled: + return [] + return [ { "pulse": { "name": "Breathe", - "transition_length": "500ms", - "update_interval": "2s" + "transition_length": "1s", + "update_interval": "1.5s", + "min_brightness": "20%", + "max_brightness": "100%" } }, { "pulse": { "name": "Blink", - "transition_length": { - "on_length": "500ms", - "off_length": "1s" - }, - "update_interval": "100ms" + "transition_length": "50ms", + "update_interval": "500ms" } } ]