# Waveshare ESP32-S3-Touch-LCD-1.85C-V2 — voice satellite + status display.
#
# See firmware/esp32-s3-touch-lcd-1.85c/README.md before flashing this: it needs
# the V2 hardware revision specifically (V1 has no echo-cancellation circuit and
# different audio pins), and two entity IDs in secrets.yaml filled in before the
# display shows anything real.
#
# Hardware bring-up (esp32/psram/i2c/pca9554/spi/display/touchscreen/i2s_audio
# sections) is adapted from the community-verified config at
# https://github.com/ulsmith/home-assistant-esphome-esp32-s3-touch-lcd-185c —
# the register-level display init sequence in particular is copied close to
# verbatim rather than re-derived, since getting it wrong just shows static.
# Everything voice/UI-behaviour related below that is new, built for this
# project's specific ask: media-with-cover-art takes priority over an idle
# weather/time/date cycle, with a voice-state visualizer overlaid on top of
# whichever of those is showing.

substitutions:
  # REQUIRED per physical unit — see README.md's "Multiple rooms" section.
  # This is what makes the device identifiable as belonging to one specific
  # room in both Home Assistant's device list and this file's own hostname;
  # it does NOT by itself make media_player_entity_id (a secret, below)
  # point at the right room's media — that's a separate, equally required
  # step covered in the same README section.
  room: "living-room" # lowercase, hyphens only — becomes part of the hostname
  friendly_name: "Voice display (Living room)"
  # Substitution (not a plain !secret) specifically so it can be interpolated
  # inside larger strings and lambda string literals below, e.g.
  # "${ha_base_url}/local/no_art.png" — !secret alone can't be embedded that way.
  ha_base_url: !secret ha_base_url

esphome:
  name: voice-display-${room}
  friendly_name: ${friendly_name}

# ESP-IDF (not Arduino) is required for octal PSRAM + this QSPI display driver.
esp32:
  board: esp32-s3-devkitc-1
  flash_size: 16MB
  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

api:
  encryption:
    key: !secret api_encryption_key

ota:
  - platform: esphome
    password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "voice-display-${room}"
    password: !secret ap_password

captive_portal:

# Required by online_image below (cover-art fetch) — LAN-only in practice,
# since ha_base_url always points at the household's own Home Assistant.
http_request:
  useragent: esphome/voice-display-${room}
  timeout: 10s

# Home Assistant's own clock — this device never needs to be right on its own.
time:
  - platform: homeassistant
    id: ha_time
    on_time:
      - seconds: 0
        then:
          - lvgl.label.update:
              id: lbl_time
              text: !lambda "return id(ha_time).now().strftime(\"%H:%M\");"
          - lvgl.label.update:
              id: lbl_date
              text: !lambda "return id(ha_time).now().strftime(\"%a %d %b\");"

# ---------------------------------------------------------------------------
# Display + touch (ST77916 360x360 round QSPI, CST816 touch) — Phase 13-style
# "copied from a verified source, not re-derived" note applies to the whole
# i2c/pca9554/spi/display block below.
# ---------------------------------------------------------------------------

i2c:
  sda: GPIO11
  scl: GPIO10
  scan: true

# I/O expander that carries the display's reset line — the V2 variant
# specifically resets via EXIO2 (pca9554 pin 2), not a plain GPIO.
pca9554:
  - id: pca9554_device
    address: 0x20

output:
  - platform: ledc
    pin: GPIO5
    id: backlight_pwm

light:
  - platform: monochromatic
    output: backlight_pwm
    id: display_backlight
    name: "Display backlight"
    restore_mode: ALWAYS_ON

spi:
  id: display_qspi
  type: quad
  clk_pin: GPIO40
  data_pins: [GPIO46, GPIO45, GPIO42, GPIO41]

display:
  - platform: qspi_dbi
    model: CUSTOM
    id: main_display
    spi_id: display_qspi
    cs_pin: GPIO21
    reset_pin:
      pca9554: pca9554_device
      number: 2 # EXIO2 — V2-variant-specific, see README
    data_rate: 80MHz
    dimensions:
      width: 360
      height: 360
    color_order: rgb
    invert_colors: true
    auto_clear_enabled: false
    update_interval: never
    # Two lines to change if the unit needs mounting upside down (see README):
    # rotation: 180° and touchscreen.transform.mirror_x/mirror_y: true below.
    rotation: 0°
    # Official Waveshare V2 init sequence — do not reorder or trim these lines.
    init_sequence:
      - [0xF0, 0x28]
      - [0xF2, 0x28]
      - [0x73, 0xF0]
      - [0x7C, 0xD1]
      - [0x83, 0xE0]
      - [0x84, 0x61]
      - [0xF2, 0x82]
      - [0xF0, 0x00]
      - [0xF0, 0x01]
      - [0xF1, 0x01]
      - [0xB0, 0x56]
      - [0xB1, 0x4D]
      - [0xB2, 0x24]
      - [0xB4, 0x87]
      - [0xB5, 0x44]
      - [0xB6, 0x8B]
      - [0xB7, 0x40]
      - [0xB8, 0x86]
      - [0xBA, 0x00]
      - [0xBB, 0x08]
      - [0xBC, 0x08]
      - [0xBD, 0x00]
      - [0xC0, 0x80]
      - [0xC1, 0x10]
      - [0xC2, 0x37]
      - [0xC3, 0x80]
      - [0xC4, 0x10]
      - [0xC5, 0x37]
      - [0xC6, 0xA9]
      - [0xC7, 0x41]
      - [0xC8, 0x01]
      - [0xC9, 0xA9]
      - [0xCA, 0x41]
      - [0xCB, 0x01]
      - [0xD0, 0x91]
      - [0xD1, 0x68]
      - [0xD2, 0x68]
      - [0xF5, 0x00, 0xA5]
      - [0xDD, 0x4F]
      - [0xDE, 0x4F]
      - [0xF1, 0x10]
      - [0xF0, 0x00]
      - [0xF0, 0x02]
      - [0xE0, 0xF0, 0x0A, 0x10, 0x09, 0x09, 0x36, 0x35, 0x33, 0x4A, 0x29, 0x15, 0x15, 0x2E, 0x34]
      - [0xE1, 0xF0, 0x0A, 0x0F, 0x08, 0x08, 0x05, 0x34, 0x33, 0x4A, 0x39, 0x15, 0x15, 0x2D, 0x33]
      - [0xF0, 0x10]
      - [0xF3, 0x10]
      - [0xE0, 0x07]
      - [0xE1, 0x00]
      - [0xE2, 0x00]
      - [0xE3, 0x00]
      - [0xE4, 0xE0]
      - [0xE5, 0x06]
      - [0xE6, 0x21]
      - [0xE7, 0x01]
      - [0xE8, 0x05]
      - [0xE9, 0x02]
      - [0xEA, 0xDA]
      - [0xEB, 0x00]
      - [0xEC, 0x00]
      - [0xED, 0x0F]
      - [0xEE, 0x00]
      - [0xEF, 0x00]
      - [0xF8, 0x00]
      - [0xF9, 0x00]
      - [0xFA, 0x00]
      - [0xFB, 0x00]
      - [0xFC, 0x00]
      - [0xFD, 0x00]
      - [0xFE, 0x00]
      - [0xFF, 0x00]
      - [0x60, 0x40]
      - [0x61, 0x04]
      - [0x62, 0x00]
      - [0x63, 0x42]
      - [0x64, 0xD9]
      - [0x65, 0x00]
      - [0x66, 0x00]
      - [0x67, 0x00]
      - [0x68, 0x00]
      - [0x69, 0x00]
      - [0x6A, 0x00]
      - [0x6B, 0x00]
      - [0x70, 0x40]
      - [0x71, 0x03]
      - [0x72, 0x00]
      - [0x73, 0x42]
      - [0x74, 0xD8]
      - [0x75, 0x00]
      - [0x76, 0x00]
      - [0x77, 0x00]
      - [0x78, 0x00]
      - [0x79, 0x00]
      - [0x7A, 0x00]
      - [0x7B, 0x00]
      - [0x80, 0x48]
      - [0x81, 0x00]
      - [0x82, 0x06]
      - [0x83, 0x02]
      - [0x84, 0xD6]
      - [0x85, 0x04]
      - [0x86, 0x00]
      - [0x87, 0x00]
      - [0x88, 0x48]
      - [0x89, 0x00]
      - [0x8A, 0x08]
      - [0x8B, 0x02]
      - [0x8C, 0xD8]
      - [0x8D, 0x04]
      - [0x8E, 0x00]
      - [0x8F, 0x00]
      - [0x90, 0x48]
      - [0x91, 0x00]
      - [0x92, 0x0A]
      - [0x93, 0x02]
      - [0x94, 0xDA]
      - [0x95, 0x04]
      - [0x96, 0x00]
      - [0x97, 0x00]
      - [0x98, 0x48]
      - [0x99, 0x00]
      - [0x9A, 0x0C]
      - [0x9B, 0x02]
      - [0x9C, 0xDC]
      - [0x9D, 0x04]
      - [0x9E, 0x00]
      - [0x9F, 0x00]
      - [0xA0, 0x48]
      - [0xA1, 0x00]
      - [0xA2, 0x05]
      - [0xA3, 0x02]
      - [0xA4, 0xD5]
      - [0xA5, 0x04]
      - [0xA6, 0x00]
      - [0xA7, 0x00]
      - [0xA8, 0x48]
      - [0xA9, 0x00]
      - [0xAA, 0x07]
      - [0xAB, 0x02]
      - [0xAC, 0xD7]
      - [0xAD, 0x04]
      - [0xAE, 0x00]
      - [0xAF, 0x00]
      - [0xB0, 0x48]
      - [0xB1, 0x00]
      - [0xB2, 0x09]
      - [0xB3, 0x02]
      - [0xB4, 0xD9]
      - [0xB5, 0x04]
      - [0xB6, 0x00]
      - [0xB7, 0x00]
      - [0xB8, 0x48]
      - [0xB9, 0x00]
      - [0xBA, 0x0B]
      - [0xBB, 0x02]
      - [0xBC, 0xDB]
      - [0xBD, 0x04]
      - [0xBE, 0x00]
      - [0xBF, 0x00]
      - [0xC0, 0x10]
      - [0xC1, 0x47]
      - [0xC2, 0x56]
      - [0xC3, 0x65]
      - [0xC4, 0x74]
      - [0xC5, 0x88]
      - [0xC6, 0x99]
      - [0xC7, 0x01]
      - [0xC8, 0xBB]
      - [0xC9, 0xAA]
      - [0xD0, 0x10]
      - [0xD1, 0x47]
      - [0xD2, 0x56]
      - [0xD3, 0x65]
      - [0xD4, 0x74]
      - [0xD5, 0x88]
      - [0xD6, 0x99]
      - [0xD7, 0x01]
      - [0xD8, 0xBB]
      - [0xD9, 0xAA]
      - [0xF3, 0x01]
      - [0xF0, 0x00]
      - [0x3A, 0x55] # RGB565
      - [0x36, 0x00] # MADCTL
      - [0x2A, 0x00, 0x00, 0x01, 0x67] # column address 0-359
      - [0x2B, 0x00, 0x00, 0x01, 0x67] # row address 0-359
      - [0x21, 0x00] # display inversion on
      - [0x11, 0x00] # sleep out
      - delay 120ms
      - [0x29, 0x00] # display on
      - delay 20ms

touchscreen:
  platform: cst816
  id: my_touchscreen
  address: 0x15
  interrupt_pin: GPIO4
  transform:
    mirror_x: false
    mirror_y: false
  on_touch:
    then:
      - light.turn_on:
          id: display_backlight
          brightness: 100%
      - lvgl.resume:

# Narrow edge strips for swipe-to-navigate between the auto-managed idle/media
# pages and the two manual pages (settings, device info) — same shape as the
# reference config's own swipe zones.
binary_sensor:
  - platform: touchscreen
    touchscreen_id: my_touchscreen
    id: swipe_left
    internal: true
    x_min: 0
    x_max: 60
    y_min: 100
    y_max: 260
    on_press:
      then:
        - lvgl.page.previous:
  - platform: touchscreen
    touchscreen_id: my_touchscreen
    id: swipe_right
    internal: true
    x_min: 300
    x_max: 360
    y_min: 100
    y_max: 260
    on_press:
      then:
        - lvgl.page.next:

# ---------------------------------------------------------------------------
# Audio: ES7210 mic ADC (with the board's echo-cancellation reference path)
# + ES8311 speaker DAC, wired for on-device wake word (micro_wake_word) plus
# the existing Phase 3 Assist pipeline for everything after the wake word.
# ---------------------------------------------------------------------------

i2s_audio:
  - id: i2s_in
    i2s_lrclk_pin: GPIO2
    i2s_bclk_pin: GPIO15
  - id: i2s_out
    i2s_lrclk_pin: GPIO38
    i2s_bclk_pin: GPIO48

microphone:
  - platform: i2s_audio
    id: i2s_microphone
    i2s_audio_id: i2s_in
    i2s_din_pin: GPIO39
    adc_type: external
    pdm: false
    channel: right
    sample_rate: 16000
    bits_per_sample: 16bit

speaker:
  - platform: i2s_audio
    id: i2s_speaker
    i2s_audio_id: i2s_out
    i2s_dout_pin: GPIO47
    dac_type: external
    i2s_mode: primary

# On-device wake-word spotting (TFLite, runs on the ESP32-S3 itself) — matches
# this project's Phase 11.8 principle that wake-word detection happens locally,
# not by streaming continuously to Home Assistant. "okay_nabu" is the same
# phrase as the thin client's VOICE_WAKE_WORD, so the household has one
# consistent wake word regardless of which kind of satellite answers.
micro_wake_word:
  microphone:
    microphone: i2s_microphone
    channels: 0
  models:
    - model: okay_nabu
      id: okay_nabu_model
  on_wake_word_detected:
    - micro_wake_word.stop:
    - voice_assistant.start:
        wake_word: !lambda "return wake_word;"

voice_assistant:
  id: va
  microphone: i2s_microphone
  speaker: i2s_speaker
  use_wake_word: false # micro_wake_word above starts a session explicitly instead
  noise_suppression_level: 2
  auto_gain: 31dBFS
  volume_multiplier: 2.0
  on_listening:
    - light.turn_on:
        id: display_backlight
        brightness: 100%
    - lvgl.resume:
    - script.execute: show_visualizer_listening
  on_stt_end:
    - script.execute: show_visualizer_thinking
  on_tts_start:
    - script.execute: show_visualizer_replying
  on_tts_end:
    - script.execute: hide_visualizer
  on_end:
    - script.execute: hide_visualizer
    - micro_wake_word.start:
  on_error:
    - script.execute: show_visualizer_error
    - delay: 2s
    - script.execute: hide_visualizer
    - micro_wake_word.start:

# ---------------------------------------------------------------------------
# Home Assistant data this device mirrors — both entity IDs are placeholders,
# see README.md. Neither is guessed at; an unset value just means that part of
# the idle page/media page stays blank, same "don't build against a guess"
# rule this repo already applies to Phase 12/13's own unresolved data sources.
# ---------------------------------------------------------------------------

sensor:
  - platform: homeassistant
    id: weather_temperature
    entity_id: !secret weather_entity_id
    attribute: temperature
    on_value:
      then:
        - lvgl.label.update:
            id: lbl_weather_temp
            text: !lambda "return (str_sprintf(\"%.0f\", x) + \"°\");"
  - platform: wifi_signal
    id: wifi_signal_sensor
    update_interval: 60s
    on_value:
      then:
        - lvgl.label.update:
            id: lbl_wifi_signal
            text: !lambda "return (\"WiFi: \" + std::to_string((int) x) + \" dBm\");"
  - platform: uptime
    id: uptime_sensor
    update_interval: 60s
    on_value:
      then:
        - lvgl.label.update:
            id: lbl_uptime
            text: !lambda |-
              int s = (int) x;
              return "Uptime: " + std::to_string(s / 3600) + "h " + std::to_string((s % 3600) / 60) + "m";

globals:
  - id: media_is_playing
    type: bool
    restore_value: no
    initial_value: "false"
  - id: idle_showing_weather
    type: bool
    restore_value: no
    initial_value: "false"

text_sensor:
  - platform: homeassistant
    id: weather_condition
    entity_id: !secret weather_entity_id
    on_value:
      then:
        - lvgl.label.update:
            id: lbl_weather_cond
            text: !lambda "return x;"

  - platform: homeassistant
    id: media_state
    entity_id: !secret media_player_entity_id
    on_value:
      then:
        - if:
            condition:
              lambda: 'return x == "playing";'
            then:
              - globals.set:
                  id: media_is_playing
                  value: "true"
              - lvgl.page.show: media_page
            else:
              - globals.set:
                  id: media_is_playing
                  value: "false"
              - lvgl.page.show: idle_page

  - platform: homeassistant
    id: media_title
    entity_id: !secret media_player_entity_id
    attribute: media_title
    on_value:
      then:
        - lvgl.label.update:
            id: lbl_media_title
            text: !lambda "return x;"

  - platform: homeassistant
    id: media_artist
    entity_id: !secret media_player_entity_id
    attribute: media_artist
    on_value:
      then:
        - lvgl.label.update:
            id: lbl_media_artist
            text: !lambda "return x;"

  # entity_picture from Home Assistant is a path relative to the HA instance
  # (e.g. "/api/media_player_proxy/...") — this device fetches it directly over
  # HTTP, not through the HA connection, so ha_base_url has to be prepended.
  - platform: homeassistant
    id: media_picture
    entity_id: !secret media_player_entity_id
    attribute: entity_picture
    on_value:
      then:
        - online_image.set_url:
            id: media_cover_image
            url: !lambda |-
              if (x.rfind("http", 0) == 0) return x;
              return std::string("${ha_base_url}") + x;

  - platform: wifi_info
    ip_address:
      id: wifi_ip
      on_value:
        then:
          - lvgl.label.update:
              id: lbl_ip
              text: !lambda "return (\"IP: \" + x);"

# Must be the online_image platform (not a plain local file/mdi image) —
# that's the only image type online_image.set_url (used above) can retarget
# at runtime. The initial URL points at Home Assistant's own /local/
# (config/www/) static folder rather than anywhere external — nothing else
# this device talks to leaves the LAN, and it stays that way here too. It
# 404s harmlessly (shows no image, not a broken canvas) until either the
# first real track plays or you drop a no_art.png into config/www/ yourself
# — see README.md.
image:
  - platform: online_image
    id: media_cover_image
    url: "${ha_base_url}/local/no_art.png"
    format: PNG
    type: RGB565
    resize: 220x220

# ---------------------------------------------------------------------------
# Screen content
# ---------------------------------------------------------------------------

font:
  - file: "gfonts://Montserrat"
    id: font_time
    size: 54
  - file: "gfonts://Montserrat"
    id: font_date
    size: 20
  - file: "gfonts://Montserrat"
    id: font_weather
    size: 32
  - file: "gfonts://Montserrat"
    id: font_weather_cond
    size: 18
  - file: "gfonts://Montserrat"
    id: font_media
    size: 20
  - file: "gfonts://Montserrat"
    id: font_small
    size: 14

# Idle-page auto-cycle: only ever touches idle_page's own two widget groups,
# and only while media isn't playing — the priority automation above already
# forces media_page the instant something starts, so this never fights it.
interval:
  - interval: 8s
    then:
      - if:
          condition:
            lambda: "return !id(media_is_playing);"
          then:
            - globals.set:
                id: idle_showing_weather
                value: !lambda "return !id(idle_showing_weather);"
            - if:
                condition:
                  lambda: "return id(idle_showing_weather);"
                then:
                  - lvgl.widget.hide: idle_clock_group
                  - lvgl.widget.show: idle_weather_group
                else:
                  - lvgl.widget.show: idle_clock_group
                  - lvgl.widget.hide: idle_weather_group

script:
  - id: show_visualizer_listening
    then:
      - lvgl.spinner.update:
          id: voice_visualizer
          arc_color: 0x003300
          indicator:
            arc_color: 0x00FF00
      - lvgl.widget.show: voice_visualizer
  - id: show_visualizer_thinking
    then:
      - lvgl.spinner.update:
          id: voice_visualizer
          arc_color: 0x332200
          indicator:
            arc_color: 0xFF9900
      - lvgl.widget.show: voice_visualizer
  - id: show_visualizer_replying
    then:
      - lvgl.spinner.update:
          id: voice_visualizer
          arc_color: 0x001a33
          indicator:
            arc_color: 0x00AAFF
      - lvgl.widget.show: voice_visualizer
  - id: show_visualizer_error
    then:
      - lvgl.spinner.update:
          id: voice_visualizer
          arc_color: 0x330000
          indicator:
            arc_color: 0xFF0000
      - lvgl.widget.show: voice_visualizer
  - id: hide_visualizer
    then:
      - lvgl.widget.hide: voice_visualizer

lvgl:
  displays:
    - main_display
  touchscreens:
    - touchscreen_id: my_touchscreen
  buffer_size: 25%
  color_depth: 16
  bg_color: 0x000000

  on_idle:
    timeout: 30s
    then:
      - lvgl.pause:
      - light.turn_off:
          id: display_backlight
          transition_length: 5s

  # Voice-state visualizer: a spinning ring covering the whole screen,
  # regardless of which page is active underneath — "a visualizer when
  # speaking" as an overlay concern, not a page of its own. Hidden by
  # default; the voice_assistant scripts above are the only things that
  # show/hide or recolour it.
  top_layer:
    widgets:
      - spinner:
          id: voice_visualizer
          align: CENTER
          width: 356
          height: 356
          arc_color: 0x003300
          arc_width: 6
          spin_time: 1200ms
          arc_length: 60
          indicator:
            arc_color: 0x00FF00
            arc_width: 6
          hidden: true

  pages:
    - id: idle_page
      widgets:
        - obj:
            id: idle_clock_group
            width: 100%
            height: 100%
            widgets:
              - label:
                  id: lbl_time
                  align: CENTER
                  y: -20
                  text_font: font_time
                  text_color: 0xFFFFFF
                  text: "--:--"
              - label:
                  id: lbl_date
                  align: CENTER
                  y: 35
                  text_font: font_date
                  text_color: 0xAAAAAA
                  text: "--- -- ---"
        - obj:
            id: idle_weather_group
            width: 100%
            height: 100%
            hidden: true
            widgets:
              - label:
                  id: lbl_weather_temp
                  align: CENTER
                  y: -20
                  text_font: font_weather
                  text_color: 0x00BFFF
                  text: "--°"
              - label:
                  id: lbl_weather_cond
                  align: CENTER
                  y: 25
                  text_font: font_weather_cond
                  text_color: 0x87CEEB
                  text: "Weather loading..."

    - id: media_page
      widgets:
        - image:
            id: img_media_cover
            src: media_cover_image
            align: CENTER
            y: -40
        - label:
            id: lbl_media_title
            align: CENTER
            y: 90
            text_font: font_media
            text_color: 0xFFFFFF
            text: ""
        - label:
            id: lbl_media_artist
            align: CENTER
            y: 118
            text_font: font_small
            text_color: 0xAAAAAA
            text: ""

    - id: settings_page
      widgets:
        - label:
            align: TOP_MID
            y: 50
            text_font: font_weather_cond
            text_color: 0xFFFFFF
            text: "Settings"
        - label:
            align: CENTER
            x: -70
            y: 0
            text_font: font_small
            text_color: 0xFFFFFF
            text: "Mute mic"
        - switch:
            id: mute_switch_widget
            align: CENTER
            x: 60
            y: 0
            # Delegates to the HA-exposed template switch below rather than
            # touching micro_wake_word directly — that switch's turn_on_action/
            # turn_off_action already does the real stop/start and reports its
            # new state back to this widget via lvgl.widget.update, so this is
            # the only place that pairing needs to be defined.
            on_click:
              then:
                - switch.toggle: mute_switch

    - id: info_page
      widgets:
        - label:
            align: TOP_MID
            y: 50
            text_font: font_weather_cond
            text_color: 0xFFFFFF
            text: "Device info"
        - label:
            id: lbl_ip
            align: CENTER
            y: -20
            text_font: font_small
            text_color: 0x00FF00
            text: "IP: connecting..."
        - label:
            id: lbl_wifi_signal
            align: CENTER
            y: 10
            text_font: font_small
            text_color: 0x00FF00
            text: "WiFi: --"
        - label:
            id: lbl_uptime
            align: CENTER
            y: 40
            text_font: font_small
            text_color: 0x00FFFF
            text: "Uptime: --"

button:
  - platform: restart
    name: "Restart"

switch:
  - platform: template
    name: "Mute microphone"
    id: mute_switch
    icon: "mdi:microphone-off"
    optimistic: true
    turn_on_action:
      - micro_wake_word.stop:
      - lvgl.widget.update:
          id: mute_switch_widget
          state:
            checked: true
    turn_off_action:
      - micro_wake_word.start:
      - lvgl.widget.update:
          id: mute_switch_widget
          state:
            checked: false
