Project setup

This commit is contained in:
2026-05-12 13:04:34 +02:00
parent 47d298f4ad
commit 43e510b93d
19 changed files with 2034 additions and 1 deletions
View File
+2
View File
@@ -0,0 +1,2 @@
"""Button+ ESPHome generator package."""
__all__ = ["generator", "snippets"]
+90
View File
@@ -0,0 +1,90 @@
"""CLI tool to compose ESPHome YAML from snippets.
Usage: python main.py --pages 3 --notifications --led --output buttonplus.yaml
"""
from __future__ import annotations
import argparse
from typing import Any, Dict
import yaml
from buttonplus_generator import snippets
from pathlib import Path
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))
return config
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
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)")
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)
print(f"Wrote {args.output}")
return 0
+61
View File
@@ -0,0 +1,61 @@
"""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",
}
],
}
+15
View File
@@ -0,0 +1,15 @@
import re
def slugify(input : str) -> str:
s = input.lower()
s = re.sub(r'\s+', '_', s)
s = re.sub(r'[^a-z0-9_]+', '', s)
s = re.sub(r'_+', '_', s)
s = s.strip('_')
if not s:
s = 'buttonplus'
while len(s) > 1 and s[0].isdigit():
s = s[1:]
return s
+4
View File
@@ -0,0 +1,4 @@
from buttonplus_generator import generator
if __name__ == "__main__":
generator.main()