From dbda5a8daa8a5bc18e84675f7cc6916f6bf0e16d Mon Sep 17 00:00:00 2001 From: kennyboy55 Date: Fri, 15 May 2026 13:04:18 +0200 Subject: [PATCH] Add base and display snippets, initial generation works --- README.md | 18 +- src/buttonplus_generator/__init__.py | 2 - src/buttonplus_generator/constants.py | 12 + src/buttonplus_generator/generator.py | 155 +++++---- src/buttonplus_generator/merger.py | 20 ++ src/buttonplus_generator/snippets.py | 61 ---- src/buttonplus_generator/snippets/bars.py | 306 +++++++++++++++++ src/buttonplus_generator/snippets/common.py | 327 +++++++++++++++++++ src/buttonplus_generator/snippets/display.py | 152 +++++++++ src/buttonplus_generator/utils.py | 18 +- 10 files changed, 937 insertions(+), 134 deletions(-) create mode 100644 src/buttonplus_generator/constants.py create mode 100644 src/buttonplus_generator/merger.py delete mode 100644 src/buttonplus_generator/snippets.py create mode 100644 src/buttonplus_generator/snippets/bars.py create mode 100644 src/buttonplus_generator/snippets/common.py create mode 100644 src/buttonplus_generator/snippets/display.py diff --git a/README.md b/README.md index 74b302f..35f3e97 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,20 @@ run `pipenv shell` after that to open the virtual environment. You can then use `python ## Config example -The configuration (handmade) on which this generator is based, can be found in the `esphome` folder. \ No newline at end of file +The configuration (handmade) on which this generator is based, can be found in the `esphome` folder. + +# Todo + +## Ideas +- Notification service: until dismissed, or with timeout +- LED effects: flash, breathe +- Top level config only for drawing, everything else hidden +- Allow adding of font and icons easily + +## Missing config +- Scripts for updating displays (see scripts.yaml) +- Draw routines (see config.yaml) +- 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) diff --git a/src/buttonplus_generator/__init__.py b/src/buttonplus_generator/__init__.py index 110510e..e69de29 100644 --- a/src/buttonplus_generator/__init__.py +++ b/src/buttonplus_generator/__init__.py @@ -1,2 +0,0 @@ -"""Button+ ESPHome generator package.""" -__all__ = ["generator", "snippets"] diff --git a/src/buttonplus_generator/constants.py b/src/buttonplus_generator/constants.py new file mode 100644 index 0000000..f4d1d16 --- /dev/null +++ b/src/buttonplus_generator/constants.py @@ -0,0 +1,12 @@ +## GPIO Pins + +GPIO_NEOPIXELS = "GPIO4" #Neopixel LED's +GPIO_SPI_MOSI = "GPIO5" #SPI MOSI +GPIO_SPI_CLK = "GPIO6" #SPI SCK +GPIO_SPI_REG = "GPIO7" #SPI Register select +GPIO_BACKLIGHT_MAIN = "GPIO3" #Backlight main display +GPIO_BACKLIGHT_MINI = "GPIO46" #Backlight mini displays +GPIO_I2C_BUTTONS_SDA = "GPIO1" #I2C SDA > Buttons +GPIO_I2C_BUTTONS_SCL = "GPIO2" #I2C SCL > Buttons +GPIO_I2C_SENSORS_SDA = "GPIO47" #I2C SDA > Temperature & Ambient Sensor +GPIO_I2C_SENSORS_SCL = "GPIO48" #I2S SCL > Temperature & Ambient Sensor \ No newline at end of file diff --git a/src/buttonplus_generator/generator.py b/src/buttonplus_generator/generator.py index 252d7e8..c9cf2af 100644 --- a/src/buttonplus_generator/generator.py +++ b/src/buttonplus_generator/generator.py @@ -2,89 +2,106 @@ Usage: python main.py --pages 3 --notifications --led --output buttonplus.yaml """ + from __future__ import annotations import argparse -from typing import Any, Dict +from typing import Any, Dict, List import yaml -from buttonplus_generator import snippets -from pathlib import Path +from buttonplus_generator.snippets.common import config_common +from buttonplus_generator.snippets.display import config_display +from buttonplus_generator.merger import deep_merge +from buttonplus_generator.snippets.bars import config_bars -def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: - """Recursively merge b into a and return a new dict. - - Lists are concatenated; scalar values in b override a. - """ - result = dict(a) - for k, v in b.items(): - if k in result: - if isinstance(result[k], dict) and isinstance(v, dict): - result[k] = deep_merge(result[k], v) - elif isinstance(result[k], list) and isinstance(v, list): - result[k] = result[k] + v - else: - result[k] = v - else: - result[k] = v - return result - - -def compose_config(device_name: str, pages: int, notifications: bool, led: bool) -> Dict[str, Any]: - config = snippets.base_config(device_name) - config = deep_merge(config, snippets.pages(pages)) - config = deep_merge(config, snippets.notifications(notifications)) - config = deep_merge(config, snippets.led_control(led)) +def compose_config(device_name: str, version: int, num_bars: int, pages: List[str]) -> Dict[str, Any]: + 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 + config = deep_merge(config, config_display(version=version, num_bars=num_bars)) + + config = deep_merge(config, config_bars(version=version, num_bars=num_bars)) + return config +def yaml_dumper(config: Dict[str, Any], fh) -> str: + + ## Handle multiline strings + def str_presenter(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) -def compose_from_profile(profile: str, device_name: str | None = None) -> str: - """Load an existing esphome profile folder and return its raw YAML text. - - We intentionally return raw text because ESPHome config files use tags - like `!include` and `!secret` that PyYAML does not understand by - default. If `device_name` is provided we do a simple textual replace of - the `esphome.name` value. - """ - base = Path(__file__).resolve().parents[2] - profile_path = base / "esphome" / profile / "config.yaml" - if not profile_path.exists(): - raise FileNotFoundError(f"Profile config not found: {profile_path}") - text = profile_path.read_text(encoding="utf-8") - if device_name: - # Replace the first occurrence of `esphome:` block name line. - # This is a conservative textual substitution and avoids full YAML parsing. - import re - - def _repl(match: re.Match) -> str: - indent = match.group(1) - return f"{indent}name: {device_name}" - - # Look for a line like: (some spaces)name: that follows an `esphome:` line - text = re.sub(r"(^\s*)name:\s*.*$", _repl, text, count=1, flags=re.M) - return text - + yaml.add_representer(str, str_presenter) + yaml.dump(config, fh, sort_keys=False, encoding="utf-8", default_flow_style=False) def main(argv=None) -> int: - parser = argparse.ArgumentParser(description="Generate ESPHome YAML for Button+") - parser.add_argument("--device-name", default="buttonplus", help="ESPHome device name") - parser.add_argument("--pages", type=int, default=1, help="Number of pages") - parser.add_argument("--notifications", action="store_true", help="Enable notification features") - parser.add_argument("--led", action="store_true", help="Enable LED control") - parser.add_argument("--output", default="buttonplus.yaml", help="Output file path") - parser.add_argument("--profile", help="Use an existing esphome profile folder (e.g. 3bar_1display)") + 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)", + ) + parser.add_argument( + "--device-name", + "--name", + "-n", + default="buttonplus", + help="ESPHome device name", + ) + parser.add_argument( + "--version", + "-v", + type=int, + choices=[1,2], + required=True, + help="The version of the display and bars. Only V2 PCB is supported.", + metavar="VER" + ) + parser.add_argument( + "--bars", + "-b", + 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" + ) + parser.add_argument( + "--pages", + "-p", + nargs="+", + required=True, + help="List of page names", + metavar="NAME" + ) + parser.add_argument( + "--output", "-o", default="buttonplus.yaml", help="Output file path" + ) args = parser.parse_args(argv) - if args.profile: - text = compose_from_profile(args.profile, device_name=args.device_name) - with open(args.output, "w", encoding="utf-8") as fh: - fh.write(text) - else: - config = compose_config(args.device_name, args.pages, args.notifications, args.led) - with open(args.output, "w", encoding="utf-8") as fh: - yaml.safe_dump(config, fh, sort_keys=False) + device_name: str = args.device_name + version: int = args.version + num_bars: int = args.bars + pages: List[str] = args.pages + output: str = args.output - print(f"Wrote {args.output}") + config = compose_config(device_name, version, num_bars, pages) + with open(output, "w", encoding="utf-8") as fh: + fh.writelines( + [ + "# Generated by ESPHome for Buttonplus\n", + "# Options: \n", + f"# - device: {device_name}\n", + f"# - version: {version}\n", + f"# - bars: {num_bars}\n", + f"# - pages: {", ".join(pages)}\n", + "\n", + ] + ) + yaml_dumper(config, fh) + + print(f"Wrote {output}") return 0 diff --git a/src/buttonplus_generator/merger.py b/src/buttonplus_generator/merger.py new file mode 100644 index 0000000..3d1522f --- /dev/null +++ b/src/buttonplus_generator/merger.py @@ -0,0 +1,20 @@ +from typing import Any, Dict + + +def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]: + """Recursively merge b into a and return a new dict. + + Lists are concatenated; scalar values in b override a. + """ + result = dict(a) + for k, v in b.items(): + if k in result: + if isinstance(result[k], dict) and isinstance(v, dict): + result[k] = deep_merge(result[k], v) + elif isinstance(result[k], list) and isinstance(v, list): + result[k] = result[k] + v + else: + result[k] = v + else: + result[k] = v + return result \ No newline at end of file diff --git a/src/buttonplus_generator/snippets.py b/src/buttonplus_generator/snippets.py deleted file mode 100644 index 206686a..0000000 --- a/src/buttonplus_generator/snippets.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Example snippet definitions for composing ESPHome YAML parts. - -Each function returns a Python dict representing part of the final YAML. -Keep snippets small and composable — the generator deep-merges them. -""" -from typing import Dict, Any -from utils import slugify - -def base_config(device_name: str = "buttonplus") -> Dict[str, Any]: - return { - "esphome": { - "name": slugify(device_name), - "friendly_name": device_name - }, - "esp32":{ - "variant": "ESP32S3", - "board": "esp32-s3-devkitc1-n16r8", - "flash_size": "16MB", - "framework": { - "type": "esp-idf" - } - }, - "wifi": { - "ssid": "!secret wifi_ssid", - "password": "!secret wifi_password", - }, - "logger": { - "level": "DEBUG" - }, - "api": { - "encryption": { - "key": "!secret encryption_key" - } - }, - "ota": [{ - "platform": "esphome", - "password": "!secret ota_password" - }], - } - - - -def notifications(enabled: bool) -> Dict[str, Any]: - if not enabled: - return {} - return { - "text_sensor": [ - { - "platform": "template", - "name": "notification_text", - "id": "notification_text", - } - ], - "binary_sensor": [ - { - "platform": "gpio", - "pin": "GPIO0", - "name": "notification_button", - } - ], - } diff --git a/src/buttonplus_generator/snippets/bars.py b/src/buttonplus_generator/snippets/bars.py new file mode 100644 index 0000000..57b129f --- /dev/null +++ b/src/buttonplus_generator/snippets/bars.py @@ -0,0 +1,306 @@ +from typing import Dict, Any, List +from buttonplus_generator.constants import * +from buttonplus_generator.merger import deep_merge + +def config_bars( + version: int, num_bars: int +) -> 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))) + config = deep_merge(config, bars_display_output(num_bars=num_bars)) + config = deep_merge(config, bars_display()) + + return config + +def bars_backlight() -> Dict[str, Any]: + return { + "output": [ + { + "platform": "ledc", + "pin": GPIO_BACKLIGHT_MINI, + "id": "displays_mini_backlight_pwm", + "inverted": True, + "max_power": 0.6, + "frequency": "500Hz", + } + ], + "light": [ + { + "platform": "monochromatic", + "output": "displays_mini_backlight_pwm", + "gamma_correct": 2.2, + "name": "Bars Display Backlight", + "id": "displays_mini_backlight", + "restore_mode": "ALWAYS_ON", + } + ], + } + + +def bars_setup(num_bars: int = 3) -> Dict[str, Any]: + bar_dict = {"mcp23008": []} + + # esphome.on_boot.then.[] + scripts = [] + + if num_bars < 1: + raise ValueError("At least one bar must be connected!") + + # At least one bar in bottom slot + if num_bars > 0: + bar_dict["mcp23008"].append( + {"id": "base_J3", "i2c_id": "buttons", "address": 0x23} + ) + + scripts.extend( + [{"script.execute": "update_bar1_left"}, + {"script.execute": "update_bar1_right"}] + ) + + # At least two bars, in bottom two slots + if num_bars > 1: + bar_dict["mcp23008"].append( + {"id": "base_J2", "i2c_id": "buttons", "address": 0x22} + ) + + scripts.extend( + [{"script.execute": "update_bar2_left"}, + {"script.execute": "update_bar2_right"}] + ) + + # At least three bars, in bottom three slots + if num_bars > 2: + bar_dict["mcp23008"].append( + {"id": "base_J1", "i2c_id": "buttons", "address": 0x21} + ) + + scripts.extend( + [{"script.execute": "update_bar3_left"}, + {"script.execute": "update_bar3_right"}] + ) + + # No display, only bars, also in top slot + if num_bars > 3: + bar_dict["mcp23008"].append( + {"id": "base_J0", "i2c_id": "buttons", "address": 0x20} + ) + + scripts.extend( + [{"script.execute": "update_bar4_left"}, + {"script.execute": "update_bar4_right"}] + ) + + bar_dict["esphome"] = {"on_boot": {"then": scripts}} + + return bar_dict + + +def bars_buttons(num_bars: int = 3) -> Dict[str, Any]: + bar_dict = {"binary_sensor": []} + + if num_bars < 1: + raise ValueError("At least one bar must be connected!") + + if num_bars == 1: + bar_dict["binary_sensor"].extend(_bar_button_entry(1, "base_J3")) + + if num_bars == 2: + bar_dict["binary_sensor"].extend(_bar_button_entry(1, "base_J2")) + bar_dict["binary_sensor"].extend(_bar_button_entry(2, "base_J3")) + + if num_bars == 3: + bar_dict["binary_sensor"].extend(_bar_button_entry(1, "base_J1")) + bar_dict["binary_sensor"].extend(_bar_button_entry(2, "base_J2")) + bar_dict["binary_sensor"].extend(_bar_button_entry(3, "base_J3")) + + if num_bars == 4: + bar_dict["binary_sensor"].extend(_bar_button_entry(1, "base_J0")) + bar_dict["binary_sensor"].extend(_bar_button_entry(2, "base_J1")) + bar_dict["binary_sensor"].extend(_bar_button_entry(3, "base_J2")) + bar_dict["binary_sensor"].extend(_bar_button_entry(4, "base_J3")) + + return bar_dict + + +def _bar_button_entry(bar_nr: int, base_id: str) -> List[Dict[str, Any]]: + return [ + { + "platform": "gpio", + "id": f"bar{bar_nr}_btn_left", + "name": f"BAR {bar_nr} button Left", + "pin": { + "mcp23xxx": base_id, + "number": 6, + "mode": "INPUT_PULLUP", + "inverted": True, + }, + }, + { + "platform": "gpio", + "id": f"bar{bar_nr}_btn_right", + "name": f"BAR {bar_nr} button Right", + "pin": { + "mcp23xxx": base_id, + "number": 2, + "mode": "INPUT_PULLUP", + "inverted": True, + }, + }, + ] + + +def bars_lights(num_bars: int, display_has_leds: bool = True) -> Dict[str, Any]: + bar_dict = {"light": []} + + if num_bars < 1: + raise ValueError("At least one bar must be connected!") + + display_offset = 4 if display_has_leds else 0 + + if num_bars > 0: + bar_dict["light"].extend(_bar_lights_entry(1, 0 + display_offset)) + + if num_bars > 1: + bar_dict["light"].extend(_bar_lights_entry(2, 4 + display_offset)) + + if num_bars > 2: + bar_dict["light"].extend(_bar_lights_entry(3, 8 + display_offset)) + + if num_bars > 3: + bar_dict["light"].extend(_bar_lights_entry(4, 12)) # no display + + return bar_dict + +def _bar_lights_entry(bar_nr: int, offset: int) -> List[Dict[str, Any]]: + 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 + } + ] + }, + { + "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 + } + ] + }, + { + "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 + } + ] + }, + { + "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 + } + ] + }, + ] + +def bars_display_output(num_bars: int = 3) -> Dict[str, Any]: + bar_dict = {"output": []} + + if num_bars < 1: + raise ValueError("At least one bar must be connected!") + + if num_bars == 1: + bar_dict["output"].extend(_bar_display_output_entry(1, "base_J3")) + + if num_bars == 2: + bar_dict["output"].extend(_bar_display_output_entry(1, "base_J2")) + bar_dict["output"].extend(_bar_display_output_entry(2, "base_J3")) + + if num_bars == 3: + bar_dict["output"].extend(_bar_display_output_entry(1, "base_J1")) + bar_dict["output"].extend(_bar_display_output_entry(2, "base_J2")) + bar_dict["output"].extend(_bar_display_output_entry(3, "base_J3")) + + if num_bars == 4: + bar_dict["output"].extend(_bar_display_output_entry(1, "base_J0")) + bar_dict["output"].extend(_bar_display_output_entry(2, "base_J1")) + bar_dict["output"].extend(_bar_display_output_entry(3, "base_J2")) + bar_dict["output"].extend(_bar_display_output_entry(4, "base_J3")) + + return bar_dict + + +def _bar_display_output_entry(bar_nr: int, base_id: str) -> List[Dict[str, Any]]: + return [ + { + "platform": "gpio", + "id": f"cs_pin_bar{bar_nr}_left", + "pin": { + "mcp23xxx": base_id, + "number": 5, + "mode": { + "output": True + }, + "inverted": True, + }, + }, + { + "platform": "gpio", + "id": f"cs_pin_bar{bar_nr}_right", + "pin": { + "mcp23xxx": base_id, + "number": 1, + "mode": { + "output": True + }, + "inverted": True, + }, + }, + ] + +def bars_display() -> Dict[str, Any]: + return { + "display": [ + { + "id": "bar_display", + "platform": "ili9xxx", + "model": "ST7735", + "color_order": "bgr", + "update_interval": "never", + "dc_pin": { + "number": GPIO_SPI_REG, + "allow_other_uses": True + }, + "invert_colors": False, + "show_test_card": False, + "auto_clear_enabled": False, + "dimensions": { + "height": 160, + "width": 80, + "offset_width": 24 + } + } + ], + } \ No newline at end of file diff --git a/src/buttonplus_generator/snippets/common.py b/src/buttonplus_generator/snippets/common.py new file mode 100644 index 0000000..bd61421 --- /dev/null +++ b/src/buttonplus_generator/snippets/common.py @@ -0,0 +1,327 @@ +from typing import Dict, Any, List +from buttonplus_generator.merger import deep_merge +from buttonplus_generator.utils import slugify, array_to_cpp_vector +from buttonplus_generator.constants import * + + +def config_common( + device_name: str, version: int, page_names: List[str], num_bars: int +) -> Dict[str, Any]: + config = base_config(device_name=device_name) + config = deep_merge(config, colors(accent_color="FF0000")) + config = deep_merge(config, font()) + config = deep_merge( + config, pages(nr_of_pages=len(page_names), page_names=page_names) + ) + config = deep_merge(config, time()) + config = deep_merge(config, i2c()) + config = deep_merge(config, spi()) + config = deep_merge( + config, sensors(ambient_gain="auto", extra_ambient_sensors=False) + ) + + num_leds = 0 + display_add = 0 if version == 1 else 4 + if num_bars > 0: + num_leds = 5 + display_add + if num_bars > 1: + num_leds = 9 + display_add + if num_bars > 2: + num_leds = 13 + display_add + if num_bars > 3: + num_leds = 17 #no display + + config = deep_merge(config, neopixels(num_leds=num_leds)) + + return config + + +def base_config(device_name: str) -> Dict[str, Any]: + return { + "esphome": { + "name": slugify(device_name), + "friendly_name": device_name, + "on_boot": { + "priority": -100, + "then": [{"script.execute": "setup_bar_displays"}], + }, + }, + "esp32": { + "variant": "ESP32S3", + "board": "esp32-s3-devkitc1-n16r8", + "flash_size": "16MB", + "framework": {"type": "esp-idf"}, + }, + "psram": {"mode": "octal", "speed": "80MHz"}, + "wifi": { + "ssid": "!secret wifi_ssid", + "password": "!secret wifi_password", + }, + "logger": {"level": "DEBUG"}, + "api": {"encryption": {"key": "!secret encryption_key"}}, + "ota": [{"platform": "esphome", "password": "!secret ota_password"}], + } + + +def colors(accent_color: str) -> Dict[str, Any]: + return { + "color": [ + {"id": "accent", "hex": accent_color}, + {"id": "white", "hex": "FFFFFF"}, + {"id": "grey", "hex": "A6A6A6"}, + ], + } + + +def font() -> Dict[str, Any]: + return { + "font": [ + { + "file": { + "url": "https://github.com/web-fonts/ttf/raw/refs/heads/master/bpg-ingiri-arial-webfont.ttf", + "type": "web", + }, + "id": "font_arial20", + "size": 20, + "bpp": 4, + } + ] + } + + +def pages(nr_of_pages: int, page_names: List[str]) -> Dict[str, Any]: + page_dict = { + "globals": [ + {"id": "nr_of_pages", "type": "int", "initial_value": str(nr_of_pages)}, + { + "id": "page_names", + "type": "std::vector", + "initial_value": array_to_cpp_vector(page_names), + }, + {"id": "active_page_nr", "type": "int", "initial_value": "0"}, + {"id": "active_page_name", "type": "std::string", "initial_value": ""}, + ], + "sensors": [ + { + "platform": "template", + "name": "Active Page Number", + "icon": "mdi:order-numeric-ascending", + "id": "active_page_nr_ha", + "update_interval": "never", + "accuracy_decimals": 0, + } + ], + "text_sensor": [ + { + "platform": "template", + "name": "Active Page Name", + "icon": "mdi:order-alphabetical-ascending", + "id": "active_page_name_ha", + "update_interval": "never", + } + ], + } + + if nr_of_pages > 1: + page_dict["number"] = [ + { + "platform": "template", + "name": "Page Selector", + "id": "pageselector", + "optimistic": True, + "min_value": 0, + "max_value": nr_of_pages - 1, + "step": 1, + "on_value": { + "then": [ + { + "lambda": "if(id(pageselector).state >= id(nr_of_pages)) {\n id(active_page_nr) = id(nr_of_pages)-1;\n} else {\n id(active_page_nr) = id(pageselector).state;\n}" + }, + {"script.execute": "set_page"}, + ] + }, + } + ] + + 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}" + }, + {"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}" + }, + {"script.execute": "update_page"}, + ], + }, + { + "id": "set_page", + "then": [ + { + "lambda": "id(active_page_name) = id(page_names)[id(active_page_nr)];" + }, + { + "sensor.template.publish": { + "id": "active_page_id_ha", + "state": "return id(active_page_nr);", + } + }, + { + "text_sensor.template.publish": { + "id": "active_page_name_ha", + "state": "return id(active_page_name);", + } + }, + {"script.execute": "update_all"}, + ], + }, + { + "id": "update_page", + "then": [ + { + "number.set": { + "id": "pageselector", + "value": "return id(active_page_nr);", + } + }, + {"script.execute": "set_page"}, + ], + }, + ] + + page_dict["image"] = [ + { + "file": "mdi:chevron-right", + "id": "icon_next_page", + "resize": "30x30", + "type": "BINARY", + }, + { + "file": "mdi:chevron-left", + "id": "icon_prev_page", + "resize": "30x30", + "type": "BINARY", + }, + ] + + return page_dict + + +def time() -> Dict[str, Any]: + return {"time": [{"platform": "homeassistant", "id": "datetime"}]} + + +def i2c() -> Dict[str, Any]: + return { + "i2c": [ + { + "id": "buttons", + "sda": GPIO_I2C_BUTTONS_SDA, + "scl": GPIO_I2C_BUTTONS_SCL, + "frequency": "50khz", + }, + { + "id": "sensors", + "sda": GPIO_I2C_SENSORS_SDA, + "scl": GPIO_I2C_SENSORS_SCL, + "frequency": "50khz", + }, + ], + } + + +def spi() -> Dict[str, Any]: + return { + "spi": {"clk_pin": GPIO_SPI_CLK, "mosi_pin": GPIO_SPI_MOSI}, + } + + +def neopixels(num_leds: int) -> Dict[str, Any]: + return { + "light": [ + { + "platform": "esp32_rmt_led_strip", + "id": "neopixels", + "rgb_order": "RGB", + "chipset": "ws2812", + "pin": GPIO_NEOPIXELS, + "num_leds": num_leds, + }, + ] + } + + +def sensors(ambient_gain: str, extra_ambient_sensors: bool) -> Dict[str, Any]: + sensors_dict = { + "sensor": [ + { + "platform": "sts3x", + "address": 0x4A, + "id": "buttonplus_temperature", + "i2c_id": "sensors", + "name": "Temperature", + "update_interval": "10s", + "unit_of_measurement": "°C", + "icon": "mdi:thermometer", + "device_class": "temperature", + "state_class": "measurement", + "accuracy_decimals": 1, + }, + { + "platform": "ltr_als_ps", + "address": 0x29, + "i2c_id": "sensors", + "update_interval": "10s", + "type": "ALS", + "auto_mode": False, + "gain": ambient_gain, + "ambient_light": { + "name": "Ambient Light", + "id": "buttonplus_ambientlight", + "unit_of_measurement": "lx", + "icon": "mdi:brightness-6", + "device_class": "illuminance", + "state_class": "measurement", + "accuracy_decimals": 1, + }, + }, + ] + } + + if extra_ambient_sensors: + sensors_dict["sensor"][2]["infrared_counts"] = { + "name": "Infrared Counts", + "id": "buttonplus_infraredcounts", + "unit_of_measurement": "#", + "icon": "mdi:brightness-5", + "accuracy_decimals": 0, + } + sensors_dict["sensor"][2]["full_spectrum_counts"] = { + "name": "Full Spectrum Counts", + "id": "buttonplus_fullspectrumcounts", + "unit_of_measurement": "#", + "icon": "mdi:brightness-7", + "accuracy_decimals": 0, + } + sensors_dict["sensor"][2]["actual_gain"] = { + "name": "Actual Gain", + "id": "buttonplus_actualgain", + "icon": "mdi:multiplication", + "accuracy_decimals": 0, + } + sensors_dict["sensor"][2]["actual_integration_time "] = { + "name": "Actual Integration Time", + "id": "buttonplus_actualintegration", + "icon": "mdi:timer-outline", + "accuracy_decimals": 0, + } + + return sensors_dict diff --git a/src/buttonplus_generator/snippets/display.py b/src/buttonplus_generator/snippets/display.py new file mode 100644 index 0000000..7c8056c --- /dev/null +++ b/src/buttonplus_generator/snippets/display.py @@ -0,0 +1,152 @@ +from typing import Dict, Any +from buttonplus_generator.constants import * +from buttonplus_generator.utils import get_display_id, get_display_address +from buttonplus_generator.merger import deep_merge + +def config_display( + version: int, num_bars: int +) -> 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))) + + return config + + +def display_backlight(num_bars: int) -> Dict[str, Any]: + return { + "output": [ + { + "platform": "ledc", + "pin": GPIO_BACKLIGHT_MAIN, + "id": "display_main_backlight_pwm", + "inverted": True, + "max_power": 1, + "frequency": "500Hz", + } + ], + "light": [ + { + "platform": "monochromatic", + "output": "display_main_backlight_pwm", + "gamma_correct": 2.2, + "name": "Main Display Backlight", + "id": "display_main_backlight", + "restore_mode": "ALWAYS_ON", + } + ], + "display": [ + { + "id": "main_display", + "platform": "ili9xxx", + "model": "ili9341", + "invert_colors": False, + "color_order": "rgb", + "update_interval": "30s", + "dc_pin": { + "number": GPIO_SPI_REG, + "allow_other_uses": True + }, + "cs_pin": { + "mcp23xxx": get_display_id(num_bars), + "number": 5, + "mode": { + "output": True + }, + "inverted": False + }, + "show_test_card": False, + "dimensions": { + "height": 240, + "width": 320 + }, + "rotation": 180 + }, + ], + } + + +def display_setup(num_bars: int) -> Dict[str, Any]: + + if num_bars > 3: + raise ValueError("At most three bars must be connected to use a display!") + + return { + "mcp23008": [ + { + "id": get_display_id(num_bars), + "i2c_id": "buttons", + "address": get_display_address(num_bars), + } + ] + } + + +def display_buttons(num_bars: int) -> Dict[str, Any]: + if num_bars > 3: + raise ValueError("At most three bars must be connected to use a display!") + + return { + "binary_sensor": [ + { + "platform": "gpio", + "id": "main_display_btn_left", + "name": "Main Display button Left", + "pin": { + "mcp23xxx": get_display_id(num_bars), + "number": 6, + "mode": "INPUT_PULLUP", + "inverted": True, + }, + }, + { + "platform": "gpio", + "id": "main_display_btn_right", + "name": "Main Display button Right", + "pin": { + "mcp23xxx": get_display_id(num_bars), + "number": 2, + "mode": "INPUT_PULLUP", + "inverted": True, + }, + }, + ] + } + + +def display_lights(num_bars: int, display_has_leds: bool = True) -> 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!") + + return { + "light": [ + { + "platform": "partition", + "name": "Main Display Left RGB Front", + "id": "main_display_left_rgb_front", + "segments": [{"id": "neopixels", "from": 0, "to": 0}], + }, + { + "platform": "partition", + "name": "Main Display Left RGB Back", + "id": "main_display_left_rgb_back", + "segments": [{"id": "neopixels", "from": 1, "to": 1}], + }, + { + "platform": "partition", + "name": "Main Display Right RGB Front", + "id": "main_display_right_rgb_front", + "segments": [{"id": "neopixels", "from": 2, "to": 2}], + }, + { + "platform": "partition", + "name": "Main Display Right RGB Back", + "id": "main_display_right_rgb_back", + "segments": [{"id": "neopixels", "from": 3, "to": 3}], + }, + ] + } diff --git a/src/buttonplus_generator/utils.py b/src/buttonplus_generator/utils.py index 289e2a9..6f5873c 100644 --- a/src/buttonplus_generator/utils.py +++ b/src/buttonplus_generator/utils.py @@ -1,5 +1,6 @@ import re +from typing import List def slugify(input : str) -> str: @@ -12,4 +13,19 @@ def slugify(input : str) -> str: s = 'buttonplus' while len(s) > 1 and s[0].isdigit(): s = s[1:] - return s \ No newline at end of file + return s + +def array_to_cpp_vector(string_array : List[str]) -> str: + return "{" + ', '.join(f'"{entry}"' for entry in string_array) + "}" + +def get_display_id(num_bars : int) -> str: + if(num_bars < 1 or num_bars > 3): + raise ValueError("Can't have a display with this number of bars") + + return "base_J" + str(3 - num_bars) + +def get_display_address(num_bars : int) -> str: + if(num_bars < 1 or num_bars > 3): + raise ValueError("Can't have a display with this number of bars") + + return 0x23 - num_bars