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" 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: " (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 }