- Created `chores_config.yaml` for defining kids, chores, and settings. - Implemented `generate.py` to generate ESPHome, Home Assistant, and Lovelace dashboard configurations based on the YAML file. - Added `chore-tracker-ha.yaml` for Home Assistant integration, including sensors and automation for tracking chores and notifications.
895 lines
29 KiB
Python
895 lines
29 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)
|
|
|
|
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
|
|
|
|
fonts:
|
|
- 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() |