Add base and display snippets, initial generation works
This commit is contained in:
@@ -9,3 +9,19 @@ You can then use `python
|
||||
|
||||
## Config example
|
||||
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)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Button+ ESPHome generator package."""
|
||||
__all__ = ["generator", "snippets"]
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
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)
|
||||
|
||||
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
|
||||
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))
|
||||
|
||||
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))
|
||||
return config
|
||||
|
||||
def yaml_dumper(config: Dict[str, Any], fh) -> str:
|
||||
|
||||
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: <value> that follows an `esphome:` line
|
||||
text = re.sub(r"(^\s*)name:\s*.*$", _repl, text, count=1, flags=re.M)
|
||||
return text
|
||||
## 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)
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -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<std::string>",
|
||||
"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
|
||||
@@ -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}],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
|
||||
def slugify(input : str) -> str:
|
||||
@@ -13,3 +14,18 @@ def slugify(input : str) -> str:
|
||||
while len(s) > 1 and s[0].isdigit():
|
||||
s = s[1:]
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user