###################################
#  _  __     _           _     _  #
# | |/ /    | |         | |   | | #
# | ' / ___ | |__   ___ | | __| | #
# |  < / _ \| '_ \ / _ \| |/ _` | #
# | . \ (_) | |_) | (_) | | (_| | #
# |_|\_\___/|_.__/ \___/|_|\__,_| #
###################################
# Voice assistant based on
#  ESP32-S3-Touch-AMOLED-1.75
#  Incorporates the following:
#   - Display    -- CO5300
#   - Microphone -- ES7210
#   - Speaker    -- ES8311
##################################

esphome:
  name: kobold
  friendly_name: Kobold

esp32:
  board: esp32-s3-devkitc-1
  flash_size: 16MB
  cpu_frequency: 240MHZ
  framework:
    type: esp-idf

###################################
#   _____             __ _        #
#  / ____|           / _(_)       #
# | |     ___  _ __ | |_ _  __ _  #
# | |    / _ \| '_ \|  _| |/ _` | #
# | |___| (_) | | | | | | | (_| | #
#  \_____\___/|_| |_|_| |_|\__, | #
#                           __/ | #
#                          |___/  #
#Config############################

substitutions:
  voice_assist_idle_phase_id: "1"
  voice_assist_listening_phase_id: "2"
  voice_assist_thinking_phase_id: "3"
  voice_assist_replying_phase_id: "4"
  voice_assist_not_ready_phase_id: "10"
  voice_assist_error_phase_id: "11"
  voice_assist_muted_phase_id: "12"
  voice_assist_timer_finished_phase_id: "20"

  i2s_bps_spk: 16bit
  i2s_bps_mic: 16bit
  i2s_sample_rate_spk: 44100
  i2s_sample_rate_mic: 16000

  loading_illustration_file: kobold/loading.png
  listening_illustration_file: kobold/listening.png
  thinking_illustration_file: kobold/thinking.png
  replying_illustration_file: kobold/responding.png
  error_illustration_file: kobold/error.png

logger:
  level: DEBUG

globals:
  - id: init_in_progress
    type: bool
    restore_value: false
    initial_value: "true"
  - id: voice_assistant_phase
    type: int
    restore_value: false
    initial_value: ${voice_assist_not_ready_phase_id}
  - id: global_first_active_timer
    type: voice_assistant::Timer
    restore_value: false
  - id: global_is_timer_active
    type: bool
    restore_value: false
  - id: global_first_timer
    type: voice_assistant::Timer
    restore_value: false
  - id: global_is_timer
    type: bool
    restore_value: false

font:
  - file: gfonts://Nanum+Gothic+Coding
    id: clock_font
    size: 160

color:
  - id: black
    hex: "000000"

  - id: white
    hex: "FFFFFF"

image:
  - file: ${error_illustration_file}
    id: kobold_error
    type: RGB565
  - file: ${listening_illustration_file}
    id: kobold_listening
    type: RGB565
  - file: ${thinking_illustration_file}
    id: kobold_thinking
    type: RGB565
  - file: ${replying_illustration_file}
    id: kobold_replying
    type: RGB565
  - file: ${loading_illustration_file}
    id: kobold_initializing
    type: RGB565

external_components:
  - source:
      type: git
      url: https://github.com/shelson/esphome-cst9217

##########################
#  ____                  #
# |  _ \                 #
# | |_) | __ _ ___  ___  #
# |  _ < / _` / __|/ _ \ #
# | |_) | (_| \__ \  __/ #
# |____/ \__,_|___/\___| #
#Base#####################

#time:
#  - platform: homeassistant
#    id: esptime
#    on_time_sync:
#      - script.execute: time_update
#    on_time:
#      - minutes: '*'
#        seconds: 0
#        then:
#          - script.execute: time_update

api:
  encryption:
    key: "DxR50LKDN8Msy5qOfP5e9C5xStJhLfxbTp+6rVMmfUQ="

ota:
  - platform: esphome
    password: "bd5f419dd73f955f31027dd98a607df4"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Kobold-Test Fallback Hotspot"
    password: "o58qyk1mgr0b"

psram:
  mode: octal
  speed: 80MHz

i2c:
  sda: GPIO15
  scl: GPIO14
  scan: true
  id: bus_a

pca9554:
  - id: 'pca9554a_device'
    address: 0x18

# Example configuration entry

spi:
  - id: spi_bus
    clk_pin: GPIO2
    mosi_pin: GPIO1
    miso_pin:
      number: GPIO3
      ignore_strapping_warning: true
  - id: quad_spi_bus
    type: quad
    clk_pin: GPIO38
    data_pins:
      - GPIO4
      - GPIO5
      - GPIO6
      - GPIO7

# Example configuration entry
uart:
  tx_pin: GPIO43
  rx_pin: GPIO44
  baud_rate: 256000
  rx_buffer_size: 1024
  parity: NONE
  stop_bits: 1


# Example configuration entry
ld2410:

binary_sensor:
  - platform: ld2410
    has_target:
      name: Presence
    has_moving_target:
      name: Moving Target
    has_still_target:
      name: Still Target
    out_pin_presence_status:
      name: Out pin presence status

sensor:
  - platform: ld2410
    light:
      name: Light
    moving_distance:
      name: Moving Distance
    still_distance:
      name: Still Distance
    moving_energy:
      name: Move Energy
    still_energy:
      name: Still Energy
    detection_distance:
      name: Detection Distance
    g0:
      move_energy:
        name: G0 move energy
      still_energy:
        name: G0 still energy
    g1:
      move_energy:
        name: G1 move energy
      still_energy:
        name: G1 still energy
    g2:
      move_energy:
        name: G2 move energy
      still_energy:
        name: G2 still energy
    g3:
      move_energy:
        name: G3 move energy
      still_energy:
        name: G3 still energy
    g4:
      move_energy:
        name: G4 move energy
      still_energy:
        name: G4 still energy
    g5:
      move_energy:
        name: G5 move energy
      still_energy:
        name: G5 still energy
    g6:
      move_energy:
        name: G6 move energy
      still_energy:
        name: G6 still energy
    g7:
      move_energy:
        name: G7 move energy
      still_energy:
        name: G7 still energy
    g8:
      move_energy:
        name: G8 move energy
      still_energy:
        name: G8 still energy

  - platform: apds9960
    type: CLEAR
    name: "APDS9960 Clear Channel"

number:
  - platform: ld2410
    timeout:
      name: Timeout
    light_threshold:
      name: Light threshold
    max_move_distance_gate:
      name: Max move distance gate
    max_still_distance_gate:
      name: Max still distance gate
    g0:
      move_threshold:
        name: G0 move threshold
      still_threshold:
        name: G0 still threshold
    g1:
      move_threshold:
        name: G1 move threshold
      still_threshold:
        name: G1 still threshold
    g2:
      move_threshold:
        name: G2 move threshold
      still_threshold:
        name: G2 still threshold
    g3:
      move_threshold:
        name: G3 move threshold
      still_threshold:
        name: G3 still threshold
    g4:
      move_threshold:
        name: G4 move threshold
      still_threshold:
        name: G4 still threshold
    g5:
      move_threshold:
        name: G5 move threshold
      still_threshold:
        name: G5 still threshold
    g6:
      move_threshold:
        name: G6 move threshold
      still_threshold:
        name: G6 still threshold
    g7:
      move_threshold:
        name: G7 move threshold
      still_threshold:
        name: G7 still threshold
    g8:
      move_threshold:
        name: G8 move threshold
      still_threshold:
        name: G8 still threshold

apds9960:
  address: 0x39
  update_interval: 10s
  ambient_light_gain: 64x
  gesture_gain: 1x
  gesture_led_drive: 100ma
  gesture_wait_time: 39.2ms

select:
  - platform: template
    entity_category: config
    name: Wake word engine location
    id: wake_word_engine_location
    icon: "mdi:account-voice"
    optimistic: true
    restore_value: true
    options:
      - In Home Assistant
      - On device
    initial_option: On device
    on_value:
      - if:
          condition:
            lambda: return !id(init_in_progress);
          then:
            - wait_until:
                lambda: return id(voice_assistant_phase) == ${voice_assist_muted_phase_id} || id(voice_assistant_phase) == ${voice_assist_idle_phase_id};
            - if:
                condition:
                  lambda: return x == "In Home Assistant";
                then:
                  - micro_wake_word.stop
                  - delay: 500ms
                  - if:
                      condition:
                        switch.is_off: mute
                      then:
                        - lambda: id(va).set_use_wake_word(true);
                        - voice_assistant.start_continuous:
            - if:
                condition:
                  lambda: return x == "On device";
                then:
                  - lambda: id(va).set_use_wake_word(false);
                  - voice_assistant.stop
                  - delay: 500ms
                  - if:
                      condition:
                        switch.is_off: mute
                      then:
                        - micro_wake_word.start


#################################
#                    _ _        #
#     /\            | (_)       #
#    /  \  _   _  __| |_  ___   #
#   / /\ \| | | |/ _` | |/ _ \  #
#  / ____ \ |_| | (_| | | (_) | #
# /_/    \_\__,_|\__,_|_|\___/  #
#Audio###########################

i2s_audio:
  - id: i2s_audio_bus
    i2s_mclk_pin: GPIO42
    i2s_bclk_pin: GPIO9
    i2s_lrclk_pin:
      number: GPIO45
      ignore_strapping_warning: true

audio_adc:
  - platform: es7210
    id: es7210_adc
    bits_per_sample: $i2s_bps_mic
    sample_rate: $i2s_sample_rate_mic

audio_dac:
  - platform: es8311
    id: es8311_dac
    bits_per_sample: $i2s_bps_spk
    sample_rate: $i2s_sample_rate_spk

microphone:
  - platform: i2s_audio
    id: box_mic
    sample_rate: $i2s_sample_rate_mic
    i2s_din_pin: GPIO10
    bits_per_sample: $i2s_bps_mic
    adc_type: external

speaker:
  - platform: i2s_audio
    id: box_speaker
    i2s_dout_pin: GPIO8
    dac_type: external
    sample_rate: $i2s_sample_rate_spk
    bits_per_sample: $i2s_bps_spk
    audio_dac: es8311_dac
    buffer_duration: 90ms
    use_apll: true

media_player:
  - platform: speaker
    name: player
    id: speaker_media_player
    volume_max: 80%
    announcement_pipeline:
      speaker: box_speaker
      format: FLAC
    on_announcement:
      - if:
          condition:
            - microphone.is_capturing:
          then:
            - script.execute: stop_wake_word
            # Ensure VA stops before moving on
            - if:
                condition:
                  - lambda: return id(wake_word_engine_location).state == "In Home Assistant";
                then:
                  - wait_until:
                      - not:
                          voice_assistant.is_running:
      - if:
          condition:
            not:
              voice_assistant.is_running:
          then:
            - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
            - logger.log: "WEH WEH!"
    on_idle:
      # Since VA isn't running, this is the end of user-intiated media playback. Restart the wake word.
      - if:
          condition:
            - and:
                - not:
                    voice_assistant.is_running:
                - lambda: return id(voice_assistant_phase) != ${voice_assist_idle_phase_id};
          then:
            - script.execute: start_wake_word
            - script.execute: set_idle_or_mute_phase

######################################
#  _____  _           _              #
# |  __ \(_)         | |             #
# | |  | |_ ___ _ __ | | __ _ _   _  #
# | |  | | / __| '_ \| |/ _` | | | | #
# | |__| | \__ \ |_) | | (_| | |_| | #
# |_____/|_|___/ .__/|_|\__,_|\__, | #
#              | |             __/ | #
#              |_|            |___/  #
#Display##############################

lvgl:
  id: voice_assistant_display
  buffer_size: 25%
    #  animations:
    #    -
    #    - id: text_fade_out
    #      duration: 1s
    #      timing:
    #        - type: ease_in_out
    #          weight: 1.0
    #      widgets:
    #        - id: lbl_time
    #          text_color:
    #            from: white
    #            to: black

    #    - id: text_fade_in
    #      duration: 1s
    #      timing:
    #        - type: ease_in_out
    #          weight: 1.0
    #      widgets:
    #        - id: lbl_time
    #          text_color:
    #            from: black
    #            to: white

  pages:
    - id: image_page
      bg_color: black
      widgets:
        - image:
            id: current_image
            align: CENTER
            src: kobold_initializing
    - id: clock_page
      bg_color: black
#      widgets:
#        - label:
#            id: lbl_time
#            text: "00:00"
#            align: CENTER
#            text_align: CENTER
#            text_font: clock_font
#            text_color: 0xffffff
#            pad_left: 22
#            outline_color: black

display:
  - platform: mipi_spi
    id: disp1
    model: CO5300
    bus_mode: quad
    reset_pin: GPIO39
    cs_pin: GPIO12
    data_rate: 80MHz
    dimensions:
      height: 466
      width: 466
      offset_width: 6

light:
  - platform: monochromatic
    id: display_backlight
    name: "Backlight"
    output: backlight_brightness
    default_transition_length:
      milliseconds: 0
    initial_state:
      brightness: 81%
    restore_mode:
      ALWAYS_ON

output:
  - platform: template
    id: backlight_brightness
    type: float
    write_action:
      then:
        - lambda: |-
            id(disp1).set_brightness(state*255);

touchscreen:
  - platform: cst9217
    display: disp1
    id: ts_disp1
    interrupt_pin: GPIO11
    reset_pin: GPIO40
    transform:
      mirror_x: true
      mirror_y: true
    on_update:
      - lambda: |-
          for (auto touch: touches)  {
              if (touch.state <= 2) {
                 ESP_LOGI("Touch points:", "id=%d x=%d, y=%d", touch.id, touch.x, touch.y);
              }
          }

############################################
#  _____       _                      _    #
# |_   _|     | |                    | |   #
#   | |  _ __ | |_ ___ _ __ __ _  ___| |_  #
#   | | | '_ \| __/ _ \ '__/ _` |/ __| __| #
#  _| |_| | | | ||  __/ | | (_| | (__| |_  #
# |_____|_| |_|\__\___|_|  \__,_|\___|\__| #
#Interact###################################

button:
  - platform: restart
    name: "Living Room Restart"

switch:
  - platform: ld2410
    engineering_mode:
      name: Engineering mode
    bluetooth:
      name: Control bluetooth

  - platform: gpio
    name: "Speaker Enable"
    pin:
      number: GPIO46
      ignore_strapping_warning: true
    restore_mode: RESTORE_DEFAULT_ON

  - platform: template
    name: Mute
    id: mute
    icon: "mdi:microphone-off"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    entity_category: config
    on_turn_off:
      - microphone.unmute:
      - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
      - logger.log: "Calling draw_display from switch on_turn_off"
    on_turn_on:
      - microphone.mute:
      - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
      - logger.log: "Calling draw_display from switch on_turn_on"


micro_wake_word:
  id: mww
  models:
    - model: https://raw.githubusercontent.com/YOUR-WORST-TACO/CustomWakeupWords/refs/heads/main/models/hey_nokari/hey_nokari.json
      id: hey_nokari
      # probability_cutoff: 0.98
      # sliding_window_size: 5
    - model: https://raw.githubusercontent.com/YOUR-WORST-TACO/CustomWakeupWords/refs/heads/main/models/nokari/nokari.json
      id: nokari
      probability_cutoff: 0.98
      sliding_window_size: 5
  on_wake_word_detected:
    - voice_assistant.start:
        wake_word: !lambda return wake_word;

voice_assistant:
  id: va
  microphone: box_mic
  media_player: speaker_media_player
  micro_wake_word: mww
  noise_suppression_level: 2
  auto_gain: 31dBFS
  volume_multiplier: 2.0
  on_listening:
    - if:
        condition:
          lambda: return id(voice_assistant_phase) != ${voice_assist_listening_phase_id};
        then:
          - lambda: id(voice_assistant_phase) = ${voice_assist_listening_phase_id};
          - logger.log: "Calling draw_display from voice_assistant on_listening"
          - script.execute: draw_display
    - text_sensor.template.publish:
        id: text_request
        state: "..."
    - text_sensor.template.publish:
        id: text_response
        state: "..."
  on_stt_vad_end:
    - lambda: id(voice_assistant_phase) = ${voice_assist_thinking_phase_id};
    - logger.log: "Calling draw_display from voice_assistant on_stt_vad_end"
    - script.execute: draw_display
  on_stt_end:
    - text_sensor.template.publish:
        id: text_request
        state: !lambda return x;
    - logger.log: "Calling draw_display from voice_assistant on_stt_end"
    - script.execute: draw_display
  on_tts_start:
    - text_sensor.template.publish:
        id: text_response
        state: !lambda return x;
    - lambda: id(voice_assistant_phase) = ${voice_assist_replying_phase_id};
    - logger.log: "Calling draw_display from voice_assistant on_tts_start"
    - script.execute: draw_display
  on_end:
    # Wait a short amount of time to see if an announcement starts
    - wait_until:
        condition:
          - media_player.is_announcing:
        timeout: 0.5s
    # Announcement is finished and the I2S bus is free
    - wait_until:
        - and:
            - not:
                media_player.is_announcing:
            - not:
                speaker.is_playing:
    # Restart only mWW if enabled; streaming wake words automatically restart
    - if:
        condition:
          - lambda: return id(wake_word_engine_location).state == "On device";
        then:
          - lambda: id(va).set_use_wake_word(false);
          - micro_wake_word.start:
    - script.execute: set_idle_or_mute_phase
    - logger.log: "Calling draw_display from voice_assistant on_end"
    - script.execute: draw_display
    # Clear text sensors
    - text_sensor.template.publish:
        id: text_request
        state: ""
    - text_sensor.template.publish:
        id: text_response
        state: ""
  on_error:
    - if:
        condition:
          lambda: return !id(init_in_progress);
        then:
          - lambda: id(voice_assistant_phase) = ${voice_assist_error_phase_id};
          - logger.log: "Calling draw_display from voice_assistant on_error"
          - script.execute: draw_display
          - delay: 1s
          - if:
              condition:
                switch.is_off: mute
              then:
                - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
              else:
                - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
          - logger.log: "Calling draw_display from voice_assistant on_error"
          - script.execute: draw_display
  on_client_connected:
    - lambda: id(init_in_progress) = false;
    - script.execute: start_wake_word
    - script.execute: set_idle_or_mute_phase
    - logger.log: "Calling draw_display from voice_assistant on_client_connected"
    - script.execute: draw_display
  on_client_disconnected:
    - script.execute: stop_wake_word
    - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id};
    - logger.log: "Calling draw_display from voice_assistant on_client_disconnected"
    - script.execute: draw_display

text_sensor:
  - id: text_request
    platform: template
    on_value:
      lambda: |-
        if(id(text_request).state.length()>32) {
          std::string name = id(text_request).state.c_str();
          std::string truncated = esphome::str_truncate(name.c_str(),31);
          id(text_request).state = (truncated+"...").c_str();
        }
  - id: text_response
    platform: template
    on_value:
      lambda: |-
        if(id(text_response).state.length()>32) {
          std::string name = id(text_response).state.c_str();
          std::string truncated = esphome::str_truncate(name.c_str(),31);
          id(text_response).state = (truncated+"...").c_str();
        }

mapping:
  - id: image_mapping
    from: int
    to: image
    entries:
      ${voice_assist_listening_phase_id}: kobold_listening
      ${voice_assist_thinking_phase_id}: kobold_thinking
      ${voice_assist_replying_phase_id}: kobold_replying
      ${voice_assist_not_ready_phase_id}: kobold_error
      ${voice_assist_error_phase_id}: kobold_error
      ${voice_assist_muted_phase_id}: kobold_error

script:
  #  - id: time_update
  #    then:
  #      - lvgl.animation.start: text_fade_out
  #      - delay: 1s
  #      - lvgl.label.update:
  #          id: lbl_time
  #          text: !lambda |-
  #             return id(esptime).now().strftime("%H:%M");
  #      - lvgl.animation.start: text_fade_in
  #      - logger.log: "update Time script ran"

  - id: draw_display
    then:
      - logger.log: "Drawing display"
      - if:
          condition:
            lambda: return !id(init_in_progress);
          then:
            - if:
                condition:
                  wifi.connected:
                then:
                  - if:
                      condition:
                        api.connected:
                      then:
                        - if:
                            condition:
                              lambda: return id(voice_assistant_phase) == ${voice_assist_idle_phase_id};
                            then:
                              - lvgl.page.show:
                                  id: clock_page
                                  animation: OUT_BOTTOM
                                  time: 500ms
                            else:
                              - lvgl.page.show:
                                  id: image_page
                                  animation: OUT_TOP
                                  time: 500ms
                              - lvgl.image.update:
                                  id: current_image
                                  src: !lambda return id(image_mapping)[id(voice_assistant_phase)];
                      else:
                        - lvgl.page.show:
                            id: image_page
                            animation: OUT_TOP
                            time: 500ms
                        - lvgl.image.update:
                            id: current_image
                            src: kobold_error
                else:
                  - lvgl.page.show:
                      id: image_page
                      animation: OUT_TOP
                      time: 500ms
                  - lvgl.image.update:
                      id: current_image
                      src: kobold_error
          else:
            - lvgl.page.show:
                id: image_page
                animation: OUT_TOP
                time: 500ms
            - lvgl.image.update:
                id: current_image
                src: kobold_initializing

  # Starts either mWW or the streaming wake word, depending on the configured location
  - id: start_wake_word
    then:
      - if:
          condition:
            and:
              - not:
                  - voice_assistant.is_running:
              - lambda: return id(wake_word_engine_location).state == "On device";
          then:
            - lambda: id(va).set_use_wake_word(false);
            - micro_wake_word.start:
      - if:
          condition:
            and:
              - not:
                  - voice_assistant.is_running:
              - lambda: return id(wake_word_engine_location).state == "In Home Assistant";
          then:
            - lambda: id(va).set_use_wake_word(true);
            - voice_assistant.start_continuous:
  # Stops either mWW or the streaming wake word, depending on the configured location
  - id: stop_wake_word
    then:
      - if:
          condition:
            lambda: return id(wake_word_engine_location).state == "In Home Assistant";
          then:
            - lambda: id(va).set_use_wake_word(false);
            - voice_assistant.stop:
      - if:
          condition:
            lambda: return id(wake_word_engine_location).state == "On device";
          then:
            - micro_wake_word.stop:
  # Set the voice assistant phase to idle or muted, depending on if the software mute switch is activated
  - id: set_idle_or_mute_phase
    then:
      - if:
          condition:
            switch.is_off: mute
          then:
            - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
          else:
            - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};