#!/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()