Implement toast and notification service

This commit is contained in:
2026-05-17 20:59:46 +02:00
parent 3bf21db643
commit 545cb824f5
10 changed files with 279 additions and 157 deletions
+17
View File
@@ -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"
}
]
}
+6 -1
View File
@@ -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
- 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?
+75 -23
View File
@@ -15,21 +15,56 @@ 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))
# Base config
config = config_common(
device_name=device_name, version=version, page_names=pages, num_bars=num_bars
)
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))
# 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
@@ -38,17 +73,17 @@ def yaml_dumper(config: Dict[str, Any], fh) -> str:
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)
@@ -59,12 +94,20 @@ def yaml_dumper(config: Dict[str, Any], fh) -> str:
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(
[
-1
View File
@@ -88,4 +88,3 @@ def list_merge(a: List[Any], b: List[Any]) -> List[Any]:
result.append(item)
return result
@@ -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()
},
]
+2 -1
View File
@@ -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"},
],
}
+6 -3
View File
@@ -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"
)
-122
View File
@@ -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
@@ -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"},
],
},
]
},
}
+22 -1
View File
@@ -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