2862 lines
119 KiB
YAML
2862 lines
119 KiB
YAML
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 }
|