Enhance configuration generation and merging

This commit is contained in:
2026-05-18 13:17:24 +02:00
parent 545cb824f5
commit fa36a8e4aa
9 changed files with 273 additions and 85 deletions
+15 -9
View File
@@ -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?
- And make effects optional (--no-effects)
+11 -3
View File
@@ -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(
+51 -3
View File
@@ -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)
+17 -13
View File
@@ -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"
)
+79 -11
View File
@@ -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,6 +391,7 @@ 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": []}
@@ -339,13 +403,17 @@ def update_scripts(num_bars: int) -> Dict[str, Any]:
]
if num_bars < 4:
update_list.extend([
update_list.extend(
[
{
"script.execute": "update_main",
},
{"delay": "0.1s"},
])
]
)
common_dict["script"].append({"id": "update_all", "then": update_list[:-1]})
common_dict["script"].append(
{"id": "update_all", "mode": "restart", "then": update_list[:-1]}
)
return common_dict
+9 -7
View File
@@ -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
},
]
}
+3 -3
View File
@@ -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);'
),
}
}
+66 -16
View File
@@ -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
+10 -8
View File
@@ -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"
}
}
]