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

1011 lines
35 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;
# # Diagnostic sensors
# - platform: wifi_signal
# name: "${device_name} WiFi Signal"
# update_interval: 60s
# - platform: uptime
# name: "${device_name} Uptime"
# update_interval: 60s
# """)
# # ── 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)
# ################################################################################
# substitutions:
# device_name: {s["device_name"]}
# friendly_name: "{s["friendly_name"]}"
# 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"]}
# manual_ip:
# static_ip: {s["static_ip"]}
# gateway: {s["gateway"]}
# subnet: {s["subnet"]}
# ap:
# ssid: "{s['friendly_name']} Hotspot"
# password: "choretracker"
# # Prevents aggressive reconnection attempts
# power_save_mode: none # Use 'light' for battery devices
# # Slower but more reliable connection
# fast_connect: true
# # Handle connection failures gracefully
# on_connect:
# - logger.log: "Wi-Fi connected!"
# on_disconnect:
# - logger.log: "Wi-Fi disconnected!"
# mdns:
# disabled: true
# safe_mode:
# disabled: true
# logger:
# level: INFO
# api:
# encryption:
# key: {s["api_key"]}
# reboot_timeout: 0s
# ota:
# - platform: esphome
# password: {s["ota_password"]}
# web_server:
# port: 8080
# # ── 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)}
# # ── Text Sensors — reported to HA ─────────────────────────────────────────────────
# text_sensor:
# - platform: wifi_info
# ip_address:
# name: "${device_name} IP Address"
# ssid:
# name: "${device_name} Connected SSID"
# bssid:
# name: "${device_name} BSSID"
# # Watchdog to auto-reboot if things go wrong
# # Note: interval fires on first tick (t≈0), so guard with millis() to ensure
# # the device has actually been running for 5+ minutes before triggering.
# interval:
# - interval: 5min
# then:
# - if:
# condition:
# and:
# - lambda: 'return millis() > 300000;' # >5 min uptime
# - not:
# wifi.connected:
# then:
# - logger.log: "Wi-Fi not connected for 5 min, rebooting..."
# - delay: 10s
# - lambda: 'App.safe_reboot();'
# # ── 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()