diff --git a/.gitignore b/.gitignore
index d8b4157..e2ba6eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,5 @@
# You can modify this file to suit your needs.
/.esphome/
/secrets.yaml
+
+.DS_Store
\ No newline at end of file
diff --git a/assets/DejaVuSans.ttf b/assets/DejaVuSans.ttf
new file mode 100644
index 0000000..5789a29
Binary files /dev/null and b/assets/DejaVuSans.ttf differ
diff --git a/clock.yaml b/clock.yaml
index fe8af07..da7cef2 100644
--- a/clock.yaml
+++ b/clock.yaml
@@ -1,33 +1,56 @@
----
+substitutions:
+ alarm_audio_player_entity: media_player.example_speaker
+ alarm_audio_start_action: script.alarmv1_start_alarm_audio
+ alarm_audio_stop_action: media_player.media_stop
+ morning_briefing_action: script.alarmv1_morning_briefing
+ morning_briefing_player_entity: media_player.example_speaker
+ home_lights_off_action: light.turn_off
+ home_lights_off_entity: all
+ timezone: Etc/UTC
+
esphome:
- name: clock
- friendly_name: Clock
+ name: esphome-web-56058c
+ friendly_name: AlarmV1
+ min_version: 2025.11.0
+ name_add_mac_suffix: false
on_boot:
+ priority: -10
then:
- - pcf8563.read_time:
- - light.turn_on: display_backlight
+ - lambda: |-
+ ESP_LOGI("boot", "boot step 1: on_boot started");
+ - output.set_max_power:
+ id: buzzer
+ max_power: 100%
+ - lambda: |-
+ ESP_LOGI("boot", "boot step 2: buzzer max power applied");
+ - delay: 2s
+ - lambda: |-
+ ESP_LOGI("boot", "boot step 3: startup delay complete");
+ - script.execute: reset_idle_timer
+ - lambda: |-
+ ESP_LOGI("boot", "boot step 4: reset_idle_timer executed");
- script.execute: update_minutes_hand
- platformio_options:
- board_build.flash_mode: dio
-
-packages:
- core: !include debug.yaml
+ - lambda: |-
+ ESP_LOGI("boot", "boot step 5: update_minutes_hand executed");
+ - delay: 3s
+ - safe_mode.mark_successful
+ - lambda: |-
+ ESP_LOGI("boot", "boot step 6: marked boot successful");
esp32:
variant: esp32s3
framework:
type: esp-idf
+safe_mode:
+ boot_is_good_after: 5s
+
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
- ap:
- ssid: "Clock Fallback Hotspot"
- password: !secret wifi_password
-
-
-captive_portal:
+preferences:
+ flash_write_interval: 10s
api:
reboot_timeout: 0s
@@ -36,7 +59,6 @@ web_server:
ota:
- platform: esphome
- password: !secret ota_password
logger:
@@ -54,12 +76,171 @@ output:
- platform: ledc
pin: GPIO3
id: buzzer
+ frequency: 2000 Hz
- platform: ledc
pin: GPIO9
id: backlight_output
-rtttl:
- output: buzzer
+globals:
+ - id: alarm_enabled
+ type: bool
+ restore_value: yes
+ initial_value: "true"
+ - id: alarm_ringing
+ type: bool
+ restore_value: no
+ initial_value: "false"
+ - id: skip_next_ring
+ type: bool
+ restore_value: yes
+ initial_value: "false"
+ - id: skip_next_ring_epoch
+ type: uint32_t
+ restore_value: yes
+ initial_value: "0"
+ - id: last_alarm_minute_key
+ type: int
+ restore_value: no
+ initial_value: "-1"
+ - id: last_alarm_debug_minute_key
+ type: int
+ restore_value: no
+ initial_value: "-1"
+ - id: last_ui_minute_key
+ type: int
+ restore_value: no
+ initial_value: "-1"
+ - id: snooze_active
+ type: bool
+ restore_value: no
+ initial_value: "false"
+ - id: snooze_minutes
+ type: int
+ restore_value: yes
+ initial_value: "10"
+ - id: snooze_day_of_year
+ type: int
+ restore_value: no
+ initial_value: "-1"
+ - id: snooze_hour
+ type: int
+ restore_value: no
+ initial_value: "-1"
+ - id: snooze_minute
+ type: int
+ restore_value: no
+ initial_value: "-1"
+ - id: last_encoder_turn_ms
+ type: uint32_t
+ restore_value: no
+ initial_value: "0"
+ - id: last_encoder_direction
+ type: int
+ restore_value: no
+ initial_value: "0"
+ - id: alarm_flash_on
+ type: bool
+ restore_value: no
+ initial_value: "false"
+ - id: last_interaction_ms
+ type: uint32_t
+ restore_value: no
+ initial_value: "0"
+ - id: last_touch_ms
+ type: uint32_t
+ restore_value: no
+ initial_value: "0"
+ - id: screen_dimmed
+ type: bool
+ restore_value: no
+ initial_value: "false"
+ - id: ring_controls_visible
+ type: bool
+ restore_value: no
+ initial_value: "false"
+ - id: home_menu_visible
+ type: bool
+ restore_value: no
+ initial_value: "false"
+ - id: media_screen_visible
+ type: bool
+ restore_value: no
+ initial_value: "false"
+ - id: media_volume_level
+ type: float
+ restore_value: no
+ initial_value: "0.5"
+ - id: media_volume_overlay_until_ms
+ type: uint32_t
+ restore_value: no
+ initial_value: "0"
+ - id: dismiss_feedback_until_ms
+ type: uint32_t
+ restore_value: no
+ initial_value: "0"
+ - id: morning_music_prompt_until_ms
+ type: uint32_t
+ restore_value: no
+ initial_value: "0"
+ - id: next_ring_override_active
+ type: bool
+ restore_value: yes
+ initial_value: "false"
+ - id: next_ring_override_epoch
+ type: uint32_t
+ restore_value: yes
+ initial_value: "0"
+ - id: next_alarm_available
+ type: bool
+ restore_value: no
+ initial_value: "false"
+ - id: next_alarm_epoch
+ type: uint32_t
+ restore_value: no
+ initial_value: "0"
+
+switch:
+ - platform: template
+ id: alarm_enabled_switch
+ name: "Alarm Enabled"
+ icon: "mdi:alarm-check"
+ restore_mode: RESTORE_DEFAULT_ON
+ lambda: |-
+ return id(alarm_enabled);
+ turn_on_action:
+ - lambda: |-
+ id(alarm_enabled) = true;
+ ESP_LOGI("alarm", "Alarm enabled");
+ - script.execute: update_minutes_hand
+ - script.execute: reset_idle_timer
+ turn_off_action:
+ - lambda: |-
+ id(alarm_enabled) = false;
+ id(skip_next_ring) = false;
+ id(skip_next_ring_epoch) = 0;
+ id(next_ring_override_active) = false;
+ id(next_ring_override_epoch) = 0;
+ ESP_LOGI("alarm", "Alarm disabled / away mode enabled");
+ - script.execute: clear_snooze
+ - script.execute: stop_alarm
+ - script.execute: update_minutes_hand
+ - script.execute: reset_idle_timer
+
+button:
+ - platform: template
+ name: "Stop Alarm"
+ icon: "mdi:alarm-off"
+ on_press:
+ - script.execute: dismiss_alarm
+ - script.execute: reset_idle_timer
+ - platform: template
+ name: "Test Alarm"
+ icon: "mdi:alarm-light"
+ on_press:
+ - logger.log: "Manual test alarm requested"
+ - script.execute: clear_snooze
+ - script.execute: start_alarm
+ - script.execute: reset_idle_timer
sensor:
- platform: rotary_encoder
@@ -67,74 +248,333 @@ sensor:
pin_a: GPIO40
pin_b: GPIO41
on_clockwise:
+ - script.execute: reset_idle_timer
- lambda: |-
- auto alarm_time_var = id(alarm_time).state_as_esptime();
- alarm_time_var.minute += 15;
- if (alarm_time_var.minute >= 60) {
- alarm_time_var.minute -= 60;
- alarm_time_var.hour = (alarm_time_var.hour + 1) % 24;
+ const int direction = 1;
+ const uint32_t now_ms = millis();
+ uint32_t delta_ms = now_ms - id(last_encoder_turn_ms);
+ if (id(last_encoder_direction) != direction) {
+ delta_ms = 1000;
+ }
+ id(last_encoder_turn_ms) = now_ms;
+ id(last_encoder_direction) = direction;
+
+ int step_minutes = 1;
+ if (delta_ms < 70) {
+ step_minutes = 15;
+ } else if (delta_ms < 140) {
+ step_minutes = 5;
+ }
+
+ if (id(media_screen_visible)) {
+ id(media_volume_level) += 0.05f;
+ if (id(media_volume_level) > 1.0f) {
+ id(media_volume_level) = 1.0f;
+ }
+ ESP_LOGI("media", "Volume adjusted clockwise to %.0f%%", id(media_volume_level) * 100.0f);
+ id(show_media_volume_overlay).execute();
+ id(set_morning_music_volume_on_ha).execute();
+ return;
+ }
+
+ if (id(alarm_ringing)) {
+ id(snooze_minutes) += step_minutes;
+ if (id(snooze_minutes) > 60) {
+ id(snooze_minutes) = 60;
+ }
+ ESP_LOGI("alarm", "Snooze duration set to %d minutes (delta=%ums, step=%d)", id(snooze_minutes), delta_ms, step_minutes);
+ id(update_alarm_status_ui).execute();
+ return;
+ }
+
+ if (id(snooze_active)) {
+ ESP_LOGI("alarm", "Ignoring clockwise encoder turn while alarm is snoozed");
+ return;
}
- id(alarm_time).make_call().set_time(alarm_time_var).perform();
+
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid() || !id(next_alarm_available)) {
+ ESP_LOGW("alarm", "Ignoring clockwise encoder turn because next ring is unavailable");
+ return;
+ }
+ uint32_t candidate_epoch = id(next_alarm_epoch) + (step_minutes * 60);
+ const uint32_t min_epoch = ((uint32_t(now.timestamp) / 60) + 1) * 60;
+ if (candidate_epoch < min_epoch) {
+ candidate_epoch = min_epoch;
+ }
+ id(next_ring_override_active) = true;
+ id(next_ring_override_epoch) = candidate_epoch;
+ id(update_minutes_hand).execute();
+ auto next_alarm = ESPTime::from_epoch_local(candidate_epoch);
+ ESP_LOGI("alarm", "Next ring override adjusted clockwise by %d minutes to %02d:%02d", step_minutes, next_alarm.hour, next_alarm.minute);
on_anticlockwise:
+ - script.execute: reset_idle_timer
- lambda: |-
- auto alarm_time_var = id(alarm_time).state_as_esptime();
- if (alarm_time_var.minute < 15) {
- alarm_time_var.minute += 60;
- alarm_time_var.hour = (alarm_time_var.hour + 23) % 24;
+ const int direction = -1;
+ const uint32_t now_ms = millis();
+ uint32_t delta_ms = now_ms - id(last_encoder_turn_ms);
+ if (id(last_encoder_direction) != direction) {
+ delta_ms = 1000;
}
- alarm_time_var.minute -= 15;
- id(alarm_time).make_call().set_time(alarm_time_var).perform();
+ id(last_encoder_turn_ms) = now_ms;
+ id(last_encoder_direction) = direction;
+
+ int step_minutes = 1;
+ if (delta_ms < 70) {
+ step_minutes = 15;
+ } else if (delta_ms < 140) {
+ step_minutes = 5;
+ }
+
+ if (id(media_screen_visible)) {
+ id(media_volume_level) -= 0.05f;
+ if (id(media_volume_level) < 0.0f) {
+ id(media_volume_level) = 0.0f;
+ }
+ ESP_LOGI("media", "Volume adjusted anticlockwise to %.0f%%", id(media_volume_level) * 100.0f);
+ id(show_media_volume_overlay).execute();
+ id(set_morning_music_volume_on_ha).execute();
+ return;
+ }
+
+ if (id(alarm_ringing)) {
+ id(snooze_minutes) -= step_minutes;
+ if (id(snooze_minutes) < 5) {
+ id(snooze_minutes) = 5;
+ }
+ ESP_LOGI("alarm", "Snooze duration set to %d minutes (delta=%ums, step=%d)", id(snooze_minutes), delta_ms, step_minutes);
+ id(update_alarm_status_ui).execute();
+ return;
+ }
+
+ if (id(snooze_active)) {
+ ESP_LOGI("alarm", "Ignoring anticlockwise encoder turn while alarm is snoozed");
+ return;
+ }
+
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid() || !id(next_alarm_available)) {
+ ESP_LOGW("alarm", "Ignoring anticlockwise encoder turn because next ring is unavailable");
+ return;
+ }
+ int32_t candidate_epoch = int32_t(id(next_alarm_epoch)) - (step_minutes * 60);
+ const uint32_t min_epoch = ((uint32_t(now.timestamp) / 60) + 1) * 60;
+ if (candidate_epoch < int32_t(min_epoch)) {
+ candidate_epoch = int32_t(min_epoch);
+ }
+ id(next_ring_override_active) = true;
+ id(next_ring_override_epoch) = uint32_t(candidate_epoch);
+ id(update_minutes_hand).execute();
+ auto next_alarm = ESPTime::from_epoch_local(uint32_t(candidate_epoch));
+ ESP_LOGI("alarm", "Next ring override adjusted anticlockwise by %d minutes to %02d:%02d", step_minutes, next_alarm.hour, next_alarm.minute);
+
+ - platform: homeassistant
+ id: media_player_volume
+ entity_id: ${morning_briefing_player_entity}
+ attribute: volume_level
+ internal: true
+ on_value:
+ then:
+ - lambda: |-
+ if (!isnan(x)) {
+ id(media_volume_level) = x;
+ }
+ - script.execute: update_media_screen_ui
time:
- platform: pcf8563
id: rtctime
i2c_id: internal_i2c
address: 0x51
- on_time:
- - minutes: "*"
- then:
- - script.execute: update_minutes_hand
+ update_interval: never
- platform: sntp
id: sntp_time
- timezone: Europe/London
+ timezone: ${timezone}
servers:
- - 0.pool.ntp.org
- - 1.pool.ntp.org
- - 2.pool.ntp.org
+ - 0.pool.ntp.org
+ - 1.pool.ntp.org
+ - 2.pool.ntp.org
on_time_sync:
then:
+ - logger.log: "SNTP time sync received, writing time back to RTC"
- pcf8563.write_time:
- script.execute: update_minutes_hand
+ - script.execute: sync_alarm_day_state
datetime:
- platform: template
- id: alarm_time
- name: "Alarm Time"
+ id: weekday_alarm_time
+ name: "Weekday Alarm Time"
type: time
- icon: "mdi:alarm"
+ icon: "mdi:calendar-week"
initial_value: "07:00:00"
restore_value: true
optimistic: yes
on_value:
then:
- script.execute: update_minutes_hand
+ - platform: template
+ id: weekend_alarm_time
+ name: "Weekend Alarm Time"
+ type: time
+ icon: "mdi:calendar-weekend"
+ initial_value: "09:00:00"
+ restore_value: true
+ optimistic: yes
+ on_value:
+ then:
+ - script.execute: update_minutes_hand
+text_sensor:
+ - platform: homeassistant
+ id: media_player_state
+ entity_id: ${morning_briefing_player_entity}
+ internal: true
+ on_value:
+ then:
+ - script.execute: update_media_screen_ui
+ - platform: homeassistant
+ id: media_track_title
+ entity_id: ${morning_briefing_player_entity}
+ attribute: media_title
+ internal: true
+ on_value:
+ then:
+ - script.execute: update_media_screen_ui
+ - platform: homeassistant
+ id: media_track_artist
+ entity_id: ${morning_briefing_player_entity}
+ attribute: media_artist
+ internal: true
+ on_value:
+ then:
+ - script.execute: update_media_screen_ui
+ - platform: homeassistant
+ id: media_track_album
+ entity_id: ${morning_briefing_player_entity}
+ attribute: media_album_name
+ internal: true
+ on_value:
+ then:
+ - script.execute: update_media_screen_ui
+ - platform: template
+ id: next_alarm_time_text
+ name: "Next Alarm Time"
+ icon: "mdi:calendar-clock"
+ device_class: timestamp
+ update_interval: 30s
+ lambda: |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid()) {
+ return {""};
+ }
+ if (!id(alarm_enabled)) {
+ return {""};
+ }
+ if (!id(next_alarm_available)) {
+ return {""};
+ }
+
+ time_t candidate_epoch = id(next_alarm_epoch);
+ auto local = ESPTime::from_epoch_local(candidate_epoch);
+ char base_buf[32];
+ local.strftime(base_buf, sizeof(base_buf), "%Y-%m-%dT%H:%M:%S");
+ int32_t tz_offset = ESPTime::timezone_offset();
+ char sign = '+';
+ if (tz_offset < 0) {
+ sign = '-';
+ tz_offset = -tz_offset;
+ }
+ int tz_hours = tz_offset / 3600;
+ int tz_minutes = (tz_offset % 3600) / 60;
+ char out_buf[40];
+ snprintf(out_buf, sizeof(out_buf), "%s%c%02d:%02d", base_buf, sign, tz_hours, tz_minutes);
+ return {out_buf};
binary_sensor:
- platform: gpio
name: Button
id: front_button
- pin: GPIO42
-
- - platform: gpio
- name: Hold Button
- pin: GPIO46
+ pin:
+ number: GPIO42
+ mode:
+ input: true
+ pullup: true
+ inverted: true
+ filters:
+ - delayed_on: 10ms
+ - delayed_off: 10ms
+ on_multi_click:
+ - timing:
+ - ON for at least 3s
+ - OFF for at least 50ms
+ then:
+ - if:
+ condition:
+ lambda: "return id(alarm_ringing) || id(snooze_active);"
+ then:
+ - script.execute: dismiss_alarm
+ else:
+ - script.execute: home_all_lights_off
+ - script.execute: close_home_menu
+ - script.execute: reset_idle_timer
+ - timing:
+ - ON for at most 500ms
+ - OFF for at least 50ms
+ then:
+ - if:
+ condition:
+ lambda: "return id(alarm_ringing);"
+ then:
+ - script.execute: snooze_alarm_five_minutes
+ else:
+ - if:
+ condition:
+ lambda: "return !id(media_screen_visible);"
+ then:
+ - if:
+ condition:
+ lambda: "return id(home_menu_visible);"
+ then:
+ - script.execute: close_home_menu
+ else:
+ - script.execute: open_home_menu
+ - script.execute: reset_idle_timer
spi:
id: spi_bus
mosi_pin: GPIO5
clk_pin: GPIO6
+touchscreen:
+ platform: ft5x06
+ id: dial_touchscreen
+ i2c_id: internal_i2c
+ address: 0x38
+ interrupt_pin: GPIO14
+ touch_timeout: 250ms
+ on_touch:
+ - if:
+ condition:
+ lambda: |-
+ const uint32_t now_ms = millis();
+ if (now_ms - id(last_touch_ms) < 700) {
+ return false;
+ }
+ id(last_touch_ms) = now_ms;
+ return true;
+ then:
+ - script.execute: reset_idle_timer
+
light:
- platform: monochromatic
name: "Backlight"
@@ -143,19 +583,41 @@ light:
default_transition_length: 0s
display:
- - platform: ili9xxx
+ - platform: mipi_spi
id: round_display
model: GC9A01A
cs_pin: GPIO7
reset_pin: GPIO8
dc_pin: GPIO4
+ color_order: bgr
invert_colors: true
auto_clear_enabled: false
update_interval: never
+ data_rate: 40MHz
+
+font:
+ - file: assets/DejaVuSans.ttf
+ id: media_text_font_14
+ size: 14
+ glyphs: &media_glyphs " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz[]_{}|~°▶Ⅱ■»♪АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя"
+ - file: assets/DejaVuSans.ttf
+ id: media_text_font_16
+ size: 16
+ glyphs: *media_glyphs
+ - file: assets/DejaVuSans.ttf
+ id: media_icon_font_28
+ size: 28
+ glyphs: "▶Ⅱ■»"
lvgl:
+ rotation: 0
+ displays:
+ - round_display
+ touchscreens:
+ - touchscreen_id: dial_touchscreen
+ long_press_time: 700ms
style_definitions:
- - id: date_style # choose an ID for your definition
+ - id: date_style
text_font: montserrat_14
align: center
text_color: White
@@ -177,7 +639,7 @@ lvgl:
pad_all: 0
text_color: White
scales:
- - range_from: 0 # minutes scale
+ - range_from: 0
range_to: 60
angle_range: 360
rotation: 270
@@ -191,9 +653,9 @@ lvgl:
id: minute_hand
width: 2
color: White
- r_mod: -25
+ length: 95
value: 0
- - range_from: 1 # hours scale for labels
+ - range_from: 1
range_to: 12
angle_range: 330
rotation: 300
@@ -207,31 +669,35 @@ lvgl:
length: 10
color: White
label_gap: 12
- - range_from: 0 # hi-res hours scale for hand
+ - range_from: 0
range_to: 720
angle_range: 360
rotation: 270
ticks:
- count: 0
+ count: 2
+ width: 1
+ length: 1
+ color: 0x000000
indicators:
- line:
id: hour_hand
width: 4
color: White
- r_mod: -40
+ length: 80
value: 0
- line:
id: secs_hand
width: 1
color: Red
- r_mod: -15
+ length: 105
value: 0
- obj:
+ id: alarm_status_circle
width: 120
height: 120
align: CENTER
bg_color: Black
- bg_opa: 85%
+ bg_opa: COVER
radius: 60
clip_corner: true
pad_all: 0
@@ -240,52 +706,1240 @@ lvgl:
- label:
styles: date_style
id: alarm_label
+ width: 104
y: -20
+ text_align: center
text: ""
- label:
id: date_label
styles: date_style
+ width: 104
y: 20
+ text_align: center
text: ""
-
+ - button:
+ id: center_tap_button
+ width: 120
+ height: 120
+ align: CENTER
+ radius: 60
+ bg_opa: TRANSP
+ border_width: 0
+ shadow_width: 0
+ press_lock: true
+ on_short_click:
+ then:
+ - if:
+ condition:
+ lambda: "return !id(alarm_enabled) && !id(alarm_ringing) && !id(home_menu_visible) && !id(media_screen_visible);"
+ then:
+ - script.execute: enable_alarm
+ else:
+ - if:
+ condition:
+ lambda: "return !id(alarm_ringing) && !id(home_menu_visible) && !id(media_screen_visible);"
+ then:
+ - script.execute: toggle_skip_next_ring
+ - script.execute: reset_idle_timer
+ on_long_press:
+ then:
+ - if:
+ condition:
+ lambda: "return !id(alarm_ringing) && !id(home_menu_visible) && !id(media_screen_visible);"
+ then:
+ - script.execute: open_home_menu
+ - script.execute: reset_idle_timer
+ - label:
+ id: sleep_hint_label
+ hidden: true
+ align: CENTER
+ y: 68
+ text_font: montserrat_18
+ text_align: center
+ text_color: 0xE53935
+ text: ""
+ - obj:
+ id: home_menu_panel
+ hidden: true
+ width: 190
+ height: 138
+ align: CENTER
+ bg_color: 0x111111
+ bg_opa: 92%
+ radius: 18
+ border_width: 1
+ border_color: 0x455A64
+ pad_all: 0
+ widgets:
+ - label:
+ align: TOP_MID
+ y: 12
+ text_color: White
+ text: "Home"
+ - button:
+ id: all_lights_off_button
+ hidden: true
+ width: 118
+ height: 42
+ align: CENTER
+ y: -16
+ radius: 21
+ bg_color: 0x37474F
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - script.execute: home_all_lights_off
+ - script.execute: close_home_menu
+ - script.execute: reset_idle_timer
+ widgets:
+ - label:
+ align: CENTER
+ text: "All Off"
+ - button:
+ id: menu_music_play_button
+ hidden: true
+ width: 118
+ height: 42
+ align: CENTER
+ y: 38
+ radius: 21
+ bg_color: 0x43A047
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - if:
+ condition:
+ lambda: |-
+ return id(media_player_state).has_state() && id(media_player_state).state == "playing";
+ then:
+ - script.execute: open_media_screen
+ else:
+ - script.execute: open_media_screen
+ - script.execute: play_morning_music_on_ha
+ - script.execute: reset_idle_timer
+ widgets:
+ - label:
+ id: menu_music_play_label
+ align: CENTER
+ text_align: center
+ text: "Start music"
+ - button:
+ id: close_menu_button
+ hidden: true
+ width: 34
+ height: 34
+ x: 66
+ y: -38
+ align: CENTER
+ radius: 17
+ bg_color: 0x546E7A
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - script.execute: close_home_menu
+ widgets:
+ - label:
+ align: CENTER
+ text: "X"
+ - button:
+ id: snooze_button
+ hidden: true
+ width: 88
+ height: 88
+ x: -66
+ y: 0
+ align: CENTER
+ radius: 44
+ bg_color: 0x1E88E5
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - script.execute: snooze_alarm
+ - script.execute: reset_idle_timer
+ widgets:
+ - label:
+ id: snooze_button_label
+ align: CENTER
+ text_align: center
+ text: "Snooze\n10 min"
+ - button:
+ id: stop_button
+ hidden: true
+ width: 88
+ height: 88
+ x: 66
+ y: 0
+ align: CENTER
+ radius: 44
+ bg_color: 0xE53935
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - script.execute: dismiss_alarm
+ - script.execute: reset_idle_timer
+ widgets:
+ - label:
+ align: CENTER
+ text_align: center
+ text: "Stop"
+ - button:
+ id: morning_music_button
+ hidden: true
+ width: 104
+ height: 38
+ align: CENTER
+ y: 74
+ radius: 19
+ bg_color: 0x43A047
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - script.execute: open_media_screen
+ - script.execute: play_morning_music_on_ha
+ - lambda: |-
+ id(morning_music_prompt_until_ms) = 0;
+ - script.execute: update_alarm_status_ui
+ - script.execute: reset_idle_timer
+ widgets:
+ - label:
+ align: CENTER
+ text_align: center
+ text: "Music"
+ - obj:
+ id: media_screen_panel
+ hidden: true
+ width: 100%
+ height: 100%
+ align: CENTER
+ bg_color: Black
+ bg_opa: COVER
+ radius: 0
+ clip_corner: false
+ pad_all: 0
+ border_width: 0
+ widgets:
+ - arc:
+ id: media_volume_arc
+ hidden: true
+ width: 226
+ height: 226
+ align: CENTER
+ value: 50
+ min_value: 0
+ max_value: 100
+ start_angle: 135
+ end_angle: 45
+ adjustable: false
+ arc_width: 8
+ arc_color: 0x43A047
+ - label:
+ id: media_title_label
+ width: 204
+ align: CENTER
+ y: -54
+ text_align: center
+ text_color: White
+ text_font: media_text_font_16
+ long_mode: dot
+ text: "No track"
+ - label:
+ id: media_artist_label
+ width: 196
+ align: CENTER
+ y: -24
+ text_align: center
+ text_color: 0xB0BEC5
+ text_font: media_text_font_14
+ long_mode: dot
+ text: ""
+ - button:
+ id: media_play_pause_button
+ width: 52
+ height: 52
+ x: -56
+ y: 46
+ align: CENTER
+ radius: 26
+ bg_color: 0x43A047
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - script.execute: media_play_pause_on_ha
+ - script.execute: reset_idle_timer
+ widgets:
+ - label:
+ id: media_play_pause_icon_label
+ width: 100%
+ height: 100%
+ align: CENTER
+ text_align: center
+ text_font: media_icon_font_28
+ text: "▶"
+ - button:
+ id: media_next_button
+ width: 52
+ height: 52
+ x: 0
+ y: 46
+ align: CENTER
+ radius: 26
+ bg_color: 0x1E88E5
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - script.execute: media_next_track_on_ha
+ - script.execute: reset_idle_timer
+ widgets:
+ - label:
+ width: 100%
+ height: 100%
+ align: CENTER
+ text_align: center
+ text_font: media_icon_font_28
+ text: "»"
+ - button:
+ id: media_stop_button
+ width: 52
+ height: 52
+ x: 56
+ y: 46
+ align: CENTER
+ radius: 26
+ bg_color: 0xE53935
+ text_color: White
+ border_width: 0
+ on_click:
+ then:
+ - script.execute: close_media_screen
+ - script.execute: reset_idle_timer
+ - script.execute: stop_morning_music_on_ha
+ widgets:
+ - label:
+ width: 100%
+ height: 100%
+ align: CENTER
+ text_align: center
+ text_font: media_icon_font_28
+ text: "■"
+ - label:
+ id: snooze_hint_label
+ hidden: true
+ align: CENTER
+ y: 76
+ text_align: center
+ text_color: 0xB0BEC5
+ text: "Rotate = snooze time"
+
interval:
- interval: 25ms
then:
-
- lvgl.indicator.update:
id: secs_hand
value: !lambda |-
- auto now = id(rtctime).now();
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid()) {
+ return 0.0f;
+ }
struct timeval tv;
gettimeofday(&tv, NULL);
return (now.second * 12) + (tv.tv_usec / 83330.0);
-
+
+ - interval: 250ms
+ then:
+ - if:
+ condition:
+ lambda: |-
+ return id(media_screen_visible) && id(media_volume_overlay_until_ms) != 0 && millis() > id(media_volume_overlay_until_ms);
+ then:
+ - lambda: |-
+ id(media_volume_overlay_until_ms) = 0;
+ - lvgl.widget.hide: media_volume_arc
+
+ - interval: 1s
+ then:
+ - lambda: |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid()) {
+ return;
+ }
+
+ const int minute_key = now.day_of_year * 1440 + now.hour * 60 + now.minute;
+ if (id(last_ui_minute_key) != minute_key) {
+ id(last_ui_minute_key) = minute_key;
+ id(sync_alarm_day_state).execute();
+ id(update_minutes_hand).execute();
+ }
+
+ if (id(last_alarm_debug_minute_key) != minute_key) {
+ id(last_alarm_debug_minute_key) = minute_key;
+ if (id(next_alarm_available)) {
+ auto next_alarm = ESPTime::from_epoch_local(id(next_alarm_epoch));
+ ESP_LOGI(
+ "alarm",
+ "Alarm check now=%02d:%02d:%02d target=%02d:%02d enabled=%d skip_next=%d ringing=%d snooze_active=%d override=%d",
+ now.hour,
+ now.minute,
+ now.second,
+ next_alarm.hour,
+ next_alarm.minute,
+ id(alarm_enabled),
+ id(skip_next_ring),
+ id(alarm_ringing),
+ id(snooze_active),
+ id(next_ring_override_active)
+ );
+ }
+ }
+ - if:
+ condition:
+ lambda: |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid()) {
+ return false;
+ }
+ if (!id(alarm_enabled) || id(alarm_ringing)) {
+ return false;
+ }
+ if (!id(next_alarm_available)) {
+ return false;
+ }
+ const uint32_t now_minute = uint32_t(now.timestamp / 60);
+ const uint32_t target_minute = uint32_t(id(next_alarm_epoch) / 60);
+ if (now_minute != target_minute) {
+ return false;
+ }
+ if (id(last_alarm_minute_key) == int(target_minute)) {
+ return false;
+ }
+ id(last_alarm_minute_key) = int(target_minute);
+ ESP_LOGI("alarm", "Alarm trigger matched at %02d:%02d:%02d", now.hour, now.minute, now.second);
+ return true;
+ then:
+ - script.execute: start_alarm
+ - if:
+ condition:
+ lambda: |-
+ if (id(alarm_ringing)) {
+ return false;
+ }
+ if (id(screen_dimmed)) {
+ return false;
+ }
+ return millis() - id(last_interaction_ms) >= 60000;
+ then:
+ - lambda: |-
+ id(screen_dimmed) = true;
+ ESP_LOGD("ui", "Screen dimmed after inactivity");
+ - script.execute: apply_idle_brightness
+
+ - interval: 400ms
+ then:
+ - if:
+ condition:
+ lambda: "return id(alarm_ringing);"
+ then:
+ - lambda: |-
+ id(alarm_flash_on) = !id(alarm_flash_on);
+ - script.execute: update_alarm_status_ui
+ else:
+ - if:
+ condition:
+ lambda: |-
+ return id(alarm_flash_on) || id(dismiss_feedback_until_ms) != 0 || id(morning_music_prompt_until_ms) != 0;
+ then:
+ - lambda: |-
+ if (id(alarm_flash_on)) {
+ id(alarm_flash_on) = false;
+ }
+ if (id(dismiss_feedback_until_ms) != 0 && millis() >= id(dismiss_feedback_until_ms)) {
+ id(dismiss_feedback_until_ms) = 0;
+ }
+ if (id(morning_music_prompt_until_ms) != 0 && millis() >= id(morning_music_prompt_until_ms)) {
+ id(morning_music_prompt_until_ms) = 0;
+ }
+ - script.execute: update_alarm_status_ui
+
script:
- - id: update_minutes_hand
+ - id: sync_next_alarm_state
then:
- - lvgl.indicator.update:
- id: hour_hand
- value: !lambda |-
- auto now = id(rtctime).now();
- return std::fmod(now.hour, 12) * 60 + now.minute;
- - lvgl.indicator.update:
- id: minute_hand
+ - lambda: |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid() || !id(alarm_enabled)) {
+ id(next_alarm_available) = false;
+ id(next_alarm_epoch) = 0;
+ return;
+ }
+
+ const uint32_t now_minute = uint32_t(now.timestamp / 60);
+ if (id(skip_next_ring)) {
+ const uint32_t skipped_minute = uint32_t(id(skip_next_ring_epoch) / 60);
+ if (id(skip_next_ring_epoch) == 0 || skipped_minute < now_minute) {
+ id(skip_next_ring) = false;
+ id(skip_next_ring_epoch) = 0;
+ }
+ }
+
+ if (id(next_ring_override_active)) {
+ const uint32_t override_minute = uint32_t(id(next_ring_override_epoch) / 60);
+ if (override_minute >= now_minute) {
+ if (id(skip_next_ring) && id(skip_next_ring_epoch) != 0 &&
+ override_minute == uint32_t(id(skip_next_ring_epoch) / 60)) {
+ // Skip this one-time override and fall through to the next scheduled alarm.
+ } else {
+ id(next_alarm_available) = true;
+ id(next_alarm_epoch) = id(next_ring_override_epoch);
+ return;
+ }
+ }
+ id(next_ring_override_active) = false;
+ id(next_ring_override_epoch) = 0;
+ }
+
+ if (id(snooze_active)) {
+ int day_offset = (id(snooze_day_of_year) == now.day_of_year) ? 0 : 1;
+ const time_t midnight_epoch = now.timestamp - (now.hour * 3600) - (now.minute * 60) - now.second;
+ const uint32_t snooze_epoch = uint32_t(midnight_epoch + (day_offset * 86400) + (id(snooze_hour) * 3600) + (id(snooze_minute) * 60));
+ if (!(id(skip_next_ring) && id(skip_next_ring_epoch) != 0 &&
+ uint32_t(snooze_epoch / 60) == uint32_t(id(skip_next_ring_epoch) / 60))) {
+ id(next_alarm_epoch) = snooze_epoch;
+ id(next_alarm_available) = true;
+ return;
+ }
+ }
+
+ const auto weekday_alarm = id(weekday_alarm_time);
+ const auto weekend_alarm = id(weekend_alarm_time);
+ if (weekday_alarm == nullptr || weekend_alarm == nullptr) {
+ id(next_alarm_available) = false;
+ id(next_alarm_epoch) = 0;
+ return;
+ }
+
+ bool found = false;
+ for (int offset = 0; offset < 8; offset++) {
+ const int candidate_dow = ((now.day_of_week - 1 + offset) % 7) + 1;
+ auto candidate_alarm = (candidate_dow == 1 || candidate_dow == 7) ? weekend_alarm : weekday_alarm;
+ const int now_total_minutes = now.hour * 60 + now.minute;
+ const int candidate_total_minutes = candidate_alarm->hour * 60 + candidate_alarm->minute;
+ const bool valid_time = offset > 0 ||
+ candidate_total_minutes >= now_total_minutes;
+ if (!valid_time) {
+ continue;
+ }
+ const time_t midnight_epoch = now.timestamp - (now.hour * 3600) - (now.minute * 60) - now.second;
+ const uint32_t candidate_epoch = uint32_t(midnight_epoch + (offset * 86400) + (candidate_alarm->hour * 3600) + (candidate_alarm->minute * 60));
+ if (id(skip_next_ring) && id(skip_next_ring_epoch) != 0 &&
+ uint32_t(candidate_epoch / 60) == uint32_t(id(skip_next_ring_epoch) / 60)) {
+ continue;
+ }
+ id(next_alarm_epoch) = candidate_epoch;
+ id(next_alarm_available) = true;
+ found = true;
+ break;
+ }
+
+ if (!found) {
+ id(next_alarm_available) = false;
+ id(next_alarm_epoch) = 0;
+ }
+
+ - id: clear_snooze
+ then:
+ - lambda: |-
+ id(snooze_active) = false;
+ id(snooze_day_of_year) = -1;
+ id(snooze_hour) = -1;
+ id(snooze_minute) = -1;
+
+ - id: enable_alarm
+ then:
+ - lambda: |-
+ if (!id(alarm_enabled)) {
+ ESP_LOGI("alarm", "Away mode disabled; alarm enabled");
+ }
+ id(alarm_enabled) = true;
+ id(alarm_enabled_switch).publish_state(true);
+ - script.execute: update_minutes_hand
+ - script.execute: reset_idle_timer
+
+ - id: dismiss_alarm
+ then:
+ - lambda: |-
+ id(dismiss_feedback_until_ms) = millis() + 3000;
+ id(morning_music_prompt_until_ms) = millis() + 60000;
+ - script.execute: clear_snooze
+ - script.execute: stop_alarm
+ - script.execute: play_morning_briefing_on_ha
+
+ - id: sync_alarm_day_state
+ then:
+ - script.execute: sync_next_alarm_state
+ - script.execute: update_alarm_status_ui
+
+ - id: toggle_skip_next_ring
+ then:
+ - script.execute: sync_next_alarm_state
+ - lambda: |-
+ if (id(skip_next_ring)) {
+ id(skip_next_ring) = false;
+ id(skip_next_ring_epoch) = 0;
+ ESP_LOGI("ui", "Skip next ring cleared");
+ return;
+ }
+ if (!id(next_alarm_available)) {
+ ESP_LOGW("ui", "Skip next ring requested but there is no next alarm available");
+ return;
+ }
+ id(skip_next_ring) = true;
+ id(skip_next_ring_epoch) = id(next_alarm_epoch);
+ auto skipped_alarm = ESPTime::from_epoch_local(id(skip_next_ring_epoch));
+ ESP_LOGI("ui", "Skip next ring set for %02d:%02d", skipped_alarm.hour, skipped_alarm.minute);
+ - script.execute: update_alarm_status_ui
+
+ - id: open_home_menu
+ then:
+ - lambda: |-
+ id(home_menu_visible) = true;
+ ESP_LOGI("ui", "Home menu opened");
+ - script.execute: update_alarm_status_ui
+ - script.execute: reset_idle_timer
+ - script.execute: auto_close_home_menu
+
+ - id: close_home_menu
+ then:
+ - lambda: |-
+ id(home_menu_visible) = false;
+ ESP_LOGI("ui", "Home menu closed");
+ - script.stop: auto_close_home_menu
+ - script.execute: update_alarm_status_ui
+ - script.execute: reset_idle_timer
+
+ - id: auto_close_home_menu
+ mode: restart
+ then:
+ - delay: 10s
+ - if:
+ condition:
+ lambda: "return id(home_menu_visible) && !id(alarm_ringing);"
+ then:
+ - lambda: |-
+ id(home_menu_visible) = false;
+ ESP_LOGI("ui", "Home menu auto-closed");
+ - script.execute: update_alarm_status_ui
+
+ - id: home_all_lights_off
+ then:
+ - lambda: |-
+ ESP_LOGI("ui", "Requesting Home Assistant action: all lights off");
+ - homeassistant.action:
+ action: ${home_lights_off_action}
+ data:
+ entity_id: ${home_lights_off_entity}
+ on_success:
+ - lambda: |-
+ ESP_LOGI("ui", "Requested Home Assistant action: all lights off");
+ on_error:
+ - lambda: |-
+ ESP_LOGW("ui", "All lights off action failed: %s", error.c_str());
+
+ - id: apply_active_brightness
+ then:
+ - light.turn_on:
+ id: display_backlight
+ brightness: !lambda |-
+ if (id(alarm_ringing)) {
+ return 1.0f;
+ }
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (now.is_valid() && (now.hour >= 23 || now.hour < 7)) {
+ return 0.30f;
+ }
+ return 1.0f;
+
+ - id: apply_idle_brightness
+ then:
+ - light.turn_on:
+ id: display_backlight
+ brightness: !lambda |-
+ if (id(alarm_ringing)) {
+ return 1.0f;
+ }
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (now.is_valid() && (now.hour >= 23 || now.hour < 7)) {
+ return 0.08f;
+ }
+ return 0.25f;
+
+ - id: start_alarm_media_on_ha
+ then:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requesting Home Assistant alarm audio start");
+ - homeassistant.action:
+ action: ${alarm_audio_start_action}
+ data:
+ entity_id: ${alarm_audio_player_entity}
+ on_success:
+ - if:
+ condition:
+ lambda: "return id(alarm_ringing);"
+ then:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requested Home Assistant alarm audio start");
+ - script.stop: alarm_loop
+ - output.turn_off: buzzer
+ else:
+ - lambda: |-
+ ESP_LOGW("alarm", "Home Assistant alarm audio started after alarm stopped; stopping media now");
+ - script.execute: stop_alarm_media_on_ha
+ on_error:
+ - lambda: |-
+ ESP_LOGW("alarm", "Home Assistant alarm audio start failed: %s", error.c_str());
+
+ - id: stop_alarm_media_on_ha
+ then:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requesting Home Assistant alarm audio stop for %s", "${alarm_audio_player_entity}");
+ - homeassistant.action:
+ action: ${alarm_audio_stop_action}
+ data:
+ entity_id: ${alarm_audio_player_entity}
+ on_success:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requested Home Assistant alarm audio stop for %s", "${alarm_audio_player_entity}");
+ on_error:
+ - lambda: |-
+ ESP_LOGW("alarm", "Home Assistant alarm audio stop failed: %s", error.c_str());
+
+ - id: play_morning_briefing_on_ha
+ then:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requesting Home Assistant morning briefing on %s", "${morning_briefing_player_entity}");
+ - homeassistant.action:
+ action: ${morning_briefing_action}
+ data:
+ media_player_entity_id: ${morning_briefing_player_entity}
+ on_success:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requested Home Assistant morning briefing");
+ on_error:
+ - lambda: |-
+ ESP_LOGW("alarm", "Home Assistant morning briefing failed: %s", error.c_str());
+
+ - id: play_morning_music_on_ha
+ then:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requesting Home Assistant morning music on %s", "${morning_briefing_player_entity}");
+ - homeassistant.action:
+ action: ${morning_briefing_action}
+ data:
+ media_player_entity_id: ${morning_briefing_player_entity}
+ music_only: "true"
+ on_success:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requested Home Assistant morning music");
+ on_error:
+ - lambda: |-
+ ESP_LOGW("alarm", "Home Assistant morning music failed: %s", error.c_str());
+ - delay: 1s
+ - homeassistant.action:
+ action: media_player.media_play
+ data:
+ entity_id: ${morning_briefing_player_entity}
+ on_error:
+ - lambda: |-
+ ESP_LOGW("alarm", "Home Assistant media_play after music start failed: %s", error.c_str());
+
+ - id: stop_morning_music_on_ha
+ then:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requesting Home Assistant morning music stop for %s", "${morning_briefing_player_entity}");
+ - homeassistant.action:
+ action: media_player.media_stop
+ data:
+ entity_id: ${morning_briefing_player_entity}
+ on_success:
+ - lambda: |-
+ ESP_LOGI("alarm", "Requested Home Assistant morning music stop for %s", "${morning_briefing_player_entity}");
+ on_error:
+ - lambda: |-
+ ESP_LOGW("alarm", "Home Assistant morning music stop failed: %s", error.c_str());
+
+ - id: open_media_screen
+ then:
+ - lambda: |-
+ id(media_screen_visible) = true;
+ id(home_menu_visible) = false;
+ ESP_LOGI("media", "Media screen opened");
+ - script.stop: auto_close_home_menu
+ - script.execute: update_media_screen_ui
+ - script.execute: update_alarm_status_ui
+ - script.execute: reset_idle_timer
+
+ - id: close_media_screen
+ then:
+ - lambda: |-
+ id(media_screen_visible) = false;
+ ESP_LOGI("media", "Media screen closed");
+ - script.execute: update_alarm_status_ui
+ - script.execute: reset_idle_timer
+
+ - id: media_play_pause_on_ha
+ then:
+ - lambda: |-
+ ESP_LOGI("media", "Requesting media play/pause for %s", "${morning_briefing_player_entity}");
+ - homeassistant.action:
+ action: media_player.media_play_pause
+ data:
+ entity_id: ${morning_briefing_player_entity}
+ on_error:
+ - lambda: |-
+ ESP_LOGW("media", "Media play/pause failed: %s", error.c_str());
+
+ - id: media_next_track_on_ha
+ then:
+ - lambda: |-
+ ESP_LOGI("media", "Requesting media next track for %s", "${morning_briefing_player_entity}");
+ - homeassistant.action:
+ action: media_player.media_next_track
+ data:
+ entity_id: ${morning_briefing_player_entity}
+ on_error:
+ - lambda: |-
+ ESP_LOGW("media", "Media next track failed: %s", error.c_str());
+
+ - id: set_morning_music_volume_on_ha
+ mode: restart
+ then:
+ - homeassistant.action:
+ action: media_player.volume_set
+ data:
+ entity_id: ${morning_briefing_player_entity}
+ volume_level: !lambda |-
+ return id(media_volume_level);
+ on_error:
+ - lambda: |-
+ ESP_LOGW("media", "Media volume set failed: %s", error.c_str());
+
+ - id: show_media_volume_overlay
+ then:
+ - lambda: |-
+ id(media_volume_overlay_until_ms) = millis() + 1500;
+ - lvgl.widget.show: media_volume_arc
+ - script.execute: update_media_screen_ui
+
+ - id: update_media_screen_ui
+ then:
+ - lvgl.arc.update:
+ id: media_volume_arc
value: !lambda |-
- return id(rtctime).now().minute;
+ return int(id(media_volume_level) * 100.0f + 0.5f);
+ - lvgl.label.update:
+ id: menu_music_play_label
+ text: !lambda |-
+ if (id(media_player_state).has_state() && id(media_player_state).state == "playing") {
+ return "Open music";
+ }
+ return "Start music";
+ - lvgl.label.update:
+ id: media_play_pause_icon_label
+ text: !lambda |-
+ if (id(media_player_state).has_state() && id(media_player_state).state == "playing") {
+ return "Ⅱ";
+ }
+ return "▶";
+ - lvgl.label.update:
+ id: media_title_label
+ text: !lambda |-
+ if (id(media_track_title).has_state() && !id(media_track_title).state.empty()) {
+ return id(media_track_title).state.c_str();
+ }
+ if (id(media_player_state).has_state() && id(media_player_state).state == "playing") {
+ return "Playing";
+ }
+ return "No track";
+ - lvgl.label.update:
+ id: media_artist_label
+ text: !lambda |-
+ if (id(media_track_artist).has_state() && !id(media_track_artist).state.empty()) {
+ return id(media_track_artist).state.c_str();
+ }
+ if (id(media_track_album).has_state() && !id(media_track_album).state.empty()) {
+ return id(media_track_album).state.c_str();
+ }
+ if (id(media_player_state).has_state()) {
+ return id(media_player_state).state.c_str();
+ }
+ return "Rotate = volume";
+
+ - id: reset_idle_timer
+ mode: restart
+ then:
+ - lambda: |-
+ id(last_interaction_ms) = millis();
+ const bool was_dimmed = id(screen_dimmed);
+ id(screen_dimmed) = false;
+ if (was_dimmed) {
+ ESP_LOGD("ui", "Screen restored from idle dim");
+ }
+ - script.execute: apply_active_brightness
+
+ - id: update_alarm_status_ui
+ then:
+ - script.execute: sync_next_alarm_state
+ - lvgl.label.update:
+ id: snooze_button_label
+ text: !lambda |-
+ static char snooze_buf[24];
+ snprintf(snooze_buf, sizeof(snooze_buf), "Snooze\n%d min", id(snooze_minutes));
+ return snooze_buf;
+ - lvgl.label.update:
+ id: sleep_hint_label
+ text: !lambda |-
+ static char sleep_buf[16];
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid() || !id(alarm_enabled) || !id(next_alarm_available) || id(snooze_active)) {
+ snprintf(sleep_buf, sizeof(sleep_buf), "");
+ return sleep_buf;
+ }
+ const int32_t seconds_until_alarm = int32_t(id(next_alarm_epoch)) - int32_t(now.timestamp);
+ if (seconds_until_alarm <= 0 || seconds_until_alarm > (8 * 3600)) {
+ snprintf(sleep_buf, sizeof(sleep_buf), "");
+ return sleep_buf;
+ }
+ const int hours = seconds_until_alarm / 3600;
+ const int minutes = (seconds_until_alarm % 3600) / 60;
+ snprintf(sleep_buf, sizeof(sleep_buf), "%dh %02dm", hours, minutes);
+ return sleep_buf;
+ - lvgl.label.update:
+ id: alarm_label
+ text_color: !lambda |-
+ if (!id(alarm_enabled)) {
+ return lv_color_hex(0xCE93D8);
+ }
+ return lv_color_hex(0xFFFFFF);
- lvgl.label.update:
id: alarm_label
text: !lambda |-
- auto alarm_time_var = id(alarm_time);
- static char alarm_buf[16];
- snprintf(alarm_buf, sizeof(alarm_buf), "Alarm %02d:%02d", alarm_time_var->hour, alarm_time_var->minute);
+ static char alarm_buf[24];
+ if (!id(alarm_enabled)) {
+ snprintf(alarm_buf, sizeof(alarm_buf), "Away Mode");
+ } else if (id(alarm_ringing)) {
+ snprintf(alarm_buf, sizeof(alarm_buf), "Alarm!");
+ } else if (id(dismiss_feedback_until_ms) != 0 && millis() < id(dismiss_feedback_until_ms)) {
+ snprintf(alarm_buf, sizeof(alarm_buf), "Stopped");
+ } else if (id(snooze_active)) {
+ snprintf(alarm_buf, sizeof(alarm_buf), "Snoozing");
+ } else if (id(next_ring_override_active)) {
+ snprintf(alarm_buf, sizeof(alarm_buf), "One-Time");
+ } else {
+ snprintf(alarm_buf, sizeof(alarm_buf), "Next Ring");
+ }
return alarm_buf;
+ - lvgl.label.update:
+ id: date_label
+ text_color: !lambda |-
+ if (!id(alarm_enabled)) {
+ return lv_color_hex(0xFFFFFF);
+ }
+ if (id(next_ring_override_active) && id(alarm_enabled) && !id(alarm_ringing) &&
+ !id(snooze_active) && !id(skip_next_ring) &&
+ !(id(dismiss_feedback_until_ms) != 0 && millis() < id(dismiss_feedback_until_ms))) {
+ return lv_color_hex(0xFFB300);
+ }
+ return lv_color_hex(0xFFFFFF);
- lvgl.label.update:
id: date_label
text: !lambda |-
- static const char * const day_names[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
- static const char * const mon_names[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
- static char date_buf[16];
- auto now = id(rtctime).now();
- snprintf(date_buf, sizeof(date_buf), "%s, %s %2d", day_names[id(rtctime).now().day_of_week - 1], mon_names[now.month-1], now.day_of_month);
- return date_buf;
\ No newline at end of file
+ static const char *const day_names[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
+ static char date_buf[24];
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+
+ if (!id(alarm_enabled)) {
+ snprintf(date_buf, sizeof(date_buf), "Tap to\nresume");
+ } else if (id(dismiss_feedback_until_ms) != 0 && millis() < id(dismiss_feedback_until_ms)) {
+ snprintf(date_buf, sizeof(date_buf), "Dismissed");
+ } else if (id(snooze_active)) {
+ if (!now.is_valid()) {
+ snprintf(date_buf, sizeof(date_buf), "Until %02d:%02d", id(snooze_hour), id(snooze_minute));
+ } else if (id(snooze_day_of_year) == now.day_of_year) {
+ snprintf(date_buf, sizeof(date_buf), "Today %02d:%02d", id(snooze_hour), id(snooze_minute));
+ } else if (id(snooze_day_of_year) == now.day_of_year + 1) {
+ snprintf(date_buf, sizeof(date_buf), "Tomorrow %02d:%02d", id(snooze_hour), id(snooze_minute));
+ } else {
+ snprintf(date_buf, sizeof(date_buf), "Until %02d:%02d", id(snooze_hour), id(snooze_minute));
+ }
+ } else if (id(skip_next_ring)) {
+ snprintf(date_buf, sizeof(date_buf), "Skipped");
+ } else if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) {
+ snprintf(date_buf, sizeof(date_buf), "--");
+ } else if (!id(next_alarm_available)) {
+ snprintf(date_buf, sizeof(date_buf), "No next ring");
+ } else {
+ auto next_alarm = ESPTime::from_epoch_local(id(next_alarm_epoch));
+ int day_delta = next_alarm.day_of_year - now.day_of_year;
+ if (day_delta < 0) {
+ day_delta += 366;
+ }
+ if (day_delta == 0) {
+ snprintf(date_buf, sizeof(date_buf), "Today\n%02d:%02d", next_alarm.hour, next_alarm.minute);
+ } else if (day_delta == 1) {
+ snprintf(date_buf, sizeof(date_buf), "Tomorrow\n%02d:%02d", next_alarm.hour, next_alarm.minute);
+ } else {
+ snprintf(date_buf, sizeof(date_buf), "%s\n%02d:%02d", day_names[next_alarm.day_of_week - 1], next_alarm.hour, next_alarm.minute);
+ }
+ }
+ return date_buf;
+ - lvgl.widget.update:
+ id: alarm_status_circle
+ bg_color: !lambda |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (id(alarm_ringing)) {
+ return id(alarm_flash_on) ? lv_color_hex(0xFF7043) : lv_color_hex(0xE53935);
+ }
+ if (id(dismiss_feedback_until_ms) != 0 && millis() < id(dismiss_feedback_until_ms)) {
+ return lv_color_hex(0x43A047);
+ }
+ if (id(snooze_active)) {
+ return lv_color_hex(0x1E88E5);
+ }
+ if (!id(alarm_enabled)) {
+ return lv_color_hex(0x7B1FA2);
+ }
+ if (id(skip_next_ring)) {
+ return lv_color_hex(0xFFD54F);
+ }
+ return lv_color_hex(0x000000);
+ - if:
+ condition:
+ lambda: "return id(alarm_ringing);"
+ then:
+ - if:
+ condition:
+ lambda: "return !id(ring_controls_visible);"
+ then:
+ - lambda: |-
+ id(ring_controls_visible) = true;
+ id(home_menu_visible) = false;
+ - lvgl.widget.hide: [alarm_status_circle, center_tap_button, sleep_hint_label, home_menu_panel, all_lights_off_button, close_menu_button, morning_music_button, menu_music_play_button, media_screen_panel]
+ - lvgl.widget.show: [snooze_button, stop_button, snooze_hint_label]
+ else:
+ - if:
+ condition:
+ lambda: "return id(ring_controls_visible);"
+ then:
+ - lambda: |-
+ id(ring_controls_visible) = false;
+ - lvgl.widget.hide: [snooze_button, stop_button, snooze_hint_label, morning_music_button, menu_music_play_button]
+ - if:
+ condition:
+ lambda: "return id(media_screen_visible);"
+ then:
+ - lvgl.widget.hide: [alarm_status_circle, center_tap_button, sleep_hint_label, home_menu_panel, all_lights_off_button, menu_music_play_button, close_menu_button, morning_music_button]
+ - lvgl.widget.show: [media_screen_panel]
+ else:
+ - lvgl.widget.hide: [media_screen_panel]
+ - if:
+ condition:
+ lambda: "return id(home_menu_visible);"
+ then:
+ - lvgl.widget.hide: [alarm_status_circle, center_tap_button, sleep_hint_label, morning_music_button]
+ - lvgl.widget.show: [home_menu_panel, all_lights_off_button, menu_music_play_button, close_menu_button]
+ else:
+ - lvgl.widget.hide: [home_menu_panel, all_lights_off_button, menu_music_play_button, close_menu_button]
+ - lvgl.widget.show: [alarm_status_circle, center_tap_button]
+ - if:
+ condition:
+ lambda: |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid() || !id(alarm_enabled) || !id(next_alarm_available) || id(snooze_active)) {
+ return false;
+ }
+ const int32_t seconds_until_alarm = int32_t(id(next_alarm_epoch)) - int32_t(now.timestamp);
+ return seconds_until_alarm > 0 && seconds_until_alarm <= (8 * 3600);
+ then:
+ - lvgl.widget.show: [sleep_hint_label]
+ else:
+ - lvgl.widget.hide: [sleep_hint_label]
+ - if:
+ condition:
+ lambda: |-
+ return id(morning_music_prompt_until_ms) != 0 && millis() < id(morning_music_prompt_until_ms);
+ then:
+ - lvgl.widget.show: [morning_music_button]
+ else:
+ - lvgl.widget.hide: [morning_music_button]
+
+ - id: apply_snooze_duration
+ parameters:
+ minutes: int
+ then:
+ - lambda: |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid()) {
+ ESP_LOGW("alarm", "Snooze requested but current time is invalid");
+ return;
+ }
+ auto snooze_until = esphome::ESPTime::from_epoch_local(now.timestamp + (minutes * 60));
+ id(snooze_active) = true;
+ id(snooze_day_of_year) = snooze_until.day_of_year;
+ id(snooze_hour) = snooze_until.hour;
+ id(snooze_minute) = snooze_until.minute;
+ ESP_LOGI("alarm", "Snoozed for %d minutes until day=%d %02d:%02d", minutes, id(snooze_day_of_year), id(snooze_hour), id(snooze_minute));
+ - script.execute: stop_alarm
+ - script.execute: update_minutes_hand
+
+ - id: snooze_alarm
+ then:
+ - script.execute:
+ id: apply_snooze_duration
+ minutes: !lambda "return id(snooze_minutes);"
+
+ - id: snooze_alarm_five_minutes
+ then:
+ - script.execute:
+ id: apply_snooze_duration
+ minutes: 5
+
+ - id: start_alarm
+ mode: restart
+ then:
+ - script.execute: clear_snooze
+ - lambda: |-
+ id(next_ring_override_active) = false;
+ id(next_ring_override_epoch) = 0;
+ id(snooze_minutes) = 10;
+ id(alarm_ringing) = true;
+ id(home_menu_visible) = false;
+ id(media_screen_visible) = false;
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ ESP_LOGI("alarm", "Starting alarm at %02d:%02d:%02d", now.hour, now.minute, now.second);
+ - script.execute: apply_active_brightness
+ - script.execute: update_minutes_hand
+ - script.execute: start_alarm_media_on_ha
+ - script.execute: alarm_loop
+ - script.execute: alarm_timeout
+
+ - id: stop_alarm
+ then:
+ - lambda: |-
+ if (id(alarm_ringing)) {
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ ESP_LOGI("alarm", "Stopping alarm at %02d:%02d:%02d", now.hour, now.minute, now.second);
+ }
+ id(alarm_ringing) = false;
+ - script.stop: alarm_loop
+ - script.stop: alarm_timeout
+ - output.turn_off: buzzer
+ - script.execute: stop_alarm_media_on_ha
+ - script.execute: update_minutes_hand
+ - script.execute: reset_idle_timer
+
+ - id: alarm_loop
+ mode: restart
+ then:
+ - while:
+ condition:
+ lambda: "return id(alarm_ringing);"
+ then:
+ - output.ledc.set_frequency:
+ id: buzzer
+ frequency: "3200Hz"
+ - output.set_level:
+ id: buzzer
+ level: 50%
+ - delay: 950ms
+ - output.turn_off: buzzer
+ - delay: 100ms
+ - output.ledc.set_frequency:
+ id: buzzer
+ frequency: "2800Hz"
+ - output.set_level:
+ id: buzzer
+ level: 50%
+ - delay: 950ms
+ - output.turn_off: buzzer
+ - delay: 250ms
+
+ - id: alarm_timeout
+ mode: restart
+ then:
+ - delay: 5min
+ - if:
+ condition:
+ lambda: "return id(alarm_ringing);"
+ then:
+ - logger.log: "Alarm timed out after 5 minutes"
+ - script.execute: stop_alarm
+
+ - id: update_minutes_hand
+ then:
+ - script.execute: update_alarm_status_ui
+ - lvgl.indicator.update:
+ id: hour_hand
+ value: !lambda |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid()) {
+ return 0.0f;
+ }
+ return std::fmod(now.hour, 12) * 60 + now.minute;
+ - lvgl.indicator.update:
+ id: minute_hand
+ value: !lambda |-
+ auto now = id(sntp_time).now();
+ if (!now.is_valid()) {
+ now = id(rtctime).now();
+ }
+ if (!now.is_valid()) {
+ return 0.0f;
+ }
+ return float(now.minute);
diff --git a/readme.md b/readme.md
index a917232..3997ede 100644
--- a/readme.md
+++ b/readme.md
@@ -1,32 +1,209 @@
-# Analogue Bedside Clock
+# M5Stack Dial Alarm Clock
-This project builds an analogue bedside clock out of a M5Stack Dial; the main purpose is to be able to set the time for an alarm routing in Home Assistant by twisting the dial.
+This repository is a fork of [tomwilkie/clock](https://github.com/tomwilkie/clock) with a much more complete alarm-clock workflow built on top of the original analogue M5Stack Dial clock idea.
-I have the alarm routine in Home Assistant slow fade up my bedsite lights, open the shutters, put on the air purifier and start the robot vacuum cleaners in the house.
+The base project turns an `M5Stack Dial` into a round bedside clock for Home Assistant. This fork keeps that core idea, but adds on-device alarm logic, snoozing, next-alarm overrides, weekday/weekend scheduling, Home Assistant controls, and optional music playback through a Home Assistant media player.
-Tip: if you use adaptive lighting, you can also set that up to control the backlight so its not too bright at night!
+
-
+## What This Fork Adds
-You'll need:
-- An M5Stack Dial: https://docs.m5stack.com/en/core/M5Dial
-- Some 3D printed parts: https://www.printables.com/model/1562621-analogue-alarm-clock-for-home-assistant
-- A low profile USB C cable: https://www.amazon.co.uk/dp/B0FMPVZJDP
+Compared to the original project, this fork adds:
+
+- A real on-device alarm that triggers when the configured alarm time is reached
+- Weekday and weekend default alarm times
+- A one-time "next ring" override using the rotary dial
+- Snooze support, including adjustable snooze duration while the alarm is ringing
+- Skip-next-ring behavior from the center touch target
+- `Alarm Enabled`, `Stop Alarm`, and `Test Alarm` entities in Home Assistant
+- `Next Alarm Time` exported as a timestamp-style text sensor for automations
+- On-screen alarm state UI with different colors for:
+ - armed
+ - skipped
+ - disabled
+ - snoozed
+ - ringing
+ - recently dismissed
+- Side-by-side on-screen `Snooze` and `Stop` controls while ringing
+- Idle screen dimming with brighter alarm-time behavior
+- Optional external alarm audio through Home Assistant / Music Assistant / Chromecast
+- More robust boot handling for current ESPHome versions
+
+## Current Behavior
+
+The device currently works like this:
+
+- The screen shows an analogue clock face on the M5Stack Dial display.
+- The RTC (`PCF8563`) is used locally, with SNTP syncing time back into the RTC.
+- The rotary encoder adjusts the next upcoming alarm.
+- Slow rotation changes by `1 minute`, faster rotation by `5 minutes`, very fast rotation by `15 minutes`.
+- During ringing, the rotary encoder adjusts snooze duration instead.
+- While snoozed, encoder alarm-time edits are locked.
+- Tapping the center area toggles `skip next ring`.
+- The single hardware button:
+ - short press while ringing = snooze 5 minutes
+ - hold for 3 seconds while ringing or snoozed = dismiss the alarm
+- If Home Assistant is configured, the alarm can also start music playback on a speaker and stop it on snooze/dismiss.
+
+## Alarm Model
+
+This fork uses a simple model:
+
+- `Weekday Alarm Time` is the default for Monday-Friday
+- `Weekend Alarm Time` is the default for Saturday-Sunday
+- Turning the dial does **not** rewrite those defaults directly
+- Turning the dial creates a one-time override for the **next actual ring**
+- After that ring happens, the override is cleared automatically
+
+This makes the device easy to use at the bedside:
+
+- keep stable weekday/weekend defaults
+- quickly tweak only tomorrow's alarm from the dial
+
+## Home Assistant Entities
+
+The ESPHome config exposes the following useful entities:
+
+- `Alarm Enabled`
+- `Weekday Alarm Time`
+- `Weekend Alarm Time`
+- `Stop Alarm`
+- `Test Alarm`
+- `Next Alarm Time`
+
+`Next Alarm Time` is published as an ISO-style local timestamp string and marked with `device_class: timestamp`, which makes it useful in Home Assistant automations.
+
+## Optional Music Alarm
+
+This repo supports starting alarm audio through Home Assistant when the alarm begins.
+
+The current ESPHome config expects:
+
+- a Home Assistant script named `script.alarmv1_start_alarm_audio`
+- a Home Assistant media player entity configured in `clock.yaml` under:
+
+```yaml
+substitutions:
+ alarm_audio_player_entity: media_player.example_speaker
+```
+
+When the alarm starts:
+
+- the device triggers `script.alarmv1_start_alarm_audio`
+- once Home Assistant confirms success, the local buzzer is stopped
+
+When the alarm is snoozed, stopped, disabled, or times out:
+
+- the device calls `media_player.media_stop` for the configured player
+
+### Example Home Assistant Script
+
+This is an example script that works with Music Assistant:
+
+```yaml
+alias: AlarmV1 Start Alarm Audio
+sequence:
+ - action: media_player.volume_set
+ target:
+ entity_id: media_player.example_speaker
+ data:
+ volume_level: 0.8
+
+ - action: music_assistant.play_media
+ target:
+ entity_id: media_player.example_speaker
+ data:
+ media_id: Techno
+ media_type: playlist
+ enqueue: replace
+
+ - delay: 1s
+
+ - action: media_player.shuffle_set
+ target:
+ entity_id: media_player.example_speaker
+ data:
+ shuffle: true
+```
+
+You must also enable ESPHome devices to perform Home Assistant actions in the ESPHome integration settings.
+
+## Optional Morning Briefing
+
+When the alarm is dismissed/stopped, the device can call a Home Assistant script. The contents of that script are intentionally left up to the user.
+
+The ESPHome config expects:
+
+```yaml
+substitutions:
+ morning_briefing_action: script.alarmv1_morning_briefing
+ morning_briefing_player_entity: media_player.example_speaker
+```
+
+Create `script.alarmv1_morning_briefing` in Home Assistant if you want a wake-up briefing. It receives `media_player_entity_id` as script data, so your script can choose whether to speak weather, commute, calendar, news, or nothing at all.
+
+## Hardware
+
+This project is built for:
+
+- [M5Stack Dial](https://docs.m5stack.com/en/core/M5Dial)
+
+Useful extras from the original project:
+
+- 3D printed parts: https://www.printables.com/model/1562621-analogue-alarm-clock-for-home-assistant
+- low-profile USB-C cable: https://www.amazon.co.uk/dp/B0FMPVZJDP
+
+## Project Files
+
+- [clock.yaml](/Users/gdavtyan/Projects/esp-alarm-clock/clock.yaml): main ESPHome firmware config
+- [debug.yaml](/Users/gdavtyan/Projects/esp-alarm-clock/debug.yaml): older/shared debug config kept in the repo
+- [clock.webp](/Users/gdavtyan/Projects/esp-alarm-clock/clock.webp): device photo
+
+## Configuration Notes
+
+Before flashing, you will probably want to adjust:
+
+- device name and friendly name
+- Wi-Fi credentials in `secrets.yaml`
+- timezone via `timezone`
+- `alarm_audio_player_entity`
+- Home Assistant action/entity substitutions:
+ - `alarm_audio_start_action`
+ - `alarm_audio_stop_action`
+ - `home_lights_off_action`
+ - `home_lights_off_entity`
+
+This repo is currently tailored to:
+
+- `Etc/UTC`
+- Home Assistant
+- Music Assistant
+- a Chromecast-style media player
+
+## Installing ESPHome on macOS
+
+```sh
+brew install pipx libmagic cairo
+pipx install esphome
+pipx runpip esphome install python-magic pillow==11.3.0 cairosvg
+```
+
+## Flashing
+
+First flash over USB:
-# Installing esphome & compiling this project on a Mac
```sh
-# We'll use pipx to install esphome, and brew to install pipx
-$ brew install pipx libmagic cairo
+~/.local/bin/esphome run --device=/dev/tty.usbmodemXXXX clock.yaml
+```
-# Install esphome in its own virtual env
-$ pipx install esphome
+Later flashes over Wi-Fi:
-# Add some dependencies to the virtual env
-$ pipx runpip esphome install python-magic pillow==11.3.0 cairosvg
+```sh
+~/.local/bin/esphome run clock.yaml
+```
-# Install the wind tunnel (first time - your device name will vary)
-$ ~/.local/bin/esphome run --device=/dev/tty.usbmodem31301 clock.yaml
+## Notes
-# Install the wind tunnel (susequent times, via WiFi)
-$ ~/.local/bin/esphome run clock.yaml
-```
\ No newline at end of file
+- The M5Stack Dial only has one normal user-facing hardware button.
+- The touchscreen can occasionally log `Failed to read status`; in this project that has mostly been harmless noise unless touch behavior is obviously broken.
+- The current config uses the modern `mipi_spi` display driver because it is more stable with recent ESPHome versions than the older `ili9xxx` setup.