Files
Home-Assistant/esphome/chore-tracker/generate.py

949 lines
31 KiB
Python

#!/usr/bin/env python3
"""
generate.py — Chore Tracker Code Generator
==========================================
Reads chores_config.yaml and generates:
• chore-tracker-esphome.yaml (flash to ESP32 via ESPHome)
• chore-tracker-ha.yaml (Home Assistant config)
• chore-tracker-dashboard.yaml (Lovelace dashboard)
Target hardware: Waveshare ESP32-S3-Touch-LCD-7 (800x480)
Home screen behaviour:
- Each kid's button has a RED outline while any chores remain undone
- Button turns SOLID GREEN when ALL chores are complete
- Resets to red automatically at midnight
Each kid must define their own chores list in chores_config.yaml.
Usage:
python3 generate.py
python3 generate.py --config my_other_config.yaml
python3 generate.py --out ./output_dir
"""
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("PyYAML not found. Install it with: pip install pyyaml")
sys.exit(1)
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
# Screen padding — space kept clear around all edges (pixels)
PAD = 12
def slug(name: str) -> str:
return name.lower().replace(" ", "_").replace("-", "_")
def kid_slug(kid: dict) -> str:
return slug(kid["name"])
def eid(kid: dict, chore: dict) -> str:
return f"{kid_slug(kid)}_{slug(chore['name'])}"
def ha_switch(kid: dict, chore: dict) -> str:
return f"switch.chore_tracker_{eid(kid, chore)}"
# mdi:checkbox-marked-circle-outline — safe default if icon is omitted
DEFAULT_ICON = "\U000F0133"
def get_chores(kid: dict) -> list:
chores = kid["chores"]
for c in chores:
if "icon" not in c:
c["icon"] = DEFAULT_ICON
return chores
# ─────────────────────────────────────────────────────────────────────────────
# ESPHome generator
# ─────────────────────────────────────────────────────────────────────────────
def gen_esphome(cfg: dict) -> str:
s = cfg["settings"]
kids = cfg["kids"]
# ── Switches (one per chore per kid) ──────────────────────────────────────
switch_blocks = []
for kid in kids:
chores = get_chores(kid)
ks = kid_slug(kid)
switch_blocks.append(f"\n # ── {kid['name']}'s chores ──────────────────────────\n")
for chore in chores:
e = eid(kid, chore)
switch_blocks.append(f"""\
- platform: template
name: "{kid['name']} - {chore['name']}"
id: {e}
icon: mdi:checkbox-marked-circle
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
on_turn_on:
then:
- script.execute: update_{ks}_ui
- homeassistant.service:
service: switch.turn_on
data:
entity_id: switch.chore_tracker_{e}
on_turn_off:
then:
- script.execute: update_{ks}_ui
- homeassistant.service:
service: switch.turn_off
data:
entity_id: switch.chore_tracker_{e}
""")
# ── Sensors (chores-done count only — no stars) ────────────────────────
sensor_blocks = []
for kid in kids:
chores = get_chores(kid)
ks = kid_slug(kid)
done_lines = "\n".join(
f" if (id({eid(kid, c)}).state) done++;" for c in chores
)
sensor_blocks.append(f"""\
- platform: template
name: "{kid['name']} Chores Done"
id: {ks}_chores_done
accuracy_decimals: 0
update_interval: 2s
lambda: |-
int done = 0;
{done_lines}
return done;
""")
# ── Midnight reset calls ──────────────────────────────────────────────────
reset_calls = "\n".join(
f" id(reset_{kid_slug(k)}_chores).execute();"
for k in kids
)
lvgl_pages = _gen_lvgl_pages(kids)
scripts = _gen_scripts(kids)
# Collect all unique MDI glyphs used (avatars + chore icons)
all_glyphs = sorted(set(
[k["avatar"] for k in kids] +
[c["icon"] for k in kids for c in get_chores(k)]
))
glyphs_str = "".join(all_glyphs)
return f"""\
################################################################################
# chore-tracker-esphome.yaml (AUTO-GENERATED — edit chores_config.yaml)
# Kids: {", ".join(k["name"] for k in kids)}
#
# HOME SCREEN BEHAVIOUR:
# Red outline = chores incomplete
# Solid green = all chores done
# Resets to red automatically at midnight
#
# Hardware: Waveshare ESP32-S3-Touch-LCD-7 (800x480)
################################################################################
esphome:
name: {s["device_name"]}
friendly_name: "{s["friendly_name"]}"
on_boot:
priority: -10
then:
- light.turn_on: backlight
- lvgl.page.show: page_home
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
wifi:
ssid: {s["wifi_ssid"]}
password: {s["wifi_password"]}
ap:
ssid: "{s['friendly_name']} Hotspot"
password: "choretracker"
captive_portal:
logger:
level: INFO
api:
encryption:
key: {s["api_key"]}
ota:
- platform: esphome
password: {s["ota_password"]}
# ── I2C ───────────────────────────────────────────────────────────────────────
i2c:
sda: 8
scl: 9
# ── CH422G IO Expander (controls LCD reset, touch reset, backlight) ───────────
ch422g:
- id: ch422g_hub
# ── Display ───────────────────────────────────────────────────────────────────
display:
- platform: mipi_rgb
model: ESP32-S3-TOUCH-LCD-7-800X480
id: main_display
update_interval: never
auto_clear_enabled: false
reset_pin:
ch422g: ch422g_hub
number: 3
mode:
output: true
# ── Touchscreen ───────────────────────────────────────────────────────────────
touchscreen:
platform: gt911
id: touch
update_interval: 120ms
reset_pin:
ch422g: ch422g_hub
number: 1
mode:
output: true
# ── Backlight ─────────────────────────────────────────────────────────────────
output:
- platform: template
id: lcd_backlight_out
type: binary
write_action:
- if:
condition:
lambda: return state;
then:
- switch.turn_on: lcd_backlight_raw
else:
- switch.turn_off: lcd_backlight_raw
light:
- platform: binary
name: "Display Backlight"
output: lcd_backlight_out
id: backlight
# ── Midnight reset ────────────────────────────────────────────────────────────
time:
- platform: homeassistant
id: ha_time
on_time:
- hours: 0
minutes: 0
seconds: 0
then:
- lambda: |-
{reset_calls}
- lvgl.page.show: page_home
# ── Fonts (place files in /config/esphome/fonts/) ────────────────────────────
font:
- file: "fonts/Nunito-Black.ttf"
id: font_title
size: 40
- file: "fonts/Nunito-ExtraBold.ttf"
id: font_name
size: 30
- file: "fonts/Nunito-Bold.ttf"
id: font_med
size: 22
- file: "fonts/Nunito-SemiBold.ttf"
id: font_small
size: 17
- file: "fonts/Nunito-SemiBold.ttf"
id: font_tiny
size: 13
# MDI icon font — used for chore icons and kid avatars
- file: "fonts/materialdesignicons-webfont.ttf"
id: font_mdi_large
size: 48
bpp: 4
glyphs: {glyphs_str}
- file: "fonts/materialdesignicons-webfont.ttf"
id: font_mdi_small
size: 32
bpp: 4
glyphs: {glyphs_str}
# ── Switches — backlight raw + one per chore per kid ─────────────────────────
switch:
- platform: gpio
id: lcd_backlight_raw
name: "LCD Backlight Raw"
restore_mode: ALWAYS_ON
pin:
ch422g: ch422g_hub
number: 2
mode:
output: true
{"".join(switch_blocks)}
# ── Sensors — reported to HA ─────────────────────────────────────────────────
sensor:
{"".join(sensor_blocks)}
# ── Scripts — reset + UI update ───────────────────────────────────────────────
script:
{scripts}
# ── LVGL UI ───────────────────────────────────────────────────────────────────
lvgl:
displays:
- main_display
touchscreens:
- touch
theme:
button:
radius: 20
border_width: 0
pages:
{lvgl_pages}
"""
# ─────────────────────────────────────────────────────────────────────────────
# LVGL page builder
# ─────────────────────────────────────────────────────────────────────────────
def _gen_lvgl_pages(kids: list) -> str:
pages = []
# ── HOME PAGE ─────────────────────────────────────────────────────────────
n = len(kids)
usable_w = 800 - PAD * 2
btn_w = min(200, max(140, (usable_w - 40 - 20 * (n - 1)) // n))
total_w = btn_w * n + 20 * (n - 1)
start_x = PAD + (usable_w - total_w) // 2
btn_y = PAD + 118
btn_h = 210
reset_all_calls = "\n".join(
f" - script.execute: reset_{kid_slug(k)}_chores"
for k in kids
)
home_btns = ""
for i, kid in enumerate(kids):
x = start_x + i * (btn_w + 20)
ks = kid_slug(kid)
home_btns += f"""\
- button:
id: home_btn_{ks}
x: {x}
y: {btn_y}
width: {btn_w}
height: {btn_h}
bg_color: 0xFF4757
bg_opa: COVER
border_width: 0
radius: 24
on_click:
then:
- lvgl.page.show: page_{ks}
widgets:
- label:
align: CENTER
y: -45
text: "{kid['avatar']}"
text_font: font_mdi_large
- label:
align: CENTER
y: 22
text: "{kid['name']}"
text_font: font_name
text_color: 0xFFFFFF
- label:
id: home_status_{ks}
align: CENTER
y: 66
text: "not done"
text_font: font_tiny
text_color: 0xFFFFFF
"""
pages.append(f"""\
- id: page_home
bg_color: 0xFFF9F0
widgets:
- label:
x: 0
y: 36
width: {800 - PAD * 2}
align: TOP_MID
text: "Chore Tracker"
text_font: font_title
text_color: 0x2D3436
- label:
x: 0
y: 90
width: {800 - PAD * 2}
align: TOP_MID
text: "Tap a name to check off chores"
text_font: font_small
text_color: 0xB2BEC3
- button:
x: 640
y: 428
width: 148
height: 40
bg_color: 0xFF4757
radius: 12
on_click:
then:
{reset_all_calls}
widgets:
- label:
align: CENTER
text: "Reset All"
text_color: 0xFFFFFF
text_font: font_tiny
{home_btns}""")
# ── PER-KID CHORE PAGES ───────────────────────────────────────────────────
for kid in kids:
chores = get_chores(kid)
ks = kid_slug(kid)
n_chores = len(chores)
cols = 3
rows = (n_chores + cols - 1) // cols
sidebar_w = 172
gap = 10
content_w = 800 - sidebar_w - gap - 12 # right pad
card_w = (content_w - gap * (cols - 1)) // cols
card_h = min(138, (480 - PAD - gap * (rows + 1)) // rows)
card_widgets = ""
for idx, chore in enumerate(chores):
row = idx // cols
col = idx % cols
x = sidebar_w + gap + col * (card_w + gap)
y = 12 + row * (card_h + gap)
e = eid(kid, chore)
icon_y = -(card_h // 4)
label_y = card_h // 8
card_widgets += f"""\
- button:
id: card_{e}
x: {x}
y: {y}
width: {card_w}
height: {card_h}
bg_color: 0xFFFFFF
border_color: 0xEEEEEE
border_width: 2
radius: 20
shadow_color: 0xCCCCCC
shadow_width: 4
shadow_ofs_y: 3
on_click:
then:
- switch.toggle: {e}
widgets:
- label:
id: check_{e}
align: TOP_RIGHT
x: -10
y: 8
text: ""
text_font: font_med
- label:
align: CENTER
y: {icon_y}
text: "{chore['icon']}"
text_font: font_mdi_small
text_color: 0x{kid['color']}
- label:
align: CENTER
y: {label_y}
text: "{chore['name']}"
text_font: font_small
text_color: 0x2D3436
"""
pages.append(f"""\
- id: page_{ks}
bg_color: 0xFFF9F0
widgets:
# ── Sidebar ──────────────────────────────────────────────────────────
- obj:
x: 0
y: 0
width: {sidebar_w}
height: 480
bg_color: 0x{kid['color']}
border_width: 0
radius: 0
pad_all: 0
widgets:
- label:
x: 0
y: 22
width: {sidebar_w}
align: TOP_MID
text: "{kid['avatar']}"
text_font: font_mdi_large
- label:
x: 0
y: 76
width: {sidebar_w}
align: TOP_MID
text: "{kid['name']}"
text_font: font_name
text_color: 0xFFFFFF
- bar:
id: progress_bar_{ks}
x: 14
y: 132
width: {sidebar_w - 28}
height: 16
bg_color: 0x{kid['color_dark']}
indicator:
bg_color: 0xFFFFFF
value: 0
min_value: 0
max_value: {n_chores}
- label:
id: progress_label_{ks}
x: 0
y: 156
width: {sidebar_w}
align: TOP_MID
text: "0 / {n_chores}"
text_font: font_small
text_color: 0xDDEEFF
- label:
id: all_done_label_{ks}
x: 0
y: 192
width: {sidebar_w}
align: TOP_MID
text: ""
text_font: font_small
text_color: 0xFFFFFF
- button:
x: 14
y: {480 - PAD - 44 - 6 - 44}
width: {sidebar_w - 28}
height: 44
bg_color: 0xFF4757
radius: 14
on_click:
then:
- script.execute: reset_{ks}_chores
widgets:
- label:
align: CENTER
text: "Reset"
text_color: 0xFFFFFF
text_font: font_small
- button:
x: 14
y: {480 - PAD - 44}
width: {sidebar_w - 28}
height: 44
bg_color: 0x{kid['color_dark']}
radius: 14
on_click:
then:
- lvgl.page.show: page_home
widgets:
- label:
align: CENTER
text: "Home"
text_color: 0xFFFFFF
text_font: font_small
# ── Chore cards ───────────────────────────────────────────────────────
{card_widgets}""")
return "\n".join(pages)
# ─────────────────────────────────────────────────────────────────────────────
# Script builder
# ─────────────────────────────────────────────────────────────────────────────
def _gen_scripts(kids: list) -> str:
blocks = []
for kid in kids:
chores = get_chores(kid)
ks = kid_slug(kid)
n = len(chores)
card_updates = ""
for chore in chores:
e = eid(kid, chore)
card_updates += f"""\
if (id({e}).state) {{
lv_obj_set_style_bg_color(id(card_{e}), lv_color_hex(0xF0FFF4), LV_PART_MAIN);
lv_obj_set_style_border_color(id(card_{e}), lv_color_hex(0x6BCB77), LV_PART_MAIN);
lv_obj_set_style_border_width(id(card_{e}), 3, LV_PART_MAIN);
lv_label_set_text(id(check_{e}), "\\u2705");
}} else {{
lv_obj_set_style_bg_color(id(card_{e}), lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_border_color(id(card_{e}), lv_color_hex(0xEEEEEE), LV_PART_MAIN);
lv_obj_set_style_border_width(id(card_{e}), 2, LV_PART_MAIN);
lv_label_set_text(id(check_{e}), "");
}}
"""
count_done = " + ".join(f"(id({eid(kid,c)}).state ? 1 : 0)" for c in chores)
reset_lines = "\n".join(
f" id({eid(kid,c)}).turn_off();" for c in chores
)
blocks.append(f"""\
- id: reset_{ks}_chores
mode: single
then:
- lambda: |-
{reset_lines}
- script.execute: update_{ks}_ui
""")
blocks.append(f"""\
- id: update_{ks}_ui
mode: single
then:
- lambda: |-
int done = {count_done};
int total = {n};
char buf[24];
// Progress bar + label
lv_bar_set_value(id(progress_bar_{ks}), done, LV_ANIM_ON);
snprintf(buf, sizeof(buf), "%d / {n}", done);
lv_label_set_text(id(progress_label_{ks}), buf);
// All-done message in sidebar
lv_label_set_text(id(all_done_label_{ks}), done == total ? "All done!" : "");
// Home button: RED outline = incomplete, SOLID GREEN = all done
if (done == total) {{
lv_obj_set_style_bg_opa(id(home_btn_{ks}), LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_color(id(home_btn_{ks}), lv_color_hex(0x6BCB77), LV_PART_MAIN);
lv_obj_set_style_border_width(id(home_btn_{ks}), 0, LV_PART_MAIN);
lv_label_set_text(id(home_status_{ks}), "\\u2713 all done!");
lv_obj_set_style_text_color(id(home_status_{ks}), lv_color_hex(0xFFFFFF), LV_PART_MAIN);
}} else {{
lv_obj_set_style_bg_opa(id(home_btn_{ks}), LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_color(id(home_btn_{ks}), lv_color_hex(0xFF4757), LV_PART_MAIN);
lv_obj_set_style_border_width(id(home_btn_{ks}), 0, LV_PART_MAIN);
snprintf(buf, sizeof(buf), "%d left", total - done);
lv_label_set_text(id(home_status_{ks}), buf);
lv_obj_set_style_text_color(id(home_status_{ks}), lv_color_hex(0xFF4757), LV_PART_MAIN);
}}
// Card colours
{card_updates}
""")
return "\n".join(blocks)
# ─────────────────────────────────────────────────────────────────────────────
# Home Assistant YAML generator
# ─────────────────────────────────────────────────────────────────────────────
def gen_ha(cfg: dict) -> str:
s = cfg["settings"]
kids = cfg["kids"]
sensor_blocks = []
for kid in kids:
chores = get_chores(kid)
ks = kid_slug(kid)
n = len(chores)
states_list = ", ".join(f"states('{ha_switch(kid,c)}')" for c in chores)
sensor_blocks.append(f"""\
- name: "{kid['name']} Chores Done Today"
unique_id: {ks}_chores_done_today
icon: mdi:check-circle
state: >
{{% set chores = [{states_list}] %}}
{{{{ chores | select('equalto', 'on') | list | count }}}}
- name: "{kid['name']} All Chores Done"
unique_id: {ks}_all_chores_done
icon: mdi:check-all
state: >
{{{{ states('sensor.{ks}_chores_done_today') | int == {n} }}}}
""")
input_buttons = "".join(f"""\
{kid_slug(k)}_reset_chores:
name: "Reset {k['name']}'s Chores"
icon: mdi:restart
""" for k in kids)
input_buttons += """\
reset_all_chores:
name: "Reset All Chores"
icon: mdi:restart-alert
"""
auto_blocks = []
for kid in kids:
ks = kid_slug(kid)
off_calls = "\n".join(
f" - service: switch.turn_off\n target:\n entity_id: {ha_switch(kid, c)}"
for c in get_chores(kid)
)
auto_blocks.append(f"""\
- id: {ks}_reset_from_ha
alias: "Chore Tracker - Reset {kid['name']}'s chores from HA"
trigger:
- platform: state
entity_id: input_button.{ks}_reset_chores
action:
{off_calls}
""")
all_off = "\n".join(
f" - service: switch.turn_off\n target:\n entity_id: {ha_switch(kid, c)}"
for kid in kids for c in get_chores(kid)
)
auto_blocks.append(f"""\
- id: reset_all_from_ha
alias: "Chore Tracker - Reset ALL chores from HA"
trigger:
- platform: state
entity_id: input_button.reset_all_chores
action:
{all_off}
""")
for kid in kids:
ks = kid_slug(kid)
n = len(get_chores(kid))
auto_blocks.append(f"""\
- id: {ks}_all_done_notify
alias: "Chore Tracker - {kid['name']} all done!"
trigger:
- platform: state
entity_id: sensor.{ks}_all_chores_done
to: "True"
action:
- service: {s['notify_service']}
data:
title: "{kid['name']} finished all chores!"
message: "{kid['name']} completed all {n} chores today!"
""")
reminder_actions = "".join(f"""\
- if:
condition: template
value_template: "{{{{ states('sensor.{kid_slug(k)}_all_chores_done') != 'True' }}}}"
then:
- service: {s['notify_service']}
data:
title: "{k['name']} has unfinished chores"
message: >
{k['name']} has done
{{{{ states('sensor.{kid_slug(k)}_chores_done_today') }}}}/{len(get_chores(k))} chores today.
""" for k in kids)
auto_blocks.append(f"""\
- id: chore_reminder_evening
alias: "Chore Tracker - Evening reminder"
trigger:
- platform: time
at: "{s['reminder_time']}:00"
action:
{reminder_actions}
""")
return f"""\
################################################################################
# chore-tracker-ha.yaml (AUTO-GENERATED — edit chores_config.yaml)
# Kids: {", ".join(k["name"] for k in kids)}
#
# BIDIRECTIONAL SYNC:
# Screen to HA: Each switch calls homeassistant.service on toggle
# HA to Screen: ESPHome native API handles this automatically
################################################################################
input_button:
{input_buttons}
template:
- sensor:
{"".join(sensor_blocks)}
automation:
{"".join(auto_blocks)}
"""
# ─────────────────────────────────────────────────────────────────────────────
# Lovelace dashboard generator
# ─────────────────────────────────────────────────────────────────────────────
def gen_dashboard(cfg: dict) -> str:
kids = cfg["kids"]
summary_cards = "\n".join(f"""\
- type: markdown
content: >
### {kid['avatar']} {kid['name']}
{{{{
'All done!' if states('sensor.{kid_slug(kid)}_all_chores_done') == 'True'
else states('sensor.{kid_slug(kid)}_chores_done_today') ~ '/{len(get_chores(kid))} chores done'
}}}}
""" for kid in kids)
chore_cards = []
for kid in kids:
chores = get_chores(kid)
ks = kid_slug(kid)
chore_rows = "\n".join(f"""\
- entity: {ha_switch(kid, c)}
name: "{c['icon']} {c['name']}"
""" for c in chores)
chore_cards.append(f"""\
- type: entities
title: "{kid['avatar']} {kid['name']}'s Chores"
entities:
{chore_rows} - type: divider
- entity: sensor.{ks}_chores_done_today
name: "Chores done today"
- entity: sensor.{ks}_all_chores_done
name: "All Done?"
- type: button
name: "Reset {kid['name']}'s Chores"
tap_action:
action: call-service
service: input_button.press
target:
entity_id: input_button.{ks}_reset_chores
""")
return f"""\
################################################################################
# chore-tracker-dashboard.yaml (AUTO-GENERATED — edit chores_config.yaml)
# Kids: {", ".join(k["name"] for k in kids)}
################################################################################
title: "Chore Tracker"
views:
- title: Today
icon: mdi:checkbox-marked-circle
path: chores
cards:
- type: horizontal-stack
cards:
{summary_cards}
- type: button
name: "Reset ALL Chores"
icon: mdi:restart-alert
tap_action:
action: call-service
service: input_button.press
target:
entity_id: input_button.reset_all_chores
{"".join(chore_cards)}
"""
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Chore Tracker config generator")
parser.add_argument("--config", default="chores_config.yaml")
parser.add_argument("--out", default=".")
args = parser.parse_args()
config_path = Path(args.config)
if not config_path.exists():
print(f"❌ Config not found: {config_path}")
sys.exit(1)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
class SecretLoader(yaml.SafeLoader):
pass
SecretLoader.add_constructor(
"!secret",
lambda loader, node: f"!secret {loader.construct_scalar(node)}"
)
with open(config_path) as f:
cfg = yaml.load(f, Loader=SecretLoader)
kids = cfg.get("kids", [])
if not kids:
print("❌ No kids defined in config!")
sys.exit(1)
errors = [k["name"] for k in kids if "chores" not in k or not k["chores"]]
if errors:
print(f"❌ These kids have no chores defined: {', '.join(errors)}")
print(" Add a 'chores:' list under each kid in chores_config.yaml")
sys.exit(1)
# Warn about missing icons (will use default, not crash)
for k in kids:
for c in k.get("chores", []):
if "icon" not in c:
print(f"⚠️ {k['name']} / '{c['name']}' has no icon — using default")
total_chores = sum(len(get_chores(k)) for k in kids)
print(f"✅ Config loaded: {len(kids)} kid(s), {total_chores} total chores")
for k in kids:
chores = get_chores(k)
print(f" {k['avatar']} {k['name']}: {len(chores)} chores"
f"{', '.join(c['name'] for c in chores)}")
print()
files = {
"chore-tracker-esphome.yaml": gen_esphome(cfg),
"chore-tracker-ha.yaml": gen_ha(cfg),
"chore-tracker-dashboard.yaml": gen_dashboard(cfg),
}
for name, content in files.items():
path = out_dir / name
path.write_text(content)
print(f"📄 {path}")
print()
print("✅ Done!")
print(" 1. Flash chore-tracker-esphome.yaml -> ESPHome dashboard")
print(" 2. Merge chore-tracker-ha.yaml -> HA config + restart HA")
print(" 3. Paste chore-tracker-dashboard.yaml -> New HA dashboard")
if __name__ == "__main__":
main()