diff --git a/wake-word-voice-assistant/esp32-s3-box-3.yaml b/wake-word-voice-assistant/esp32-s3-box-3.yaml index 7445e63..16023ee 100644 --- a/wake-word-voice-assistant/esp32-s3-box-3.yaml +++ b/wake-word-voice-assistant/esp32-s3-box-3.yaml @@ -8,6 +8,7 @@ substitutions: thinking_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/thinking_320_240.png replying_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/replying_320_240.png error_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/error_320_240.png + timer_finished_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/timer_finished_320_240.png loading_illustration_background_color: "000000" idle_illustration_background_color: "000000" @@ -23,6 +24,7 @@ substitutions: 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" # These unique characters have been extracted from every test file of every language available on https://github.com/home-assistant/intents (14 March 2024) allowed_characters: " !#%'()+,-./0123456789:;<>?@ABCDEFGHIJKLMNOPQRSTUVWYZ[]_abcdefghijklmnopqrstuvwxyz{|}°²³µ¿ÁÂÄÅÉÖÚßàáâãäåæçèéêëìíîðñòóôõöøùúûüýþāăąćčďĐđēėęěğĮįıļľŁłńňőřśšťũūůűųźŻżŽžơưșțΆΈΌΐΑΒΓΔΕΖΗΘΚΜΝΠΡΣΤΥΦάέήίαβγδεζηθικλμνξοπρςστυφχψωϊόύώАБВГДЕЖЗИКЛМНОПРСТУХЦЧШЪЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяёђєіїјљњћאבגדהוזחטיכלםמןנסעפץצקרשת،ءآأإئابةتجحخدذرزسشصضطظعغفقكلمنهوىيٹپچڈکگںھہیےংকচতধনফবযরলশষস়ািু্చయలిెొ్ംഅആഇഈഉഎഓകഗങചജഞടഡണതദധനപഫബഭമയരറലളവശസഹാിീുൂെേൈ്ൺൻർൽൾაბგდევზთილმნოპრსტუფქყშჩცძჭხạảấầẩậắặẹẽếềểệỉịọỏốồổỗộớờởợụủứừửữựỳ—、一上不个中为主乾了些亮人任低佔何作供依侧係個側偵充光入全关冇冷几切到制前動區卧厅厨及口另右吊后吗启吸呀咗哪唔問啟嗎嘅嘛器圍在场執場外多大始安定客室家密寵对將小少左已帘常幫幾库度庫廊廚廳开式後恆感態成我戲戶户房所扇手打执把拔换掉控插摄整斯新明是景暗更最會有未本模機檯櫃欄次正氏水沒没洗活派温測源溫漏潮激濕灯為無煙照熱燈燥物狀玄现現瓦用發的盞目着睡私空窗立笛管節簾籬紅線红罐置聚聲脚腦腳臥色节著行衣解設調請謝警设调走路車车运連遊運過道邊部都量鎖锁門閂閉開關门闭除隱離電震霧面音頂題顏颜風风食餅餵가간감갔강개거게겨결경고공과관그금급기길깥꺼껐꼽나난내네놀누는능니다닫담대더데도동됐되된됨둡드든등디때떤뜨라래러렇렌려로료른를리림링마많명몇모무문물뭐바밝방배변보부불블빨뽑사산상색서설성세센션소쇼수스습시신실싱아안않알았애야어얼업없었에여연열옆오온완외왼요운움워원위으은을음의이인일임입있작잠장재전절정제져조족종주줄중줘지직진짐쪽차창천최추출충치침커컴켜켰쿠크키탁탄태탬터텔통트튼티파팬퍼폰표퓨플핑한함해했행혀현화활후휴힘,?" @@ -75,6 +77,9 @@ external_components: - source: github://pr#5230 components: esp_adf refresh: 0s + - source: github://jesserockz/esphome-components + components: [file] + refresh: 0s api: on_client_connected: @@ -129,6 +134,11 @@ binary_sensor: disabled_by_default: true entity_category: diagnostic on_multi_click: + - timing: + - ON for at least 50ms + - OFF for at least 50ms + then: + - switch.turn_off: timer_ringing - timing: - ON for at least 10s then: @@ -207,6 +217,7 @@ voice_assistant: and: - switch.is_off: mute - lambda: return id(wake_word_engine_location).state == "On device"; + - lambda: return id(voice_assistant_phase) != ${voice_assist_timer_finished_phase_id}; then: - wait_until: not: @@ -229,41 +240,41 @@ voice_assistant: - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; - script.execute: draw_display on_client_connected: - - if: - condition: - switch.is_off: mute - then: - - wait_until: - not: ble.enabled - - if: - condition: - lambda: return id(wake_word_engine_location).state == "In Home Assistant"; - then: - - lambda: id(va).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 - - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; - else: - - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; + - wait_until: + not: ble.enabled - lambda: id(init_in_progress) = false; + - script.execute: start_voice_assistant - script.execute: draw_display on_client_disconnected: - - 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: + - script.execute: stop_voice_assistant + - 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: + - script.execute: stop_voice_assistant + - lambda: id(voice_assistant_phase) = ${voice_assist_timer_finished_phase_id}; + - switch.turn_on: timer_ringing + - script.execute: draw_display + - wait_until: + not: + microphone.is_capturing: + - while: condition: - lambda: return id(wake_word_engine_location).state == "On device"; + switch.is_on: timer_ringing then: - - micro_wake_word.stop - - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; + - lambda: id(box_speaker).play(id(timer_finished_wave_file), sizeof(id(timer_finished_wave_file))); + - delay: 1s + - wait_until: + not: + speaker.is_playing: + - switch.turn_off: timer_ringing + - script.execute: start_voice_assistant - script.execute: draw_display script: @@ -307,6 +318,10 @@ script: 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(); @@ -321,6 +336,133 @@ script: - display.page.show: initializing_page - component.update: s3_box_lcd + - id: fetch_first_active_timer + then: + - lambda: | + const auto timers = id(va).get_timers(); + auto output_timer = timers.begin()->second; + for (auto &iterable_timer : timers) { + if (iterable_timer.second.is_active && iterable_timer.second.seconds_left <= output_timer.seconds_left) { + output_timer = iterable_timer.second; + } + } + id(global_first_active_timer) = output_timer; + - id: check_if_timers_active + then: + - lambda: | + const auto timers = id(va).get_timers(); + bool output = false; + if (timers.size() > 0) { + for (auto &iterable_timer : timers) { + if(iterable_timer.second.is_active) { + output = true; + } + } + } + id(global_is_timer_active) = output; + - id: fetch_first_timer + then: + - lambda: | + const auto timers = id(va).get_timers(); + auto output_timer = timers.begin()->second; + for (auto &iterable_timer : timers) { + if (iterable_timer.second.seconds_left <= output_timer.seconds_left) { + output_timer = iterable_timer.second; + } + } + id(global_first_timer) = output_timer; + - id: check_if_timers + then: + - lambda: | + const auto timers = id(va).get_timers(); + bool output = false; + if (timers.size() > 0) { + output = true; + } + id(global_is_timer) = output; + + - 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(1)) ); + if (active_pixels > 0){ + id(s3_box_lcd).filled_rectangle(0 , 225 , 320 , 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(1))); + if (active_pixels > 0){ + id(s3_box_lcd).filled_rectangle(0 , 225 , 320 , 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_voice_assistant + then: + - if: + condition: + switch.is_off: mute + then: + - if: + condition: + lambda: return id(wake_word_engine_location).state == "In Home Assistant"; + then: + - lambda: id(va).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 + - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; + else: + - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; + + - id: stop_voice_assistant + 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: + - voice_assistant.stop: + - micro_wake_word.stop: + - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; + switch: - platform: template name: Display conversation @@ -369,6 +511,15 @@ switch: - 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_on: + - delay: 15min + - switch.turn_off: timer_ringing + select: - platform: template entity_category: config @@ -413,6 +564,18 @@ globals: 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 image: - file: ${error_illustration_file} @@ -440,6 +603,11 @@ image: resize: 320x240 type: RGB24 use_transparency: true + - file: ${timer_finished_illustration_file} + id: casita_timer_finished + resize: 320x240 + type: RGB24 + use_transparency: true - file: ${loading_illustration_file} id: casita_initializing resize: 320x240 @@ -472,6 +640,13 @@ font: glyphs: ${allowed_characters} id: font_response size: 15 + - file: + type: gfonts + family: Figtree + weight: 300 + glyphs: ${allowed_characters} + id: font_timer + size: 30 text_sensor: - id: text_request @@ -507,6 +682,14 @@ 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" + +file: + - id: timer_finished_wave_file + file: https://github.com/esphome/firmware/raw/main/voice-assistant/sounds/timer_finished.wav spi: clk_pin: 7 @@ -528,10 +711,13 @@ display: lambda: |- it.fill(id(idle_color)); it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_idle), ImageAlign::CENTER); + 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)); @@ -541,6 +727,7 @@ display: it.rectangle(20 , 20 , 280 , 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)); @@ -553,6 +740,11 @@ display: it.printf(30, 25, id(font_request), Color::BLACK, "%s", id(text_request).state.c_str()); it.printf(30, 195, 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)); @@ -570,3 +762,5 @@ display: - id: muted_page lambda: |- it.fill(Color::BLACK); + id(draw_timer_timeline).execute(); + id(draw_active_timer_widget).execute(); diff --git a/wake-word-voice-assistant/esp32-s3-box-lite.yaml b/wake-word-voice-assistant/esp32-s3-box-lite.yaml index 7e6fd64..9414860 100644 --- a/wake-word-voice-assistant/esp32-s3-box-lite.yaml +++ b/wake-word-voice-assistant/esp32-s3-box-lite.yaml @@ -7,6 +7,7 @@ substitutions: thinking_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/thinking_320_240.png replying_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/replying_320_240.png error_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/error_320_240.png + timer_finished_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/timer_finished_320_240.png loading_illustration_background_color: "000000" idle_illustration_background_color: "000000" @@ -22,6 +23,7 @@ substitutions: 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" # These unique characters have been extracted from every test file of every language available on https://github.com/home-assistant/intents (14 March 2024) allowed_characters: " !#%'()+,-./0123456789:;<>?@ABCDEFGHIJKLMNOPQRSTUVWYZ[]_abcdefghijklmnopqrstuvwxyz{|}°²³µ¿ÁÂÄÅÉÖÚßàáâãäåæçèéêëìíîðñòóôõöøùúûüýþāăąćčďĐđēėęěğĮįıļľŁłńňőřśšťũūůűųźŻżŽžơưșțΆΈΌΐΑΒΓΔΕΖΗΘΚΜΝΠΡΣΤΥΦάέήίαβγδεζηθικλμνξοπρςστυφχψωϊόύώАБВГДЕЖЗИКЛМНОПРСТУХЦЧШЪЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяёђєіїјљњћאבגדהוזחטיכלםמןנסעפץצקרשת،ءآأإئابةتجحخدذرزسشصضطظعغفقكلمنهوىيٹپچڈکگںھہیےংকচতধনফবযরলশষস়ািু্చయలిెొ్ംഅആഇഈഉഎഓകഗങചജഞടഡണതദധനപഫബഭമയരറലളവശസഹാിീുൂെേൈ്ൺൻർൽൾაბგდევზთილმნოპრსტუფქყშჩცძჭხạảấầẩậắặẹẽếềểệỉịọỏốồổỗộớờởợụủứừửữựỳ—、一上不个中为主乾了些亮人任低佔何作供依侧係個側偵充光入全关冇冷几切到制前動區卧厅厨及口另右吊后吗启吸呀咗哪唔問啟嗎嘅嘛器圍在场執場外多大始安定客室家密寵对將小少左已帘常幫幾库度庫廊廚廳开式後恆感態成我戲戶户房所扇手打执把拔换掉控插摄整斯新明是景暗更最會有未本模機檯櫃欄次正氏水沒没洗活派温測源溫漏潮激濕灯為無煙照熱燈燥物狀玄现現瓦用發的盞目着睡私空窗立笛管節簾籬紅線红罐置聚聲脚腦腳臥色节著行衣解設調請謝警设调走路車车运連遊運過道邊部都量鎖锁門閂閉開關门闭除隱離電震霧面音頂題顏颜風风食餅餵가간감갔강개거게겨결경고공과관그금급기길깥꺼껐꼽나난내네놀누는능니다닫담대더데도동됐되된됨둡드든등디때떤뜨라래러렇렌려로료른를리림링마많명몇모무문물뭐바밝방배변보부불블빨뽑사산상색서설성세센션소쇼수스습시신실싱아안않알았애야어얼업없었에여연열옆오온완외왼요운움워원위으은을음의이인일임입있작잠장재전절정제져조족종주줄중줘지직진짐쪽차창천최추출충치침커컴켜켰쿠크키탁탄태탬터텔통트튼티파팬퍼폰표퓨플핑한함해했행혀현화활후휴힘,?" @@ -68,6 +70,9 @@ external_components: - source: github://pr#5230 components: esp_adf refresh: 0s + - source: github://jesserockz/esphome-components + components: [file] + refresh: 0s api: on_client_connected: @@ -160,12 +165,18 @@ binary_sensor: - platform: template id: left name: "Left" + on_press: + - switch.turn_off: timer_ringing - platform: template id: middle name: "Middle" + on_press: + - switch.turn_off: timer_ringing - platform: template id: right name: "Right" + on_press: + - switch.turn_off: timer_ringing - platform: gpio pin: @@ -175,6 +186,11 @@ binary_sensor: name: Left Top Button disabled_by_default: true on_multi_click: + - timing: + - ON for at least 50ms + - OFF for at least 50ms + then: + - switch.turn_off: timer_ringing - timing: - ON for at least 10s then: @@ -276,41 +292,41 @@ voice_assistant: - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; - script.execute: draw_display on_client_connected: - - if: - condition: - switch.is_off: mute - then: - - wait_until: - not: ble.enabled - - if: - condition: - lambda: return id(wake_word_engine_location).state == "In Home Assistant"; - then: - - lambda: id(va).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 - - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; - else: - - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; + - wait_until: + not: ble.enabled - lambda: id(init_in_progress) = false; + - script.execute: start_voice_assistant - script.execute: draw_display on_client_disconnected: - - 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: + - script.execute: stop_voice_assistant + - 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: + - script.execute: stop_voice_assistant + - lambda: id(voice_assistant_phase) = ${voice_assist_timer_finished_phase_id}; + - switch.turn_on: timer_ringing + - script.execute: draw_display + - wait_until: + not: + microphone.is_capturing: + - while: condition: - lambda: return id(wake_word_engine_location).state == "On device"; + switch.is_on: timer_ringing then: - - micro_wake_word.stop - - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; + - lambda: id(box_speaker).play(id(timer_finished_wave_file), sizeof(id(timer_finished_wave_file))); + - delay: 1s + - wait_until: + not: + speaker.is_playing: + - switch.turn_off: timer_ringing + - script.execute: start_voice_assistant - script.execute: draw_display script: @@ -354,6 +370,10 @@ script: 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(); @@ -368,6 +388,133 @@ script: - display.page.show: initializing_page - component.update: s3_box_lcd + - id: fetch_first_active_timer + then: + - lambda: | + const auto timers = id(va).get_timers(); + auto output_timer = timers.begin()->second; + for (auto &iterable_timer : timers) { + if (iterable_timer.second.is_active && iterable_timer.second.seconds_left <= output_timer.seconds_left) { + output_timer = iterable_timer.second; + } + } + id(global_first_active_timer) = output_timer; + - id: check_if_timers_active + then: + - lambda: | + const auto timers = id(va).get_timers(); + bool output = false; + if (timers.size() > 0) { + for (auto &iterable_timer : timers) { + if(iterable_timer.second.is_active) { + output = true; + } + } + } + id(global_is_timer_active) = output; + - id: fetch_first_timer + then: + - lambda: | + const auto timers = id(va).get_timers(); + auto output_timer = timers.begin()->second; + for (auto &iterable_timer : timers) { + if (iterable_timer.second.seconds_left <= output_timer.seconds_left) { + output_timer = iterable_timer.second; + } + } + id(global_first_timer) = output_timer; + - id: check_if_timers + then: + - lambda: | + const auto timers = id(va).get_timers(); + bool output = false; + if (timers.size() > 0) { + output = true; + } + id(global_is_timer) = output; + + - 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(1)) ); + if (active_pixels > 0){ + id(s3_box_lcd).filled_rectangle(0 , 225 , 320 , 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(1))); + if (active_pixels > 0){ + id(s3_box_lcd).filled_rectangle(0 , 225 , 320 , 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_voice_assistant + then: + - if: + condition: + switch.is_off: mute + then: + - if: + condition: + lambda: return id(wake_word_engine_location).state == "In Home Assistant"; + then: + - lambda: id(va).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 + - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; + else: + - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; + + - id: stop_voice_assistant + 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: + - voice_assistant.stop: + - micro_wake_word.stop: + - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; + switch: - platform: template name: Display conversation @@ -416,6 +563,15 @@ switch: - 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_on: + - delay: 15min + - switch.turn_off: timer_ringing + select: - platform: template entity_category: config @@ -460,6 +616,18 @@ globals: 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 image: - file: ${error_illustration_file} @@ -487,6 +655,11 @@ image: resize: 320x240 type: RGB24 use_transparency: true + - file: ${timer_finished_illustration_file} + id: casita_timer_finished + resize: 320x240 + type: RGB24 + use_transparency: true - file: ${loading_illustration_file} id: casita_initializing resize: 320x240 @@ -519,6 +692,13 @@ font: glyphs: ${allowed_characters} id: font_response size: 15 + - file: + type: gfonts + family: Figtree + weight: 300 + glyphs: ${allowed_characters} + id: font_timer + size: 30 text_sensor: - id: text_request @@ -554,6 +734,14 @@ 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" + +file: + - id: timer_finished_wave_file + file: https://github.com/esphome/firmware/raw/main/voice-assistant/sounds/timer_finished.wav spi: clk_pin: 7 @@ -573,10 +761,13 @@ display: lambda: |- it.fill(id(idle_color)); it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_idle), ImageAlign::CENTER); + 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)); @@ -586,6 +777,7 @@ display: it.rectangle(20 , 20 , 280 , 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)); @@ -598,6 +790,11 @@ display: it.printf(30, 25, id(font_request), Color::BLACK, "%s", id(text_request).state.c_str()); it.printf(30, 195, 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)); @@ -615,3 +812,5 @@ display: - id: muted_page lambda: |- it.fill(Color::BLACK); + id(draw_timer_timeline).execute(); + id(draw_active_timer_widget).execute(); diff --git a/wake-word-voice-assistant/esp32-s3-box.yaml b/wake-word-voice-assistant/esp32-s3-box.yaml index cfd6e28..01f733c 100644 --- a/wake-word-voice-assistant/esp32-s3-box.yaml +++ b/wake-word-voice-assistant/esp32-s3-box.yaml @@ -8,6 +8,7 @@ substitutions: thinking_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/thinking_320_240.png replying_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/replying_320_240.png error_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/error_320_240.png + timer_finished_illustration_file: https://github.com/esphome/firmware/raw/main/voice-assistant/casita/timer_finished_320_240.png loading_illustration_background_color: "000000" idle_illustration_background_color: "000000" @@ -23,6 +24,7 @@ substitutions: 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" # These unique characters have been extracted from every test file of every language available on https://github.com/home-assistant/intents (14 March 2024) allowed_characters: " !#%'()+,-./0123456789:;<>?@ABCDEFGHIJKLMNOPQRSTUVWYZ[]_abcdefghijklmnopqrstuvwxyz{|}°²³µ¿ÁÂÄÅÉÖÚßàáâãäåæçèéêëìíîðñòóôõöøùúûüýþāăąćčďĐđēėęěğĮįıļľŁłńňőřśšťũūůűųźŻżŽžơưșțΆΈΌΐΑΒΓΔΕΖΗΘΚΜΝΠΡΣΤΥΦάέήίαβγδεζηθικλμνξοπρςστυφχψωϊόύώАБВГДЕЖЗИКЛМНОПРСТУХЦЧШЪЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяёђєіїјљњћאבגדהוזחטיכלםמןנסעפץצקרשת،ءآأإئابةتجحخدذرزسشصضطظعغفقكلمنهوىيٹپچڈکگںھہیےংকচতধনফবযরলশষস়ািু্చయలిెొ్ംഅആഇഈഉഎഓകഗങചജഞടഡണതദധനപഫബഭമയരറലളവശസഹാിീുൂെേൈ്ൺൻർൽൾაბგდევზთილმნოპრსტუფქყშჩცძჭხạảấầẩậắặẹẽếềểệỉịọỏốồổỗộớờởợụủứừửữựỳ—、一上不个中为主乾了些亮人任低佔何作供依侧係個側偵充光入全关冇冷几切到制前動區卧厅厨及口另右吊后吗启吸呀咗哪唔問啟嗎嘅嘛器圍在场執場外多大始安定客室家密寵对將小少左已帘常幫幾库度庫廊廚廳开式後恆感態成我戲戶户房所扇手打执把拔换掉控插摄整斯新明是景暗更最會有未本模機檯櫃欄次正氏水沒没洗活派温測源溫漏潮激濕灯為無煙照熱燈燥物狀玄现現瓦用發的盞目着睡私空窗立笛管節簾籬紅線红罐置聚聲脚腦腳臥色节著行衣解設調請謝警设调走路車车运連遊運過道邊部都量鎖锁門閂閉開關门闭除隱離電震霧面音頂題顏颜風风食餅餵가간감갔강개거게겨결경고공과관그금급기길깥꺼껐꼽나난내네놀누는능니다닫담대더데도동됐되된됨둡드든등디때떤뜨라래러렇렌려로료른를리림링마많명몇모무문물뭐바밝방배변보부불블빨뽑사산상색서설성세센션소쇼수스습시신실싱아안않알았애야어얼업없었에여연열옆오온완외왼요운움워원위으은을음의이인일임입있작잠장재전절정제져조족종주줄중줘지직진짐쪽차창천최추출충치침커컴켜켰쿠크키탁탄태탬터텔통트튼티파팬퍼폰표퓨플핑한함해했행혀현화활후휴힘,?" @@ -69,6 +71,9 @@ external_components: - source: github://pr#5230 components: esp_adf refresh: 0s + - source: github://jesserockz/esphome-components + components: [file] + refresh: 0s api: on_client_connected: @@ -123,6 +128,11 @@ binary_sensor: disabled_by_default: true entity_category: diagnostic on_multi_click: + - timing: + - ON for at least 50ms + - OFF for at least 50ms + then: + - switch.turn_off: timer_ringing - timing: - ON for at least 10s then: @@ -222,41 +232,41 @@ voice_assistant: - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; - script.execute: draw_display on_client_connected: - - if: - condition: - switch.is_off: mute - then: - - wait_until: - not: ble.enabled - - if: - condition: - lambda: return id(wake_word_engine_location).state == "In Home Assistant"; - then: - - lambda: id(va).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 - - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; - else: - - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; + - wait_until: + not: ble.enabled - lambda: id(init_in_progress) = false; + - script.execute: start_voice_assistant - script.execute: draw_display on_client_disconnected: - - 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: + - script.execute: stop_voice_assistant + - 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: + - script.execute: stop_voice_assistant + - lambda: id(voice_assistant_phase) = ${voice_assist_timer_finished_phase_id}; + - switch.turn_on: timer_ringing + - script.execute: draw_display + - wait_until: + not: + microphone.is_capturing: + - while: condition: - lambda: return id(wake_word_engine_location).state == "On device"; + switch.is_on: timer_ringing then: - - micro_wake_word.stop - - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; + - lambda: id(box_speaker).play(id(timer_finished_wave_file), sizeof(id(timer_finished_wave_file))); + - delay: 1s + - wait_until: + not: + speaker.is_playing: + - switch.turn_off: timer_ringing + - script.execute: start_voice_assistant - script.execute: draw_display script: @@ -300,6 +310,10 @@ script: 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(); @@ -314,6 +328,133 @@ script: - display.page.show: initializing_page - component.update: s3_box_lcd + - id: fetch_first_active_timer + then: + - lambda: | + const auto timers = id(va).get_timers(); + auto output_timer = timers.begin()->second; + for (auto &iterable_timer : timers) { + if (iterable_timer.second.is_active && iterable_timer.second.seconds_left <= output_timer.seconds_left) { + output_timer = iterable_timer.second; + } + } + id(global_first_active_timer) = output_timer; + - id: check_if_timers_active + then: + - lambda: | + const auto timers = id(va).get_timers(); + bool output = false; + if (timers.size() > 0) { + for (auto &iterable_timer : timers) { + if(iterable_timer.second.is_active) { + output = true; + } + } + } + id(global_is_timer_active) = output; + - id: fetch_first_timer + then: + - lambda: | + const auto timers = id(va).get_timers(); + auto output_timer = timers.begin()->second; + for (auto &iterable_timer : timers) { + if (iterable_timer.second.seconds_left <= output_timer.seconds_left) { + output_timer = iterable_timer.second; + } + } + id(global_first_timer) = output_timer; + - id: check_if_timers + then: + - lambda: | + const auto timers = id(va).get_timers(); + bool output = false; + if (timers.size() > 0) { + output = true; + } + id(global_is_timer) = output; + + - 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(1)) ); + if (active_pixels > 0){ + id(s3_box_lcd).filled_rectangle(0 , 225 , 320 , 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(1))); + if (active_pixels > 0){ + id(s3_box_lcd).filled_rectangle(0 , 225 , 320 , 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_voice_assistant + then: + - if: + condition: + switch.is_off: mute + then: + - if: + condition: + lambda: return id(wake_word_engine_location).state == "In Home Assistant"; + then: + - lambda: id(va).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 + - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; + else: + - lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id}; + + - id: stop_voice_assistant + 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: + - voice_assistant.stop: + - micro_wake_word.stop: + - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; + switch: - platform: template name: Display conversation @@ -362,6 +503,15 @@ switch: - 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_on: + - delay: 15min + - switch.turn_off: timer_ringing + select: - platform: template entity_category: config @@ -406,6 +556,18 @@ globals: 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 image: - file: ${error_illustration_file} @@ -433,6 +595,11 @@ image: resize: 320x240 type: RGB24 use_transparency: true + - file: ${timer_finished_illustration_file} + id: casita_timer_finished + resize: 320x240 + type: RGB24 + use_transparency: true - file: ${loading_illustration_file} id: casita_initializing resize: 320x240 @@ -465,6 +632,13 @@ font: glyphs: ${allowed_characters} id: font_response size: 15 + - file: + type: gfonts + family: Figtree + weight: 300 + glyphs: ${allowed_characters} + id: font_timer + size: 30 text_sensor: - id: text_request @@ -500,6 +674,14 @@ 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" + +file: + - id: timer_finished_wave_file + file: https://github.com/esphome/firmware/raw/main/voice-assistant/sounds/timer_finished.wav spi: clk_pin: 7 @@ -519,10 +701,13 @@ display: lambda: |- it.fill(id(idle_color)); it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_idle), ImageAlign::CENTER); + 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)); @@ -532,6 +717,7 @@ display: it.rectangle(20 , 20 , 280 , 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)); @@ -544,6 +730,11 @@ display: it.printf(30, 25, id(font_request), Color::BLACK, "%s", id(text_request).state.c_str()); it.printf(30, 195, 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)); @@ -561,3 +752,6 @@ display: - id: muted_page lambda: |- it.fill(Color::BLACK); + id(draw_timer_timeline).execute(); + id(draw_active_timer_widget).execute(); +