Created
April 19, 2026 15:10
-
-
Save diyfr/bc8bc62cf949d753a266665df8911c3c to your computer and use it in GitHub Desktop.
SpotPear Ball V2
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
| substitutions: | |
| name: esphome-web-e4968c | |
| friendly_name: SpotPearBall | |
| ############ 2026.2.0 FIXED ############ | |
| ## TODO: https://github.com/RealDeco/xiaozhi-esphome/blob/main/devices/Under_Development/Modular/HW/ball_v2_hw.yaml | |
| ## ISSUE https://github.com/esphome/esphome/issues/15775 | |
| ## v1.07 26-jul-2025 ############################################################################################################################# | |
| ## changed how to play startup sound to avoid double triggering | |
| ## v1.06 19-jul-2025 ############################################################################################################################# | |
| ## Added: Startup sound when connected to HA, optional with switch, and option to select other sounds in settings below. | |
| ## Added: Mute and Playing icon to all emos. | |
| ## v1.05 18-jul-2025 ############################################################################################################################# | |
| ## Fixes: minor bugfixes, sound level to max, show muted mic when playing music or playing TTS, not when playing internal sounds. | |
| ## v1.04 13-jul-2025 ############################################################################################################################# | |
| ## Added: optional wake sound, delays the time before listening, so optional switch in HA. | |
| ## Moved: show_text and show_battery_status to switches in HA. | |
| ## v1.03 08-jul-2025 ############################################################################################################################# | |
| ## Added: optional show text boxes | |
| ## v1.02 30-jun-2025 ############################################################################################################################# | |
| ## Added optional Battery Status | |
| ## SETTINGS ###################################################################################################################################### | |
| imagemodel: "Eyes" # (options are: Alfred,Astrobot,Buzz,Casita,Cybergirl,Dory,EVE,Eyes,Eyes2,GLaDOS,Girl1,Guy1,Guy2,Gwen,HA-character,Harley,Jarvis,Luffy,Mario,Max,Prime,Robochibi,Robocop,Robot,Robotgirl,Shaun) | |
| startup_sound: "Home_Connected" # (options are: available,Home_Connected,Computer_Ready) | |
| imagewidth: "240" # GC9A01A (Ball v2 & Muma & Puck) "240" | |
| imageheight: "240" # GC9A01A (Ball v2 & Muma & Puck) "240" | |
| displaymodel: "GC9A01A" # GC9A01A (Ball v2 & Puck) or ST7789V (Muma) | |
| invertcolors: "true" # GC9A01A/ST7789V (Ball v2 & Muma & Puck) "true" | |
| ################################################################################################################################################## | |
| # Hardware v2 pin mappings | |
| sda_pin_bus_a: "15" # I2C Bus A SDA | |
| scl_pin_bus_a: "14" # I2C BUS A SCL | |
| sda_pin_bus_b: "11" # I2C Bus B SDA | |
| scl_pin_bus_b: "7" # I2C BUS B SCL | |
| i2s_lrclk_pin: "45" # I2S LRCLK (Word Select) | |
| i2s_bclk_pin: "9" # I2S BCLK (Bit Clock) | |
| i2s_mclk_pin: "16" # I2S MCLK (Master Clock) | |
| i2s_din_pin: "10" # I2S Data In (Mic) | |
| i2s_dout_pin: "8" # I2S Data Out (Speaker) | |
| speaker_enable_pin: "46" # Speaker Enable | |
| touch_input_pin: "12" # Touch interrupt | |
| touch_reset_pin: "6" # Touch Reset | |
| backlight_output_pin: "42" # Display Backlight | |
| lcd_cs_pin: "5" # Display CS (Chip Select) | |
| lcd_dc_pin: "47" # Display DC (Data/Command) | |
| lcd_reset_pin: "38" # Display Reset | |
| spi_clk_pin: "4" # SPI Clock | |
| spi_mosi_pin: "2" # SPI MOSI (Data Out) | |
| left_top_button_pin: "0" # Main Button | |
| led_pin: "48" # RGB LED (WS2812) | |
| battery_adc_pin: "1" # Battery Voltage ADC | |
| ################################################################################################################################################## | |
| loading_illustration_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/images/${imagemodel}/${imagewidth}x${imageheight}/loading.png | |
| idle_illustration_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/images/${imagemodel}/${imagewidth}x${imageheight}/idle.png | |
| listening_illustration_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/images/${imagemodel}/${imagewidth}x${imageheight}/listening.png | |
| thinking_illustration_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/images/${imagemodel}/${imagewidth}x${imageheight}/thinking.png | |
| replying_illustration_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/images/${imagemodel}/${imagewidth}x${imageheight}/replying.png | |
| error_illustration_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/images/${imagemodel}/${imagewidth}x${imageheight}/error.png | |
| timer_finished_illustration_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/images/${imagemodel}/${imagewidth}x${imageheight}/timer_finished.png | |
| mute_illustration_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/images/${imagemodel}/${imagewidth}x${imageheight}/mute.png | |
| startup_sound_file: https://github.com/RealDeco/xiaozhi-esphome/raw/main/sounds/${startup_sound}.flac | |
| loading_illustration_background_color: "000000" | |
| idle_illustration_background_color: "FF0000" | |
| listening_illustration_background_color: "000000" | |
| thinking_illustration_background_color: "000000" | |
| replying_illustration_background_color: "000000" | |
| error_illustration_background_color: "000000" | |
| 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" | |
| allowed_characters: " !#%'()+,-./0123456789:;<>?@ABCDEFGHIJKLMNOPQRSTUVWYZ[]_abcdefghijklmnopqrstuvwxyz{|}°²³µ¿ÁÂÄÅÉÖÚßàáâãäåæçèéêëìíîðñòóôõöøùúûüýþāăąćčďĐđēėęěğĮįıļľŁłńňőřśšťũūůűųźŻżŽžơưșțΆΈΌΐΑΒΓΔΕΖΗΘΚΜΝΠΡΣΤΥΦάέήίαβγδεζηθικλμνξοπρςστυφχψωϊόύώАБВГДЕЖЗИКЛМНОПРСТУХЦЧШЪЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяёђєіїјљњћאבגדהוזחטיכלםמןנסעפץצקרשת،ءآأإئابةتجحخدذرزسشصضطظعغفقكلمنهوىيٹپچڈکگںھہیےংকচতধনফবযরলশষস়ািু্చయలిెొ్ംഅആഇഈഉഎഓകഗങചജഞടഡണതദധനപഫബഭമയരറലളവശസഹാിീുൂെേൈ്ൺൻർൽൾაბგდევზთილმნოპრსტუფქყშჩცძჭხạảấầẩậắặẹẽếềểệỉịọỏốồổỗộớờởợụủứừửữựỳ—、一上不个中为主乾了些亮人任低佔何作供依侧係個側偵充光入全关冇冷几切到制前動區卧厅厨及口另右吊后吗启吸呀咗哪唔問啟嗎嘅嘛器圍在场執場外多大始安定客室家密寵对將小少左已帘常幫幾库度庫廊廚廳开式後恆感態成我戲戶户房所扇手打执把拔换掉控插摄整斯新明是景暗更最會有未本模機檯櫃欄次正氏水沒没洗活派温測源溫漏潮激濕灯為無煙照熱燈燥物狀玄现現瓦用發的盞目着睡私空窗立笛管節簾籬紅線红罐置聚聲脚腦腳臥色节著行衣解設調請謝警设调走路車车运連遊運過道邊部都量鎖锁門閂閉開關门闭除隱離電震霧面音頂題顏颜風风食餅餵가간감갔강개거게겨결경고공과관그금급기길깥꺼껐꼽나난내네놀누는능니다닫담대더데도동됐되된됨둡드든등디때떤뜨라래러렇렌려로료른를리림링마많명몇모무문물뭐바밝방배변보부불블빨뽑사산상색서설성세센션소쇼수스습시신실싱아안않알았애야어얼업없었에여연열옆오온완외왼요운움워원위으은을음의이인일임입있작잠장재전절정제져조족종주줄중줘지직진짐쪽차창천최추출충치침커컴켜켰쿠크키탁탄태탬터텔통트튼티파팬퍼폰표퓨플핑한함해했행혀현화활후휴힘,?" | |
| font_glyphsets: "GF_Latin_Core" | |
| font_family: Figtree | |
| esphome: | |
| name: ${name} | |
| friendly_name: ${friendly_name} | |
| min_version: 2025.5.0 | |
| name_add_mac_suffix: false | |
| on_boot: | |
| priority: 600 | |
| then: | |
| - script.execute: draw_display | |
| - component.update: battery_voltage | |
| - component.update: battery_percentage | |
| - delay: 30s | |
| - if: | |
| condition: | |
| lambda: return id(init_in_progress); | |
| then: | |
| - lambda: id(init_in_progress) = false; | |
| - script.execute: draw_display | |
| esp32: | |
| board: esp32-s3-devkitc-1 | |
| flash_size: 16MB | |
| cpu_frequency: 240MHz | |
| 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" | |
| psram: | |
| mode: octal | |
| speed: 80MHz | |
| api: | |
| on_client_connected: | |
| - script.execute: draw_display | |
| - lambda: |- | |
| if (!id(boot_sound_played)) { | |
| id(boot_sound_played) = true; | |
| if (id(startup_sound_switch).state) { | |
| id(play_sound)->execute(true, id(ready_sound)); | |
| } | |
| } | |
| on_client_disconnected: | |
| - script.execute: draw_display | |
| ota: | |
| - platform: esphome | |
| id: ota_esphome | |
| logger: | |
| hardware_uart: USB_SERIAL_JTAG | |
| wifi: | |
| ssid: !secret wifi_ssid | |
| password: !secret wifi_password | |
| ap: | |
| ssid: "Ball v2 Hotspot" | |
| password: "RZ7D3EzJdPM6" | |
| on_connect: | |
| - script.execute: draw_display | |
| on_disconnect: | |
| - script.execute: draw_display | |
| captive_portal: | |
| button: | |
| - platform: factory_reset | |
| id: factory_reset_btn | |
| internal: true | |
| sensor: | |
| - platform: adc | |
| pin: GPIO${battery_adc_pin} | |
| name: "Battery Voltage" | |
| id: battery_voltage | |
| attenuation: 12db | |
| accuracy_decimals: 2 | |
| update_interval: 1s | |
| unit_of_measurement: "V" | |
| icon: mdi:battery-medium | |
| filters: | |
| - multiply: 2.0 | |
| - median: | |
| window_size: 7 | |
| send_every: 7 | |
| send_first_at: 7 | |
| - throttle: 1min | |
| on_value: | |
| then: | |
| - component.update: battery_percentage | |
| - platform: template | |
| id: battery_percentage | |
| name: "Battery Percentage" | |
| lambda: return id(battery_voltage).state; | |
| accuracy_decimals: 0 | |
| unit_of_measurement: "%" | |
| icon: mdi:battery-medium | |
| filters: | |
| - calibrate_linear: | |
| method: exact | |
| datapoints: | |
| - 2.80 -> 0.0 | |
| - 3.10 -> 10.0 | |
| - 3.30 -> 20.0 | |
| - 3.45 -> 30.0 | |
| - 3.60 -> 40.0 | |
| - 3.70 -> 50.0 | |
| - 3.75 -> 60.0 | |
| - 3.80 -> 70.0 | |
| - 3.90 -> 80.0 | |
| - 4.00 -> 90.0 | |
| - 4.20 -> 100.0 | |
| - lambda: |- | |
| if (x > 100) return 100; | |
| if (x < 0) return 0; | |
| return x; | |
| on_value: | |
| then: | |
| - lambda: |- | |
| int current = (int)x; | |
| if (current != id(last_battery_percent)) { | |
| id(last_battery_percent) = current; | |
| id(draw_display).execute(); | |
| } | |
| ##touchscreen: | |
| ## - platform: cst816 | |
| ## i2c_id: bus_b | |
| ## interrupt_pin: ${touch_input_pin} | |
| ## reset_pin: ${touch_reset_pin} | |
| ## id: touch_dp | |
| ## on_touch: | |
| ## then: | |
| ## - binary_sensor.template.publish: | |
| ## id: touch_input | |
| ## state: ON | |
| ## on_release: | |
| ## then: | |
| ## - binary_sensor.template.publish: | |
| ## id: touch_input | |
| ## state: OFF | |
| binary_sensor: | |
| - platform: template | |
| name: "Touch Button" | |
| id: touch_input | |
| on_multi_click: | |
| # Single tap | |
| - timing: | |
| - ON for 40ms to 400ms | |
| - OFF for at least 330ms | |
| then: | |
| - if: | |
| condition: | |
| switch.is_on: timer_ringing | |
| then: | |
| - switch.turn_off: timer_ringing | |
| else: | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).current_option() == "On device"; | |
| then: | |
| - if: | |
| condition: | |
| not: | |
| voice_assistant.is_running: | |
| then: | |
| - voice_assistant.start: | |
| else: | |
| - voice_assistant.stop: | |
| - timing: | |
| - ON for at least 1s | |
| then: | |
| - switch.toggle: show_battery_status | |
| - script.execute: draw_display | |
| - platform: gpio | |
| pin: | |
| number: GPIO${left_top_button_pin} | |
| mode: INPUT_PULLUP | |
| ignore_strapping_warning: true | |
| inverted: true | |
| id: left_top_button | |
| internal: true | |
| on_multi_click: | |
| - timing: | |
| - ON for at least 50ms | |
| - OFF for at least 50ms | |
| then: | |
| - if: | |
| condition: | |
| switch.is_on: timer_ringing | |
| then: | |
| - switch.turn_off: timer_ringing | |
| else: | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).current_option() == "On device"; | |
| then: | |
| - if: | |
| condition: | |
| not: | |
| voice_assistant.is_running: | |
| then: | |
| - voice_assistant.start: | |
| else: | |
| - voice_assistant.stop: | |
| - timing: | |
| - ON for at least 10s | |
| then: | |
| - button.press: factory_reset_btn | |
| output: | |
| - platform: ledc | |
| pin: GPIO${backlight_output_pin} | |
| id: backlight_output | |
| inverted: true | |
| light: | |
| - platform: monochromatic | |
| id: Sled | |
| name: Screen | |
| icon: "mdi:television" | |
| entity_category: config | |
| output: backlight_output | |
| restore_mode: ALWAYS_ON | |
| default_transition_length: 250ms | |
| - platform: esp32_rmt_led_strip | |
| id: led | |
| name: none | |
| disabled_by_default: false | |
| entity_category: config | |
| pin: GPIO${led_pin} | |
| default_transition_length: 0s | |
| chipset: WS2812 | |
| num_leds: 1 | |
| rgb_order: grb | |
| effects: | |
| - pulse: | |
| name: "Slow Pulse" | |
| transition_length: 250ms | |
| update_interval: 250ms | |
| min_brightness: 50% | |
| max_brightness: 100% | |
| - pulse: | |
| name: "Fast Pulse" | |
| transition_length: 100ms | |
| update_interval: 100ms | |
| min_brightness: 50% | |
| max_brightness: 100% | |
| i2c: | |
| - id: bus_a | |
| sda: GPIO${sda_pin_bus_a} | |
| scl: GPIO${scl_pin_bus_a} | |
| scan: true | |
| - id: bus_b | |
| sda: GPIO${sda_pin_bus_b} | |
| scl: GPIO${scl_pin_bus_b} | |
| scan: true | |
| i2s_audio: | |
| - id: i2s_audio_bus | |
| i2s_lrclk_pin: GPIO${i2s_lrclk_pin} | |
| i2s_bclk_pin: GPIO${i2s_bclk_pin} | |
| i2s_mclk_pin: GPIO${i2s_mclk_pin} | |
| audio_dac: | |
| - platform: es8311 | |
| i2c_id: bus_a | |
| id: es8311_dac | |
| bits_per_sample: 16bit | |
| sample_rate: 16000 | |
| microphone: | |
| - platform: i2s_audio | |
| id: i2s_mics | |
| sample_rate: 16000 | |
| i2s_din_pin: GPIO${i2s_din_pin} | |
| bits_per_sample: 16bit | |
| adc_type: external | |
| channel: left | |
| speaker: | |
| - platform: i2s_audio | |
| id: i2s_audio_speaker | |
| i2s_dout_pin: GPIO${i2s_dout_pin} | |
| dac_type: external | |
| sample_rate: 16000 | |
| bits_per_sample: 16bit | |
| channel: left | |
| audio_dac: es8311_dac | |
| buffer_duration: 100ms | |
| media_player: | |
| - platform: speaker | |
| name: None | |
| id: external_media_player | |
| announcement_pipeline: | |
| speaker: i2s_audio_speaker | |
| format: FLAC | |
| sample_rate: 16000 | |
| num_channels: 1 # S3 Box only has one output channel | |
| files: | |
| - id: timer_finished_sound | |
| file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac | |
| - id: wake_word_triggered_sound | |
| file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/wake_word_triggered.flac | |
| - id: ready_sound | |
| file: ${startup_sound_file} | |
| on_announcement: | |
| - if: | |
| condition: | |
| - microphone.is_capturing: | |
| then: | |
| - script.execute: stop_wake_word | |
| - if: | |
| condition: | |
| - lambda: return id(wake_word_engine_location).current_option() == "In Home Assistant"; | |
| then: | |
| - wait_until: | |
| - not: | |
| voice_assistant.is_running: | |
| - if: | |
| condition: | |
| not: | |
| voice_assistant.is_running: | |
| then: | |
| - if: | |
| condition: | |
| lambda: 'return id(playing_internal_sound);' | |
| then: | |
| - lambda: 'id(playing_internal_sound) = false;' | |
| else: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; | |
| - script.execute: draw_display | |
| on_idle: | |
| - if: | |
| condition: | |
| not: | |
| voice_assistant.is_running: | |
| then: | |
| - script.execute: start_wake_word | |
| - script.execute: set_idle_or_mute_phase | |
| - script.execute: draw_display | |
| micro_wake_word: | |
| id: mww | |
| models: | |
| - okay_nabu | |
| - hey_jarvis | |
| - alexa | |
| on_wake_word_detected: | |
| - if: | |
| condition: | |
| switch.is_on: wake_sound | |
| then: | |
| - script.execute: | |
| id: play_sound | |
| priority: true | |
| sound_file: !lambda return id(wake_word_triggered_sound); | |
| - delay: 300ms | |
| - voice_assistant.start: | |
| wake_word: !lambda return wake_word; | |
| voice_assistant: | |
| id: va | |
| microphone: i2s_mics | |
| media_player: external_media_player | |
| micro_wake_word: mww | |
| noise_suppression_level: 2 | |
| auto_gain: 31dBFS | |
| volume_multiplier: 4.0 | |
| on_listening: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_listening_phase_id}; | |
| - text_sensor.template.publish: | |
| id: text_request | |
| state: "..." | |
| - text_sensor.template.publish: | |
| id: text_response | |
| state: "..." | |
| - script.execute: draw_display | |
| on_stt_vad_end: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_thinking_phase_id}; | |
| - script.execute: draw_display | |
| on_stt_end: | |
| - text_sensor.template.publish: | |
| id: text_request | |
| state: !lambda return x; | |
| - 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}; | |
| - script.execute: draw_display | |
| on_end: | |
| - wait_until: | |
| condition: | |
| - media_player.is_announcing: | |
| timeout: 0.5s | |
| - wait_until: | |
| - and: | |
| - not: | |
| media_player.is_announcing: | |
| - not: | |
| speaker.is_playing: | |
| - if: | |
| condition: | |
| - lambda: return id(wake_word_engine_location).current_option() == "On device"; | |
| then: | |
| - lambda: id(va).set_use_wake_word(false); | |
| - micro_wake_word.start: | |
| - script.execute: set_idle_or_mute_phase | |
| - script.execute: draw_display | |
| - 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}; | |
| - 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}; | |
| - 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 | |
| - script.execute: draw_display | |
| on_client_disconnected: | |
| - script.execute: stop_wake_word | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; | |
| - script.execute: draw_display | |
| on_timer_started: | |
| - script.execute: draw_display | |
| on_timer_cancelled: | |
| - script.execute: draw_display | |
| on_timer_updated: | |
| - script.execute: draw_display | |
| on_timer_tick: | |
| - script.execute: draw_display | |
| on_timer_finished: | |
| - switch.turn_on: timer_ringing | |
| - wait_until: | |
| media_player.is_announcing: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_timer_finished_phase_id}; | |
| - script.execute: draw_display | |
| script: | |
| - id: draw_display | |
| then: | |
| - if: | |
| condition: | |
| lambda: return !id(init_in_progress); | |
| then: | |
| - if: | |
| condition: | |
| wifi.connected: | |
| then: | |
| - if: | |
| condition: | |
| api.connected: | |
| then: | |
| - lambda: | | |
| switch(id(voice_assistant_phase)) { | |
| case ${voice_assist_listening_phase_id}: | |
| id(s3_box_lcd).show_page(listening_page); | |
| id(s3_box_lcd).update(); | |
| break; | |
| case ${voice_assist_thinking_phase_id}: | |
| id(s3_box_lcd).show_page(thinking_page); | |
| id(s3_box_lcd).update(); | |
| break; | |
| case ${voice_assist_replying_phase_id}: | |
| id(s3_box_lcd).show_page(replying_page); | |
| id(s3_box_lcd).update(); | |
| break; | |
| case ${voice_assist_error_phase_id}: | |
| id(s3_box_lcd).show_page(error_page); | |
| id(s3_box_lcd).update(); | |
| break; | |
| case ${voice_assist_muted_phase_id}: | |
| id(s3_box_lcd).show_page(muted_page); | |
| id(s3_box_lcd).update(); | |
| break; | |
| case ${voice_assist_not_ready_phase_id}: | |
| id(s3_box_lcd).show_page(no_ha_page); | |
| id(s3_box_lcd).update(); | |
| break; | |
| case ${voice_assist_timer_finished_phase_id}: | |
| id(s3_box_lcd).show_page(timer_finished_page); | |
| id(s3_box_lcd).update(); | |
| break; | |
| default: | |
| id(s3_box_lcd).show_page(idle_page); | |
| id(s3_box_lcd).update(); | |
| } | |
| else: | |
| - display.page.show: no_ha_page | |
| - component.update: s3_box_lcd | |
| else: | |
| - display.page.show: no_wifi_page | |
| - component.update: s3_box_lcd | |
| else: | |
| - display.page.show: initializing_page | |
| - component.update: s3_box_lcd | |
| ############ 2026.2.0 FIX END ############ | |
| - id: fetch_first_active_timer | |
| then: | |
| - lambda: | | |
| const auto &timers = id(va).get_timers(); | |
| if (timers.empty()) return; | |
| auto output_timer = *timers.begin(); | |
| for (const auto &timer : timers) { | |
| if (timer.is_active && timer.seconds_left <= output_timer.seconds_left) { | |
| output_timer = timer; | |
| } | |
| } | |
| id(global_first_active_timer) = output_timer; | |
| - id: check_if_timers_active | |
| then: | |
| - lambda: | | |
| const auto &timers = id(va).get_timers(); | |
| bool output = false; | |
| for (const auto &timer : timers) { | |
| if (timer.is_active) { | |
| output = true; | |
| break; | |
| } | |
| } | |
| id(global_is_timer_active) = output; | |
| - id: fetch_first_timer | |
| then: | |
| - lambda: | | |
| const auto &timers = id(va).get_timers(); | |
| if (timers.empty()) return; | |
| auto output_timer = *timers.begin(); | |
| for (const auto &timer : timers) { | |
| if (timer.seconds_left <= output_timer.seconds_left) { | |
| output_timer = timer; | |
| } | |
| } | |
| id(global_first_timer) = output_timer; | |
| - id: check_if_timers | |
| then: | |
| - lambda: | | |
| const auto &timers = id(va).get_timers(); | |
| id(global_is_timer) = !timers.empty(); | |
| ############ 2026.2.0 FIX END ############ | |
| - id: draw_timer_timeline | |
| then: | |
| - lambda: | | |
| id(check_if_timers_active).execute(); | |
| id(check_if_timers).execute(); | |
| if (id(global_is_timer_active)){ | |
| id(fetch_first_active_timer).execute(); | |
| int active_pixels = round( 320 * id(global_first_active_timer).seconds_left / max(id(global_first_active_timer).total_seconds , static_cast<uint32_t>(1)) ); | |
| if (active_pixels > 0){ | |
| id(s3_box_lcd).filled_rectangle(0 , 225 , 240 , 15 , Color::WHITE ); | |
| id(s3_box_lcd).filled_rectangle(0 , 226 , active_pixels , 13 , id(active_timer_color) ); | |
| } | |
| } else if (id(global_is_timer)){ | |
| id(fetch_first_timer).execute(); | |
| int active_pixels = round( 320 * id(global_first_timer).seconds_left / max(id(global_first_timer).total_seconds , static_cast<uint32_t>(1))); | |
| if (active_pixels > 0){ | |
| id(s3_box_lcd).filled_rectangle(0 , 225 , 240 , 15 , Color::WHITE ); | |
| id(s3_box_lcd).filled_rectangle(0 , 226 , active_pixels , 13 , id(paused_timer_color) ); | |
| } | |
| } | |
| - id: draw_active_timer_widget | |
| then: | |
| - lambda: | | |
| id(check_if_timers_active).execute(); | |
| if (id(global_is_timer_active)){ | |
| id(s3_box_lcd).filled_rectangle(80 , 40 , 160 , 50 , Color::WHITE ); | |
| id(s3_box_lcd).rectangle(80 , 40 , 160 , 50 , Color::BLACK ); | |
| id(fetch_first_active_timer).execute(); | |
| int hours_left = floor(id(global_first_active_timer).seconds_left / 3600); | |
| int minutes_left = floor((id(global_first_active_timer).seconds_left - hours_left * 3600) / 60); | |
| int seconds_left = id(global_first_active_timer).seconds_left - hours_left * 3600 - minutes_left * 60 ; | |
| auto display_hours = (hours_left < 10 ? "0" : "") + std::to_string(hours_left); | |
| auto display_minute = (minutes_left < 10 ? "0" : "") + std::to_string(minutes_left); | |
| auto display_seconds = (seconds_left < 10 ? "0" : "") + std::to_string(seconds_left) ; | |
| std::string display_string = ""; | |
| if (hours_left > 0) { | |
| display_string = display_hours + ":" + display_minute; | |
| } else { | |
| display_string = display_minute + ":" + display_seconds; | |
| } | |
| id(s3_box_lcd).printf(120, 47, id(font_timer), Color::BLACK, "%s", display_string.c_str()); | |
| } | |
| - id: start_wake_word | |
| then: | |
| - if: | |
| condition: | |
| and: | |
| - not: | |
| - voice_assistant.is_running: | |
| - lambda: return id(wake_word_engine_location).current_option() == "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).current_option() == "In Home Assistant"; | |
| then: | |
| - lambda: id(va).set_use_wake_word(true); | |
| - voice_assistant.start_continuous: | |
| - id: stop_wake_word | |
| then: | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).current_option() == "In Home Assistant"; | |
| then: | |
| - lambda: id(va).set_use_wake_word(false); | |
| - voice_assistant.stop: | |
| - if: | |
| condition: | |
| lambda: return id(wake_word_engine_location).current_option() == "On device"; | |
| then: | |
| - micro_wake_word.stop: | |
| - 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}; | |
| # Script executed when we want to play sounds on the device. | |
| - id: play_sound | |
| parameters: | |
| priority: bool | |
| sound_file: "audio::AudioFile*" | |
| then: | |
| - lambda: |- | |
| id(playing_internal_sound) = true; | |
| if (priority) { | |
| id(external_media_player) | |
| ->make_call() | |
| .set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_STOP) | |
| .set_announcement(true) | |
| .perform(); | |
| } | |
| if ( (id(external_media_player).state != media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING ) || priority) { | |
| id(external_media_player) | |
| ->play_file(sound_file, true, false); | |
| } | |
| switch: | |
| - 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}; | |
| - script.execute: draw_display | |
| on_turn_on: | |
| - microphone.mute: | |
| - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; | |
| - script.execute: draw_display | |
| - platform: template | |
| id: timer_ringing | |
| optimistic: true | |
| internal: true | |
| restore_mode: ALWAYS_OFF | |
| on_turn_off: | |
| - lambda: |- | |
| id(external_media_player) | |
| ->make_call() | |
| .set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_OFF) | |
| .set_announcement(true) | |
| .perform(); | |
| id(external_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 0); | |
| - media_player.stop: | |
| announcement: true | |
| on_turn_on: | |
| - lambda: |- | |
| id(external_media_player) | |
| ->make_call() | |
| .set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_ONE) | |
| .set_announcement(true) | |
| .perform(); | |
| id(external_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 1000); | |
| - media_player.speaker.play_on_device_media_file: | |
| media_file: timer_finished_sound | |
| announcement: true | |
| - delay: 15min | |
| - switch.turn_off: timer_ringing | |
| - platform: gpio | |
| name: Speaker Enable | |
| icon: "mdi:speaker" | |
| entity_category: config | |
| pin: GPIO${speaker_enable_pin} | |
| restore_mode: RESTORE_DEFAULT_ON | |
| - platform: template | |
| id: wake_sound | |
| name: Wake sound | |
| icon: "mdi:bullhorn" | |
| entity_category: config | |
| optimistic: true | |
| restore_mode: RESTORE_DEFAULT_ON | |
| - platform: template | |
| id: startup_sound_switch | |
| name: Startup sound | |
| icon: "mdi:card-text-outline" | |
| entity_category: config | |
| optimistic: true | |
| restore_mode: RESTORE_DEFAULT_ON | |
| - platform: template | |
| id: show_text | |
| name: Show Text | |
| icon: "mdi:card-text-outline" | |
| entity_category: config | |
| optimistic: true | |
| restore_mode: RESTORE_DEFAULT_ON | |
| - platform: template | |
| id: show_battery_status | |
| name: Show Battery Status | |
| icon: "mdi:card-text-outline" | |
| entity_category: config | |
| optimistic: true | |
| restore_mode: RESTORE_DEFAULT_ON | |
| on_turn_on: | |
| - script.execute: draw_display | |
| on_turn_off: | |
| - script.execute: draw_display | |
| 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 | |
| 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 | |
| - id: last_battery_percent | |
| type: int | |
| restore_value: no | |
| initial_value: '-1' | |
| - id: playing_internal_sound | |
| type: bool | |
| restore_value: no | |
| initial_value: 'false' | |
| - id: boot_sound_played | |
| type: bool | |
| restore_value: no | |
| initial_value: "false" | |
| image: | |
| - file: ${error_illustration_file} | |
| id: casita_error | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: ${idle_illustration_file} | |
| id: casita_idle | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: ${listening_illustration_file} | |
| id: casita_listening | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: ${thinking_illustration_file} | |
| id: casita_thinking | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: ${replying_illustration_file} | |
| id: casita_replying | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: ${timer_finished_illustration_file} | |
| id: casita_timer_finished | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: ${loading_illustration_file} | |
| id: casita_initializing | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: ${mute_illustration_file} | |
| id: casita_muted | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-wifi.png | |
| id: error_no_wifi | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| - file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-ha.png | |
| id: error_no_ha | |
| resize: ${imagewidth}x${imageheight} | |
| type: RGB565 | |
| transparency: alpha_channel | |
| font: | |
| - file: | |
| type: gfonts | |
| family: ${font_family} | |
| weight: 300 | |
| italic: true | |
| id: font_request | |
| size: 15 | |
| glyphsets: | |
| - ${font_glyphsets} | |
| - file: | |
| type: gfonts | |
| family: ${font_family} | |
| weight: 300 | |
| id: font_response | |
| size: 15 | |
| glyphsets: | |
| - ${font_glyphsets} | |
| - file: | |
| type: gfonts | |
| family: ${font_family} | |
| weight: 300 | |
| id: font_battery | |
| size: 24 | |
| glyphsets: | |
| - ${font_glyphsets} | |
| - file: | |
| type: gfonts | |
| family: ${font_family} | |
| weight: 300 | |
| id: font_timer | |
| size: 30 | |
| glyphsets: | |
| - ${font_glyphsets} | |
| 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(); | |
| } | |
| color: | |
| - id: idle_color | |
| hex: ${idle_illustration_background_color} | |
| - id: listening_color | |
| hex: ${listening_illustration_background_color} | |
| - id: thinking_color | |
| hex: ${thinking_illustration_background_color} | |
| - id: replying_color | |
| hex: ${replying_illustration_background_color} | |
| - id: loading_color | |
| hex: ${loading_illustration_background_color} | |
| - id: error_color | |
| hex: ${error_illustration_background_color} | |
| - id: active_timer_color | |
| hex: "26ed3a" | |
| - id: paused_timer_color | |
| hex: "3b89e3" | |
| spi: | |
| - id: spi_bus | |
| clk_pin: GPIO${spi_clk_pin} | |
| mosi_pin: GPIO${spi_mosi_pin} | |
| display: | |
| - platform: mipi_spi | |
| id: s3_box_lcd | |
| model: ${displaymodel} | |
| invert_colors: ${invertcolors} | |
| data_rate: 40MHz | |
| cs_pin: GPIO${lcd_cs_pin} | |
| dc_pin: GPIO${lcd_dc_pin} | |
| byte_order: little_endian | |
| # pixel_mode: 16bit # test 19/04 -> failed | |
| # color_order: BGR # test 19/04 -> failed | |
| # draw_rounding: 1 # test 19/04 -> failed | |
| reset_pin: GPIO${lcd_reset_pin} | |
| update_interval: never | |
| dimensions: | |
| height: ${imageheight} | |
| width: ${imagewidth} | |
| pages: | |
| - id: idle_page | |
| lambda: |- | |
| it.fill(id(idle_color)); | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_idle), ImageAlign::CENTER); | |
| if (id(show_battery_status).state) { | |
| const int box_top = 2; | |
| const int box_height = 28; | |
| it.filled_rectangle((it.get_width()/2)-40, box_top, 80, box_height, Color::WHITE); | |
| it.rectangle((it.get_width()/2)-40, box_top, 80, box_height, Color::BLACK); | |
| char battery_text[16]; | |
| snprintf(battery_text, sizeof(battery_text), "%.0f%%", id(battery_percentage).state); | |
| it.printf(it.get_width() / 2, 4, id(font_battery), Color::BLACK, TextAlign::TOP_CENTER, "%s", battery_text); | |
| } | |
| id(draw_timer_timeline).execute(); | |
| id(draw_active_timer_widget).execute(); | |
| - id: listening_page | |
| lambda: |- | |
| it.fill(id(listening_color)); | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_listening), ImageAlign::CENTER); | |
| id(draw_timer_timeline).execute(); | |
| - id: thinking_page | |
| lambda: |- | |
| it.fill(id(thinking_color)); | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_thinking), ImageAlign::CENTER); | |
| if (id(show_text).state) { | |
| it.filled_rectangle(20 , 20 , 200 , 30 , Color::WHITE ); | |
| it.rectangle(20 , 20 , 200 , 30 , Color::BLACK ); | |
| it.printf(30, 25, id(font_request), Color::BLACK, "%s", id(text_request).state.c_str()); | |
| } | |
| id(draw_timer_timeline).execute(); | |
| - id: replying_page | |
| lambda: |- | |
| it.fill(id(replying_color)); | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_replying), ImageAlign::CENTER); | |
| int y_offset = 190; // default for 240px display | |
| if (it.get_height() == 320) { | |
| y_offset = 270; // Move box lower for 320px tall screens | |
| } | |
| if (id(show_text).state) { | |
| it.filled_rectangle(20, y_offset, 200, 30, Color::WHITE); | |
| it.rectangle(20, y_offset, 200, 30, Color::BLACK); | |
| it.printf(30, y_offset + 5, id(font_response), Color::BLACK, "%s", id(text_response).state.c_str()); | |
| } | |
| id(draw_timer_timeline).execute(); | |
| - id: timer_finished_page | |
| lambda: |- | |
| it.fill(id(idle_color)); | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_timer_finished), ImageAlign::CENTER); | |
| - id: error_page | |
| lambda: |- | |
| it.fill(id(error_color)); | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_error), ImageAlign::CENTER); | |
| - id: no_ha_page | |
| lambda: |- | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(error_no_ha), ImageAlign::CENTER); | |
| - id: no_wifi_page | |
| lambda: |- | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(error_no_wifi), ImageAlign::CENTER); | |
| - id: initializing_page | |
| lambda: |- | |
| it.fill(id(loading_color)); | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_initializing), ImageAlign::CENTER); | |
| - id: muted_page | |
| lambda: |- | |
| it.fill(Color::BLACK); | |
| it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_muted), ImageAlign::CENTER); | |
| id(draw_timer_timeline).execute(); | |
| id(draw_active_timer_widget).execute(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment