Implement config split (with AI help)
This commit is contained in:
@@ -1,21 +1,42 @@
|
|||||||
# buttonplus-esphome-generator
|
# buttonplus-esphome-generator
|
||||||
|
|
||||||
A python script that generates a full ESPHome config for the Button+ V2
|
A python script that generates a full ESPHome config for the Button+ with V2 PCB
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Notification Action: `esphome.<name>_notification`
|
- Notification Action: `esphome.<name>_notification`
|
||||||
- Shows a notification, which has to be dismissed
|
- Shows a notification, which has to be dismissed
|
||||||
|
- Clear notification Action: `esphome.<name>_notification_clear`
|
||||||
- Toast Action: `esphome.<name>_toast`
|
- Toast Action: `esphome.<name>_toast`
|
||||||
- Shows a quick toast message which dissapears after a timeout
|
- Shows a quick toast message which dissapears after a timeout
|
||||||
- Perfect for confirming actions
|
- Perfect for confirming actions
|
||||||
- Get info Action: `esphome.<name>_get_page_info`
|
- Get info Action: `esphome.<name>_get_info`
|
||||||
- Shows data needed for the set_page_led action
|
- Shows data needed for the set_page_led action
|
||||||
|
- Including the available effects, led names and page names
|
||||||
- Set Led per page Action: `esphome.<name>_set_page_led`
|
- Set Led per page Action: `esphome.<name>_set_page_led`
|
||||||
- Allows you to set the LED for a specific page
|
- Allows you to set the LED for a specific page
|
||||||
- So that you don't need to keep track of which page is active when setting a LED
|
- So that you don't need to keep track of which page is active when setting a LED
|
||||||
|
- All fields are required, because optionality is not possible at this moment
|
||||||
|
- Effect can be set to None
|
||||||
|
- RGB and Brightness should be values between 0 and 255
|
||||||
|
- Reset all LEDS: `esphome.<name>_reset_all_page_leds`
|
||||||
|
- Every LED config for every page will be set back to off and clear all data
|
||||||
|
- Show web image: `esphome.<name>_show_web_jpeg_image`
|
||||||
|
- Must be a url to a JPG image!
|
||||||
|
- Will be resized to 320x240 pixels.
|
||||||
|
- Will disappear after the timeout and be removed from memory
|
||||||
|
- Will look absolutely horrendous
|
||||||
|
- Show HA image: `esphome.<name>_show_ha_jpeg_image`
|
||||||
|
- Only available if a Home Assistant `--token TOKEN` is set during generation
|
||||||
|
- Allows loading of images which require login from Home Assistant
|
||||||
|
- Sets the `Authorization: Bearer <token>` header
|
||||||
|
- All other things from the web image action
|
||||||
|
- Clear images before their timeout: `esphome.<name>_clear_image`
|
||||||
|
- Will clear both web or HA image immediatly and release the memory
|
||||||
- Generates pages and all buttons and events belonging to each page
|
- Generates pages and all buttons and events belonging to each page
|
||||||
- Breathe and Blink effect for all LEDs
|
- Breathe and Blink effect for all LEDs
|
||||||
- more to come..
|
- Confirmation LED pulse on button press
|
||||||
|
|
||||||
|
And all of that is split into two files for convenience. Everything you can and should edit or create manually in the top level file, while everything else is in the secondary file.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
### Project uses pipenv (Optional)
|
### Project uses pipenv (Optional)
|
||||||
@@ -27,29 +48,23 @@ run `pipenv shell` after that to open the virtual environment.
|
|||||||
|
|
||||||
You can then use `python src/main.py --help` to run the generator
|
You can then use `python src/main.py --help` to run the generator
|
||||||
|
|
||||||
### Example run command
|
### Example run commands
|
||||||
`python src/main.py -v 1 -b 3 -p Start Music Lights Security -c single double hold`
|
`python src/main.py -v 1 -b 3 -p Start Music Lights Security -c single double hold --token EXAMPLE_TOKEN --no-click-confirm`
|
||||||
|
`python src/main.py --device-name Generated_ButtonPlus --version 2 --bars 1 --pages Home Living --click-types single hold --no-split --output buttonplus`
|
||||||
|
|
||||||
## Config example
|
## Config example
|
||||||
The configuration (handmade) on which this generator is based, can be found in the `esphome` folder.
|
The configuration (handmade) on which this generator was initially based, can be found in the `esphome` folder. There is also an example of the output of this generator.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
Initially based on the configuration of [https://github.com/dixi83/ESPhome_ButtonPlus](https://github.com/dixi83/ESPhome_ButtonPlus), all initial effort for this was done by [dixi83](https://github.com/dixi83) and [balk77](https://github.com/balk77)
|
||||||
|
|
||||||
|
[More information (in Dutch) can be found here on Tweakers.net](https://gathering.tweakers.net/forum/list_messages/2270086)
|
||||||
|
|
||||||
## Todo
|
## Todo
|
||||||
|
|
||||||
### Ideas
|
### Ideas
|
||||||
- Show image service
|
None
|
||||||
- Top level config only for drawing, everything else hidden
|
|
||||||
- back to using packages or includes probably
|
|
||||||
- with --no-split option
|
|
||||||
- two files
|
|
||||||
|
|
||||||
### Missing config
|
### Missing config
|
||||||
- Event lambda's (see config.yaml)
|
None
|
||||||
- Implement --no-click-confirm
|
|
||||||
- Make effects optional (--no-effects)
|
|
||||||
- service: get led state
|
|
||||||
- led name
|
|
||||||
- page name
|
|
||||||
- page num
|
|
||||||
- service: get_page_info
|
|
||||||
- rename the service
|
|
||||||
- add the effect options
|
|
||||||
|
|||||||
+426
-1407
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ class IdSuffix(StrEnum):
|
|||||||
CS_PIN = "cs_pin"
|
CS_PIN = "cs_pin"
|
||||||
|
|
||||||
SCRIPT_UPDATE = "script_update"
|
SCRIPT_UPDATE = "script_update"
|
||||||
|
SCRIPT_DRAW_USER = "script_draw_user"
|
||||||
SCRIPT_DRAW = "script_draw"
|
SCRIPT_DRAW = "script_draw"
|
||||||
|
|
||||||
class ClickType(StrEnum):
|
class ClickType(StrEnum):
|
||||||
@@ -86,6 +87,9 @@ TIMING_HOLD_CLICK = [
|
|||||||
"ON for at least 1s",
|
"ON for at least 1s",
|
||||||
"OFF for at least 0.2s"
|
"OFF for at least 0.2s"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
class YAMLInclude:
|
||||||
|
def __init__(self, value): self.value = value
|
||||||
|
|
||||||
class YAMLExtend:
|
class YAMLExtend:
|
||||||
def __init__(self, value): self.value = value
|
def __init__(self, value): self.value = value
|
||||||
@@ -94,4 +98,4 @@ class YAMLLambda:
|
|||||||
def __init__(self, value): self.value = value
|
def __init__(self, value): self.value = value
|
||||||
|
|
||||||
class YAMLSecret:
|
class YAMLSecret:
|
||||||
def __init__(self, value): self.value = value
|
def __init__(self, value): self.value = value
|
||||||
|
|||||||
@@ -8,16 +8,17 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from buttonplus_generator.constants import YAMLExtend, YAMLLambda, YAMLSecret, ClickType
|
from buttonplus_generator.constants import YAMLExtend, YAMLInclude, 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.snippets.services import config_services
|
from buttonplus_generator.snippets.services import config_services
|
||||||
from buttonplus_generator.snippets.leds import config_leds
|
from buttonplus_generator.snippets.leds import config_leds
|
||||||
from buttonplus_generator.merger import deep_merge
|
from buttonplus_generator.merger import deep_merge, deep_split
|
||||||
from buttonplus_generator.utils import list_to_click_type
|
from buttonplus_generator.utils import list_to_click_type
|
||||||
|
|
||||||
def compose_config(
|
def compose_config(
|
||||||
@@ -28,6 +29,7 @@ def compose_config(
|
|||||||
click_types: List[ClickType],
|
click_types: List[ClickType],
|
||||||
click_confirm: bool,
|
click_confirm: bool,
|
||||||
services: bool,
|
services: bool,
|
||||||
|
token: str,
|
||||||
effects: bool
|
effects: bool
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ def compose_config(
|
|||||||
|
|
||||||
# Services, only available with display
|
# Services, only available with display
|
||||||
if services:
|
if services:
|
||||||
config = deep_merge(config, config_services(version=version))
|
config = deep_merge(config, config_services(version=version, token=token))
|
||||||
|
|
||||||
# Bars
|
# Bars
|
||||||
config = deep_merge(
|
config = deep_merge(
|
||||||
@@ -70,27 +72,31 @@ def compose_config(
|
|||||||
def yaml_dumper(config: Dict[str, Any], fh):
|
def yaml_dumper(config: Dict[str, Any], fh):
|
||||||
|
|
||||||
## Handle multiline strings
|
## Handle multiline strings
|
||||||
def represent_multistr(dumper, data):
|
def represent_multistr(dumper, data: str):
|
||||||
"""configures yaml for dumping multiline strings
|
"""configures yaml for dumping multiline strings
|
||||||
Ref: https://stackoverflow.com/questions/8640959/how-can-i-control-what-scalar-form-pyyaml-uses-for-my-data
|
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
|
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.strip(), style="|")
|
||||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
||||||
|
|
||||||
def represent_extend(dumper, data):
|
|
||||||
return dumper.represent_scalar("!extend", data.value)
|
|
||||||
|
|
||||||
def represent_lambda(dumper, data):
|
def represent_lambda(dumper, data):
|
||||||
if len(data.value.splitlines()) > 1: # check for multiline string
|
if len(data.value.splitlines()) > 1: # check for multiline string
|
||||||
return dumper.represent_scalar("!lambda", data.value, style="|")
|
return dumper.represent_scalar("!lambda", data.value.strip(), style="|")
|
||||||
return dumper.represent_scalar("!lambda", data.value, style='"')
|
return dumper.represent_scalar("!lambda", data.value, style='"')
|
||||||
|
|
||||||
|
def represent_include(dumper, data):
|
||||||
|
return dumper.represent_scalar("!include", data.value)
|
||||||
|
|
||||||
|
def represent_extend(dumper, data):
|
||||||
|
return dumper.represent_scalar("!extend", data.value)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
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(YAMLInclude, represent_include)
|
||||||
yaml.add_representer(YAMLLambda, represent_lambda)
|
yaml.add_representer(YAMLLambda, represent_lambda)
|
||||||
yaml.add_representer(YAMLSecret, represent_secret)
|
yaml.add_representer(YAMLSecret, represent_secret)
|
||||||
|
|
||||||
@@ -152,6 +158,10 @@ 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(
|
||||||
|
"--token",
|
||||||
|
help="Home Assistant token used for loading home assistant images, creates a second image service",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-click-confirm",
|
"--no-click-confirm",
|
||||||
default=False,
|
default=False,
|
||||||
@@ -171,7 +181,13 @@ def main(argv=None) -> int:
|
|||||||
help="Don't add any effects to the LEDs.",
|
help="Don't add any effects to the LEDs.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", "-o", default="buttonplus.yaml", help="Output file path"
|
"--no-split",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
help="Don't split the configuration in two files.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", "-o", default="buttonplus", help="Output file path"
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
@@ -183,24 +199,61 @@ def main(argv=None) -> int:
|
|||||||
click_confirm: bool = not args.no_click_confirm
|
click_confirm: bool = not args.no_click_confirm
|
||||||
effects: bool = not args.no_effects
|
effects: bool = not args.no_effects
|
||||||
services: bool = not args.no_services
|
services: bool = not args.no_services
|
||||||
output: str = args.output
|
split: bool = not args.no_split
|
||||||
|
token: str = args.token or ""
|
||||||
|
output: str = args.output.removesuffix(".yaml")
|
||||||
|
|
||||||
|
generate(
|
||||||
|
output, split, device_name, version, num_bars, pages, click_types, click_confirm, services, token, effects
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
output: str,
|
||||||
|
split: bool,
|
||||||
|
device_name: str,
|
||||||
|
version: int,
|
||||||
|
num_bars: int,
|
||||||
|
pages: List[str],
|
||||||
|
click_types: List[ClickType],
|
||||||
|
click_confirm: bool,
|
||||||
|
services: bool,
|
||||||
|
token: str,
|
||||||
|
effects: bool):
|
||||||
|
|
||||||
config = compose_config(
|
config = compose_config(
|
||||||
device_name, version, num_bars, pages, click_types, click_confirm, services, effects
|
device_name, version, num_bars, pages, click_types, click_confirm, services, token, effects
|
||||||
)
|
)
|
||||||
with open(output, "w", encoding="utf-8") as fh:
|
|
||||||
fh.writelines(
|
user_file = output + ".yaml"
|
||||||
[
|
base_file = output + "_base.yaml"
|
||||||
"# Generated by ESPHome for Buttonplus\n",
|
|
||||||
"# Options: \n",
|
files = {user_file: config}
|
||||||
f"# - device: {device_name}\n",
|
|
||||||
f"# - version: {version}\n",
|
if split:
|
||||||
f"# - bars: {num_bars}\n",
|
base, user = deep_split(config, os.path.basename(base_file))
|
||||||
f"# - pages: {", ".join(pages)}\n",
|
|
||||||
"\n",
|
files = {
|
||||||
]
|
base_file: base,
|
||||||
)
|
user_file: user
|
||||||
yaml_dumper(config, fh)
|
}
|
||||||
|
|
||||||
|
for yfile, yconf in files.items():
|
||||||
|
|
||||||
|
with open(yfile, "w", encoding="utf-8") as fh:
|
||||||
|
fh.writelines(
|
||||||
|
[
|
||||||
|
"# Generated by ESPHome for Buttonplus\n",
|
||||||
|
f"# File: {yfile}\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(yconf, fh)
|
||||||
|
|
||||||
print(f"Wrote {output}")
|
print(f"Wrote {yfile}")
|
||||||
return 0
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from typing import Any, Dict, List
|
import copy
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from buttonplus_generator.utils import display_id, bar_id
|
||||||
|
from buttonplus_generator.constants import IdSuffix, Side, YAMLExtend, YAMLInclude
|
||||||
|
|
||||||
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]:
|
||||||
"""Recursively merge b into a and return a new dict.
|
"""Recursively merge b into a and return a new dict.
|
||||||
|
|
||||||
Lists are concatenated; scalar values in b override a.
|
Scalar values in b overwrite a. Lists are merged using the list_merge function.
|
||||||
"""
|
"""
|
||||||
result = dict(a)
|
result = dict(a)
|
||||||
for k, v in b.items():
|
for k, v in b.items():
|
||||||
@@ -15,20 +18,20 @@ def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
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
|
# 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] = list_merge(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]:
|
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.
|
Merge two list together based on id's. This is similar to !extend in YAML.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Check if both lists contain Dicts with an `id` key in them
|
1. Check if both lists contain Dicts with an `id` key in them
|
||||||
1a. Otherwise do a pre-append list merge (extend)
|
1a. Otherwise do a pre-append list merge (extend, see other function)
|
||||||
2. For every entry in a:
|
2. For every entry in a:
|
||||||
3. Store id in a list for later
|
3. Store id in a list for later
|
||||||
4. Find the same id in b, if not found add to result without merge
|
4. Find the same id in b, if not found add to result without merge
|
||||||
@@ -47,7 +50,7 @@ def list_merge(a: List[Any], b: List[Any]) -> List[Any]:
|
|||||||
|
|
||||||
# If there are no id-based dicts in either list, just extend
|
# If there are no id-based dicts in either list, just extend
|
||||||
if not (a_has_id and b_has_id):
|
if not (a_has_id and b_has_id):
|
||||||
return pre_append_list_merge(a, b)
|
return _pre_append_list_merge(a, b)
|
||||||
|
|
||||||
result: List[Any] = []
|
result: List[Any] = []
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
@@ -103,7 +106,16 @@ def list_merge(a: List[Any], b: List[Any]) -> List[Any]:
|
|||||||
# Return the merged list
|
# Return the merged list
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def pre_append_list_merge(a: List[Any], b: List[Any]) -> List[Any]:
|
def _pre_append_list_merge(a: List[Any], b: List[Any]) -> List[Any]:
|
||||||
|
"""
|
||||||
|
Merge two list together with optional pre-appending. This is step two
|
||||||
|
of the list merge. Checks if any list is requesting a pre-append (prepend key present),
|
||||||
|
in which case this function will process them. Otherwise just extend them together.
|
||||||
|
|
||||||
|
For every list element, if prepend=true, add it to the front. Otherwise add it to the back.
|
||||||
|
List order is preserved. Meaning that multiple prepend entries will be added to the front
|
||||||
|
in the same order that they were in the original list.
|
||||||
|
"""
|
||||||
|
|
||||||
# Detect whether both lists contain dicts with an 'prepend' key
|
# Detect whether both lists contain dicts with an 'prepend' key
|
||||||
a_has_prepend = any(isinstance(x, dict) and "prepend" in x for x in a)
|
a_has_prepend = any(isinstance(x, dict) and "prepend" in x for x in a)
|
||||||
@@ -135,4 +147,248 @@ def pre_append_list_merge(a: List[Any], b: List[Any]) -> List[Any]:
|
|||||||
for item in b:
|
for item in b:
|
||||||
do_pre_or_append(item)
|
do_pre_or_append(item)
|
||||||
|
|
||||||
return list(prepend_list) + list(append_list)
|
return list(prepend_list) + list(append_list)
|
||||||
|
|
||||||
|
|
||||||
|
### SPLITTER ###
|
||||||
|
|
||||||
|
SPLITOUT_KEYS = [
|
||||||
|
"esphome.name",
|
||||||
|
"esphome.friendly_name",
|
||||||
|
"wifi",
|
||||||
|
"logger",
|
||||||
|
"ota",
|
||||||
|
"api.encryption",
|
||||||
|
"color.[id=accent]",
|
||||||
|
f"script.[id={display_id(IdSuffix.SCRIPT_DRAW_USER)}]",
|
||||||
|
f"script.[id={bar_id(IdSuffix.SCRIPT_DRAW, 1, Side.LEFT)}]",
|
||||||
|
f"script.[id={bar_id(IdSuffix.SCRIPT_DRAW, 1, Side.RIGHT)}]",
|
||||||
|
f"script.[id={bar_id(IdSuffix.SCRIPT_DRAW, 2, Side.LEFT)}]",
|
||||||
|
f"script.[id={bar_id(IdSuffix.SCRIPT_DRAW, 2, Side.RIGHT)}]",
|
||||||
|
f"script.[id={bar_id(IdSuffix.SCRIPT_DRAW, 3, Side.LEFT)}]",
|
||||||
|
f"script.[id={bar_id(IdSuffix.SCRIPT_DRAW, 3, Side.RIGHT)}]",
|
||||||
|
f"event.[id={display_id(IdSuffix.EVENT_BUTTON, Side.LEFT)}].on_event",
|
||||||
|
f"event.[id={display_id(IdSuffix.EVENT_BUTTON, Side.RIGHT)}].on_event",
|
||||||
|
f"event.[id={bar_id(IdSuffix.EVENT_BUTTON, 1, Side.LEFT)}].on_event",
|
||||||
|
f"event.[id={bar_id(IdSuffix.EVENT_BUTTON, 1, Side.RIGHT)}].on_event",
|
||||||
|
f"event.[id={bar_id(IdSuffix.EVENT_BUTTON, 2, Side.LEFT)}].on_event",
|
||||||
|
f"event.[id={bar_id(IdSuffix.EVENT_BUTTON, 2, Side.RIGHT)}].on_event",
|
||||||
|
f"event.[id={bar_id(IdSuffix.EVENT_BUTTON, 3, Side.LEFT)}].on_event",
|
||||||
|
f"event.[id={bar_id(IdSuffix.EVENT_BUTTON, 3, Side.RIGHT)}].on_event",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _parse_split_path(path: str) -> List[Any]:
|
||||||
|
"""Parse a split path string into tokens."""
|
||||||
|
tokens = []
|
||||||
|
for raw in re.findall(r"[^.\[\]]+|\[[^\]]+\]", path):
|
||||||
|
if raw.startswith('[') and raw.endswith(']'):
|
||||||
|
selector = raw[1:-1]
|
||||||
|
if '=' not in selector:
|
||||||
|
raise ValueError(f"Unsupported selector format: {path}")
|
||||||
|
key, value = selector.split('=', 1)
|
||||||
|
tokens.append(("filter", key, value.strip("\"'")))
|
||||||
|
else:
|
||||||
|
tokens.append(raw)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _find_target(container: Any, tokens: List[Any]) -> Optional[Tuple[Any, Any, Any, Any]]:
|
||||||
|
"""Find the target value described by tokens and return its parent context."""
|
||||||
|
current = container
|
||||||
|
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
if isinstance(token, str):
|
||||||
|
if not isinstance(current, dict) or token not in current:
|
||||||
|
return None
|
||||||
|
if index == len(tokens) - 1:
|
||||||
|
return current, token, current[token], None
|
||||||
|
current = current[token]
|
||||||
|
else:
|
||||||
|
kind, key, value = token
|
||||||
|
if kind != "filter" or not isinstance(current, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for item_index, item in enumerate(current):
|
||||||
|
if isinstance(item, dict) and item.get(key) == value:
|
||||||
|
if index == len(tokens) - 1:
|
||||||
|
return current, item_index, item, item
|
||||||
|
next_result = _find_target(item, tokens[index + 1:])
|
||||||
|
if next_result is not None:
|
||||||
|
return next_result[0], next_result[1], next_result[2], item
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_split_fragment(item: Dict[str, Any], tokens: List[Any]) -> Dict[str, Any]:
|
||||||
|
"""Create the fragment for a selector-based split path."""
|
||||||
|
selector_index = next((i for i, token in enumerate(tokens) if isinstance(token, tuple)), None)
|
||||||
|
if selector_index is None:
|
||||||
|
return copy.deepcopy(item)
|
||||||
|
|
||||||
|
if selector_index == len(tokens) - 1:
|
||||||
|
return copy.deepcopy(item)
|
||||||
|
|
||||||
|
fragment: Dict[str, Any] = {}
|
||||||
|
if isinstance(item, dict) and "id" in item:
|
||||||
|
fragment["id"] = YAMLExtend(item['id'])
|
||||||
|
|
||||||
|
current = fragment
|
||||||
|
for token in tokens[selector_index + 1:-1]:
|
||||||
|
if isinstance(token, str):
|
||||||
|
current[token] = {}
|
||||||
|
current = current[token]
|
||||||
|
|
||||||
|
last_token = tokens[-1]
|
||||||
|
if isinstance(last_token, str) and isinstance(item, dict) and last_token in item:
|
||||||
|
current[last_token] = copy.deepcopy(item[last_token])
|
||||||
|
|
||||||
|
return fragment
|
||||||
|
|
||||||
|
|
||||||
|
def deep_split(origin: Dict[str, Any], origin_filename: str, splitout: List[str] = SPLITOUT_KEYS) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Split a complete YAML dict into two files based on a list of keys that are requested to be split out.
|
||||||
|
|
||||||
|
Input:
|
||||||
|
origin: a yaml structure, consisting of nested dictionaries, lists, lists of dictionaries and scalar values
|
||||||
|
splitout: list of json-path like strings that declare which keys should be split out into a separate structure
|
||||||
|
|
||||||
|
Splitout-path specification:
|
||||||
|
<key-a>.<key-b> : key-a is a dictionary key, which contains a dictionary of which key-b is a key.
|
||||||
|
key-b and its value (which can be another nested structure), should me moved to <key-a>.<key-b> inside the output structure
|
||||||
|
assume <key-a>.<key-c> also exists, this should remain in the origin structure
|
||||||
|
<key-a>.[<key-b>=<value-b>]: key-a is a dictionary key, which contains a list of dictionaries,
|
||||||
|
of which we want the entry which has key-b with the value of value-b
|
||||||
|
there might also be other dictionaries in the list of key-a, but these must stay in the origin structure
|
||||||
|
the extracted dictionary should be placed in a list at the same path in the output structure, <key-a>.[]
|
||||||
|
<key-a>.[<key-b>=<value-b>].<key-c>: key-a is a dictionary key, which contains a list of dictionaries,
|
||||||
|
of which we want the entry which has key-b with the value of value-b
|
||||||
|
we only want to move the key-c entry of this dictionary in the list
|
||||||
|
the extracted element should be placed in a new list at the same path in the output structure, <key-a>.[<key-b>=<value-b>].<key-c>
|
||||||
|
|
||||||
|
Example:
|
||||||
|
paths =
|
||||||
|
list.[id=entry1]
|
||||||
|
list.[id=entry2].c
|
||||||
|
dict.e.g
|
||||||
|
move
|
||||||
|
|
||||||
|
origin = {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: entry1
|
||||||
|
key1: moves_with_list_entry
|
||||||
|
key2: moves_with_list_entry
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: entry2
|
||||||
|
c: value_to_move
|
||||||
|
d: stays_here_value
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dict: {
|
||||||
|
e: {
|
||||||
|
g: value_to_move
|
||||||
|
},
|
||||||
|
f: stays_here_value
|
||||||
|
},
|
||||||
|
move: value_to_move
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_split = {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: entry1
|
||||||
|
key1: moves_with_list_entry
|
||||||
|
key2: moves_with_list_entry
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: !extend entry2
|
||||||
|
c: value_to_move
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dict: {
|
||||||
|
e: {
|
||||||
|
g: value_to_move
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move: value_to_move
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_origin = {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: entry2
|
||||||
|
d: stays_here_value
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dict: {
|
||||||
|
f: stays_here_value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Parse the splitout keys into tokens
|
||||||
|
2. For every splitout key, find the entry in the origin dictionary.
|
||||||
|
2a. It is important to retain the entire parent path, as we want to reconstruct it in the new output
|
||||||
|
3. Create a new output dictionary where the entries are moved to
|
||||||
|
3a. Only move the leaf node value and copy it's parent structure
|
||||||
|
3b. Do not move any adjecant nodes, if there are no adjecant nodes it should be cleaned from the origin
|
||||||
|
3c. The moved leaf should not remain present in the origin, it has been moved out to the output
|
||||||
|
4. Ensure that the origin dictionary is still valid, after moving the node to the output structure
|
||||||
|
4a. If the leaf is a list element (<key-a>.[<key-b>=<value-b>]), remove it from the list. If this list is now empty, remove the list key
|
||||||
|
4b. If the leaf has a list as parent node at some point (<key-a>.[<key-b>=<value-b>].<key-c>), move this leaf only. Keep the rest of the node and structure intact.
|
||||||
|
4bb. Origin keeps the `id: <value>`, output gets `id: !extend <value>`.
|
||||||
|
4c. If the leaf is a dictionary or scalar (<key-a>.<key-b>), move it over entirely and remove it from origin
|
||||||
|
5. Return a tuple of the modified origin and the new output structure.
|
||||||
|
|
||||||
|
NOTE: This function and its subfunctions were generated by AI
|
||||||
|
"""
|
||||||
|
output: Dict[str, Any] = {"packages": [YAMLInclude(origin_filename)]}
|
||||||
|
|
||||||
|
for path in splitout:
|
||||||
|
tokens = _parse_split_path(path)
|
||||||
|
found = _find_target(origin, tokens)
|
||||||
|
if found is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parent, key, value, selected_item = found
|
||||||
|
selector_index = next((i for i, token in enumerate(tokens) if isinstance(token, tuple)), None)
|
||||||
|
|
||||||
|
if isinstance(parent, dict):
|
||||||
|
if isinstance(key, int):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if selector_index is not None and selector_index < len(tokens) - 1:
|
||||||
|
fragment = _make_split_fragment(copy.deepcopy(selected_item), tokens)
|
||||||
|
output.setdefault(tokens[0], []).append(fragment)
|
||||||
|
else:
|
||||||
|
current = output
|
||||||
|
for token in tokens[:-1]:
|
||||||
|
if isinstance(token, str):
|
||||||
|
current = current.setdefault(token, {})
|
||||||
|
current[tokens[-1]] = copy.deepcopy(value)
|
||||||
|
|
||||||
|
del parent[key]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(parent, list) and isinstance(key, int):
|
||||||
|
item = parent[key]
|
||||||
|
if selector_index is None or selector_index == len(tokens) - 1:
|
||||||
|
output.setdefault(tokens[0], []).append(copy.deepcopy(item))
|
||||||
|
parent.pop(key)
|
||||||
|
continue
|
||||||
|
|
||||||
|
fragment = _make_split_fragment(copy.deepcopy(item), tokens)
|
||||||
|
output.setdefault(tokens[0], []).append(fragment)
|
||||||
|
|
||||||
|
current = item
|
||||||
|
for token in tokens[selector_index + 1:-1]:
|
||||||
|
if isinstance(token, str) and isinstance(current, dict) and token in current:
|
||||||
|
current = current[token]
|
||||||
|
if isinstance(current, dict) and isinstance(tokens[-1], str):
|
||||||
|
current.pop(tokens[-1], None)
|
||||||
|
|
||||||
|
return origin, output
|
||||||
@@ -169,31 +169,45 @@ def update_scripts(page_names: List[str]) -> Dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
generated_cpp = (
|
generated_cpp = (
|
||||||
"// All pages\n"
|
|
||||||
f"id({display_id(IdSuffix.DISPLAY)}).image(0, 235, id(icon_prev_page), ImageAlign::BOTTOM_LEFT, id(grey));\n"
|
f"id({display_id(IdSuffix.DISPLAY)}).image(0, 235, id(icon_prev_page), ImageAlign::BOTTOM_LEFT, id(grey));\n"
|
||||||
f"id({display_id(IdSuffix.DISPLAY)}).image(320, 235, id(icon_next_page), ImageAlign::BOTTOM_RIGHT, id(grey));\n"
|
f"id({display_id(IdSuffix.DISPLAY)}).image(320, 235, id(icon_next_page), ImageAlign::BOTTOM_RIGHT, id(grey));\n"
|
||||||
f"id({display_id(IdSuffix.DISPLAY)}).print(160, 235, id(font_arial20), id(white), TextAlign::BOTTOM_CENTER, id(active_page_name).c_str());\n"
|
f"id({display_id(IdSuffix.DISPLAY)}).print(160, 235, id(font_arial20), id(white), TextAlign::BOTTOM_CENTER, id(active_page_name).c_str());\n"
|
||||||
"\n"
|
)
|
||||||
f"id({display_id(IdSuffix.DISPLAY)}).strftime(160, 5, id(font_arial20), TextAlign::TOP_CENTER, \"%H:%M\", id(datetime).now());\n"
|
|
||||||
f"id({display_id(IdSuffix.DISPLAY)}).strftime(160, 80, id(font_arial20), TextAlign::TOP_CENTER, \"%d-%m-%Y\", id(datetime).now());\n"
|
generated_cpp_user = (
|
||||||
|
"// All pages\n"
|
||||||
|
f"id({display_id(IdSuffix.DISPLAY)}).strftime(160, 5, id(font_arial20), TextAlign::TOP_CENTER, \"%H:%M\", id(datetime).now());\n"
|
||||||
|
f"id({display_id(IdSuffix.DISPLAY)}).strftime(160, 80, id(font_arial20), TextAlign::TOP_CENTER, \"%d-%m-%Y\", id(datetime).now());\n"
|
||||||
"\n"
|
"\n"
|
||||||
"switch (id(active_page_nr)){\n"
|
"switch (id(active_page_nr)){\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
for page_num in range(0, len(page_names)):
|
for page_num in range(0, len(page_names)):
|
||||||
generated_cpp += (
|
generated_cpp_user += (
|
||||||
f" // Page {page_num} - {page_names[page_num]}\n"
|
f" // Page {page_num} - {page_names[page_num]}\n"
|
||||||
f" case {page_num}:\n"
|
f" case {page_num}:\n"
|
||||||
f" break;\n"
|
f" break;\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
generated_cpp += "}\n"
|
generated_cpp_user += "}\n"
|
||||||
|
|
||||||
display_dict["script"].append(
|
display_dict["script"].append(
|
||||||
{
|
{
|
||||||
"id": display_id(IdSuffix.SCRIPT_DRAW),
|
"id": display_id(IdSuffix.SCRIPT_DRAW),
|
||||||
"then": [
|
"then": [
|
||||||
{"lambda": generated_cpp}
|
{"lambda": generated_cpp},
|
||||||
|
{
|
||||||
|
"script.execute": display_id(IdSuffix.SCRIPT_DRAW_USER)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
display_dict["script"].append(
|
||||||
|
{
|
||||||
|
"id": display_id(IdSuffix.SCRIPT_DRAW_USER),
|
||||||
|
"then": [
|
||||||
|
{"lambda": generated_cpp_user},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user