Files
esphome-guition-jc3636k518c…/base/core.yaml
T
2026-10-18 08:02:07 +10:00

2862 lines
119 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
esphome:
name: ${name}
friendly_name: ${friendly_name}
on_boot:
- priority: 800
then:
- lambda: |-
id(g_order_n) = 0;
id(g_set_n) = 0;
id(g_set_labels) = "";
for (int i = 0; i < 16; i++) id(g_present)[i] = false;
id(g_present)[0] = true; // clock is always present
id(g_wgrp_n) = 0; id(g_wgrp_labels) = ""; id(g_wgrp_idx) = 0;
id(g_wopt_n) = 0; id(g_wcur_n) = 0; id(g_wopt_idx) = 0;
- priority: 790
then:
- lambda: |-
// core settings list: label + the g_mode each opens. (Screen packages add their
// own entries under Widgets via the g_wgrp_* / g_wopt_* registry, not here.)
id(g_set_labels) += "Display"; id(g_set_items)[id(g_set_n)++] = 11;
id(g_set_labels) += "\nHome screen"; id(g_set_items)[id(g_set_n)++] = 12;
id(g_set_labels) += "\nWidgets"; id(g_set_items)[id(g_set_n)++] = 17;
id(g_set_labels) += "\nVoice Assistant"; id(g_set_items)[id(g_set_n)++] = 13;
- priority: 760
then:
- lambda: |-
// Resolve a friendly icon name to its MDI glyph. Unknown -> use the value as-is.
auto ic = [](const std::string &s) -> std::string {
if (s == "bulb" || s == "light") return "\U000f0335";
if (s == "lamp") return "\U000f06b5";
if (s == "led" || s == "strip") return "\U000f07d6";
if (s == "power") return "\U000f0425";
if (s == "plug") return "\U000f06a5";
if (s == "switch") return "\U000f0521";
if (s == "fan") return "\U000f0210";
if (s == "ac" || s == "aircon") return "\U000f001b";
if (s == "heater" || s == "radiator") return "\U000f0438";
if (s == "thermometer" || s == "temp") return "\U000f050f";
if (s == "lock") return "\U000f033e";
if (s == "unlock") return "\U000f033f";
if (s == "door") return "\U000f081a";
if (s == "blinds") return "\U000f00ac";
if (s == "tv") return "\U000f0502";
if (s == "speaker") return "\U000f04c3";
if (s == "bell") return "\U000f009a";
if (s == "wifi") return "\U000f05a9";
if (s == "home") return "\U000f02dc";
if (s == "flash") return "\U000f0241";
if (s == "air" || s == "pm" || s == "airquality") return "\U000f0d44";
if (s == "plant" || s == "sprout" || s == "moisture") return "\U000f0e66";
if (s == "plex") return "\U000f06ba";
if (s == "server") return "\U000f048b";
if (s == "shield" || s == "alarm" || s == "security") return "\U000f068a";
return s;
};
id(g_tile1_glyph) = ic("${tile1_icon}");
id(g_tile2_glyph) = ic("${tile2_icon}");
id(g_tile3_glyph) = ic("${tile3_icon}");
- priority: 595
then:
- lambda: 'id(g_set_labels) += "\nSystem"; id(g_set_items)[id(g_set_n)++] = 15;' # always last
- priority: 600
then:
- lambda: |-
// build the carousel order from ${screen_order}, skipping screens not in this build
std::string s = "${screen_order}";
size_t pos = 0;
while (true) {
size_t comma = s.find(',', pos);
std::string tok = (comma == std::string::npos) ? s.substr(pos) : s.substr(pos, comma - pos);
while (!tok.empty() && tok.front() == ' ') tok.erase(tok.begin());
while (!tok.empty() && tok.back() == ' ') tok.pop_back();
int id = -1;
if (tok == "clock") id = 0;
else if (tok == "player") id = 1;
else if (tok == "timer") id = 2;
else if (tok == "cars") id = 3;
else if (tok == "space") id = 4;
else if (tok == "weather") id = 5;
else if (tok == "demo") id = 6;
else if (tok == "thermostat") id = 7;
else if (tok == "sensors") id = 8;
else if (tok == "snake") id = 9;
else if (tok == "gyruss" || tok == "knobuss") id = 10;
if (id >= 0 && id < 16 && id(g_present)[id]) id(g_order)[id(g_order_n)++] = id;
if (comma == std::string::npos) break;
pos = comma + 1;
}
if (id(g_order_n) == 0) id(g_order)[id(g_order_n)++] = 0; // safety: keep the clock
- priority: -50
then:
- if:
condition: { lambda: 'return id(g_factory_reset);' }
then:
- delay: 2s # give the game packages' interval watchers time to clear their scores
- lambda: 'id(g_factory_reset) = false;'
- priority: -100
then:
- lambda: |-
// The device has no IANA tz database, so the clock needs a POSIX TZ string. To keep the
// `timezone` substitution friendly, map common IANA names -> POSIX here and force the
// result into the environment ourselves (on some builds the homeassistant time platform
// never applies the zone and the clock sticks on UTC). An explicit POSIX string is used
// as-is; an IANA name not in the table falls back to whatever the time platform resolved.
// The 1s interval re-applies g_tz_posix so a runtime reset to UTC can't stick.
static const char* TZMAP[][2] = {
{"Europe/Warsaw","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/London","GMT0BST,M3.5.0/1,M10.5.0"},
{"Europe/Berlin","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Paris","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Madrid","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Rome","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Brussels","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Vienna","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Prague","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Zurich","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Copenhagen","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Stockholm","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Oslo","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Budapest","CET-1CEST,M3.5.0,M10.5.0/3"},
{"Europe/Lisbon","WET0WEST,M3.5.0/1,M10.5.0"},
{"Europe/Athens","EET-2EEST,M3.5.0/3,M10.5.0/4"},
{"Europe/Helsinki","EET-2EEST,M3.5.0/3,M10.5.0/4"},
{"Europe/Bucharest","EET-2EEST,M3.5.0/3,M10.5.0/4"},
{"Europe/Sofia","EET-2EEST,M3.5.0/3,M10.5.0/4"},
{"Europe/Kyiv","EET-2EEST,M3.5.0/3,M10.5.0/4"},
{"Europe/Moscow","MSK-3"},
{"Europe/Istanbul","<+03>-3"},
{"Europe/Dublin","IST-1GMT0,M10.5.0,M3.5.0/1"},
{"America/New_York","EST5EDT,M3.2.0,M11.1.0"},
{"America/Chicago","CST6CDT,M3.2.0,M11.1.0"},
{"America/Denver","MST7MDT,M3.2.0,M11.1.0"},
{"America/Los_Angeles","PST8PDT,M3.2.0,M11.1.0"},
{"America/Phoenix","MST7"},
{"America/Toronto","EST5EDT,M3.2.0,M11.1.0"},
{"America/Sao_Paulo","<-03>3"},
{"America/Mexico_City","CST6"},
{"Asia/Tokyo","JST-9"},
{"Asia/Shanghai","CST-8"},
{"Asia/Hong_Kong","HKT-8"},
{"Asia/Singapore","<+08>-8"},
{"Asia/Dubai","<+04>-4"},
{"Asia/Kolkata","IST-5:30"},
{"Asia/Seoul","KST-9"},
{"Asia/Jerusalem","IST-2IDT,M3.4.4/26,M10.5.0"},
{"Australia/Sydney","AEST-10AEDT,M10.1.0,M4.1.0/3"},
{"Australia/Perth","AWST-8"},
{"Pacific/Auckland","NZST-12NZDT,M9.5.0,M4.1.0/3"},
{"UTC","UTC0"},
};
std::string cfg = "${timezone}";
std::string tz = cfg;
bool found = false;
for (auto &e : TZMAP) if (cfg == e[0]) { tz = e[1]; found = true; break; }
if (!found && cfg.find('/') != std::string::npos) {
const char* en = getenv("TZ"); if (en && en[0]) tz = en; // unknown IANA -> platform's resolved zone
}
id(g_tz_posix) = tz;
if (!tz.empty()) { setenv("TZ", tz.c_str(), 1); tzset(); }
- script.execute: night_apply # brightness per g_bright / night mode
- script.execute: disp_apply # show/hide weather + temp/humidity per settings
- script.execute: settings_build # push the dynamic settings list into the roller
- lambda: 'id(g_wf_present)[0] = true; id(g_wf_name)[0] = "Classic";' # register the built-in Classic watchface (id 0)
- lvgl.label.update: { id: tile1_ic, text: !lambda 'return id(g_tile1_glyph);' } # tiles get their icon glyph now (set at priority 760)
- lvgl.label.update: { id: tile2_ic, text: !lambda 'return id(g_tile2_glyph);' }
- lvgl.label.update: { id: tile3_ic, text: !lambda 'return id(g_tile3_glyph);' }
# Watchface finalize - runs after every face package has registered (priority < 650).
# If the saved face isn't present (its file was removed), fall back to Classic, then build
# the Home settings roller line. The face itself is shown at the end of the boot splash.
- priority: 500
then:
- lambda: 'if (id(g_watchface) < 0 || id(g_watchface) >= 8 || !id(g_wf_present)[id(g_watchface)]) id(g_watchface) = 0;'
- script.execute: home_roller_build
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB # 16 MB flash
partitions: partitions.csv # MAX app partitions (~7.9 MB/slot) - see partitions.csv. Requires USB flashing!
framework:
type: esp-idf
sdkconfig_options:
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: y
CONFIG_SPIRAM_RODATA: y
psram:
mode: octal
speed: 80MHz
logger:
level: INFO # less logging = less overhead (DEBUG was very noisy, esp. during VA)
api:
ota:
- platform: esphome
# OTA rollback safety. On esp-idf the bootloader rolls back to the PREVIOUS firmware if the device
# resets before the boot is marked "good" (default 1 min). That is why a quick "Restart" from the
# menu right after flashing booted the OLD build again. We shorten that window AND mark the boot good
# as soon as the splash finishes (see the boot interval -> safe_mode.mark_successful).
safe_mode:
boot_is_good_after: 30s
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "${friendly_name} Setup"
captive_portal:
time:
- platform: homeassistant
id: ha_time
# The homeassistant time platform syncs the epoch from HA but NOT the timezone, so
# without this the clock falls back to the compile host's zone (often UTC). Set your
# IANA zone in the `timezone` substitution (top of file / thin config). DST is handled.
timezone: "${timezone}"
###############################################################################
# AUDIO - DAC PCM5100A (out) + microphone + voice assistant (HA Assist)
# DAC: BCK=3 WS=45 DO=42 (no MCK) MUTE=46 (LOW=mute -> we hold HIGH)
# MIC: data(SD)=4 clk(SCK)=5 -> configured as PDM
###############################################################################
switch:
- platform: gpio
pin: GPIO0
name: "DAC unmute"
id: dac_unmute
restore_mode: ALWAYS_ON # HIGH = not muted
i2s_audio:
- id: i2s_out # DAC PCM5100A
i2s_bclk_pin: GPIO39
i2s_lrclk_pin: GPIO40
- id: i2s_in # PDM microphone: clock=GPIO5
i2s_lrclk_pin: GPIO45
speaker:
# Raw I2S output to the DAC
- platform: i2s_audio
id: i2s_speaker_raw
i2s_audio_id: i2s_out
i2s_dout_pin: GPIO41
dac_type: external
channel: stereo
# Mixer: combines the music channel and the announcement (TTS) channel on one speaker
- platform: mixer
id: mixing_speaker
output_speaker: i2s_speaker_raw
source_speakers:
- id: announcement_mixing_input
- id: media_mixing_input
# Resamplers (match source sample rates to the mixer)
- platform: resampler
id: media_resampling_speaker
output_speaker: media_mixing_input
- platform: resampler
id: announcement_resampling_speaker
output_speaker: announcement_mixing_input
# Player visible in HA / Music Assistant (album art etc.)
media_player:
- platform: speaker
name: "Player"
id: media_player_speaker
announcement_pipeline:
speaker: announcement_resampling_speaker
media_pipeline:
speaker: media_resampling_speaker
files:
- id: wake_sound
file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sounds/wake.wav # beep after the wake word
- id: alarm_sound
file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sounds/alarm.wav # timer alarm (generated with make_sounds.py)
microphone:
- platform: i2s_audio
id: i2s_microphone
i2s_audio_id: i2s_in
i2s_din_pin: GPIO46
adc_type: external
pdm: true
sample_rate: 16000
bits_per_sample: 16bit
voice_assistant:
id: va
microphone: i2s_microphone
speaker: announcement_resampling_speaker # TTS via the announcement channel (ducks the music)
noise_suppression_level: 2
auto_gain: 31dBFS
volume_multiplier: 2.0
# Wake word works only when connected to HA (Assist pipeline)
on_client_connected:
- micro_wake_word.start:
on_client_disconnected:
- micro_wake_word.stop:
on_listening:
- lambda: |-
id(g_va_active) = true;
id(g_tts_played) = false;
id(g_va_phase) = 1;
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 20
duration: 0.2s
- lvgl.label.update: { id: va_text, text: "Listening..." }
- script.execute: va_show
on_stt_end:
- lambda: 'id(g_va_phase) = 2;'
- lvgl.label.update: { id: va_text, text: "Thinking..." }
on_tts_start:
- lambda: |-
id(g_tts_played) = true;
id(g_va_phase) = 3;
- lvgl.label.update: { id: va_text, text: "Speaking..." }
# TTS actually stopped -> clear the flag, hide the overlay, restore music volume
on_tts_stream_end:
- lambda: 'id(g_va_active) = false;'
- script.execute: va_hide
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 0
duration: 0.5s
on_end:
# Cleanup only when there was NO TTS (command without a response); otherwise on_tts_stream_end handles it
- if:
condition:
lambda: 'return !id(g_tts_played);'
then:
- lambda: 'id(g_va_active) = false;'
- script.execute: va_hide
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 0
duration: 0.3s
- micro_wake_word.start: # resume listening for the wake word
on_error:
- lambda: 'id(g_va_active) = false;'
- script.execute: va_hide
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 0
duration: 0.3s
- micro_wake_word.start:
# --- Timers set by VOICE (HA counts down, the puck shows the same screen as the knob timer) ---
on_timer_started:
- lambda: |-
id(g_timer_secs) = (int) timer.seconds_left;
id(g_timer_total) = (int) timer.seconds_left;
id(g_timer_active) = true;
id(g_timer_paused) = false;
id(g_timer_local) = false; // HA counts down and will call finished; locally just smooth display
- script.execute: timer_render
on_timer_updated:
- lambda: |-
id(g_timer_secs) = (int) timer.seconds_left;
if (timer.is_active) { id(g_timer_active) = true; id(g_timer_paused) = false; }
- script.execute: timer_render
on_timer_finished:
- script.execute: alarm_start
on_timer_cancelled:
- lambda: 'id(g_timer_active) = false; id(g_timer_paused) = false; id(g_timer_secs) = 0;'
- script.execute: timer_render
###############################################################################
# WAKE WORD on-device (built-in micro_wake_word, no external component; model = ${wake_word})
###############################################################################
micro_wake_word:
microphone: i2s_microphone
vad:
models:
- model: ${wake_word}
on_wake_word_detected:
- lambda: |-
id(g_va_active) = true;
id(g_va_phase) = 1;
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 20
duration: 0.15s
- lvgl.label.update: { id: va_text, text: "Yes?" }
- script.execute: va_show
- if:
condition: { lambda: 'return id(g_beep_ww);' }
then:
- media_player.speaker.play_on_device_media_file:
id: media_player_speaker
media_file: wake_sound
announcement: true
- voice_assistant.start:
# Variables for swipe gestures: dx/dy relative to touch start (x_org/y_org)
globals:
- id: g_va_active
type: bool
restore_value: no
initial_value: 'false'
- id: g_tts_played
type: bool
restore_value: no
initial_value: 'false'
- id: g_x0
type: int
restore_value: no
initial_value: '0'
- id: g_y0
type: int
restore_value: no
initial_value: '0'
- id: g_t0
type: uint32_t
restore_value: no
initial_value: '0'
- id: g_dx
type: int
restore_value: no
initial_value: '0'
- id: g_dy
type: int
restore_value: no
initial_value: '0'
# --- UI mode system --- 0=normal 1=menu 2=timer_set 3=timer_run 4=alarm
- id: g_mode
type: int
restore_value: no
initial_value: '0'
- id: g_mode0 # snapshot of the mode at gesture time (routing), so one gesture doesn't cascade
type: int
restore_value: no
initial_value: '0'
- id: g_gesture # 0=tap 1=hold 2=left 3=right 4=up 5=down -1=ignore
type: int
restore_value: no
initial_value: '0'
- id: g_vol_shown # true = volume overlay on top (tap hides it and consumes the click)
type: bool
restore_value: no
initial_value: 'false'
- id: g_cur_page # current page: 0=base 1=page_mode 2=page_settings 3=page_controls
type: int
restore_value: no
initial_value: '0'
- id: g_exit_anim # one-shot direction of the mode-exit animation: 0=right, 1=down
type: int
restore_value: no
initial_value: '0'
- id: g_show_weather # Display: show weather on the home screen (persistent)
type: bool
restore_value: yes
initial_value: 'true'
- id: g_show_climate # Display: show room temperature+humidity (persistent)
type: bool
restore_value: yes
initial_value: 'true'
- id: g_watchface # Home watchface: 0 = Classic, 1 = Neon (persistent)
type: int
restore_value: yes
initial_value: '0'
- id: g_wf_prev # watchface picker: face to restore if the user cancels
type: int
restore_value: no
initial_value: '0'
- id: g_wf_pick # watchface picker: face currently being previewed
type: int
restore_value: no
initial_value: '0'
- id: g_wf_present # registry: which watchface ids exist (0=Classic in core; others self-register)
type: bool[8]
restore_value: no
- id: g_wf_name # registry: display name per watchface id
type: std::string[8]
restore_value: no
- id: g_wf_req # request flag: a non-Classic face self-shows its page when this is set
type: bool
restore_value: no
initial_value: 'false'
- id: g_home_last_min # Classic clock: last drawn minute (perf - redraw the clock only on change)
type: int
restore_value: no
initial_value: '-1'
- id: g_home_upd_min
type: bool
restore_value: no
initial_value: 'false'
- id: g_nf_glyph # current weather MDI glyph (set when the weather state changes; reused by the Neon face)
type: std::string
restore_value: no
initial_value: '""'
- id: g_nf_cond # current weather condition, friendly UPPERCASE (Neon face)
type: std::string
restore_value: no
initial_value: '""'
- id: g_night # Night mode: screen dimming (persistent)
type: bool
restore_value: yes
initial_value: 'false'
- id: g_disp_idx # selection in the Display submenu (roller)
type: int
restore_value: no
initial_value: '0'
- id: g_base # current carousel screen id (fixed: 0=clock 1=player 2=timer 3=cars 4=space 5=weather 6=demo)
type: int
restore_value: no
initial_value: '0'
- id: g_timer_active # timer has an active session (running or paused) - regardless of screen
type: bool
restore_value: no
initial_value: 'false'
- id: g_timer_paused
type: bool
restore_value: no
initial_value: 'false'
- id: g_timer_total # total timer time (s) - for the progress ring
type: int
restore_value: no
initial_value: '300'
- id: g_set_min # default timer minutes (after restart); the knob changes it
type: int
restore_value: no
initial_value: '60'
- id: g_timer_secs # remaining seconds (display + local countdown)
type: int
restore_value: no
initial_value: '0'
- id: g_timer_local # true = knob timer (we count down locally); false = from VA (HA counts down)
type: bool
restore_value: no
initial_value: 'false'
- id: g_alarm_cnt
type: int
restore_value: no
initial_value: '0'
- id: g_alarm_pulse # alarm screen: toggles each tick for the pulsing bell/scrim
type: bool
restore_value: no
initial_value: 'false'
- id: g_bright # screen brightness in % (settings)
type: int
restore_value: no
initial_value: '100'
- id: g_batt # battery level in % (-1 = no reading yet -> show "-")
type: int
restore_value: no
initial_value: '-1'
- id: g_charging # true = charging/on USB (voltage heuristic - no STAT pin on GPIO)
type: bool
restore_value: no
initial_value: 'false'
- id: g_v_med # last median VCC_OUT voltage (to detect rising = charging)
type: float
restore_value: no
initial_value: '0.0'
- id: g_chg_anim # frame of the battery "filling" animation while charging (0..4)
type: int
restore_value: no
initial_value: '0'
- id: g_night_from
type: int
restore_value: yes
initial_value: '22'
- id: g_night_to
type: int
restore_value: yes
initial_value: '7'
- id: g_blank_idx
type: int
restore_value: yes
initial_value: '0'
- id: g_idle_ticks
type: int
restore_value: no
initial_value: '0'
- id: g_disp_off
type: bool
restore_value: no
initial_value: 'false'
- id: g_want_off
type: bool
restore_value: no
initial_value: 'false'
- id: g_wake_consume
type: bool
restore_value: no
initial_value: 'false'
- id: g_sub_idx # selection inside a settings submenu (Home/Assistant/Ring/System/Demo/Weather)
type: int
restore_value: no
initial_value: '0'
- id: g_beep_ww # beep on wake word detection (Sound category)
type: bool
restore_value: yes
initial_value: 'true'
- id: g_hold_talk # press-and-hold the screen to start the assistant
type: bool
restore_value: yes
initial_value: 'true'
- id: g_tz_posix # resolved POSIX timezone captured at boot (to re-apply if runtime resets it to UTC)
type: std::string
restore_value: no
- id: g_set_idx # selected item in the settings list (roller)
type: int
restore_value: no
initial_value: '0'
- id: g_ww_on # wake word enabled
type: bool
restore_value: no
initial_value: 'true'
- id: g_va_phase # assistant phase for the ring: 1=listening 2=thinking 3=speaking
type: int
restore_value: no
initial_value: '0'
- id: g_vol # last volume from HA (0..1) - for the ring volume bar
type: float
restore_value: no
initial_value: '0.0'
- id: g_vol_show # true = show volume on the ring (briefly, ~2 s after turning)
type: bool
restore_value: no
initial_value: 'false'
# --- Modular carousel contract: shared globals so the core never names a screen's symbols ---
- id: g_order
type: int[16]
restore_value: no
- id: g_order_n
type: int
restore_value: no
initial_value: '0'
- id: g_nav_req
type: bool
restore_value: no
initial_value: 'false'
- id: g_nav_anim
type: int
restore_value: no
initial_value: '1'
- id: g_knob_capture
type: bool
restore_value: no
initial_value: 'false'
- id: g_knob_delta
type: int
restore_value: no
initial_value: '0'
# --- Dynamic settings registry: each screen package can add its own entries ---
- id: g_set_items # g_mode each settings row opens
type: int[12]
restore_value: no
- id: g_set_n
type: int
restore_value: no
initial_value: '0'
- id: g_set_labels # newline-joined roller labels, applied at boot
type: std::string
restore_value: no
- id: g_present # which screens registered (by base id); carousel is built from screen_order
type: bool[16]
restore_value: no
- id: g_tap_seq # bumped on every normal-mode tap; screens watch it to react (shared infra)
type: int
restore_value: no
initial_value: '0'
- id: g_boot # boot splash active (gates input until it hands off to the clock)
type: bool
restore_value: no
initial_value: 'true'
- id: g_boot_t # boot splash tick counter
type: int
restore_value: no
initial_value: '0'
- id: g_tile1_glyph
type: std::string
restore_value: no
- id: g_tile2_glyph
type: std::string
restore_value: no
- id: g_tile3_glyph
type: std::string
restore_value: no
- id: g_wgrp_labels # Widgets submenu: group names, appended by each screen package at boot
type: std::string
restore_value: no
- id: g_wopt_n # generic per-screen settings registry (filled by screen packages at boot)
type: int
restore_value: no
initial_value: '0'
- id: g_wopt_group # which Widgets group each option belongs to
type: int[16]
restore_value: no
- id: g_wopt_label # option label
type: std::string[16]
restore_value: no
- id: g_wopt_kind # 0 = toggle, 1 = action (confirm)
type: int[16]
restore_value: no
- id: g_wopt_ptr # &bool for toggles / &flag for actions
type: "std::array<bool*, 16>"
restore_value: no
- id: g_wopt_idx # selection within the current group's options
type: int
restore_value: no
initial_value: '0'
- id: g_wcur_opts # registry indices of the current group's options
type: int[16]
restore_value: no
- id: g_wcur_n
type: int
restore_value: no
initial_value: '0'
- id: g_wcur_name # current group name (header)
type: std::string
restore_value: no
- id: g_factory_reset # one-shot (persists a reboot); game packages clear their scores when set
type: bool
restore_value: yes
initial_value: 'false'
- id: g_wgrp_n
type: int
restore_value: no
initial_value: '0'
- id: g_wgrp_idx
type: int
restore_value: no
initial_value: '0'
# Shows the volume arc for ~2 s while turning the knob, then hides it (mode: restart)
script:
# Carousel router: show the screen for g_base. Core pages directly; optional screens
# (cars/space) show themselves via g_nav_req. g_nav_anim: 0=left 1=right 2=top 3=bottom.
- id: show_base
then:
# Home base: Classic is core's page_home (directional); other faces self-show via g_wf_req.
- if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)==0 && id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_home, animation: move_left, time: 250ms }] }
- if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)==0 && id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_home, animation: move_right, time: 250ms }] }
- if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)==0 && id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_home, animation: move_top, time: 250ms }] }
- if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)==0 && id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_home, animation: move_bottom, time: 250ms }] }
- if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)!=0;' }, then: [ lambda: 'id(g_wf_req) = true;' ] }
- if: { condition: { lambda: 'return id(g_base)==1;' }, then: [ lambda: 'id(g_nav_req) = true;' ] }
- if: { condition: { lambda: 'return id(g_base)==2 && id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_timer, animation: move_left, time: 250ms }] }
- if: { condition: { lambda: 'return id(g_base)==2 && id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_timer, animation: move_right, time: 250ms }] }
- if: { condition: { lambda: 'return id(g_base)==2 && id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_timer, animation: move_top, time: 250ms }] }
- if: { condition: { lambda: 'return id(g_base)==2 && id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_timer, animation: move_bottom, time: 250ms }] }
- if: { condition: { lambda: 'return id(g_base) >= 3;' }, then: [ lambda: 'id(g_nav_req) = true;' ] }
# Apply the dynamically-built settings list to the roller (called once at boot).
- id: settings_build
then:
- lambda: 'lv_roller_set_options(id(settings_roller)->obj, id(g_set_labels).c_str(), LV_ROLLER_MODE_NORMAL);'
- lambda: 'lv_roller_set_options(id(wid_roller)->obj, id(g_wgrp_labels).c_str(), LV_ROLLER_MODE_NORMAL);'
# Home watchface: make the active face the current home view. Classic (id 0) is core's
# page_home; other faces self-show their own page when g_wf_req is set. Each face owns its
# own weather/room visibility (classic via disp_apply; others gate themselves).
- id: face_apply
then:
- if:
condition: { lambda: 'return id(g_watchface) == 0;' }
then:
- lambda: 'id(g_wf_req) = false;' # Classic shows directly - clear any pending request
- lvgl.page.show: { id: page_home, animation: fade_in, time: 250ms }
- script.execute: disp_apply
else:
- lambda: 'id(g_wf_req) = true;'
# Rebuild the Home settings roller so its 3rd line shows the current watchface name.
- id: home_roller_build
then:
- lambda: |-
int wf = id(g_watchface); if (wf < 0 || wf >= 8 || !id(g_wf_present)[wf]) wf = 0;
std::string opts = std::string("Show weather\nShow climate\nWatchface: ") + id(g_wf_name)[wf];
lv_roller_set_options(id(home_roller)->obj, opts.c_str(), LV_ROLLER_MODE_NORMAL);
# Watchface picker (mode 33): update + show the overlay hint with the previewed face name.
- id: facepick_render
then:
- lvgl.label.update:
id: pick_name
text: !lambda |-
int wf = id(g_wf_pick); if (wf < 0 || wf >= 8) wf = 0;
std::string s = id(g_wf_name)[wf];
for (auto &c : s) { if (c >= 'a' && c <= 'z') c -= 32; }
return s;
- lvgl.widget.show: pick_bar
# Draws the battery indicator on the home screen. Charging: lightning bolt + green +
# "filling" animation (icon cycles empty→full). On battery: static level icon.
- id: batt_render
then:
- lvgl.label.update:
id: lbl_batt
text: !lambda |-
int p = id(g_batt);
if (p < 0) return std::string(""); // no reading yet (after boot) - empty instead of tofu (montserrat has no "-")
if (id(g_charging)) { // charging: lightning bolt + animation, WITHOUT % (voltage reads high)
const char* fr[5] = {"","","","",""};
return std::string("") + fr[id(g_chg_anim) % 5];
}
std::string ic = p > 87 ? "" : p > 62 ? "" : p > 37 ? "" : p > 12 ? "" : "";
char bf[12]; snprintf(bf, sizeof(bf), " %d%%", p);
return ic + bf;
- if:
condition:
lambda: 'return id(g_charging);'
then:
- lvgl.label.update: { id: lbl_batt, text_color: 0x35c759 }
else:
- lvgl.label.update: { id: lbl_batt, text_color: 0x6f9a92 }
- id: flash_volume
mode: restart
then:
- lvgl.widget.show: vol_dim
- lvgl.widget.show: vol_arc
- lvgl.widget.show: vol_label
- lambda: 'id(g_vol_shown) = true;'
- delay: 2000ms
- lvgl.widget.hide: vol_dim
- lvgl.widget.hide: vol_arc
- lvgl.widget.hide: vol_label
- lambda: 'id(g_vol_shown) = false;'
# Immediately hides the volume overlay (after tapping it) - consumes the click
- id: vol_hide_now
then:
- script.stop: flash_volume
- lvgl.widget.hide: vol_dim
- lvgl.widget.hide: vol_arc
- lvgl.widget.hide: vol_label
- lambda: 'id(g_vol_shown) = false;'
# Assistant overlay (dim + frame + text) - always on top
- id: va_show
then:
- lvgl.widget.show: va_dim
- lvgl.widget.show: va_frame
- lvgl.widget.show: va_text
- id: va_hide
then:
- lvgl.widget.hide: va_dim
- lvgl.widget.hide: va_frame
- lvgl.widget.hide: va_text
# Draws the menu/timer/alarm overlay per g_mode (hidden in normal mode and during VA)
- id: ui_render
then:
# === A: animated transitions: BASE(0) / page_mode(menu,timer,alarm,sub) / page_settings(5) / page_controls(10) ===
# g_cur_page: 0=base 1=page_mode 2=page_settings 3=page_controls. Animation only when the page changes.
- if:
condition:
lambda: 'int w=(id(g_mode)==0||id(g_mode)==33)?0:(id(g_mode)==5)?2:(id(g_mode)==10)?3:1; return w != id(g_cur_page);'
then:
- if: # -> BASE (also the watchface picker, mode 33): return per g_base; direction per g_exit_anim
condition: { lambda: 'return id(g_mode) == 0 || id(g_mode) == 33;' }
then:
- lambda: |-
// g_exit_anim 0=right 1=down 2=up -> g_nav_anim 1=right 3=bottom 2=top
id(g_nav_anim) = (id(g_exit_anim)==1) ? 3 : (id(g_exit_anim)==2) ? 2 : 1;
id(g_exit_anim) = 0;
- script.execute: show_base
- if: # -> SETTINGS (list): from base "from top" (swipe ↓), from a sub-screen "back" (to the right)
condition: { lambda: 'return id(g_mode) == 5;' }
then:
- if:
condition: { lambda: 'return id(g_cur_page) == 0;' }
then: [lvgl.page.show: { id: page_settings, animation: move_bottom, time: 250ms }]
else: [lvgl.page.show: { id: page_settings, animation: move_right, time: 250ms }]
- if: # -> CONTROLS (tiles, swipe ↑ from base = "from bottom")
condition: { lambda: 'return id(g_mode) == 10;' }
then: [lvgl.page.show: { id: page_controls, animation: move_top, time: 250ms }]
- if: # -> MODE: from settings "deeper" (to the left), menu from base "from bottom", rest fade
condition: { lambda: 'return id(g_mode) != 0 && id(g_mode) != 5 && id(g_mode) != 10 && id(g_mode) != 33;' }
then:
- if:
condition: { lambda: 'return id(g_cur_page) == 2;' }
then: [lvgl.page.show: { id: page_mode, animation: move_left, time: 250ms }]
else:
- if:
condition: { lambda: 'return id(g_mode) == 1;' }
then: [lvgl.page.show: { id: page_mode, animation: move_top, time: 250ms }]
else: [lvgl.page.show: { id: page_mode, animation: fade_in, time: 250ms }]
- lambda: 'id(g_cur_page) = (id(g_mode)==0||id(g_mode)==33)?0:(id(g_mode)==5)?2:(id(g_mode)==10)?3:1;'
- if:
condition:
lambda: 'return id(g_mode) == 0 || id(g_mode) == 33 || id(g_va_active);'
then:
- lvgl.widget.hide: ui_dim
- lvgl.widget.hide: ui_head_bg
- lvgl.widget.hide: ui_head
- lvgl.widget.hide: ui_big
- lvgl.widget.hide: ui_sub
- lvgl.widget.hide: ui_bar
- lvgl.widget.hide: disp_roller
- lvgl.widget.hide: disp_lbl
- lvgl.widget.hide: disp_switch
- lvgl.widget.hide: asys_roller
- lvgl.widget.hide: sys_roller
- lvgl.widget.hide: home_roller
- lvgl.widget.hide: wid_roller
- lvgl.widget.hide: wopt_roller
else:
- lvgl.widget.show: ui_dim
- lvgl.widget.show: ui_head_bg
- lvgl.widget.show: ui_head
- if: # subtext ONLY on Info (IP/version) - the rest without instructions
condition: { lambda: 'return id(g_mode) == 7 || id(g_mode) == 23 || id(g_mode) == 31 || id(g_mode) == 9 || id(g_mode) == 22;' }
then: [lvgl.widget.show: ui_sub]
else: [lvgl.widget.hide: ui_sub]
- if: # settings-list roller (5) - lives on page_settings (always visible there); here only the selection
condition: { lambda: 'return id(g_mode) == 5;' }
then:
- lvgl.roller.update:
id: settings_roller
animated: true
selected_index: !lambda 'return id(g_set_idx);'
- if: # bar only for brightness (6)
condition: { lambda: 'return id(g_mode) == 6;' }
then:
- lvgl.widget.show: ui_bar
- lvgl.bar.update:
id: ui_bar
value: !lambda 'return id(g_bright);'
else:
- lvgl.widget.hide: ui_bar
- if: # Display submenu (11): roller + (% for Brightness / switch for toggles)
condition: { lambda: 'return id(g_mode) == 11;' }
then:
- lvgl.widget.show: disp_roller
- lvgl.roller.update:
id: disp_roller
animated: true
selected_index: !lambda 'return id(g_disp_idx);'
- if: # Brightness (0) -> show %, hide switch
condition: { lambda: 'return id(g_disp_idx) == 0;' }
then:
- lvgl.widget.show: disp_lbl
- lvgl.widget.hide: disp_switch
- lvgl.label.update:
id: disp_lbl
text: !lambda 'char b[10]; snprintf(b, sizeof(b), "%d%%", id(g_bright)); return std::string(b);'
else:
- lvgl.widget.hide: disp_lbl
- if:
condition: { lambda: 'return id(g_disp_idx) == 1;' }
then:
- lvgl.widget.show: disp_switch
- lvgl.widget.update: { id: disp_switch, state: { checked: !lambda 'return id(g_night);' } }
else:
- lvgl.widget.hide: disp_switch
else:
- lvgl.widget.hide: disp_roller
- lvgl.widget.hide: disp_lbl
- lvgl.widget.hide: disp_switch
- if: # Home (12): home-screen widget toggles (weather / climate)
condition: { lambda: 'return id(g_mode) == 12;' }
then:
- lvgl.widget.show: home_roller
- lvgl.roller.update: { id: home_roller, animated: true, selected_index: !lambda 'return id(g_sub_idx);' }
- if: # items 0/1 are toggles; item 2 (Watchface) is a tap-to-cycle, no switch
condition: { lambda: 'return id(g_sub_idx) <= 1;' }
then:
- lvgl.widget.show: disp_switch
- lvgl.widget.update: { id: disp_switch, state: { checked: !lambda 'return id(g_sub_idx) == 0 ? id(g_show_weather) : id(g_show_climate);' } }
else: [ lvgl.widget.hide: disp_switch ]
else: [ lvgl.widget.hide: home_roller ]
- if: # Widgets (17): list of per-screen groups (Demo / Games / Weather)
condition: { lambda: 'return id(g_mode) == 17;' }
then:
- lvgl.widget.show: wid_roller
- lvgl.roller.update: { id: wid_roller, animated: true, selected_index: !lambda 'return id(g_wgrp_idx);' }
else: [ lvgl.widget.hide: wid_roller ]
- if: # Widget options (30): generic per-screen options for the selected group
condition: { lambda: 'return id(g_mode) == 30;' }
then:
- lvgl.widget.show: wopt_roller
- lvgl.roller.update: { id: wopt_roller, animated: true, selected_index: !lambda 'return id(g_wopt_idx);' }
- if:
condition: { lambda: 'return id(g_wcur_n) > 0 && id(g_wopt_kind)[id(g_wcur_opts)[id(g_wopt_idx)]] == 0;' }
then:
- lvgl.widget.show: disp_switch
- lvgl.widget.update: { id: disp_switch, state: { checked: !lambda 'int o = id(g_wcur_opts)[id(g_wopt_idx)]; return id(g_wopt_ptr)[o] && *id(g_wopt_ptr)[o];' } }
else: [ lvgl.widget.hide: disp_switch ]
else: [ lvgl.widget.hide: wopt_roller ]
- if: # Assistant (13): both items = toggles (0=wake word, 1=beep)
condition: { lambda: 'return id(g_mode) == 13;' }
then:
- lvgl.widget.show: asys_roller
- lvgl.roller.update: { id: asys_roller, animated: true, selected_index: !lambda 'return id(g_sub_idx);' }
- lvgl.widget.show: disp_switch
- lvgl.widget.update: { id: disp_switch, state: { checked: !lambda 'return id(g_sub_idx) == 0 ? id(g_ww_on) : id(g_sub_idx) == 1 ? id(g_beep_ww) : id(g_hold_talk);' } }
else: [ lvgl.widget.hide: asys_roller ]
- if:
condition: { lambda: 'return id(g_mode) == 15;' }
then: [ lvgl.widget.show: sys_roller, lvgl.roller.update: { id: sys_roller, animated: true, selected_index: !lambda 'return id(g_sub_idx);' } ]
else: [ lvgl.widget.hide: sys_roller ]
- if: # we hide ui_big in the list (5), info (7), wake word (8), Display (11)
condition: { lambda: 'return id(g_mode) == 5 || id(g_mode) == 7 || id(g_mode) == 9 || id(g_mode) == 11 || id(g_mode) == 12 || id(g_mode) == 13 || id(g_mode) == 15 || id(g_mode) == 17 || id(g_mode) == 22 || id(g_mode) == 30 || id(g_mode) == 31;' }
then: [lvgl.widget.hide: ui_big]
else: [lvgl.widget.show: ui_big]
- lvgl.label.update:
id: ui_big
text: !lambda |-
char b[16];
int m = id(g_mode);
if (m == 1) return std::string("Timer");
if (m == 2) { snprintf(b, sizeof(b), "%d min", id(g_set_min)); return std::string(b); }
if (m == 6) { snprintf(b, sizeof(b), "%d%%", id(g_bright)); return std::string(b); }
if (m == 9) return std::string("Restart?");
if (m == 23) { if (id(g_batt) < 0) return std::string("--"); char b[8]; snprintf(b, sizeof(b), "%d%%", id(g_batt)); return std::string(b); }
if (m == 24) { const char* L[5]={"Never","15s","30s","1min","5min"}; return std::string(L[id(g_blank_idx) % 5]); }
if (m == 25) { char b[8]; snprintf(b, sizeof(b), "%02d:00", id(g_night_from)); return std::string(b); }
if (m == 26) { char b[8]; snprintf(b, sizeof(b), "%02d:00", id(g_night_to)); return std::string(b); }
int s = id(g_timer_secs); if (s < 0) s = 0;
snprintf(b, sizeof(b), "%d:%02d", s / 60, s % 60);
return std::string(b);
- lvgl.label.update:
id: ui_sub
text: !lambda |-
if (id(g_mode) == 23) { char b[24]; snprintf(b, sizeof(b), "%.2f V", id(batt_volt).state); return std::string(b); }
if (id(g_mode) == 31) { std::string l = (id(g_wcur_n) > 0) ? id(g_wopt_label)[id(g_wcur_opts)[id(g_wopt_idx)]] : std::string(""); return l + "?\ntap = yes"; }
if (id(g_mode) == 9) return std::string("Restart?\ntap = yes");
if (id(g_mode) == 22) return std::string("Factory reset?\ntap = yes");
if (id(g_mode) != 7) return std::string("");
std::string ip = id(wifi_ip).state.empty() ? std::string("-") : id(wifi_ip).state;
return std::string("IP ") + ip + "\nESPHome " ESPHOME_VERSION;
- lvgl.label.update:
id: ui_head
text: !lambda |-
int m = id(g_mode);
if (m == 1) return std::string("MENU");
if (m == 2 || m == 3) return std::string("TIMER");
if (m == 4) return std::string("ALARM");
if (m == 5) return std::string("SETTINGS");
if (m == 6) return std::string("BRIGHTNESS");
if (m == 7) return std::string("INFO");
if (m == 9) return std::string("RESTART");
if (m == 11) return std::string("DISPLAY");
if (m == 12) return std::string("HOME SCREEN");
if (m == 13) return std::string("VOICE ASSISTANT");
if (m == 15) return std::string("SYSTEM");
if (m == 17) return std::string("WIDGETS");
if (m == 30) return id(g_wcur_name);
if (m == 31) return std::string("CONFIRM");
if (m == 23) return std::string("BATTERY");
if (m == 24) return std::string("SCREEN OFF");
if (m == 25) return std::string("NIGHT FROM");
if (m == 26) return std::string("NIGHT TO");
if (m == 22) return std::string("FACTORY");
return std::string("");
- if:
condition: { lambda: 'return id(g_mode) >= 5;' }
then: [lvgl.widget.update: { id: ui_head_bg, bg_color: 0xF5B83D }]
- if:
condition: { lambda: 'return id(g_mode) == 4;' }
then: [lvgl.widget.update: { id: ui_head_bg, bg_color: 0xFF5555 }]
- if:
condition: { lambda: 'return id(g_mode) >= 1 && id(g_mode) <= 3;' }
then: [lvgl.widget.update: { id: ui_head_bg, bg_color: 0x42F4DC }]
# Alarm screen (mode 4): show the dedicated bell layout, hide the generic overlay
- if:
condition: { lambda: 'return id(g_mode) == 4;' }
then:
- lvgl.widget.show: alarm_scrim
- lvgl.widget.show: alarm_bell
- lvgl.widget.show: alarm_title
- lvgl.widget.show: alarm_sub
- lvgl.widget.hide: ui_dim
- lvgl.widget.hide: ui_head_bg
- lvgl.widget.hide: ui_head
- lvgl.widget.hide: ui_big
- lvgl.widget.hide: ui_sub
else:
- lvgl.widget.hide: alarm_scrim
- lvgl.widget.hide: alarm_bell
- lvgl.widget.hide: alarm_title
- lvgl.widget.hide: alarm_sub
- id: timer_start_local
then:
- lambda: |-
id(g_timer_secs) = id(g_set_min) * 60;
id(g_timer_local) = true;
id(g_mode) = 3;
- script.execute: ui_render
- id: timer_cancel
then:
- lambda: 'id(g_mode) = 0; id(g_timer_local) = false; id(g_timer_secs) = 0;'
- script.execute: ui_render
- id: alarm_start
then:
- lambda: 'id(g_mode) = 4; id(g_alarm_cnt) = 0; id(g_timer_active) = false; id(g_timer_paused) = false; id(g_timer_secs) = 0;'
- script.execute: ui_render
- script.execute: timer_render
- media_player.speaker.play_on_device_media_file:
id: media_player_speaker
media_file: alarm_sound
announcement: true
- id: alarm_stop
then:
- lambda: 'id(g_mode) = 0; id(g_timer_active) = false; id(g_timer_paused) = false; id(g_timer_secs) = 0;'
- script.execute: ui_render
- script.execute: timer_render
# --- Timer as a SCREEN (page_timer): render + button controls ---
- id: timer_render
then:
- lvgl.label.update:
id: timer_big
text: !lambda |-
int s = id(g_timer_active) ? id(g_timer_secs) : id(g_set_min) * 60;
if (s < 0) s = 0;
char b[12]; snprintf(b, sizeof(b), "%d:%02d", s / 60, s % 60);
return std::string(b);
- lvgl.arc.update:
id: timer_arc
value: !lambda |-
if (!id(g_timer_active)) return 100.0f;
int tot = id(g_timer_total); if (tot <= 0) return 0.0f;
float f = 100.0f * id(g_timer_secs) / tot;
if (f < 0) f = 0; if (f > 100) f = 100;
return f;
- if:
condition: { lambda: 'return id(g_timer_active);' }
then:
- lvgl.widget.hide: timer_play
- lvgl.widget.show: timer_pause
- lvgl.widget.show: timer_stop
- lvgl.widget.show: timer_dot
- lvgl.label.update:
id: timer_pause_ic
text: !lambda 'return id(g_timer_paused) ? std::string("") : std::string("");'
else:
- lvgl.widget.show: timer_play
- lvgl.widget.hide: timer_pause
- lvgl.widget.hide: timer_stop
- lvgl.widget.hide: timer_dot
- if: # ride the head-dot on the leading edge of the remaining arc
condition: { lambda: 'return id(g_timer_active);' }
then:
- lambda: |-
int tot = id(g_timer_total); float v = (tot > 0) ? 100.0f * id(g_timer_secs) / tot : 0.0f;
if (v < 0) v = 0; if (v > 100) v = 100;
float a = (271.0f + v / 100.0f * 358.0f) * 0.01745329f; // arc head angle -> radians
int x = 180 + (int) lroundf(143.0f * cosf(a)) - 9;
int y = 180 + (int) lroundf(143.0f * sinf(a)) - 9;
lv_obj_set_pos(id(timer_dot), x, y);
# status caption + arc/time colour by remaining: SET grey, then green > amber > red
- lvgl.label.update:
id: timer_cap
text: !lambda |-
if (!id(g_timer_active)) return std::string("SET");
return id(g_timer_paused) ? std::string("PAUSED") : std::string("RUNNING");
- if:
condition: { lambda: 'return !id(g_timer_active);' }
then:
- lvgl.arc.update: { id: timer_arc, indicator: { arc_color: 0x2FB85C } }
- lvgl.label.update: { id: timer_cap, text_color: 0x6F9A92 }
- lvgl.label.update: { id: timer_big, text_color: 0xFFFFFF }
else:
- if:
condition: { lambda: 'int t=id(g_timer_total); return t>0 && (long)id(g_timer_secs)*100 > (long)t*50;' }
then:
- lvgl.arc.update: { id: timer_arc, indicator: { arc_color: 0x2FB85C } }
- lvgl.label.update: { id: timer_cap, text_color: 0x2FB85C }
- lvgl.label.update: { id: timer_big, text_color: 0xFFFFFF }
else:
- if:
condition: { lambda: 'int t=id(g_timer_total); return t>0 && (long)id(g_timer_secs)*100 > (long)t*20;' }
then:
- lvgl.arc.update: { id: timer_arc, indicator: { arc_color: 0xF5B83D } }
- lvgl.label.update: { id: timer_cap, text_color: 0xF5B83D }
- lvgl.label.update: { id: timer_big, text_color: 0xFFFFFF }
else:
- lvgl.arc.update: { id: timer_arc, indicator: { arc_color: 0xFF3B30 } }
- lvgl.label.update: { id: timer_cap, text_color: 0xFF6B60 }
- lvgl.label.update: { id: timer_big, text_color: 0xFF6B60 }
- id: timer_start
then:
- lambda: |-
id(g_timer_secs) = id(g_set_min) * 60;
id(g_timer_total) = id(g_timer_secs);
id(g_timer_active) = true;
id(g_timer_paused) = false;
id(g_timer_local) = true; // timer from the button -> the interval counts down and fires the alarm
- script.execute: timer_render
- id: timer_toggle
then:
- lambda: 'id(g_timer_paused) = !id(g_timer_paused);'
- script.execute: timer_render
- id: timer_halt
then:
- lambda: 'id(g_timer_active) = false; id(g_timer_paused) = false; id(g_timer_secs) = 0;'
- script.execute: timer_render
# Shows/hides home-screen elements per Display settings
- id: disp_apply # gate the Classic face's weather/climate widgets per the Display settings
then:
- if:
condition: { lambda: 'return id(g_show_weather);' }
then: [ lvgl.widget.show: lbl_wx_ic, lvgl.widget.show: lbl_wx_val ]
else: [ lvgl.widget.hide: lbl_wx_ic, lvgl.widget.hide: lbl_wx_val ]
- if:
condition: { lambda: 'return id(g_show_climate);' }
then: [ lvgl.widget.show: lbl_ct_ic, lvgl.widget.show: lbl_ct_val, lvgl.widget.show: lbl_ch_ic, lvgl.widget.show: lbl_ch_val ]
else: [ lvgl.widget.hide: lbl_ct_ic, lvgl.widget.hide: lbl_ct_val, lvgl.widget.hide: lbl_ch_ic, lvgl.widget.hide: lbl_ch_val ]
# Night mode: ON -> dim screen to 15%, OFF -> restore brightness from settings
- id: night_apply
then:
- light.turn_on:
id: backlight_light
brightness: !lambda 'return id(g_bright) / 100.0f;'
# Volume (0..1) from the entity -> vol_arc value + % label
sensor:
- platform: homeassistant
id: vol_sensor
entity_id: ${player_entity}
attribute: volume_level
on_value:
- lambda: 'id(g_vol) = x;'
- lvgl.arc.update:
id: vol_arc
value: !lambda 'return x * 100.0;'
- lvgl.label.update:
id: vol_label
text: !lambda |-
char buf[8]; snprintf(buf, sizeof(buf), "%d%%", (int) roundf(x * 100.0f)); return std::string(buf);
# --- Home screen: weather (outdoor temp) + room temperature/humidity ---
- platform: homeassistant
id: wx_temp
entity_id: ${weather_entity}
attribute: temperature
on_value:
- lvgl.label.update:
id: lbl_wx_val
text: !lambda 'char b[10]; snprintf(b, sizeof(b), "%d°", (int) roundf(x)); return std::string(b);'
- platform: homeassistant
id: room_temp
entity_id: ${room_temp_entity}
on_value:
- lvgl.label.update:
id: lbl_ct_val
text: !lambda 'char b[12]; snprintf(b, sizeof(b), "%.1f°", x); return std::string(b);'
- platform: homeassistant
id: room_hum
entity_id: ${room_hum_entity}
on_value:
- lvgl.label.update:
id: lbl_ch_val
text: !lambda 'char b[8]; snprintf(b, sizeof(b), "%d%%", (int) roundf(x)); return std::string(b);'
# Battery: ADC on GPIO6 (BAT_ADC, ~10k/10k divider -> x2). PIN/RATIO to verify live.
- platform: adc
pin: GPIO1
id: batt_volt
name: "Battery voltage"
attenuation: 12db
update_interval: 1s
samples: 64 # hardware oversampling: 64 readings/measurement averaged
# -> cuts ripple from the ETA6003 converter (no capacitor on BAT_ADC)
filters:
- multiply: 2.0
# Reject values physically impossible for Li-ion (that's switching ripple, not voltage):
- lambda: 'return (x >= 3.20f && x <= 4.30f) ? x : NAN;'
# On the cleaned signal: moving average, output every ~10 s
- sliding_window_moving_average:
window_size: 20
send_every: 10
send_first_at: 5
on_value:
- lambda: |-
// x = VCC_OUT (median, after ×2) = system rail ≈ battery voltage UNDER load.
float v = x;
// Voltage→% curve CALIBRATED on a real discharge (under load):
// start ~4.10 (≈90%), nearly linear drop, brownout ~3.20 V. Range 3.20-4.15.
static const float vt[] = {3.20f,3.25f,3.39f,3.57f,3.74f,3.84f,4.10f};
static const float st[] = {0.f, 4.f, 20.f, 40.f, 60.f, 80.f, 100.f};
const int n = 7;
float pct;
if (v <= vt[0]) pct = 0.f;
else if (v >= vt[n-1]) pct = 100.f;
else { pct = 0.f; for (int i = 0; i < n-1; i++) { if (v >= vt[i] && v < vt[i+1]) { pct = st[i] + (v - vt[i]) / (vt[i+1] - vt[i]) * (st[i+1] - st[i]); break; } } }
int p = (int) roundf(pct);
if (p < 0) p = 0; if (p > 100) p = 100;
id(g_batt) = p;
// --- Charging detection (no STAT pin on GPIO -> voltage heuristic) ---
// The puck constantly draws current (backlight+ESP+audio); only USB keeps the voltage high.
float prev = id(g_v_med);
id(g_v_med) = v;
bool rising = (prev > 0.1f) && (v - prev > 0.010f); // voltage rising = charging
bool high = (v >= 4.13f); // on battery under load max ~4.10 -> only USB holds ≥4.13
bool low = (v < 4.05f); // clearly on battery
if (high || rising) id(g_charging) = true;
else if (low) id(g_charging) = false;
// 4.05-4.13 zone without a rise: keep the previous state (hysteresis, no flicker)
- script.execute: batt_render
# Push-to-talk: screen TAP (handled in touchscreen: on_release). Swipe = next/prev.
# Eventually the wake word (micro_wake_word) will be better.
binary_sensor:
# ── KNOB: this "encoder" is NOT classic quadrature. Confirmed by logs:
# direction is encoded by WHICH pin pulses (not A/B phase):
# • LEFT → GPIO7 pulses → quieter
# • RIGHT → GPIO8 pulses → louder
# Each detent = one clean LOW pulse (sequence 0101...), zero bounce.
# That's why rotary_encoder (expects interleaved A/B) counted zero.
# So we read two independent pulses: inverted+pullup → detent = "on_press".
- platform: gpio
name: "Knob left"
id: knob_left
pin:
number: GPIO7
inverted: true
mode:
input: true
pullup: true
on_press:
- if:
condition: { lambda: 'return id(g_disp_off);' } # asleep -> just wake, don't act (same as touch)
then:
- light.turn_on: { id: backlight_light, brightness: !lambda 'return id(g_bright) / 100.0f;' }
- lambda: 'id(g_disp_off) = false; id(g_idle_ticks) = 0;'
else:
- if:
condition:
lambda: 'return id(g_mode) == 0 && (id(g_knob_capture) || (id(g_base) == 2 && !id(g_timer_active)));'
then:
- if:
condition: { lambda: 'return id(g_knob_capture);' }
then: [ lambda: 'id(g_knob_delta)--;' ]
else:
- lambda: 'id(g_set_min)--; if (id(g_set_min) < 1) id(g_set_min) = 1;'
- script.execute: timer_render
else:
- if:
condition:
lambda: 'return id(g_mode) == 5 || id(g_mode) == 11 || (id(g_mode) >= 12 && id(g_mode) <= 17) || id(g_mode) == 30;'
then:
- lambda: |-
if (id(g_mode) == 11) { if (id(g_disp_idx) > 0) id(g_disp_idx)--; }
else if (id(g_mode) == 5) { if (id(g_set_idx) > 0) id(g_set_idx)--; }
else if (id(g_mode) == 17) { if (id(g_wgrp_idx) > 0) id(g_wgrp_idx)--; }
else if (id(g_mode) == 30) { if (id(g_wopt_idx) > 0) id(g_wopt_idx)--; }
else { if (id(g_sub_idx) > 0) id(g_sub_idx)--; }
- script.execute: ui_render
else:
- if:
condition:
lambda: 'return id(g_mode) == 6;'
then:
- lambda: 'id(g_bright) -= 10; if (id(g_bright) < 10) id(g_bright) = 10;'
- light.turn_on:
id: backlight_light
brightness: !lambda 'return id(g_bright) / 100.0f;'
- script.execute: ui_render
else:
- if:
condition: { lambda: 'return id(g_mode) == 24 || id(g_mode) == 25 || id(g_mode) == 26;' }
then:
- lambda: |-
if (id(g_mode) == 24) { if (id(g_blank_idx) > 0) id(g_blank_idx)--; }
else if (id(g_mode) == 25) { id(g_night_from)--; if (id(g_night_from) < 0) id(g_night_from) = 23; }
else { id(g_night_to)--; if (id(g_night_to) < 0) id(g_night_to) = 23; }
- script.execute: ui_render
else:
- if: # watchface picker: previous present face
condition: { lambda: 'return id(g_mode) == 33;' }
then:
- lambda: 'int n=id(g_wf_pick); for(int k=0;k<8;k++){ n=(n+7)%8; if(id(g_wf_present)[n]) break; } id(g_wf_pick)=n; id(g_watchface)=n;'
- script.execute: face_apply
- script.execute: facepick_render
else:
- media_player.volume_down:
id: media_player_speaker
- script.execute: flash_volume
- platform: gpio
name: "Knob right"
id: knob_right
pin:
number: GPIO8
inverted: true
mode:
input: true
pullup: true
on_press:
- if:
condition: { lambda: 'return id(g_disp_off);' } # asleep -> just wake, don't act (same as touch)
then:
- light.turn_on: { id: backlight_light, brightness: !lambda 'return id(g_bright) / 100.0f;' }
- lambda: 'id(g_disp_off) = false; id(g_idle_ticks) = 0;'
else:
- if:
condition:
lambda: 'return id(g_mode) == 0 && (id(g_knob_capture) || (id(g_base) == 2 && !id(g_timer_active)));'
then:
- if:
condition: { lambda: 'return id(g_knob_capture);' }
then: [ lambda: 'id(g_knob_delta)++;' ]
else:
- lambda: 'id(g_set_min)++; if (id(g_set_min) > 180) id(g_set_min) = 180;'
- script.execute: timer_render
else:
- if:
condition:
lambda: 'return id(g_mode) == 5 || id(g_mode) == 11 || (id(g_mode) >= 12 && id(g_mode) <= 17) || id(g_mode) == 30;'
then:
- lambda: |-
if (id(g_mode) == 11) { if (id(g_disp_idx) < 4) id(g_disp_idx)++; }
else if (id(g_mode) == 5) { if (id(g_set_idx) < id(g_set_n) - 1) id(g_set_idx)++; }
else if (id(g_mode) == 17) { if (id(g_wgrp_idx) < id(g_wgrp_n) - 1) id(g_wgrp_idx)++; }
else if (id(g_mode) == 30) { if (id(g_wopt_idx) < id(g_wcur_n) - 1) id(g_wopt_idx)++; }
else { int mx = (id(g_mode) == 15) ? 3 : (id(g_mode) == 13) ? 2 : (id(g_mode) == 12) ? 2 : 0; if (id(g_sub_idx) < mx) id(g_sub_idx)++; }
- script.execute: ui_render
else:
- if:
condition:
lambda: 'return id(g_mode) == 6;'
then:
- lambda: 'id(g_bright) += 10; if (id(g_bright) > 100) id(g_bright) = 100;'
- light.turn_on:
id: backlight_light
brightness: !lambda 'return id(g_bright) / 100.0f;'
- script.execute: ui_render
else:
- if:
condition: { lambda: 'return id(g_mode) == 24 || id(g_mode) == 25 || id(g_mode) == 26;' }
then:
- lambda: |-
if (id(g_mode) == 24) { if (id(g_blank_idx) < 4) id(g_blank_idx)++; }
else if (id(g_mode) == 25) { id(g_night_from)++; if (id(g_night_from) > 23) id(g_night_from) = 0; }
else { id(g_night_to)++; if (id(g_night_to) > 23) id(g_night_to) = 0; }
- script.execute: ui_render
else:
- if: # watchface picker: next present face
condition: { lambda: 'return id(g_mode) == 33;' }
then:
- lambda: 'int n=id(g_wf_pick); for(int k=0;k<8;k++){ n=(n+1)%8; if(id(g_wf_present)[n]) break; } id(g_wf_pick)=n; id(g_watchface)=n;'
- script.execute: face_apply
- script.execute: facepick_render
else:
- media_player.volume_up:
id: media_player_speaker
- script.execute: flash_volume
###############################################################################
# DISPLAY (QSPI ST77916) + BACKLIGHT + TOUCH (CST816)
# Pins: SCK=11 CS=12 D0=13 D1=14 D2=15 D3=16 RST=17 BLK=21
# TOUCH SDA=9 SCL=10 INT=7 RST=8
###############################################################################
spi:
id: display_qspi
type: quad
clk_pin: 13
data_pins: [15, 16, 17, 18] # TFT_SDA0..3
i2c:
sda: 11 # TOUCH_PIN_NUM_I2C_SDA
scl: 12 # TOUCH_PIN_NUM_I2C_SCL
scan: true
id: touchscreen_bus
output:
- platform: ledc
pin: GPIO47 # your backlight GPIO
id: backlight_output
light:
- platform: monochromatic
output: backlight_output
name: "Backlight"
id: backlight_light
restore_mode: ALWAYS_ON
button:
- platform: restart
id: btn_restart
name: "Restart"
touchscreen:
platform: cst816
id: my_touchscreen
interrupt_pin: GPIO9 # TOUCH_PIN_NUM_INT
reset_pin: GPIO10 # TOUCH_PIN_NUM_RST
display: main_display
on_touch:
- lambda: |-
id(g_x0) = (int) touch.x;
id(g_y0) = (int) touch.y;
id(g_t0) = millis();
id(g_dx) = 0;
id(g_dy) = 0;
id(g_idle_ticks) = 0;
- if:
condition: { lambda: 'return id(g_disp_off);' }
then:
- light.turn_on: { id: backlight_light, brightness: !lambda 'return id(g_bright) / 100.0f;' }
- lambda: 'id(g_disp_off) = false; id(g_wake_consume) = true;'
on_update:
- lambda: |-
if (!touches.empty()) {
id(g_dx) = (int) touches[0].x - id(g_x0);
id(g_dy) = (int) touches[0].y - id(g_y0);
}
on_release:
# Gesture classification + mode snapshot (routing by g_mode0, so one gesture doesn't cascade)
- lambda: |-
if (id(g_boot)) { id(g_gesture) = -1; id(g_mode0) = 0; return; } // ignore input during the boot splash
int dx = id(g_dx), dy = id(g_dy);
uint32_t held = millis() - id(g_t0);
int g;
if (abs(dx) > 50 && abs(dx) > abs(dy)) g = (dx < 0) ? 2 : 3; // 2=left 3=right
else if (abs(dy) > 50 && abs(dy) > abs(dx)) g = (dy < 0) ? 4 : 5; // 4=up 5=down
else if (abs(dx) < 16 && abs(dy) < 16) g = (held >= 500) ? 1 : 0; // 1=hold 0=tap
else g = -1;
if (id(g_wake_consume)) { g = -1; id(g_wake_consume) = false; }
id(g_idle_ticks) = 0;
id(g_gesture) = g;
id(g_mode0) = id(g_mode);
# === Volume overlay on top: ANY gesture (tap/swipe/hold) hides it and is CONSUMED ===
- if:
condition:
lambda: 'return id(g_vol_shown) && id(g_gesture) >= 0;'
then:
- script.execute: vol_hide_now
- lambda: 'id(g_gesture) = -1;' # neutralize the gesture -> no mode will handle it
# === NORMAL MODE ===
- if:
condition:
lambda: 'return id(g_mode0) == 0;'
then:
- if: # tap -> bump a generic signal so the active screen can react (e.g. demo)
condition: { lambda: 'return id(g_gesture) == 0;' }
then: [ lambda: 'id(g_tap_seq)++;' ]
- if: # swipe up -> controls (light tiles)
condition:
lambda: 'return id(g_gesture) == 4;'
then:
- lambda: 'id(g_mode) = 10;'
- script.execute: ui_render
- if: # swipe down -> settings (list)
condition:
lambda: 'return id(g_gesture) == 5;'
then:
- lambda: 'id(g_mode) = 5; id(g_set_idx) = 0;'
- script.execute: ui_render
# Horizontal carousel: swipe steps through g_order (built from screen_order at boot),
# wrapping around. Each screen shows itself via show_base / its nav handler.
- if: # swipe ← -> next
condition: { lambda: 'return id(g_gesture) == 2;' }
then:
- lambda: |-
int i = 0; for (; i < id(g_order_n); i++) if (id(g_order)[i] == id(g_base)) break;
if (i >= id(g_order_n)) i = 0;
id(g_base) = id(g_order)[(i + 1) % id(g_order_n)];
id(g_nav_anim) = 0;
id(g_knob_capture) = false; id(g_knob_delta) = 0;
- script.execute: show_base
- if: # swipe → -> previous
condition: { lambda: 'return id(g_gesture) == 3;' }
then:
- lambda: |-
int i = 0; for (; i < id(g_order_n); i++) if (id(g_order)[i] == id(g_base)) break;
if (i >= id(g_order_n)) i = 0;
id(g_base) = id(g_order)[(i - 1 + id(g_order_n)) % id(g_order_n)];
id(g_nav_anim) = 1;
id(g_knob_capture) = false; id(g_knob_delta) = 0;
- script.execute: show_base
# game: START/Play buttons on the game screen handle start/restart (not a global tap)
# prev/play/next are handled by the buttons on the player screen (transport)
- if: # hold -> start the assistant (works on every screen; toggle in Settings > Assistant)
condition:
lambda: 'return id(g_gesture) == 1 && id(g_hold_talk);'
then:
- if:
condition: voice_assistant.is_running
then: [voice_assistant.stop: {}]
else: [voice_assistant.start: {}]
# === MENU ===
- if:
condition:
lambda: 'return id(g_mode0) == 1;'
then:
- if: # tap -> open timer (the only item)
condition:
lambda: 'return id(g_gesture) == 0;'
then:
- lambda: 'id(g_mode) = 2; id(g_set_min) = 5;'
- script.execute: ui_render
- if: # hold / swipe down / swipe right -> close menu (back)
condition:
lambda: 'return id(g_gesture) == 1 || id(g_gesture) == 5 || id(g_gesture) == 3;'
then:
- lambda: 'id(g_mode) = 0;'
- script.execute: ui_render
# === TIMER_SET ===
- if:
condition:
lambda: 'return id(g_mode0) == 2;'
then:
- if: # tap -> start
condition:
lambda: 'return id(g_gesture) == 0;'
then:
- script.execute: timer_start_local
- if: # hold / swipe right -> cancel (back)
condition:
lambda: 'return id(g_gesture) == 1 || id(g_gesture) == 3;'
then:
- script.execute: timer_cancel
# === TIMER_RUN ===
- if:
condition:
lambda: 'return id(g_mode0) == 3;'
then:
- if:
condition:
lambda: 'return id(g_gesture) == 0 || id(g_gesture) == 1 || id(g_gesture) == 3;'
then:
- script.execute: timer_cancel
# === ALARM ===
- if:
condition:
lambda: 'return id(g_mode0) == 4;'
then:
- if:
condition:
lambda: 'return id(g_gesture) == 0 || id(g_gesture) == 1 || id(g_gesture) == 3;'
then:
- script.execute: alarm_stop
# === CONTROLS (tiles) === tap=tile buttons; swipe ↓/→/hold = back
- if:
condition:
lambda: 'return id(g_mode0) == 10;'
then:
- if: # swipe ↓ -> close with a "downward slide"
condition: { lambda: 'return id(g_gesture) == 5;' }
then:
- lambda: 'id(g_exit_anim) = 1; id(g_mode) = 0;'
- script.execute: ui_render
- if: # back (right / hold) -> close "to the right"
condition: { lambda: 'return id(g_gesture) == 1 || id(g_gesture) == 3;' }
then:
- lambda: 'id(g_exit_anim) = 0; id(g_mode) = 0;'
- script.execute: ui_render
# === SETTINGS (list) === knob=scroll; tap=enter; right/hold=back
- if:
condition:
lambda: 'return id(g_mode0) == 5;'
then:
- if: # tap -> enter the selected item
condition:
lambda: 'return id(g_gesture) == 0;'
then:
- lambda: 'id(g_mode) = id(g_set_items)[id(g_set_idx)]; id(g_disp_idx) = 0; id(g_sub_idx) = 0; id(g_wgrp_idx) = 0;'
- script.execute: ui_render
- if: # back: swipe right / hold -> "to the right"
condition:
lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;'
then:
- lambda: 'id(g_exit_anim) = 0; id(g_mode) = 0;'
- script.execute: ui_render
- if: # swipe ↑ -> close "upward" (reverse of settings entering from the bottom)
condition:
lambda: 'return id(g_gesture) == 4;'
then:
- lambda: 'id(g_exit_anim) = 2; id(g_mode) = 0;'
- script.execute: ui_render
# === DISPLAY (11) === knob=scroll; tap=action; right/hold=back to settings
- if:
condition:
lambda: 'return id(g_mode0) == 11;'
then:
- if: # tap -> action per the selected item
condition: { lambda: 'return id(g_gesture) == 0;' }
then:
- if: { condition: { lambda: 'return id(g_disp_idx) == 0;' }, then: [ lambda: 'id(g_mode) = 6;', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_disp_idx) == 1;' }, then: [ lambda: 'id(g_night) = !id(g_night);', script.execute: night_apply, script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_disp_idx) == 2;' }, then: [ lambda: 'id(g_mode) = 25;', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_disp_idx) == 3;' }, then: [ lambda: 'id(g_mode) = 26;', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_disp_idx) == 4;' }, then: [ lambda: 'id(g_mode) = 24;', script.execute: ui_render ] }
- if: # back -> settings list
condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }
then:
- lambda: 'id(g_mode) = 5;'
- script.execute: ui_render
# === HOME (12) === tap: 0=toggle weather widget, 1=toggle climate widget, 2=cycle watchface; right/hold=back
- if:
condition: { lambda: 'return id(g_mode0) == 12;' }
then:
- if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 0;' }, then: [ lambda: 'id(g_show_weather) = !id(g_show_weather);', script.execute: disp_apply, script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 1;' }, then: [ lambda: 'id(g_show_climate) = !id(g_show_climate);', script.execute: disp_apply, script.execute: ui_render ] }
- if: # enter the live watchface picker (mode 33) on the home page
condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 2;' }
then:
- lambda: 'id(g_wf_prev) = id(g_watchface); id(g_wf_pick) = id(g_watchface); id(g_mode) = 33;'
- script.execute: ui_render
- script.execute: face_apply
- script.execute: facepick_render
- if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 5;', script.execute: ui_render ] }
# === WATCHFACE PICKER (33) === knob switches faces live; tap = keep, swipe-right/hold = cancel (revert)
- if:
condition: { lambda: 'return id(g_mode0) == 33;' }
then:
- if: # tap -> keep the previewed face (already applied + persisted), drop to home
condition: { lambda: 'return id(g_gesture) == 0;' }
then:
- lvgl.widget.hide: pick_bar
- script.execute: home_roller_build
- lambda: 'id(g_mode) = 0; id(g_exit_anim) = 0;'
- script.execute: ui_render
- if: # swipe-right / hold -> cancel: restore the previous face
condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }
then:
- lvgl.widget.hide: pick_bar
- lambda: 'id(g_watchface) = id(g_wf_prev);'
- script.execute: face_apply
- script.execute: home_roller_build
- lambda: 'id(g_mode) = 0; id(g_exit_anim) = 0;'
- script.execute: ui_render
# === WIDGETS (17) === list of per-screen groups; tap opens one; right/hold = back to settings
- if:
condition: { lambda: 'return id(g_mode0) == 17;' }
then:
- if:
condition: { lambda: 'return id(g_gesture) == 0 && id(g_wgrp_n) > 0;' }
then:
- lambda: |-
int g = id(g_wgrp_idx);
id(g_wcur_n) = 0;
for (int o = 0; o < id(g_wopt_n); o++) if (id(g_wopt_group)[o] == g) id(g_wcur_opts)[id(g_wcur_n)++] = o;
std::string s; for (int k = 0; k < id(g_wcur_n); k++) { if (k) s += "\n"; s += id(g_wopt_label)[id(g_wcur_opts)[k]]; }
lv_roller_set_options(id(wopt_roller)->obj, s.c_str(), LV_ROLLER_MODE_NORMAL);
id(g_wcur_name) = ""; { std::string gl = id(g_wgrp_labels); int idx = 0; size_t pp = 0; while (true) { size_t nl = gl.find('\n', pp); std::string tok = (nl == std::string::npos) ? gl.substr(pp) : gl.substr(pp, nl - pp); if (idx == g) { id(g_wcur_name) = tok; break; } if (nl == std::string::npos) break; pp = nl + 1; idx++; } }
id(g_wopt_idx) = 0; id(g_mode) = 30;
- script.execute: ui_render
- if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 5;', script.execute: ui_render ] }
# === WIDGET OPTIONS (30) === tap: toggle / confirm action; right/hold = back to Widgets
- if:
condition: { lambda: 'return id(g_mode0) == 30;' }
then:
- if:
condition: { lambda: 'return id(g_gesture) == 0 && id(g_wcur_n) > 0;' }
then:
- if:
condition: { lambda: 'return id(g_wopt_kind)[id(g_wcur_opts)[id(g_wopt_idx)]] == 0;' }
then: [ lambda: 'int o = id(g_wcur_opts)[id(g_wopt_idx)]; if (id(g_wopt_ptr)[o]) *id(g_wopt_ptr)[o] = !*id(g_wopt_ptr)[o];', script.execute: ui_render ]
else: [ lambda: 'id(g_mode) = 31;', script.execute: ui_render ]
- if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 17;', script.execute: ui_render ] }
# === CONFIRM (31) === generic action confirm; tap = YES (fire), right/hold = NO
- if:
condition: { lambda: 'return id(g_mode0) == 31;' }
then:
- if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_wcur_n) > 0;' }, then: [ lambda: 'int o = id(g_wcur_opts)[id(g_wopt_idx)]; if (id(g_wopt_ptr)[o]) *id(g_wopt_ptr)[o] = true; id(g_mode) = 30;', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 30;', script.execute: ui_render ] }
# === ASSISTANT (13) === tap: 0=toggle wake word, 1=toggle beep; right/hold=back
- if:
condition: { lambda: 'return id(g_mode0) == 13;' }
then:
- if:
condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 0;' }
then:
- lambda: 'id(g_ww_on) = !id(g_ww_on);'
- if:
condition: { lambda: 'return id(g_ww_on);' }
then: [micro_wake_word.start: {}]
else: [micro_wake_word.stop: {}]
- script.execute: ui_render
- if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 1;' }, then: [ lambda: 'id(g_beep_ww) = !id(g_beep_ww);', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 2;' }, then: [ lambda: 'id(g_hold_talk) = !id(g_hold_talk);', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 5;', script.execute: ui_render ] }
# === SYSTEM (15) ===
- if:
condition: { lambda: 'return id(g_mode0) == 15;' }
then:
- if:
condition: { lambda: 'return id(g_gesture) == 0;' }
then:
- if: { condition: { lambda: 'return id(g_sub_idx) == 0;' }, then: [ lambda: 'id(g_mode) = 7;', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_sub_idx) == 1;' }, then: [ lambda: 'id(g_mode) = 23;', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_sub_idx) == 2;' }, then: [ lambda: 'id(g_mode) = 9;', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_sub_idx) == 3;' }, then: [ lambda: 'id(g_mode) = 22;', script.execute: ui_render ] }
- if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 5;', script.execute: ui_render ] }
# === RESTART confirm (9) === tap = YES (restart); right/hold = NO
- if:
condition:
lambda: 'return id(g_mode0) == 9;'
then:
- if:
condition: { lambda: 'return id(g_gesture) == 0;' }
then:
- button.press: btn_restart
- if:
condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }
then:
- lambda: 'id(g_mode) = 5;'
- script.execute: ui_render
# === BRIGHTNESS (6) === any gesture = return to Display (we enter from there)
- if:
condition:
lambda: 'return id(g_mode0) == 6;'
then:
- if:
condition: { lambda: 'return id(g_gesture) >= 0;' }
then:
- lambda: 'id(g_mode) = 11;'
- script.execute: ui_render
# === SCREEN OFF (24) === any gesture = return to Display
- if:
condition: { lambda: 'return id(g_mode0) == 24;' }
then:
- if:
condition: { lambda: 'return id(g_gesture) >= 0;' }
then: [ lambda: 'id(g_mode) = 11;', script.execute: ui_render ]
# === NIGHT FROM/TO (25/26) === any gesture = return to Display
- if:
condition: { lambda: 'return id(g_mode0) == 25 || id(g_mode0) == 26;' }
then:
- if: { condition: { lambda: 'return id(g_gesture) >= 0;' }, then: [ lambda: 'id(g_mode) = 11;', script.execute: ui_render ] }
# === BATTERY (23) === any gesture = return to System
- if:
condition: { lambda: 'return id(g_mode0) == 23;' }
then:
- if: { condition: { lambda: 'return id(g_gesture) >= 0;' }, then: [ lambda: 'id(g_mode) = 15;', script.execute: ui_render ] }
# === FACTORY confirm (22) === tap=YES (reset+restart); right/hold=NO
- if:
condition: { lambda: 'return id(g_mode0) == 22;' }
then:
- if:
condition: { lambda: 'return id(g_gesture) == 0;' }
then:
- lambda: |-
id(g_bright)=100; id(g_night)=false; id(g_show_weather)=false; id(g_show_climate)=false; id(g_watchface)=0; id(g_ww_on)=true; id(g_set_min)=60; id(g_beep_ww)=true; id(g_hold_talk)=true; id(g_night_from)=22; id(g_night_to)=7; id(g_blank_idx)=0; id(g_factory_reset)=true;
- button.press: btn_restart
- if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 15;', script.execute: ui_render ] }
# === INFO (7) === any gesture = return to the settings list
- if:
condition:
lambda: 'return id(g_mode0) == 7;'
then:
- if:
condition: { lambda: 'return id(g_gesture) >= 0;' }
then:
- lambda: 'id(g_mode) = 5;'
- script.execute: ui_render
display:
- platform: mipi_spi
id: main_display
model: JC3636W518V2
data_rate: 80MHz
color_order: rgb
rotation: 0
cs_pin: 14
reset_pin: 21
invert_colors: true
auto_clear_enabled: false
update_interval: never
dimensions:
height: 360
width: 360
###############################################################################
# FONTS (Latin glyphs incl. diacritics) + PLAYER DATA + ALBUM ART
###############################################################################
font:
- file: "gfonts://Roboto"
id: font_med
size: 22
glyphsets: [GF_Latin_Core]
ignore_missing_glyphs: true # GF_Latin_Core lists combining accents Roboto's static face lacks - harmless, silence the warning
- file: "gfonts://Roboto"
id: font_artist
size: 16
glyphsets: [GF_Latin_Core]
ignore_missing_glyphs: true
- file: "gfonts://Roboto"
id: font_sel
size: 28
glyphsets: [GF_Latin_Core]
ignore_missing_glyphs: true
# Big bell for the alarm screen (single MDI glyph)
- file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/v7.4.47/fonts/materialdesignicons-webfont.ttf"
id: font_bell
size: 72
bpp: 4
glyphs: [ "\U000F009A" ]
# Material Design icons (for the control tiles) - ESPHome downloads the TTF at compile time
- file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/v7.4.47/fonts/materialdesignicons-webfont.ttf"
id: icons34
size: 34
bpp: 4
glyphs:
# smart-home icon set (reference these by name in tileN_icon)
- "\U000f0335"
- "\U000f06b5"
- "\U000f07d6"
- "\U000f0425"
- "\U000f06a5"
- "\U000f0521"
- "\U000f0210"
- "\U000f001b"
- "\U000f0438"
- "\U000f050f"
- "\U000f033e"
- "\U000f033f"
- "\U000f081a"
- "\U000f00ac"
- "\U000f0502"
- "\U000f04c3"
- "\U000f009a"
- "\U000f05a9"
- "\U000f02dc"
- "\U000f0241"
- "\U000f0d44"
- "\U000f0e66"
- "\U000f06ba"
- "\U000f048b"
- "\U000f068a"
- "󰖐"
- "󰖑"
- "󰖓"
- "󰖔"
- "󰖕"
- "󰖖"
- "󰖗"
- "󰖘"
- "󰖙"
- "󰖝"
- file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/v7.4.47/fonts/materialdesignicons-webfont.ttf"
id: icons18
size: 20
bpp: 4
glyphs:
- "󰋑"
- "󰔏"
- "󰖎"
- "\U000f09de"
- "\U000f09df"
text_sensor:
- platform: wifi_info # IP for the Info screen (settings)
ip_address:
id: wifi_ip
# Control-tile states -> tile colour (on = accent, off = dark)
- platform: homeassistant
id: st_tile1
entity_id: ${tile1_entity}
on_value:
- if:
condition: { lambda: 'return x == "on";' }
then:
- lvgl.widget.update: { id: tile1, bg_color: 0xF5B83D, border_color: 0xFFD27A, shadow_opa: 70% }
- lvgl.label.update: { id: tile1_ic, text_color: 0x231803 }
- lvgl.label.update: { id: tile1_lbl, text_color: 0x231803 }
else:
- lvgl.widget.update: { id: tile1, bg_color: 0x161A22, border_color: 0x2A303C, shadow_opa: 0% }
- lvgl.label.update: { id: tile1_ic, text_color: 0x707A8A }
- lvgl.label.update: { id: tile1_lbl, text_color: 0x9AA3B0 }
- platform: homeassistant
id: st_tile2
entity_id: ${tile2_entity}
on_value:
- if:
condition: { lambda: 'return x == "on";' }
then:
- lvgl.widget.update: { id: tile2, bg_color: 0xF5B83D, border_color: 0xFFD27A, shadow_opa: 70% }
- lvgl.label.update: { id: tile2_ic, text_color: 0x231803 }
- lvgl.label.update: { id: tile2_lbl, text_color: 0x231803 }
else:
- lvgl.widget.update: { id: tile2, bg_color: 0x161A22, border_color: 0x2A303C, shadow_opa: 0% }
- lvgl.label.update: { id: tile2_ic, text_color: 0x707A8A }
- lvgl.label.update: { id: tile2_lbl, text_color: 0x9AA3B0 }
- platform: homeassistant
id: st_tile3
entity_id: ${tile3_entity}
on_value:
- if:
condition: { lambda: 'return x == "on";' }
then:
- lvgl.widget.update: { id: tile3, bg_color: 0xF5B83D, border_color: 0xFFD27A, shadow_opa: 70% }
- lvgl.label.update: { id: tile3_ic, text_color: 0x231803 }
- lvgl.label.update: { id: tile3_lbl, text_color: 0x231803 }
else:
- lvgl.widget.update: { id: tile3, bg_color: 0x161A22, border_color: 0x2A303C, shadow_opa: 0% }
- lvgl.label.update: { id: tile3_ic, text_color: 0x707A8A }
- lvgl.label.update: { id: tile3_lbl, text_color: 0x9AA3B0 }
# Weather: state (rainy/sunny/...) -> MDI icon on the home screen
- platform: homeassistant
id: wx_state
entity_id: ${weather_entity}
on_value:
# Map the HA condition -> MDI glyph + a friendly UPPERCASE label, stash both in globals
# (lbl_wx_ic reads the glyph; the Neon watchface reuses glyph + condition text).
- lambda: |-
std::string s = x, g, c;
if (s == "sunny") { g = "󰖙"; c = "SUNNY"; }
else if (s == "clear-night") { g = "󰖔"; c = "CLEAR"; }
else if (s == "partlycloudy") { g = "󰖕"; c = "PARTLY CLOUDY"; }
else if (s == "fog") { g = "󰖑"; c = "FOG"; }
else if (s == "windy" || s == "windy-variant") { g = "󰖝"; c = "WINDY"; }
else if (s == "lightning" || s == "lightning-rainy") { g = "󰖓"; c = "STORM"; }
else if (s == "pouring") { g = "󰖖"; c = "POURING"; }
else if (s == "rainy") { g = "󰖗"; c = "RAINY"; }
else if (s == "snowy" || s == "snowy-rainy" || s == "hail") { g = "󰖘"; c = "SNOW"; }
else { g = "󰖐"; c = "CLOUDY"; }
id(g_nf_glyph) = g; id(g_nf_cond) = c;
- lvgl.label.update: { id: lbl_wx_ic, text: !lambda 'return id(g_nf_glyph);' }
###############################################################################
# LVGL - home page (clock + VA status) and PLAYER page (album art)
###############################################################################
interval:
# Alarm screen pulse: flash the bell + scrim while the alarm is ringing (mode 4)
- interval: 400ms
then:
- if:
condition: { lambda: 'return id(g_mode) == 4;' }
then:
- lambda: 'id(g_alarm_pulse) = !id(g_alarm_pulse);'
- if:
condition: { lambda: 'return id(g_alarm_pulse);' }
then:
- lvgl.widget.update: { id: alarm_scrim, bg_color: 0x520F0F }
- lvgl.label.update: { id: alarm_bell, text_color: 0xFFFFFF }
else:
- lvgl.widget.update: { id: alarm_scrim, bg_color: 0x2E0808 }
- lvgl.label.update: { id: alarm_bell, text_color: 0xFF7A6A }
# Boot splash: type out "HELLO!" then cross-fade to the clock (~5 s), once at startup.
- interval: 150ms
then:
- if:
condition: { lambda: 'return id(g_boot);' }
then:
- lambda: 'id(g_boot_t)++;'
- lvgl.label.update: { id: lbl_boot, text: !lambda 'std::string w = "HELLO!"; int n = id(g_boot_t); if (n > (int) w.size()) n = w.size(); return w.substr(0, n);' }
- if:
condition: { lambda: 'return id(g_boot_t) >= 33;' } # 33 * 150ms ~= 5 s
then:
- lambda: 'id(g_boot) = false;'
- safe_mode.mark_successful: # commit this OTA image now so a restart won't roll back to the old firmware
- script.execute: face_apply # reveal the active home watchface (Classic page_home, or a face's own page)
- interval: 2s
then:
- lambda: |-
id(g_idle_ticks)++;
bool off = false;
if (id(g_night) && id(ha_time).now().is_valid()) {
int h = id(ha_time).now().hour, f = id(g_night_from), t = id(g_night_to);
bool inwin = (f < t) ? (h >= f && h < t) : (h >= f || h < t);
if (inwin && id(g_idle_ticks) * 2 >= 10) off = true;
}
int secs[5] = {0, 15, 30, 60, 300};
int bs = secs[id(g_blank_idx) % 5];
if (bs > 0 && id(g_idle_ticks) * 2 >= bs) off = true;
if (id(g_va_active) || id(g_mode) == 3 || id(g_mode) == 4) off = false;
id(g_want_off) = off;
- if:
condition: { lambda: 'return id(g_want_off) && !id(g_disp_off);' }
then:
- light.turn_off: backlight_light
- lambda: 'id(g_disp_off) = true;'
- if:
condition: { lambda: 'return !id(g_want_off) && id(g_disp_off);' }
then:
- light.turn_on: { id: backlight_light, brightness: !lambda 'return id(g_bright) / 100.0f;' }
- lambda: 'id(g_disp_off) = false;'
- interval: 1s
then:
# TZ self-heal: some ESPHome builds reset the device timezone to UTC at runtime (e.g. on a
# menu restart or when a log/API client without a TZ reconnects), so the clock jumps to UTC.
# g_tz_posix was resolved at boot (from the `timezone` substitution, or the time component);
# re-assert it every tick so a stray UTC reset can't stick. (No-op once it is correct.)
- lambda: 'if (!id(g_tz_posix).empty()) { setenv("TZ", id(g_tz_posix).c_str(), 1); tzset(); }'
# Battery: while charging - "filling" animation; when full (≥99%) - static full icon.
- lambda: 'if (id(g_charging)) id(g_chg_anim) = (id(g_chg_anim) + 1) % 5;'
- script.execute: batt_render
# Clock/date: redraw only when the minute actually changes (not every second)
- lambda: |-
id(g_home_upd_min) = false;
auto t = id(ha_time).now();
if (t.is_valid()) { int mn = t.hour * 60 + t.minute; if (mn != id(g_home_last_min)) { id(g_home_last_min) = mn; id(g_home_upd_min) = true; } }
- if:
condition: { lambda: 'return id(g_home_upd_min);' }
then:
- lvgl.label.update:
id: lbl_clock
text: !lambda 'return id(ha_time).now().strftime("%H:%M");'
- lvgl.label.update:
id: lbl_date
text: !lambda |-
auto t = id(ha_time).now();
static const char* d[8] = {"","Su","Mo","Tu","We","Th","Fr","Sa"};
std::string day = (t.day_of_week >= 1 && t.day_of_week <= 7) ? std::string(d[t.day_of_week]) : std::string("");
return t.strftime("%d.%m") + " " + day;
# Screen-timer countdown (g_timer_active). From the button: local. From VA: HA calls finished, locally smooth.
- if:
condition:
lambda: 'return id(g_timer_active) && !id(g_timer_paused);'
then:
- lambda: 'id(g_timer_secs)--;'
- script.execute: timer_render
- if: # button timer reached zero -> alarm (with VA the alarm fires on on_timer_finished)
condition: { lambda: 'return id(g_timer_local) && id(g_timer_secs) <= 0;' }
then:
- script.execute: alarm_start
# Timer badge on the home screen (red) - time left (rounded up to minutes)
- if:
condition: { lambda: 'return id(g_timer_active);' }
then:
- lvgl.widget.show: lbl_timer
- lvgl.label.update:
id: lbl_timer
text: !lambda |-
int s = id(g_timer_secs); if (s < 0) s = 0;
int m = (s + 59) / 60;
char b[24]; snprintf(b, sizeof(b), " %d min", m);
return std::string(b);
else:
- lvgl.widget.hide: lbl_timer
- if: # refresh the alarm overlay every second
condition:
lambda: 'return id(g_mode) == 4;'
then:
- script.execute: ui_render
- if: # alarm: repeat the sound every ~2 s
condition:
lambda: 'return id(g_mode) == 4;'
then:
- lambda: 'id(g_alarm_cnt)++;'
- if:
condition:
lambda: 'return id(g_alarm_cnt) % 2 == 0;'
then:
- media_player.speaker.play_on_device_media_file:
id: media_player_speaker
media_file: alarm_sound
announcement: true
lvgl:
# A <25% buffer lands in FAST internal RAM (instead of slow PSRAM at the default 100%)
buffer_size: 24%
displays:
- main_display
touchscreens:
- my_touchscreen
# Always-on-top layer: assistant overlay (dim + frame + text)
top_layer:
widgets:
# Watchface picker overlay (shown only in pick mode 33) - last so it draws on top.
# A bottom sheet: rounded top, square bottom pushed BELOW the screen edge (y:24 puts the
# bottom + its corner radius off-display), so only the straight sides reach the edge.
- obj:
id: pick_bar
align: BOTTOM_MID
y: 24
width: 210
height: 96
radius: 24
bg_color: 0x0A0E16
bg_opa: 82%
border_width: 1
border_color: 0xF5B83D
border_opa: 35%
pad_all: 0
clickable: false
scrollable: false
hidden: true
widgets:
- label: # left chevron (turn the knob)
id: pick_chevl
align: TOP_LEFT
x: 16
y: 16
text: "<"
text_font: font_sel
text_color: 0x55606E
- label: # right chevron
id: pick_chevr
align: TOP_RIGHT
x: -16
y: 16
text: ">"
text_font: font_sel
text_color: 0x55606E
- label: # face name (set by facepick_render)
id: pick_name
align: TOP_MID
y: 12
text: ""
text_font: font_sel
text_color: 0xFFFFFF
- label: # subtle hint
id: pick_help
align: TOP_MID
y: 44
text: "Tap to keep"
text_font: font_artist
text_color: 0x6A7280
- obj:
id: va_dim
x: 0
y: 0
width: 360
height: 360
radius: 0
border_width: 0
bg_color: 0x000000
bg_opa: 100%
hidden: true
- arc:
id: va_frame
align: CENTER
width: 320
height: 320
arc_width: 2
min_value: 0
max_value: 100
value: 0
start_angle: 0
end_angle: 360
adjustable: false
arc_color: 0x0E5E54
indicator:
arc_opa: 0%
hidden: true
- label:
id: va_text
align: CENTER
text: ""
text_font: font_med
text_color: 0x5FF0D9
hidden: true
# Volume overlay - on top_layer, so it shows on EVERY screen while turning
- obj:
id: vol_dim
x: 0
y: 0
width: 360
height: 360
radius: 0
border_width: 0
bg_color: 0x000000
bg_opa: 55%
hidden: true
- arc:
id: vol_arc
align: CENTER
width: 350
height: 350
arc_width: 8
min_value: 0
max_value: 100
value: 50
start_angle: 135
end_angle: 45
adjustable: false
hidden: true
arc_color: 0x0C4A44
indicator:
arc_color: 0x42F4DC
arc_width: 8
- label:
id: vol_label
align: TOP_MID
y: 40
hidden: true
text: "50%"
text_color: 0x5FF0D9
text_font: montserrat_20
pages:
# --- Home page: clock + Assistant status ---
# --- Boot splash: typewriter "HELLO!" then fade to the clock (driven by the 150ms interval) ---
- id: page_boot
bg_color: 0x000000
widgets:
# Rotating arc around the greeting - the "spinning" boot animation.
- spinner:
id: boot_spin
align: CENTER
width: 300
height: 300
spin_time: 1500ms
arc_length: 70deg
arc_width: 6
arc_color: 0x16302B # faint static track
indicator:
arc_color: 0x5FF0D9 # bright sweeping arc (brand cyan)
arc_width: 6
- label: { id: lbl_boot, align: CENTER, y: -10, text: "", text_font: montserrat_48, text_color: 0xFFFFFF }
- label: { id: lbl_boot_sub, align: CENTER, y: 40, text: "${friendly_name}", text_font: font_med, text_color: 0x5FF0D9 }
- id: page_home
bg_color: 0x000000
widgets:
- obj: # faint outer ring for depth (matches the other screens)
id: home_ring_out
align: CENTER
width: 340
height: 340
radius: 170
bg_opa: TRANSP
border_width: 2
border_color: 0x1A1F2A
border_opa: 70%
clickable: false
scrollable: false
- label:
id: lbl_clock
align: CENTER
y: -22
text: "--:--"
text_color: 0xFFFFFF
text_font: montserrat_48
- label:
id: lbl_date
align: CENTER
y: 34
text: ""
text_color: 0x6f9a92
text_font: font_med
- label:
id: lbl_batt
align: BOTTOM_MID
y: -30
text: ""
text_color: 0x6f9a92
text_font: montserrat_18
- label: # red badge: timer active - time left (TOP)
id: lbl_timer
align: TOP_MID
y: 22
text: ""
text_font: montserrat_18
text_color: 0xFFFFFF
bg_color: 0xFF3B30
bg_opa: COVER
radius: 14
pad_left: 12
pad_right: 12
pad_top: 5
pad_bottom: 5
hidden: true
- label:
id: lbl_wx_ic
align: CENTER
x: -22
y: -92
text: "󰖐"
text_font: icons34
text_color: 0xBFD2DA
hidden: true
- label:
id: lbl_wx_val
align: CENTER
x: 22
y: -92
text: "-"
text_font: font_med
text_color: 0xE8E8EA
hidden: true
- label:
id: lbl_ct_ic
align: CENTER
x: -88
y: 78
text: "󰔏"
text_font: icons18
text_color: 0xFF8A5C
hidden: true
- label:
id: lbl_ct_val
align: CENTER
x: -52
y: 78
text: "-"
text_font: font_med
text_color: 0xE8E8EA
hidden: true
- label:
id: lbl_ch_ic
align: CENTER
x: 24
y: 78
text: "󰖎"
text_font: icons18
text_color: 0x3FA9D6
hidden: true
- label:
id: lbl_ch_val
align: CENTER
x: 56
y: 78
text: "-"
text_font: font_med
text_color: 0xE8E8EA
hidden: true
- id: page_mode
bg_color: 0x000000
widgets:
# Menu / timer / alarm overlay (controlled by ui_render)
- obj:
id: ui_dim
x: 0
y: 0
width: 360
height: 360
radius: 0
border_width: 0
bg_color: 0x000000
bg_opa: 100%
hidden: true
- obj:
id: ui_head_bg
align: TOP_MID
y: 0
width: 360
height: 46
radius: 0
border_width: 0
bg_color: 0x42F4DC
bg_opa: COVER
hidden: true
- label:
id: ui_head
align: TOP_MID
y: 15
text: ""
text_font: font_artist
text_color: 0x000000
hidden: true
- label:
id: ui_big
align: CENTER
y: -12
text: ""
text_font: montserrat_48
text_color: 0xFFFFFF
hidden: true
- label:
id: ui_sub
align: CENTER
y: 56
width: 300
text_align: CENTER
text: ""
text_font: font_med
text_color: 0x5FF0D9
hidden: true
# Brightness bar - visible only in mode 6
- bar:
id: ui_bar
align: CENTER
y: 36
width: 180
height: 8
min_value: 0
max_value: 100
value: 100
hidden: true
indicator:
bg_color: 0xF5B83D
# "Display" submenu (mode 11): item roller + status label of the selected one
- roller:
id: disp_roller
align: CENTER
y: -14
width: 290
visible_row_count: 3
clickable: false
text_font: font_med
text_color: 0x9AA3B0
bg_opa: TRANSP
border_width: 0
selected:
text_color: 0x231803
bg_color: 0xF5B83D
bg_opa: COVER
radius: 14
options:
- "Brightness"
- "Night mode"
- "Night from"
- "Night to"
- "Screen auto-off"
selected_index: 0
hidden: true
- label: # only for "Brightness" (shows %); toggles use disp_switch
id: disp_lbl
align: CENTER
y: 92
text: ""
text_font: font_artist
text_color: 0x5FF0D9
hidden: true
- switch: # visual switch state (clickable: false - tapping the screen toggles)
id: disp_switch
align: CENTER
y: 88
clickable: false
hidden: true
- roller:
id: home_roller
align: CENTER
y: -14
width: 290
visible_row_count: 3
clickable: false
text_font: font_med
text_color: 0x9AA3B0
bg_opa: TRANSP
border_width: 0
selected:
text_color: 0x231803
bg_color: 0xF5B83D
bg_opa: COVER
radius: 14
options: # 3rd line is rebuilt at runtime to "Watchface: <name>" (home_roller_build)
- "Show weather"
- "Show climate"
- "Watchface"
selected_index: 0
hidden: true
- roller:
id: wid_roller
align: CENTER
y: -14
width: 290
visible_row_count: 3
clickable: false
text_font: font_med
text_color: 0x9AA3B0
bg_opa: TRANSP
border_width: 0
selected:
text_color: 0x231803
bg_color: 0xF5B83D
bg_opa: COVER
radius: 14
options:
- "..."
selected_index: 0
hidden: true
- roller:
id: wopt_roller
align: CENTER
y: -14
width: 290
visible_row_count: 3
clickable: false
text_font: font_med
text_color: 0x9AA3B0
bg_opa: TRANSP
border_width: 0
selected:
text_color: 0x231803
bg_color: 0xF5B83D
bg_opa: COVER
radius: 14
options:
- "..."
selected_index: 0
hidden: true
- roller:
id: asys_roller
align: CENTER
y: -14
width: 290
visible_row_count: 3
clickable: false
text_font: font_med
text_color: 0x9AA3B0
bg_opa: TRANSP
border_width: 0
selected:
text_color: 0x231803
bg_color: 0xF5B83D
bg_opa: COVER
radius: 14
options:
- "Wake word"
- "Beep wake word"
- "Hold to talk"
selected_index: 0
hidden: true
- roller:
id: sys_roller
align: CENTER
y: -14
width: 290
visible_row_count: 3
clickable: false
text_font: font_med
text_color: 0x9AA3B0
bg_opa: TRANSP
border_width: 0
selected:
text_color: 0x231803
bg_color: 0xF5B83D
bg_opa: COVER
radius: 14
options:
- "Info"
- "Battery"
- "Restart"
- "Factory reset"
selected_index: 0
hidden: true
# ───────── Alarm screen (mode 4): red scrim + pulsing bell + TIME'S UP ─────────
- obj:
id: alarm_scrim
x: 0
y: 0
width: 360
height: 360
radius: 0
border_width: 0
bg_color: 0x3A0A0A
bg_opa: COVER
clickable: false
scrollable: false
hidden: true
- label:
id: alarm_bell
align: CENTER
y: -52
text: "\U000F009A"
text_font: font_bell
text_color: 0xFFFFFF
hidden: true
- label:
id: alarm_title
align: CENTER
y: 38
text: "TIME'S UP!"
text_font: font_sel
text_color: 0xFFFFFF
hidden: true
- label:
id: alarm_sub
align: CENTER
y: 76
text: "Tap to stop"
text_font: font_artist
text_color: 0xF0B4B4
hidden: true
- id: page_settings
bg_color: 0x000000
widgets:
- obj: # header bar (amber)
id: ui_head_bg_s
align: TOP_MID
y: 0
width: 360
height: 46
radius: 0
border_width: 0
bg_color: 0xF5B83D
bg_opa: COVER
- label:
id: ui_head_s
align: TOP_MID
y: 15
text: "SETTINGS"
text_font: font_artist
text_color: 0x000000
# Settings list (mode 5) - animated roller; controlled by the knob (clickable: false)
- roller:
id: settings_roller
align: CENTER
y: 8
width: 260
visible_row_count: 5
clickable: false
text_font: font_med
text_color: 0x9AA3B0
bg_opa: TRANSP
border_width: 0
selected:
text_color: 0x231803
bg_color: 0xF5B83D
bg_opa: COVER
radius: 14
options:
- "Display"
- "Ring"
- "Assistant"
- "System"
selected_index: 0
# --- TIMER page (left of the clock): ring + controls ---
- id: page_timer
bg_color: 0x000000
widgets:
- obj: # faint outer ring for depth
id: timer_ring_out
align: CENTER
width: 332
height: 332
radius: 166
bg_opa: TRANSP
border_width: 2
border_color: 0x252B37
border_opa: 65%
clickable: false
scrollable: false
- arc:
id: timer_arc
align: CENTER
width: 300
height: 300
arc_width: 14
adjustable: false
min_value: 0
max_value: 100
value: 100
start_angle: 271
end_angle: 269
arc_color: 0x1C2027
indicator:
arc_color: 0x2FB85C
arc_width: 14
- obj: # head-dot: a white bead riding the leading edge of the remaining arc (positioned in timer_render)
id: timer_dot
x: 0
y: 0
width: 18
height: 18
radius: 9
bg_color: 0xFFFFFF
bg_opa: COVER
border_width: 0
shadow_color: 0xFFFFFF
shadow_width: 10
shadow_opa: 55%
clickable: false
scrollable: false
hidden: true
- label:
id: timer_cap
align: CENTER
y: -72
text: "SET"
text_font: font_artist
text_color: 0x6F9A92
- label:
id: timer_big
align: CENTER
y: -20
text: "60:00"
text_font: montserrat_48
text_color: 0xFFFFFF
- button:
id: timer_play
align: CENTER
y: 54
width: 58
height: 58
radius: 29
bg_color: 0x2FB85C
bg_opa: COVER
border_width: 0
shadow_color: 0x2FB85C
shadow_width: 18
shadow_opa: 55%
on_click:
- script.execute: timer_start
widgets:
- label: { align: CENTER, text: "", text_font: montserrat_28, text_color: 0xFFFFFF }
- button:
id: timer_pause
align: CENTER
x: -40
y: 54
width: 52
height: 52
radius: 26
bg_color: 0xF5B83D
bg_opa: COVER
border_width: 0
shadow_color: 0xF5B83D
shadow_width: 14
shadow_opa: 50%
hidden: true
on_click:
- script.execute: timer_toggle
widgets:
- label: { id: timer_pause_ic, align: CENTER, text: "", text_font: montserrat_22, text_color: 0x231803 }
- button:
id: timer_stop
align: CENTER
x: 40
y: 54
width: 52
height: 52
radius: 26
bg_color: 0x1E232E
bg_opa: COVER
border_width: 2
border_color: 0x2E3540
hidden: true
on_click:
- script.execute: timer_halt
widgets:
- label: { align: CENTER, text: "", text_font: montserrat_22, text_color: 0xFF6B60 }
# --- CONTROLS page (swipe up): 4 configurable HA toggles (entity/icon/name in substitutions) ---
- id: page_controls
bg_color: 0x0A0E16
widgets:
- button:
id: tile1
align: CENTER
x: -61
y: -61
width: 110
height: 110
radius: 22
bg_color: 0x161A22
bg_opa: COVER
border_width: 2
border_color: 0x2A303C
shadow_color: 0xF5B83D
shadow_width: 18
shadow_spread: 1
shadow_opa: 0%
on_click:
- homeassistant.action: { action: homeassistant.toggle, data: { entity_id: "${tile1_entity}" } }
widgets:
- label: { id: tile1_ic, align: CENTER, y: -16, text: "", text_font: icons34, text_color: 0x707A8A }
- label: { id: tile1_lbl, align: CENTER, y: 26, width: 100, long_mode: wrap, text_align: CENTER, text: "${tile1_name}", text_font: font_artist, text_color: 0x9AA3B0, text_letter_space: 2 }
- button:
id: tile2
align: CENTER
x: 61
y: -61
width: 110
height: 110
radius: 22
bg_color: 0x161A22
bg_opa: COVER
border_width: 2
border_color: 0x2A303C
shadow_color: 0xF5B83D
shadow_width: 18
shadow_spread: 1
shadow_opa: 0%
on_click:
- homeassistant.action: { action: homeassistant.toggle, data: { entity_id: "${tile2_entity}" } }
widgets:
- label: { id: tile2_ic, align: CENTER, y: -16, text: "", text_font: icons34, text_color: 0x707A8A }
- label: { id: tile2_lbl, align: CENTER, y: 26, width: 100, long_mode: wrap, text_align: CENTER, text: "${tile2_name}", text_font: font_artist, text_color: 0x9AA3B0, text_letter_space: 2 }
- button:
id: tile3
align: CENTER
x: 61
y: 61
width: 110
height: 110
radius: 22
bg_color: 0x161A22
bg_opa: COVER
border_width: 2
border_color: 0x2A303C
shadow_color: 0xF5B83D
shadow_width: 18
shadow_spread: 1
shadow_opa: 0%
on_click:
- homeassistant.action: { action: homeassistant.toggle, data: { entity_id: "${tile3_entity}" } }
widgets:
- label: { id: tile3_ic, align: CENTER, y: -16, text: "", text_font: icons34, text_color: 0x707A8A }
- label: { id: tile3_lbl, align: CENTER, y: 26, width: 100, long_mode: wrap, text_align: CENTER, text: "${tile3_name}", text_font: font_artist, text_color: 0x9AA3B0, text_letter_space: 2 }