Project setup
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Button+ ESPHome generator package."""
|
||||
__all__ = ["generator", "snippets"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
from buttonplus_generator import generator
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator.main()
|
||||
Reference in New Issue
Block a user