#!/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) 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 No star tracking — chores are simply done or not done. 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 # ───────────────────────────────────────────────────────────────────────────── 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)}" def get_chores(kid: dict) -> list: """Each kid must define their own chore list.""" return kid["chores"] # ───────────────────────────────────────────────────────────────────────────── # ESPHome generator # ───────────────────────────────────────────────────────────────────────────── def gen_esphome(cfg: dict) -> str: s = cfg["settings"] kids = cfg["kids"] # ── Switches ────────────────────────────────────────────────────────────── switch_blocks = [] for kid in kids: chores = get_chores(kid) ks = kid_slug(kid) switch_blocks.append(f"\n # ── {kid['name']}'s chores ──────────────────────────") 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) ────────────────────────────────────── 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 ──────────────────────────────────────────────────────── 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) 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 ################################################################################ esphome: name: {s["device_name"]} friendly_name: "{s["friendly_name"]}" esp32: board: esp32s3box framework: type: esp-idf 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"]} # ── Display — Waveshare ESP32-S3 7" (adjust pins for your board revision) ───── display: - platform: rpi_dpi_rgb id: main_display auto_clear_enabled: false color_order: RGB dimensions: width: 800 height: 480 de_pin: number: GPIO40 ignore_strapping_warning: true hsync_pin: number: GPIO39 ignore_strapping_warning: true vsync_pin: number: GPIO41 pclk_pin: GPIO42 data_pins: red: [GPIO45, GPIO48, GPIO47, GPIO21, GPIO14] green: [GPIO5, GPIO6, GPIO7, GPIO15, GPIO16, GPIO4] blue: [GPIO8, GPIO3, GPIO46, GPIO9, GPIO1] touchscreen: - platform: gt911 id: touch display: main_display i2c_id: i2c_touch interrupt_pin: GPIO2 reset_pin: GPIO38 i2c: - id: i2c_touch sda: GPIO19 scl: GPIO20 frequency: 400kHz output: - platform: ledc pin: GPIO17 id: backlight_output light: - platform: monochromatic output: backlight_output name: "Display Backlight" id: backlight restore_mode: ALWAYS_ON # ── 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 font: - file: "gfonts://Nunito:wght@900" id: font_title size: 40 - file: "gfonts://Nunito:wght@800" id: font_name size: 30 - file: "gfonts://Nunito:wght@700" id: font_med size: 22 - file: "gfonts://Nunito:wght@600" id: font_small size: 17 - file: "gfonts://Nunito:wght@600" id: font_tiny size: 13 # ── Switches — one per chore per kid, synced to HA ─────────────────────────── switch: {"".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: btn: radius: 20 border_width: 0 pages: {lvgl_pages} """ # ───────────────────────────────────────────────────────────────────────────── # LVGL page builder # ───────────────────────────────────────────────────────────────────────────── def _gen_lvgl_pages(kids: list) -> str: pages = [] # ── HOME PAGE ───────────────────────────────────────────────────────────── n = len(kids) btn_w = min(200, max(140, (760 - 20 * (n - 1)) // n)) total_w = btn_w * n + 20 * (n - 1) start_x = (800 - total_w) // 2 btn_y = 130 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"""\ - btn: id: home_btn_{ks} x: {x} y: {btn_y} width: {btn_w} height: {btn_h} bg_color: 0xFFFFFF bg_opa: TRANSP border_color: 0xFF4757 border_width: 5 radius: 24 on_click: then: - lvgl.page.show: page_{ks} widgets: - label: align: CENTER y: -45 text: "{kid['avatar']}" text_font: font_title - label: align: CENTER y: 22 text: "{kid['name']}" text_font: font_name text_color: 0x2D3436 - label: id: home_status_{ks} align: CENTER y: 66 text: "not done" text_font: font_tiny text_color: 0xFF4757 """ pages.append(f"""\ - id: page_home bg_color: 0xFFF9F0 widgets: - label: x: 0 y: 36 width: 800 align: TOP_MID text: "Chore Tracker" text_font: font_title text_color: 0x2D3436 - label: x: 0 y: 90 width: 800 align: TOP_MID text: "Tap a name to check off chores" text_font: font_small text_color: 0xB2BEC3 - btn: x: 640 y: 424 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 * 2 card_w = (content_w - gap * (cols - 1)) // cols card_h = min(138, (480 - 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 = gap + row * (card_h + gap) e = eid(kid, chore) icon_y = -(card_h // 4) label_y = card_h // 8 card_widgets += f"""\ - btn: 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_med - 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_title - 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 - btn: x: 14 y: 364 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 - btn: x: 14 y: 420 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 ) # Reset script blocks.append(f"""\ - id: reset_{ks}_chores mode: single then: - lambda: |- {reset_lines} - script.execute: update_{ks}_ui """) # UI update script 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 ? "\\U0001F389 All done!" : ""); // Home button colour 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_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_color(id(home_btn_{ks}), lv_color_hex(0xFF4757), LV_PART_MAIN); lv_obj_set_style_border_width(id(home_btn_{ks}), 5, 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_button helpers 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 """ # Automations auto_blocks = [] # Per-kid HA reset button 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} """) # Reset All from HA all_off_calls = "\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_calls} """) # All-done notification 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!" """) # Evening reminder 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 → HA: Each switch calls homeassistant.service on toggle # HA → 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) # Validate every kid has their own chore list 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) 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 — {', '.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()