Last active
January 24, 2026 22:46
-
-
Save martin-kokos/e9767c016c9112530644fd2be2911545 to your computer and use it in GitHub Desktop.
ESP32-Korvo V1.1 ESPhome Voice assistant
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # https://github.com/espressif/esp-skainet/blob/master/docs/en/hw-reference/esp32/user-guide-esp32-korvo-v1.1.md | |
| # Works: | |
| # - shows up in HomeAssistant | |
| # - leds, although flicker sometimes | |
| # - buttons | |
| # - wake word, audio sending, audio receive | |
| # Doesn't work: | |
| # - speaker playback: I2S_MCLK problem | |
| substitutions: | |
| name: homeassist03 | |
| friendly_name: HomeAssist Speaker 3 | |
| 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" | |
| micro_wake_word_model: hey_jarvis # current esphome wake word models are alexa,okay_nabu,hey_jarvis use one of these for micro_wake_word_model | |
| esphome: | |
| name: "${name}" | |
| friendly_name: "${friendly_name}" | |
| min_version: 2024.12.3 | |
| platformio_options: | |
| board_build.flash_mode: dio | |
| board_build.flash: 8MB | |
| on_boot: | |
| - priority: -100 | |
| then: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 0% | |
| red: 100% | |
| green: 0% | |
| effect: Fast Pulse192.168.1.10 | |
| - delay: 1s | |
| - wait_until: | |
| condition: | |
| wifi.connected: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 0% | |
| red: 100% | |
| green: 50% | |
| effect: Slow Pulse | |
| - wait_until: | |
| condition: | |
| api.connected | |
| - lambda: id(init_in_progress) = false; | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; | |
| - script.execute: reset_led | |
| esp32: | |
| board: esp-wrover-kit | |
| flash_size: 8MB | |
| framework: | |
| type: esp-idf | |
| version: recommended | |
| sdkconfig_options: | |
| CONFIG_ESP32_SPIRAM_SUPPORT: y | |
| CONFIG_SPIRAM_SPEED_80M: y | |
| CONFIG_SPIRAM_MODE_QUAD: y | |
| CONFIG_ESP32_DEFAULT_CPU_FREQ_240: y | |
| CONFIG_ESP32_DATA_CACHE_64KB: y | |
| CONFIG_ESP32_DATA_CACHE_LINE_64B: y | |
| CONFIG_AUDIO_BOARD_CUSTOM: y | |
| micro_wake_word: | |
| id: wake_word | |
| microphone: external_mic | |
| models: | |
| - model: github://esphome/micro-wake-word-models/models/v2/${micro_wake_word_model}.json | |
| on_wake_word_detected: | |
| then: | |
| - lambda: ESP_LOGW("DEBUG", "Wake word detected, starting voice assistant..."); | |
| - delay: 300ms | |
| - lambda: ESP_LOGW("DEBUG", "About to call voice_assistant.start"); | |
| - voice_assistant.start: | |
| id: voice_asst | |
| wake_word: !lambda return wake_word; | |
| - lambda: ESP_LOGW("DEBUG", "voice_assistant.start called"); | |
| text_sensor: | |
| - platform: wifi_info | |
| ip_address: | |
| name: "${friendly_name} IP Address" | |
| time: | |
| platform: homeassistant | |
| id: homeassistant_time | |
| i2c: | |
| sda: GPIO19 | |
| scl: GPIO32 | |
| scan: true | |
| frequency: 400kHz | |
| audio_dac: | |
| - id: es8311_dac | |
| platform: es8311 | |
| use_mclk: true | |
| audio_adc: | |
| - platform: es7210 | |
| id: es7210_adc | |
| address: 0x40 | |
| sample_rate: 16000 | |
| i2s_audio: | |
| - id: i2s_out | |
| i2s_lrclk_pin: GPIO22 | |
| i2s_bclk_pin: GPIO25 | |
| i2s_mclk_pin: | |
| number: GPIO0 | |
| allow_other_uses: true | |
| - id: i2s_in | |
| i2s_lrclk_pin: GPIO26 | |
| i2s_bclk_pin: GPIO27 | |
| i2s_mclk_pin: | |
| number: GPIO0 | |
| allow_other_uses: true | |
| speaker: | |
| - id: external_speaker | |
| i2s_audio_id: i2s_out | |
| platform: i2s_audio | |
| dac_type: external | |
| i2s_dout_pin: GPIO13 | |
| audio_dac: es8311_dac | |
| sample_rate: 16000 | |
| bits_per_sample: 16bit | |
| channel: mono | |
| microphone: | |
| - platform: i2s_audio | |
| id: external_mic | |
| adc_type: external | |
| i2s_audio_id: i2s_in | |
| i2s_din_pin: GPIO36 | |
| channel: left | |
| on_data: | |
| - lambda: |- | |
| static int count = 0; | |
| count++; | |
| if (count >= 100) { | |
| ESP_LOGD("mic", "Received %d bytes", x.size()); | |
| count = 0; | |
| } | |
| media_player: | |
| - platform: speaker | |
| name: "Test Media Player" | |
| id: speaker_media_player | |
| codec_support_enabled: true # Enable FLAC/MP3 decoders | |
| announcement_pipeline: | |
| speaker: external_speaker | |
| format: FLAC | |
| num_channels: 1 | |
| on_announcement: | |
| - micro_wake_word.stop: | |
| - delay: 50ms | |
| on_idle: | |
| - micro_wake_word.start: | |
| number: | |
| - platform: template | |
| name: "DAC Volume" | |
| id: dac_volume | |
| min_value: 0 | |
| max_value: 100 | |
| step: 1 | |
| initial_value: 80 | |
| optimistic: true | |
| set_action: | |
| - audio_dac.set_volume: | |
| id: es8311_dac | |
| volume: !lambda 'return x / 100.0;' | |
| voice_assistant: | |
| id: voice_asst | |
| microphone: external_mic | |
| speaker: external_speaker | |
| noise_suppression_level: 1 | |
| auto_gain: 31dBFS | |
| volume_multiplier: 6.0 | |
| use_wake_word: false | |
| on_listening: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_listening_phase_id}; | |
| - script.execute: reset_led | |
| on_stt_vad_end: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_thinking_phase_id}; | |
| - script.execute: reset_led | |
| on_tts_start: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 0% | |
| red: 0% | |
| green: 100% | |
| brightness: 50% | |
| effect: pulse | |
| - micro_wake_word.stop: # Stop wake word detection | |
| on_stt_end: | |
| - homeassistant.service: | |
| service: media_player.play_media | |
| data: | |
| entity_id: media_player.ke_ting | |
| media_content_id: !lambda return x; | |
| media_content_type: music | |
| announce: "true" | |
| on_tts_stream_start: | |
| - delay: 100ms | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_replying_phase_id}; | |
| - script.execute: reset_led | |
| on_end: | |
| - wait_until: | |
| not: | |
| speaker.is_playing: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; | |
| - script.execute: reset_led | |
| - if: | |
| condition: | |
| and: | |
| - switch.is_off: mute | |
| then: | |
| - wait_until: | |
| lambda: return !id(wake_word).is_running() && !id(voice_asst).is_running(); | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).state == "On device"; | |
| then: | |
| - lambda: ESP_LOGD("micro_wake_word", "Resetting micro wake word pipeline (On device)..."); | |
| - micro_wake_word.stop | |
| - delay: 500ms | |
| - micro_wake_word.start | |
| on_error: | |
| - if: | |
| condition: | |
| lambda: return !id(init_in_progress); | |
| then: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_error_phase_id}; | |
| - script.execute: reset_led | |
| - delay: 2s | |
| - 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}; | |
| - script.execute: reset_led | |
| on_client_connected: | |
| - if: | |
| condition: | |
| switch.is_off: mute | |
| then: | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).state == "In Home Assistant"; | |
| then: | |
| - lambda: id(voice_asst).set_use_wake_word(true); | |
| - voice_assistant.start_continuous: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; | |
| else: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; | |
| - lambda: id(init_in_progress) = false; | |
| - script.execute: reset_led | |
| on_client_disconnected: | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).state == "In Home Assistant"; | |
| then: | |
| - lambda: id(voice_asst).set_use_wake_word(false); | |
| - voice_assistant.stop: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; | |
| - script.execute: reset_led | |
| on_tts_end: | |
| - micro_wake_word.start: | |
| select: | |
| - platform: template | |
| entity_category: config | |
| name: Wake word engine location | |
| id: wake_word_engine_location | |
| optimistic: true | |
| restore_value: true | |
| options: | |
| - In Home Assistant | |
| - On device | |
| initial_option: On device | |
| on_value: | |
| - 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: | |
| - wait_until: | |
| lambda: return !id(voice_asst).is_running(); | |
| - micro_wake_word.stop | |
| - delay: 300ms | |
| - if: | |
| condition: | |
| switch.is_off: mute | |
| then: | |
| - lambda: id(voice_asst).set_use_wake_word(true); | |
| - voice_assistant.start_continuous: | |
| else: | |
| - wait_until: | |
| lambda: return !id(wake_word).is_running(); | |
| - voice_assistant.stop | |
| - delay: 300ms | |
| - lambda: id(voice_asst).set_use_wake_word(false); | |
| - micro_wake_word.start | |
| script: | |
| - id: reset_led | |
| then: | |
| - if: | |
| condition: | |
| lambda: return !id(init_in_progress); | |
| then: | |
| - if: | |
| condition: | |
| lambda: return id(voice_assistant_phase) == ${voice_assist_listening_phase_id}; | |
| then: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 100% | |
| red: 0% | |
| green: 0% | |
| brightness: 100% | |
| effect: wakeword | |
| - if: | |
| condition: | |
| lambda: return id(voice_assistant_phase) == ${voice_assist_thinking_phase_id}; | |
| then: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 100% | |
| red: 100% | |
| green: 0% | |
| brightness: 100% | |
| effect: Working | |
| - delay: 100ms | |
| - if: | |
| condition: | |
| lambda: return id(voice_assistant_phase) == ${voice_assist_replying_phase_id}; | |
| then: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 100% | |
| red: 0% | |
| green: 0% | |
| brightness: 100% | |
| effect: Working | |
| - if: | |
| condition: | |
| lambda: return id(voice_assistant_phase) == ${voice_assist_idle_phase_id}; | |
| then: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 100% | |
| red: 0% | |
| green: 0% | |
| brightness: 40% | |
| effect: none | |
| - delay: 200ms | |
| - if: | |
| condition: | |
| lambda: return id(voice_assistant_phase) == ${voice_assist_not_ready_phase_id}; | |
| then: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 40% | |
| red: 100% | |
| green: 0% | |
| effect: Slow Pulse | |
| - if: | |
| condition: | |
| lambda: return id(voice_assistant_phase) == ${voice_assist_error_phase_id}; | |
| then: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 0% | |
| red: 100% | |
| green: 0% | |
| brightness: 100% | |
| effect: none | |
| - if: | |
| condition: | |
| lambda: return id(voice_assistant_phase) == ${voice_assist_muted_phase_id}; | |
| then: | |
| - light.turn_off: led_ring | |
| else: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 0% | |
| red: 100% | |
| green: 0% | |
| effect: Fast Pulse | |
| switch: | |
| - platform: template | |
| name: Mute | |
| id: mute | |
| optimistic: true | |
| restore_mode: RESTORE_DEFAULT_OFF | |
| entity_category: config | |
| on_turn_off: | |
| - if: | |
| condition: | |
| lambda: return !id(init_in_progress); | |
| then: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; | |
| - if: | |
| condition: | |
| not: | |
| - voice_assistant.is_running | |
| then: | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).state == "In Home Assistant"; | |
| then: | |
| - lambda: id(voice_asst).set_use_wake_word(true); | |
| - voice_assistant.start_continuous | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).state == "On device"; | |
| then: | |
| - micro_wake_word.start | |
| - script.execute: reset_led | |
| on_turn_on: | |
| - if: | |
| condition: | |
| lambda: return !id(init_in_progress); | |
| then: | |
| - lambda: id(voice_asst).set_use_wake_word(false); | |
| - voice_assistant.stop | |
| - micro_wake_word.stop | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; | |
| - script.execute: reset_led | |
| - platform: restart | |
| name: "${name} Restart" | |
| - platform: gpio | |
| pin: GPIO12 | |
| id: pa_ctrl | |
| name: "${name} power amplifier" | |
| restore_mode: ALWAYS_ON | |
| light: | |
| - platform: esp32_rmt_led_strip | |
| id: led_ring | |
| name: "${friendly_name} Light" | |
| pin: GPIO33 # GPIO33 for esp32-korvo-1.1 GPIO19 for esp32-s3-korvo-1 | |
| num_leds: 12 | |
| rmt_symbols: 96 | |
| rgb_order: GRB | |
| chipset: ws2812 | |
| default_transition_length: 0s | |
| effects: | |
| - pulse: | |
| name: "Pulse" | |
| transition_length: 0.5s | |
| update_interval: 0.5s | |
| - addressable_twinkle: | |
| name: "Working" | |
| twinkle_probability: 5% | |
| progress_interval: 4ms | |
| - addressable_color_wipe: | |
| name: "Wakeword" | |
| colors: | |
| - red: 28% | |
| green: 100% | |
| blue: 90% | |
| num_leds: 12 | |
| add_led_interval: 40ms | |
| reverse: false | |
| - addressable_color_wipe: | |
| name: "Connecting" | |
| colors: | |
| - red: 60% | |
| green: 60% | |
| blue: 60% | |
| num_leds: 12 | |
| - red: 60% | |
| green: 60% | |
| blue: 0% | |
| num_leds: 12 | |
| add_led_interval: 100ms | |
| reverse: true | |
| - addressable_color_wipe: | |
| name: "Thinking" | |
| colors: | |
| - red: 1% | |
| green: 90% | |
| blue: 99% | |
| num_leds: 2 | |
| - red: 13% | |
| green: 17% | |
| blue: 87% | |
| num_leds: 6 | |
| add_led_interval: 75ms | |
| reverse: true | |
| - pulse: | |
| name: "Slow Pulse" | |
| transition_length: 0.5s | |
| update_interval: 1s | |
| min_brightness: 0% | |
| max_brightness: 100% | |
| - pulse: | |
| name: "Fast Pulse" | |
| transition_length: 50ms | |
| update_interval: 100ms | |
| min_brightness: 50% | |
| max_brightness: 100% | |
| 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: speaker_volume | |
| type: int | |
| restore_value: yes | |
| initial_value: '50' # Initial volume level (0-100) | |
| button: | |
| - platform: template | |
| name: "DAC Unmute" | |
| on_press: | |
| - audio_dac.mute_off: es8311_dac | |
| - platform: template | |
| name: "DAC Mute" | |
| on_press: | |
| - audio_dac.mute_on: es8311_dac | |
| - platform: template | |
| name: "${friendly_name} Volume Up" | |
| id: btn_volume_up | |
| on_press: | |
| then: | |
| - lambda: |- | |
| id(speaker_volume) = max(0.0f, id(speaker_volume) + 0.05f); | |
| - platform: template | |
| name: "${friendly_name} Volume Down" | |
| id: btn_volume_down | |
| on_press: | |
| then: | |
| - lambda: |- | |
| id(speaker_volume) = max(0.0f, id(speaker_volume) - 0.05f); | |
| - platform: template | |
| name: "Test Tone" | |
| on_press: | |
| - lambda: |- | |
| // Simple square wave test | |
| const int samples = 16000; // 1 second at 16kHz | |
| std::vector<int16_t> buffer(samples); | |
| for (int i = 0; i < samples; i++) { | |
| buffer[i] = (i % 100 < 50) ? 16000 : -16000; | |
| } | |
| id(external_speaker).play((const uint8_t*)buffer.data(), samples * 2); | |
| - delay: 1s | |
| - lambda: id(external_speaker).stop(); | |
| binary_sensor: | |
| - platform: template | |
| name: "${friendly_name} Volume Up" | |
| id: btn_vol_up | |
| trigger_on_initial_state : True | |
| on_press: | |
| then: | |
| - lambda: |- | |
| id(speaker_volume) = max(0.0f, id(speaker_volume) + 0.05f); | |
| - platform: template | |
| name: "${friendly_name} Volume Down" | |
| id: btn_vol_down | |
| trigger_on_initial_state : True | |
| on_press: | |
| then: | |
| - lambda: |- | |
| id(speaker_volume) = max(0.0f, id(speaker_volume) - 0.05f); | |
| - platform: template | |
| name: "${friendly_name} Set" | |
| id: btn_set | |
| trigger_on_initial_state : True | |
| - platform: template | |
| name: "${friendly_name} Play" | |
| id: btn_play | |
| trigger_on_initial_state : True | |
| - platform: template | |
| name: "${friendly_name} Mode" | |
| id: btn_mode | |
| trigger_on_initial_state : True | |
| - platform: template | |
| name: "${friendly_name} Record" | |
| id: btn_record | |
| trigger_on_initial_state : True | |
| on_press: | |
| - voice_assistant.start: | |
| - light.turn_on: | |
| id: led_ring | |
| blue: 0% | |
| red: 0% | |
| green: 100% | |
| brightness: 100% | |
| effect: "Wakeword" | |
| # Connection status | |
| - platform: status | |
| name: "${friendly_name} Status" | |
| sensor: | |
| - id: button_adc | |
| platform: adc | |
| internal: true | |
| pin: GPIO39 # pin GPIO39 for esp32-korvo-1.1 GPIO8 for esp32-s3-korvo-1 | |
| attenuation: auto | |
| update_interval: 15ms | |
| filters: | |
| - median: | |
| window_size: 5 | |
| send_every: 5 | |
| send_first_at: 1 | |
| - delta: 0.1 | |
| on_value_range: | |
| - below: 0.55 | |
| then: | |
| - binary_sensor.template.publish: | |
| id: btn_vol_up | |
| state: ON | |
| - above: 0.65 | |
| below: 0.92 | |
| then: | |
| - binary_sensor.template.publish: | |
| id: btn_vol_down | |
| state: ON | |
| - above: 1.02 | |
| below: 1.33 | |
| then: | |
| - binary_sensor.template.publish: | |
| id: btn_set | |
| state: ON | |
| - above: 1.43 | |
| below: 1.77 | |
| then: | |
| - binary_sensor.template.publish: | |
| id: btn_play | |
| state: ON | |
| - above: 1.87 | |
| below: 2.15 | |
| then: | |
| - binary_sensor.template.publish: | |
| id: btn_mode | |
| state: ON | |
| - above: 2.25 | |
| below: 2.56 | |
| then: | |
| - binary_sensor.template.publish: | |
| id: btn_record | |
| state: ON | |
| - above: 2.8 | |
| then: | |
| - binary_sensor.template.publish: | |
| id: btn_vol_up | |
| state: OFF | |
| - binary_sensor.template.publish: | |
| id: btn_vol_down | |
| state: OFF | |
| - binary_sensor.template.publish: | |
| id: btn_set | |
| state: OFF | |
| - binary_sensor.template.publish: | |
| id: btn_play | |
| state: OFF | |
| - binary_sensor.template.publish: | |
| id: btn_mode | |
| state: OFF | |
| - binary_sensor.template.publish: | |
| id: btn_record | |
| state: OFF | |
| # Wifi signal | |
| - platform: wifi_signal | |
| name: "${friendly_name} WiFi Signal" | |
| update_interval: 60s | |
| # Generic volume sensor was used so it can be used in the esp_adf_speaker to get volume into HA | |
| - platform: template | |
| id: generic_volume_sensor | |
| internal: true | |
| update_interval: never | |
| - platform: template | |
| name: "${friendly_name} Volume" | |
| accuracy_decimals: 0 | |
| id: scaled_volume_sensor | |
| lambda: |- | |
| static float last_volume_value = -1; // Initialize with a value outside valid range | |
| float current_value = id(generic_volume_sensor).state / 10.0; // Scale the value | |
| if (current_value != last_volume_value) { | |
| last_volume_value = current_value; // Update the last value | |
| ESP_LOGI("Volume Sensor", "Volume changed to %.1f", current_value); | |
| return current_value; // Publish the updated value | |
| } | |
| return {}; // Do not publish if there's no change | |
| update_interval: 1s | |
| # Enable logging | |
| logger: | |
| # Enable Home Assistant API | |
| api: | |
| encryption: | |
| key: !secret home_assistant_api_key | |
| ota: | |
| - platform: !secret ota_platform | |
| password: !secret ota_password | |
| wifi: | |
| ssid: !secret wifi_ssid | |
| password: !secret wifi_password | |
| # Enable fallback hotspot (captive portal) in case wifi connection fails | |
| ap: | |
| ssid: !secret pairing_hotspot_ssid | |
| password: !secret pairing_hotspot_password | |
| captive_portal: |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment