From 545cb824f514a25c6e30ce16ed7dffb429e423e6 Mon Sep 17 00:00:00 2001 From: kennyboy55 Date: Sun, 17 May 2026 20:59:46 +0200 Subject: [PATCH] Implement toast and notification service --- .vscode/launch.json | 17 +++ README.md | 7 +- src/buttonplus_generator/generator.py | 108 ++++++++++---- src/buttonplus_generator/merger.py | 1 - src/buttonplus_generator/snippets/bars.py | 5 + src/buttonplus_generator/snippets/common.py | 3 +- src/buttonplus_generator/snippets/display.py | 9 +- src/buttonplus_generator/snippets/events.py | 122 --------------- src/buttonplus_generator/snippets/services.py | 141 ++++++++++++++++++ src/buttonplus_generator/utils.py | 23 ++- 10 files changed, 279 insertions(+), 157 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/buttonplus_generator/snippets/services.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5cdba97 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Main", + "type": "debugpy", + "request": "launch", + "program": "src/main.py", + "args": ["-v", "1", "-b", "3", "-p", "Start", "Music", "Lights", "Suus", "-c", "single", "double", "hold"], + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 25e143d..0db7214 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ The configuration (handmade) on which this generator is based, can be found in t ## Ideas - Notification service: until dismissed, or with timeout +- Toast service, see above +- SHow image service - LED effects: flash, breathe - Top level config only for drawing, everything else hidden - back to using packages or includes probably @@ -21,4 +23,7 @@ The configuration (handmade) on which this generator is based, can be found in t ## Missing config - Event lambda's (see config.yaml) -- Allow turn off click-confirm \ No newline at end of file +- 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 diff --git a/src/buttonplus_generator/generator.py b/src/buttonplus_generator/generator.py index ca5bdfe..76b700e 100644 --- a/src/buttonplus_generator/generator.py +++ b/src/buttonplus_generator/generator.py @@ -15,56 +15,99 @@ 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.services import config_services 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[ClickType], click_confirm: bool) -> Dict[str, Any]: - config = config_common(device_name=device_name, version=version, page_names=pages, num_bars=num_bars) +def compose_config( + device_name: str, + version: int, + num_bars: int, + pages: List[str], + click_types: List[ClickType], + click_confirm: bool, + services: bool, +) -> Dict[str, Any]: - 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, click_confirm=click_confirm)) + # Base config + config = config_common( + device_name=device_name, version=version, page_names=pages, num_bars=num_bars + ) + + # Display, if present + if num_bars < 4: + config = deep_merge( + config, config_display(version=version, num_bars=num_bars, page_names=pages) + ) + + # Services, only available with display + if services: + config = deep_merge(config, config_services(version=version)) + + # Bars + config = deep_merge( + config, config_bars(version=version, num_bars=num_bars, page_names=pages) + ) + # Events + 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 + def yaml_dumper(config: Dict[str, Any], fh) -> str: - + ## Handle multiline strings def represent_multistr(dumper, data): """configures yaml for dumping multiline strings Ref: https://stackoverflow.com/questions/8640959/how-can-i-control-what-scalar-form-pyyaml-uses-for-my-data """ if len(data.splitlines()) > 1: # check for multiline string - return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') - return dumper.represent_scalar('tag:yaml.org,2002:str', data) + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) def represent_extend(dumper, data): - return dumper.represent_scalar('!extend', data.value) + return dumper.represent_scalar("!extend", data.value) def represent_lambda(dumper, data): - return dumper.represent_scalar('!lambda', data.value, style='"') - + 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) - + 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) + + yaml.dump( + config, + fh, + sort_keys=False, + encoding="utf-8", + default_flow_style=False, + Dumper=NoAliasDumper, + ) + def main(argv=None) -> int: parser = argparse.ArgumentParser( description="Generate ESPHome YAML for Button+ V2", - epilog="The version of the display and bars matters because the V1 Display does not have LEDs.\n\nMade by Kenneth van Ewijk (kennyboy55)", + epilog="The version of the display and bars matters because the V1 Display does not have LEDs.\nMade by Kenneth van Ewijk (kennyboy55)", ) parser.add_argument( "--device-name", @@ -76,10 +119,10 @@ def main(argv=None) -> int: "--version", "-v", type=int, - choices=[1,2], + choices=[1, 2], required=True, help="The version of the display and bars. Only V2 PCB is supported.", - metavar="[1|2]" + metavar="[1|2]", ) parser.add_argument( "--bars", @@ -87,7 +130,7 @@ def main(argv=None) -> int: type=int, required=True, help="Number of bars attached, at least 1, at most 4. A display is assumed for up to three bars attached.", - metavar="NUM" + metavar="NUM", ) parser.add_argument( "--pages", @@ -95,7 +138,7 @@ def main(argv=None) -> int: nargs="+", required=True, help="List of page names", - metavar="NAME" + metavar="NAME", ) parser.add_argument( "--click-types", @@ -103,13 +146,19 @@ def main(argv=None) -> int: nargs="+", default=["single", "double", "triple", "hold"], help="List click types buttons, choices are [single|double|triple|hold].", - metavar="TYPE" + 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." + default=False, + action="store_true", + help="Don't flash the LED as a confirmation of the click event.", + ) + parser.add_argument( + "--no-services", + default=False, + action="store_true", + help="Don't add any of the toast, notification or image services.", ) parser.add_argument( "--output", "-o", default="buttonplus.yaml", help="Output file path" @@ -121,10 +170,13 @@ def main(argv=None) -> int: num_bars: int = args.bars pages: List[str] = args.pages click_types: List[ClickType] = list_to_click_type(args.click_types) - click_confirm: bool = args.click_confirm + click_confirm: bool = not args.no_click_confirm + services: bool = not args.no_services output: str = args.output - config = compose_config(device_name, version, num_bars, pages, click_types, click_confirm) + config = compose_config( + device_name, version, num_bars, pages, click_types, click_confirm, services + ) 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 6713ac3..d6ed508 100644 --- a/src/buttonplus_generator/merger.py +++ b/src/buttonplus_generator/merger.py @@ -88,4 +88,3 @@ def list_merge(a: List[Any], b: List[Any]) -> List[Any]: 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 0021f90..e9cdad8 100644 --- a/src/buttonplus_generator/snippets/bars.py +++ b/src/buttonplus_generator/snippets/bars.py @@ -1,6 +1,7 @@ from typing import Dict, Any, List from buttonplus_generator.constants import * 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]: @@ -200,24 +201,28 @@ def _bar_lights_entry(bar_nr: int, offset: int) -> List[Dict[str, Any]]: "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() }, { "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() }, { "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() }, { "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() }, ] diff --git a/src/buttonplus_generator/snippets/common.py b/src/buttonplus_generator/snippets/common.py index 7496a7e..0df4838 100644 --- a/src/buttonplus_generator/snippets/common.py +++ b/src/buttonplus_generator/snippets/common.py @@ -59,8 +59,8 @@ def base_config(device_name: str) -> Dict[str, Any]: "password": YAMLSecret("wifi_password"), }, "logger": {"level": "DEBUG"}, - "api": {"encryption": {"key": YAMLSecret("encryption_key")}}, "ota": [{"platform": "esphome", "password": YAMLSecret("ota_password")}], + "api": {"encryption": {"key": YAMLSecret("encryption_key")}}, } @@ -69,6 +69,7 @@ def colors(accent_color: str) -> Dict[str, Any]: "color": [ {"id": "accent", "hex": accent_color}, {"id": "white", "hex": "FFFFFF"}, + {"id": "black", "hex": "000000"}, {"id": "grey", "hex": "A6A6A6"}, ], } diff --git a/src/buttonplus_generator/snippets/display.py b/src/buttonplus_generator/snippets/display.py index fed7fcb..b72d1f0 100644 --- a/src/buttonplus_generator/snippets/display.py +++ b/src/buttonplus_generator/snippets/display.py @@ -1,6 +1,6 @@ from typing import Dict, Any, List from buttonplus_generator.constants import * -from buttonplus_generator.utils import get_display_id, get_display_address +from buttonplus_generator.utils import get_display_id, get_display_address, get_light_effects from buttonplus_generator.merger import deep_merge @@ -126,29 +126,32 @@ 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() }, { "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() }, { "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() }, { "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() }, ] } - def update_scripts(page_names: List[str]) -> Dict[str, Any]: display_dict = {"script": []} @@ -167,6 +170,7 @@ def update_scripts(page_names: List[str]) -> Dict[str, Any]: "// All pages\n" "id(main_display).image(0, 235, id(icon_prev_page), ImageAlign::BOTTOM_LEFT, id(grey));\n" "id(main_display).image(320, 235, id(icon_next_page), ImageAlign::BOTTOM_RIGHT, id(grey));\n" + "id(main_display).print(160, 235, id(font_arial20), id(white), TextAlign::BOTTOM_CENTER, id(active_page_name).c_str());\n" "\n" "id(main_display).strftime(160, 5, id(font_arial20), TextAlign::TOP_CENTER, \"%H:%M\", id(datetime).now());\n" "id(main_display).strftime(160, 80, id(font_arial20), TextAlign::TOP_CENTER, \"%d-%m-%Y\", id(datetime).now());\n" @@ -178,7 +182,6 @@ def update_scripts(page_names: List[str]) -> Dict[str, Any]: generated_cpp += ( f" // Page {page_num} - {page_names[page_num]}\n" f" case {page_num}:\n" - f" id(main_display).print(160, 235, id(font_arial20), id(white), TextAlign::BOTTOM_CENTER, id(active_page_name).c_str());\n" f" break;\n" ) diff --git a/src/buttonplus_generator/snippets/events.py b/src/buttonplus_generator/snippets/events.py index 19123b2..8ed7724 100644 --- a/src/buttonplus_generator/snippets/events.py +++ b/src/buttonplus_generator/snippets/events.py @@ -274,125 +274,3 @@ def _timing_entry( ], } ] - - -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/snippets/services.py b/src/buttonplus_generator/snippets/services.py new file mode 100644 index 0000000..0440820 --- /dev/null +++ b/src/buttonplus_generator/snippets/services.py @@ -0,0 +1,141 @@ +from typing import Dict, Any, List +from buttonplus_generator.merger import deep_merge +from buttonplus_generator.constants import * + + +def config_services(version: int) -> Dict[str, Any]: + # Toast needs to be after notification, so that the toast is temporarily shown over a notification if needed + config = notification_service(version=version) + config = deep_merge(config, toast_service()) + # config = deep_merge(config, image_service()) + + return config + + +def toast_service() -> Dict[str, Any]: + generated_cpp = ( + "if(id(internal_toast).size() > 0){\n" + " // Draw white box over existing text\n" + " id(main_display).filled_rectangle(0, 200, 320, 40, white);\n" + " // Show toast message\n" + " id(main_display).print(160, 235, id(font_arial20), id(black), TextAlign::BOTTOM_CENTER, id(internal_toast).c_str());\n" + "}\n" + ) + + return { + "globals": [ + {"id": "internal_toast", "type": "std::string", "initial_value": ""} + ], + "script": [{"id": "main_draw", "then": [{"lambda": generated_cpp}]}], + "api": { + "actions": [ + { + "action": "toast", + "variables": {"timeout": "int", "message": "string"}, + "then": [ + { + "globals.set": { + "id": "internal_toast", + "value": YAMLLambda("return message;"), + } + }, + {"script.execute": "update_main"}, + {"delay": YAMLLambda("return timeout * 1000;")}, + { + "globals.set": { + "id": "internal_toast", + "value": YAMLLambda("return std::string();"), + } + }, + {"script.execute": "update_main"}, + ], + } + ] + }, + } + + +def notification_service(version: int) -> Dict[str, Any]: + generated_cpp = ( + "if(id(internal_notify).size() > 0){\n" + " // Draw white box over existing text\n" + " id(main_display).filled_rectangle(0, 200, 320, 40, white);\n" + " id(main_display).rectangle(0, 200, 320, 40, accent);\n" + " // Show notification message\n" + " id(main_display).print(160, 235, id(font_arial20), id(black), TextAlign::BOTTOM_CENTER, id(internal_notify).c_str());\n" + "}\n" + ) + + led_left = "main_display_left_rgb_front" + 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" + + return { + "globals": [ + {"id": "internal_notify", "type": "std::string", "initial_value": ""} + ], + "script": [{"id": "main_draw", "then": [{"lambda": generated_cpp}]}], + "api": { + "actions": [ + { + "action": "notification", + "variables": {"flash_leds": "bool", "message": "string"}, + "then": [ + { + "globals.set": { + "id": "internal_notify", + "value": YAMLLambda("return message;"), + } + }, + {"script.execute": "update_main"}, + { + "while": { + "condition": { + "lambda": "return (flash_leds && id(internal_notify).size() > 0);" + }, + "then": [ + { + "light.turn_on": { + "id": led_left, + "brightness": "100%", + "red": "100%", + "green": "100%", + "blue": "100%", + "flash_length": "1s", + } + }, + { + "light.turn_on": { + "id": led_right, + "brightness": "100%", + "red": "100%", + "green": "100%", + "blue": "100%", + "flash_length": "1s", + } + }, + {"delay": "2s"}, + ], + } + }, + ], + }, + { + "action": "notification_clear", + "then": [ + { + "globals.set": { + "id": "internal_notify", + "value": YAMLLambda("return std::string();"), + } + }, + {"script.execute": "update_main"}, + ], + }, + ] + }, + } diff --git a/src/buttonplus_generator/utils.py b/src/buttonplus_generator/utils.py index 2248b91..f804189 100644 --- a/src/buttonplus_generator/utils.py +++ b/src/buttonplus_generator/utils.py @@ -1,6 +1,6 @@ import re -from typing import List +from typing import List, Dict, Any from buttonplus_generator.constants import * @@ -32,6 +32,27 @@ def get_display_address(num_bars : int) -> str: return 0x23 - num_bars +def get_light_effects() -> List[Dict[str, Any]]: + return [ + { + "pulse": { + "name": "Breathe", + "transition_length": "500ms", + "update_interval": "2s" + } + }, + { + "pulse": { + "name": "Blink", + "transition_length": { + "on_length": "500ms", + "off_length": "1s" + }, + "update_interval": "100ms" + } + } + ] + def string_to_click_type(ct:str) -> ClickType: if ct == "single": return ClickType.SINGLE