1011 lines
35 KiB
Python
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() |