Implement button events

This commit is contained in:
2026-05-16 13:58:52 +02:00
parent fac18c17ac
commit 3bf21db643
8 changed files with 596 additions and 18 deletions
+1 -3
View File
@@ -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 - Allow adding of font and icons easily
## Missing config ## 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) - Event lambda's (see config.yaml)
- Allow turn off click-confirm
+53 -4
View File
@@ -18,12 +18,61 @@ class BarSide(StrEnum):
LEFT = "left" LEFT = "left"
RIGHT = "right" RIGHT = "right"
class ClickTypes(StrEnum): class ClickType(StrEnum):
SINGLE = "single" SINGLE = "click"
DOUBLE = "double" DOUBLE = "double_click"
TRIPLE = "triple" TRIPLE = "triple_click"
HOLD = "hold" 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: class YAMLExtend:
def __init__(self, value): self.value = value def __init__(self, value): self.value = value
+20 -8
View File
@@ -10,22 +10,23 @@ from typing import Any, Dict, List
import yaml 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.common import config_common
from buttonplus_generator.snippets.bars import config_bars from buttonplus_generator.snippets.bars import config_bars
from buttonplus_generator.snippets.display import config_display 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.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) 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 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_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_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 return config
@@ -47,14 +48,18 @@ def yaml_dumper(config: Dict[str, Any], fh) -> str:
return dumper.represent_scalar('!lambda', data.value, style='"') return dumper.represent_scalar('!lambda', data.value, style='"')
def represent_secret(dumper, data): 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(str, represent_multistr)
yaml.add_representer(YAMLExtend, represent_extend) yaml.add_representer(YAMLExtend, represent_extend)
yaml.add_representer(YAMLLambda, represent_lambda) yaml.add_representer(YAMLLambda, represent_lambda)
yaml.add_representer(YAMLSecret, represent_secret) 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: def main(argv=None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -100,6 +105,12 @@ def main(argv=None) -> int:
help="List click types buttons, choices are [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."
)
parser.add_argument( parser.add_argument(
"--output", "-o", default="buttonplus.yaml", help="Output file path" "--output", "-o", default="buttonplus.yaml", help="Output file path"
) )
@@ -109,10 +120,11 @@ def main(argv=None) -> int:
version: int = args.version version: int = args.version
num_bars: int = args.bars num_bars: int = args.bars
pages: List[str] = args.pages 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 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: with open(output, "w", encoding="utf-8") as fh:
fh.writelines( fh.writelines(
[ [
+73 -2
View File
@@ -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]: 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) result = dict(a)
for k, v in b.items(): for k, v in b.items():
# Key from b also in a
if k in result: 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): if isinstance(result[k], dict) and isinstance(v, dict):
result[k] = deep_merge(result[k], v) 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): elif isinstance(result[k], list) and isinstance(v, list):
result[k] = result[k] + v result[k] = list_merge(result[k], v)
else: else:
result[k] = v result[k] = v
else: else:
result[k] = v result[k] = v
return result 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
@@ -147,6 +147,7 @@ def _bar_button_entry(bar_nr: int, base_id: str) -> List[Dict[str, Any]]:
"platform": "gpio", "platform": "gpio",
"id": f"bar{bar_nr}_btn_left", "id": f"bar{bar_nr}_btn_left",
"name": f"BAR {bar_nr} button Left", "name": f"BAR {bar_nr} button Left",
"internal": True,
"pin": { "pin": {
"mcp23xxx": base_id, "mcp23xxx": base_id,
"number": 6, "number": 6,
@@ -158,6 +159,7 @@ def _bar_button_entry(bar_nr: int, base_id: str) -> List[Dict[str, Any]]:
"platform": "gpio", "platform": "gpio",
"id": f"bar{bar_nr}_btn_right", "id": f"bar{bar_nr}_btn_right",
"name": f"BAR {bar_nr} button Right", "name": f"BAR {bar_nr} button Right",
"internal": True,
"pin": { "pin": {
"mcp23xxx": base_id, "mcp23xxx": base_id,
"number": 2, "number": 2,
@@ -88,6 +88,7 @@ def display_buttons(num_bars: int) -> Dict[str, Any]:
"platform": "gpio", "platform": "gpio",
"id": "main_display_btn_left", "id": "main_display_btn_left",
"name": "Main Display button Left", "name": "Main Display button Left",
"internal": True,
"pin": { "pin": {
"mcp23xxx": get_display_id(num_bars), "mcp23xxx": get_display_id(num_bars),
"number": 6, "number": 6,
@@ -99,6 +100,7 @@ def display_buttons(num_bars: int) -> Dict[str, Any]:
"platform": "gpio", "platform": "gpio",
"id": "main_display_btn_right", "id": "main_display_btn_right",
"name": "Main Display button Right", "name": "Main Display button Right",
"internal": True,
"pin": { "pin": {
"mcp23xxx": get_display_id(num_bars), "mcp23xxx": get_display_id(num_bars),
"number": 2, "number": 2,
+398
View File
@@ -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
+46
View File
@@ -2,6 +2,8 @@
import re import re
from typing import List from typing import List
from buttonplus_generator.constants import *
def slugify(input : str) -> str: def slugify(input : str) -> str:
s = input.lower() 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") raise ValueError("Can't have a display with this number of bars")
return 0x23 - num_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