Compare commits

..
Author SHA1 Message Date
randy eea872b930 mcu-stackchan: spectrum analyzer, head touch gestures, status bar
- Winamp style spectrum analyzer instead of the face while music plays,
  using audio_spectrum from randybb/esphome-components
- head touch: tap starts the assistant, stroking shows an in-love face;
  SI12T sensitivity raised so the pads actually trigger
- battery (AXP2101) and wifi indicators, ear icon while listening
2026-09-27 15:19:07 +02:00
randy 39e744900d mcu-stackchan: add M5Stack StackChan config
Voice assistant satellite with animated face on the CoreS3 display:
- power rails, IO expander resets and servo power as in the M5 BSP
- mic and speaker share one I2S bus, so the mic is released before playback
- AW88298 volume limited to the audible range (0.5-1.0)
- partial display redraws, status screen until Home Assistant connects
- battery and wifi indicators, listening icon
- camera converted to JPEG on demand, no periodic frames
2026-09-27 14:02:40 +02:00
2 changed files with 887 additions and 0 deletions

No files matched your search

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1194" height="240" fill="none" viewBox="0 0 1194 240"><g clip-path="url(#clip0_2_63)"><path fill="#18BCF2" d="M240 219C240 227.24 233.24 234 225 234H15C6.76 234 0 227.24 0 219V129C0 120.76 4.78 109.22 10.6 103.4L109.4 4.61999C115.24 -1.22001 124.78 -1.22001 130.62 4.61999L229.4 103.4C235.24 109.24 240 120.76 240 129V219Z"/><path fill="#F2F4F9" d="M160 78.66H80C76.68 78.66 74 81.34 74 84.66V234H86V90.66H154V102.66H104C100.68 102.66 98 105.34 98 108.66V132.66C98 135.98 100.68 138.66 104 138.66H154V150.66H104C100.68 150.66 98 153.34 98 156.66V180.66C98 183.98 100.68 186.66 104 186.66H154V198.66H104C100.68 198.66 98 201.34 98 204.66C98 207.98 100.68 210.66 104 210.66H160C163.32 210.66 166 207.98 166 204.66V180.66C166 177.34 163.32 174.66 160 174.66H110V162.66H160C163.32 162.66 166 159.98 166 156.66V132.66C166 129.34 163.32 126.66 160 126.66H110V114.66H160C163.32 114.66 166 111.98 166 108.66V84.66C166 81.34 163.32 78.66 160 78.66Z"/><path fill="#F2F4F9" d="M423 167.61V190.11H336V49.95H423V72.63H359.82V108.91H416.26V130.01H359.82V167.61H423Z"/><path fill="#F2F4F9" d="M491.08 47.41C503.9 47.41 514.54 50.53 523.04 56.79C531.54 63.03 536.76 71.39 538.7 81.83L516.3 87.93C515.04 81.93 512.12 77.31 507.54 74.05C502.94 70.81 497.28 69.17 490.52 69.17C483.26 69.17 477.52 70.91 473.28 74.37C469.02 77.83 466.9 82.45 466.9 88.19C466.9 97.25 472.52 103.13 483.78 105.81L506.66 111.53C518.48 114.59 527.32 119.45 533.2 126.11C539.08 132.77 542.02 140.97 542.02 150.71C542.02 163.21 537.48 173.31 528.42 181.03C519.36 188.75 507.24 192.61 492.04 192.61C478.36 192.61 466.78 189.39 457.36 182.95C448.04 176.27 442.54 167.45 440.86 156.51L463.26 150.69C464.08 156.87 467.16 161.73 472.5 165.23C477.84 168.73 484.64 170.47 492.9 170.47C501.16 170.47 507.08 168.79 511.6 165.45C516.14 162.11 518.4 157.57 518.4 151.81C518.4 142.81 512.78 136.87 501.52 133.99L478.64 128.45C466.88 125.77 458.06 120.99 452.16 114.15C446.26 107.31 443.3 98.95 443.3 89.07C443.3 76.69 447.7 66.65 456.48 58.93C465.26 51.21 476.8 47.35 491.12 47.35L491.08 47.41Z"/><path fill="#F2F4F9" d="M666.1 95.61C666.1 109.37 661.92 120.49 653.58 128.99C645.24 137.49 634 141.75 619.88 141.75H588.38V190.13H564.56V49.95H620.06C634.3 49.95 645.54 54.07 653.76 62.27C661.98 70.49 666.08 81.59 666.08 95.59L666.1 95.61ZM641.16 95.05C641.16 87.99 638.96 82.25 634.56 77.85C630.16 73.45 623.92 71.25 615.86 71.25H588.4V120.85H615.5C623.74 120.85 630.1 118.51 634.54 113.81C638.98 109.13 641.2 102.87 641.2 95.05H641.16Z"/><path fill="#F2F4F9" d="M687.38 49.95H711.2V108.55L772.98 108.73V49.95H796.98V190.11H772.98V129.83L711.2 129.65V190.11H687.38V49.95Z"/><path fill="#F2F4F9" d="M868.6 92.79C883.1 92.79 894.92 97.45 904.04 106.75C913.16 116.07 917.72 128.03 917.72 142.65C917.72 157.27 913.16 169.15 904.04 178.47C894.92 187.79 883.1 192.43 868.6 192.43C854.1 192.43 842.1 187.77 832.98 178.47C823.86 169.15 819.3 157.23 819.3 142.65C819.3 128.07 823.86 115.97 832.98 106.69C842.1 97.41 853.98 92.77 868.6 92.77V92.79ZM868.6 172.01C876.34 172.01 882.64 169.27 887.5 163.81C892.34 158.35 894.76 151.23 894.76 142.49C894.76 133.75 892.34 126.67 887.5 121.25C882.66 115.85 876.36 113.15 868.6 113.15C860.84 113.15 854.26 115.85 849.38 121.25C844.5 126.65 842.06 133.73 842.06 142.49C842.06 151.25 844.5 158.35 849.38 163.81C854.26 169.27 860.66 172.01 868.6 172.01Z"/><path fill="#F2F4F9" d="M1081.6 129.83V190.11H1059.56V133.29C1059.56 126.73 1057.88 121.61 1054.5 117.91C1051.12 114.23 1046.54 112.37 1040.72 112.37C1034.54 112.37 1029.6 114.39 1025.9 118.41C1022.22 122.45 1020.36 128.09 1020.36 135.33V190.09H998.04V133.27C998.04 126.71 996.4 121.59 993.12 117.89C989.84 114.21 985.3 112.35 979.48 112.35C973.3 112.35 968.32 114.37 964.58 118.39C960.84 122.41 958.96 128.07 958.96 135.31V190.07H936.28V95.39H957.38L958.14 104.49C963.64 96.43 972.6 92.39 985.04 92.39C992.28 92.39 998.58 93.89 1003.88 96.89C1009.2 99.89 1013.22 104.27 1015.98 110.01C1018.3 104.51 1022.12 100.19 1027.46 97.07C1032.8 93.95 1039.16 92.39 1046.54 92.39C1057.28 92.39 1065.82 95.71 1072.14 102.33C1078.46 108.95 1081.6 118.11 1081.6 129.79V129.83Z"/><path fill="#F2F4F9" d="M1193.54 151.11H1120.98C1121.74 158.23 1124.42 163.67 1129.04 167.43C1133.66 171.19 1139.54 173.05 1146.66 173.05C1158.22 173.05 1166.32 168.23 1170.94 158.61L1189.88 166.11C1186.32 174.43 1180.7 180.93 1173.06 185.61C1165.4 190.29 1156.6 192.65 1146.66 192.65C1132.66 192.65 1121.3 188.07 1112.58 178.91C1103.86 169.75 1099.5 157.73 1099.5 142.87C1099.5 128.01 1103.9 115.91 1112.68 106.59C1121.46 97.27 1132.94 92.63 1147.14 92.63C1161.34 92.63 1172.3 97.23 1180.8 106.41C1189.3 115.59 1193.56 127.69 1193.56 142.69V151.13L1193.54 151.11ZM1121.36 135.17H1170.48C1170.04 127.49 1167.72 121.63 1163.5 117.59C1159.28 113.55 1153.6 111.55 1146.48 111.55C1139.36 111.55 1133.66 113.59 1129.24 117.69C1124.8 121.79 1122.18 127.61 1121.36 135.17Z"/></g><defs><clipPath id="clip0_2_63"><rect width="1193.56" height="240" fill="#fff"/></cliLine truncated

After

Width:  |  Height:  |  Size: 4.9 KiB

+886
View File
@@ -0,0 +1,886 @@
substitutions:
device: stackchan
name: StackChan
area: DEV
comment: StackChan
esphome:
name: mcu-${device}
friendly_name: ${name}
area: ${area}
comment: ${comment}
on_boot:
- priority: -100
then:
- light.turn_on:
id: light_bar
brightness: 20%
red: 0%
green: 0%
blue: 100%
- component.update: display_tft
esp32:
variant: esp32s3
flash_size: 16MB
toolchain: esp-idf
framework:
type: esp-idf
psram:
mode: quad
speed: 80MHz
packages:
common: !include common/common.yaml
external_components:
- source: github://m5stack/esphome-yaml/components
components: [axp2101, aw88298, aw9523b, si12t, m5ioe1, ftservo, scs9009]
refresh: 0s
- source: github://randybb/esphome-components
components: [audio_spectrum]
refresh: 1d
globals:
- id: in_love
type: bool
initial_value: 'false'
- id: show_status
type: bool
initial_value: 'true'
- id: mouth_open
type: float
initial_value: '0.0'
- id: eyes_open
type: bool
initial_value: 'true'
- id: speaking
type: bool
initial_value: 'false'
- id: listening
type: bool
initial_value: 'false'
- id: gaze_x
type: int
initial_value: '0'
- id: gaze_y
type: int
initial_value: '0'
i2c:
- id: i2c_bsp
sda: 12
scl: 11
frequency: 100kHz
axp2101:
id: axp2101_pmu
i2c_id: i2c_bsp
aw9523b:
id: aw9523b_hub
i2c_id: i2c_bsp
p0_drive_mode: PUSH_PULL
m5ioe1:
id: m5ioe1_hub
i2c_id: i2c_bsp
reset: true
output:
- platform: axp2101
id: lcd_backlight_output
type: range
channel: DLDO1
min_voltage: 2600
max_voltage: 3300
- platform: axp2101
channel: ALDO1
voltage: 1800
- platform: axp2101
channel: ALDO2
voltage: 3300
- platform: axp2101
channel: ALDO3
voltage: 3300
- platform: axp2101
channel: ALDO4
voltage: 3300
- platform: axp2101
channel: BLDO1
voltage: 2800
- platform: axp2101
channel: BLDO2
voltage: 1500
- platform: axp2101
channel: DCDC1
voltage: 3300
- platform: axp2101
channel: DCDC4
voltage: 1800
- platform: axp2101
channel: DCDC3
voltage: 3300
- platform: axp2101
channel: DCDC5
voltage: 3300
switch:
- platform: gpio
id: boost_en
name: Boost
entity_category: config
pin:
aw9523b_id: aw9523b_hub
number: 15
restore_mode: RESTORE_DEFAULT_ON
- platform: gpio
id: usb_otg_en
name: USB OTG
entity_category: config
pin:
aw9523b_id: aw9523b_hub
number: 5
restore_mode: RESTORE_DEFAULT_OFF
- platform: gpio
id: bus_out_en
name: BUS Out
entity_category: config
pin:
aw9523b_id: aw9523b_hub
number: 1
restore_mode: RESTORE_DEFAULT_ON
- platform: gpio
id: light_bar_en
internal: true
pin:
m5ioe1_id: m5ioe1_hub
number: 13
mode:
output: true
pullup: true
restore_mode: ALWAYS_ON
- platform: gpio
id: aw_rst
internal: true
pin:
aw9523b_id: aw9523b_hub
number: 2
restore_mode: ALWAYS_ON
- platform: gpio
id: cam_rst
internal: true
pin:
aw9523b_id: aw9523b_hub
number: 8
restore_mode: ALWAYS_ON
- platform: gpio
id: servo_power
internal: true
pin:
m5ioe1_id: m5ioe1_hub
number: 0
mode:
output: true
pullup: true
restore_mode: ALWAYS_ON
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: 33
i2s_bclk_pin: 34
i2s_mclk_pin:
number: 0
ignore_strapping_warning: true
audio_adc:
- platform: es7210
id: es7210_adc
i2c_id: i2c_bsp
bits_per_sample: 16bit
sample_rate: 16000
mic_gain: 36
audio_dac:
- platform: aw88298
id: aw88298_dac
i2c_id: i2c_bsp
sample_rate: 48000
microphone:
- platform: i2s_audio
id: stackchan_microphone
i2s_audio_id: i2s_bus
i2s_din_pin: 14
adc_type: external
sample_rate: 16000
bits_per_sample: 16bit
speaker:
- platform: i2s_audio
id: speaker_id
i2s_audio_id: i2s_bus
dac_type: external
i2s_dout_pin: 13
audio_dac: aw88298_dac
sample_rate: 48000
buffer_duration: 100ms
# pass-through that computes the spectrum shown on the display while music plays
- platform: audio_spectrum
id: spectrum
output_speaker: speaker_id
bands: 20
- platform: mixer
id: mixer_speaker_id
output_speaker: spectrum
num_channels: 1
source_speakers:
- id: announcement_spk_mixer_input
- id: media_spk_mixer_input
- platform: resampler
id: media_spk_resampling_input
output_speaker: media_spk_mixer_input
- platform: resampler
id: announcement_spk_resampling_input
output_speaker: announcement_spk_mixer_input
media_player:
- platform: speaker
id: media_out
name: Player
buffer_size: 14000
# AW88298 maps volume linearly to -96..0 dB, the lower half is inaudible
volume_min: 0.5
volume_max: 1.0
media_pipeline:
speaker: media_spk_resampling_input
format: NONE
announcement_pipeline:
speaker: announcement_spk_resampling_input
format: NONE
on_announcement:
- script.execute: release_i2s_bus
on_play:
- script.execute: release_i2s_bus
on_idle:
- if:
condition:
not:
voice_assistant.is_running:
then:
- micro_wake_word.start:
micro_wake_word:
id: mww
microphone: stackchan_microphone
models:
- model: okay_nabu
on_wake_word_detected:
- voice_assistant.start:
wake_word: !lambda return wake_word;
voice_assistant:
id: va
microphone: stackchan_microphone
media_player: media_out
micro_wake_word: mww
noise_suppression_level: 2
auto_gain: 31dBFS
on_client_connected:
- micro_wake_word.start:
- delay: 2s
- lambda: id(show_status) = false;
- component.update: display_tft
on_client_disconnected:
- micro_wake_word.stop:
- lambda: id(show_status) = true;
- component.update: display_tft
on_listening:
- lambda: |-
id(listening) = true;
id(gaze_x) = 0;
id(gaze_y) = 0;
- component.update: display_tft
- light.turn_on: {id: light_bar, brightness: 60%, red: 0%, green: 60%, blue: 100%}
on_stt_end:
- lambda: id(listening) = false;
- component.update: display_tft
- light.turn_on: {id: light_bar, brightness: 40%, red: 80%, green: 50%, blue: 0%}
on_tts_start:
- lambda: id(speaking) = true;
- light.turn_on: {id: light_bar, brightness: 60%, red: 0%, green: 100%, blue: 20%}
on_end:
- wait_until:
not:
media_player.is_announcing: media_out
- lambda: |-
id(speaking) = false;
id(listening) = false;
id(mouth_open) = 0.0f;
- component.update: display_tft
- light.turn_on: {id: light_bar, brightness: 20%, red: 0%, green: 0%, blue: 100%}
- wait_until:
not: voice_assistant.is_running
- micro_wake_word.start:
on_error:
- light.turn_on: {id: light_bar, brightness: 60%, red: 100%, green: 0%, blue: 0%}
script:
# short tap on the head starts the assistant
- id: head_tap
then:
- if:
condition:
not: voice_assistant.is_running
then:
- voice_assistant.start:
# stroking the head shows the in-love face for a while
- id: head_stroke
mode: restart
then:
- lambda: id(in_love) = true;
- component.update: display_tft
- delay: 5s
- lambda: id(in_love) = false;
- component.update: display_tft
# mic and speaker share one I2S bus - the mic must be released before playback
- id: release_i2s_bus
mode: single
then:
- if:
condition:
microphone.is_capturing:
then:
- micro_wake_word.stop:
# stop VA only while it is listening (e.g. continued conversation), not during its own response
- if:
condition:
lambda: return id(listening);
then:
- voice_assistant.stop:
- wait_until:
condition:
not:
microphone.is_capturing:
timeout: 2s
interval:
- interval: 80ms # spectrum analyzer frame rate while music plays
then:
- if:
condition:
and:
- media_player.is_playing: media_out
- not: voice_assistant.is_running
then:
- component.update: display_tft
# head touch gestures: a touch episode ends after 250 ms without contact;
# tap = short episode, stroke = longer episode that reaches the pads one after another
- interval: 50ms
then:
- lambda: |-
static bool active = false;
static uint32_t start = 0, last_on = 0;
static uint32_t first_on[3];
const bool on[3] = {id(head_touch_1).state != "No touch", id(head_touch_2).state != "No touch",
id(head_touch_3).state != "No touch"};
const uint32_t now = millis();
if (on[0] || on[1] || on[2]) {
if (!active) {
active = true;
start = now;
first_on[0] = first_on[1] = first_on[2] = 0;
}
for (int i = 0; i < 3; i++)
if (on[i] && first_on[i] == 0)
first_on[i] = now;
last_on = now;
return;
}
if (!active || now - last_on < 250)
return;
active = false;
const uint32_t duration = last_on - start;
int pads = 0;
uint32_t earliest = UINT32_MAX, latest = 0;
for (int i = 0; i < 3; i++) {
if (first_on[i] == 0)
continue;
pads++;
earliest = std::min(earliest, first_on[i]);
latest = std::max(latest, first_on[i]);
}
const uint32_t spread = latest - earliest;
ESP_LOGD("head_touch", "duration %u ms, pads %d, spread %u ms, order 1:%d 2:%d 3:%d", (unsigned) duration,
pads, (unsigned) spread, first_on[0] ? (int) (first_on[0] - earliest) : -1,
first_on[1] ? (int) (first_on[1] - earliest) : -1, first_on[2] ? (int) (first_on[2] - earliest) : -1);
if (pads >= 2 && spread >= 150 && duration >= 400) {
id(head_stroke).execute();
} else if (duration <= 600) {
id(head_tap).execute();
}
- interval: 1s # status screen refresh until Home Assistant connects
then:
- if:
condition:
lambda: return id(show_status);
then:
- component.update: display_tft
- interval: 90ms
then:
- lambda: |-
if (id(speaking)) {
id(mouth_open) = 0.15f + (esp_random() % 85) / 100.0f;
id(display_tft).update();
} else if (id(mouth_open) > 0) {
id(mouth_open) = 0;
id(display_tft).update();
}
- interval: 3s # blinking
then:
- delay: !lambda return esp_random() % 3000;
- lambda: id(eyes_open) = false;
- component.update: display_tft
- delay: 150ms
- lambda: id(eyes_open) = true;
- component.update: display_tft
- interval: 7s # looking around while idle
then:
- lambda: |-
if (!id(speaking) && !id(listening)) {
id(gaze_x) = (int) (esp_random() % 21) - 10;
id(gaze_y) = (int) (esp_random() % 11) - 5;
id(display_tft).update();
}
spi:
- id: spi_bus
clk_pin: 36
mosi_pin: 37
image:
- platform: file
file: assets/images/esphome-logo-text-on-dark.svg
id: esphome_logo
resize: 280x56
type: RGB565
transparency: alpha_channel
- platform: file
file: mdi:ear-hearing
id: icon_listening
resize: 28x28
type: BINARY
font:
- file: "gfonts://Roboto"
id: font_title
size: 24
- file: "gfonts://Roboto"
id: font_status
size: 16
color:
- id: status_ok
red: 30%
green: 85%
blue: 40%
- id: status_wait
red: 100%
green: 70%
blue: 0%
- id: love_red
red: 100%
green: 25%
blue: 45%
- id: status_low
red: 100%
green: 20%
blue: 20%
- id: status_dim
red: 55%
green: 55%
blue: 55%
- id: face_fg
red: 100%
green: 100%
blue: 100%
- id: face_bg
red: 0%
green: 0%
blue: 0%
display:
- platform: mipi_spi
id: display_tft
model: M5CORE
dc_pin: 35
reset_pin:
aw9523b_id: aw9523b_hub
number: 9
cs_pin:
number: 3
ignore_strapping_warning: true
data_rate: 40MHz
invert_colors: true
update_interval: never
auto_clear_enabled: false
lambda: |-
static bool first_draw = true;
static bool dot_shown = false;
static bool bar_drawn = false;
static int last_bars = -1, last_batt = -2;
static bool last_charging = false;
const bool wifi_ok = wifi::global_wifi_component->is_connected();
const int8_t rssi = wifi_ok ? wifi::global_wifi_component->wifi_rssi() : -127;
const int wifi_bars = !wifi_ok ? 0 : rssi > -55 ? 4 : rssi > -65 ? 3 : rssi > -75 ? 2 : 1;
const int batt = std::isnan(id(battery_level).state) ? -1 : (int) roundf(id(battery_level).state);
const bool charging = id(battery_charging).state;
// status screen until Home Assistant connects
if (id(show_status)) {
first_draw = true;
it.fill(id(face_bg));
it.image(160, 45, id(esphome_logo), ImageAlign::CENTER);
it.print(160, 95, id(font_title), id(face_fg), TextAlign::CENTER, "${name}");
if (wifi_ok) {
it.printf(20, 135, id(font_status), id(status_ok), "WiFi: %s (%d dBm)",
id(ip_address_sensor).state.c_str(), rssi);
} else {
it.print(20, 135, id(font_status), id(status_wait), "WiFi: connecting...");
}
const bool api_ok = api::global_api_server->is_connected();
it.print(20, 162, id(font_status), api_ok ? id(status_ok) : id(status_wait),
api_ok ? "Home Assistant: connected" : "Home Assistant: waiting...");
if (batt >= 0) {
it.printf(20, 189, id(font_status), batt <= 20 && !charging ? id(status_low) : id(status_dim),
"Battery: %d%%%s", batt, charging ? " (charging)" : "");
}
it.printf(20, 212, id(font_status), id(status_dim), "ESPHome %s", ESPHOME_VERSION);
it.printf(300, 212, id(font_status), id(status_dim), TextAlign::TOP_RIGHT, "up %us",
(unsigned) (millis() / 1000));
return;
}
// stack-chan face: 2 eyes + mouth, 320x240
// the whole screen is cleared only once, then just the face areas, so that only the
// changed region is sent over SPI instead of the whole 150 kB buffer
const bool love = id(in_love);
const bool music = id(media_out).state == media_player::MEDIA_PLAYER_STATE_PLAYING && !id(va).is_running();
// switching between face, in-love face and spectrum repaints everything
static int last_mode = -1;
const int mode = music ? 2 : love ? 1 : 0;
if (mode != last_mode) {
last_mode = mode;
first_draw = true;
}
// the in-love face is drawn outside the partially cleared areas, so it always repaints everything
static bool spectrum_reset = true;
if (first_draw || love) {
it.fill(id(face_bg));
first_draw = false;
bar_drawn = false;
spectrum_reset = true;
} else if (!music) {
it.filled_rectangle(70, 78, 181, 31, id(face_bg)); // eyes including gaze range
it.filled_rectangle(105, 130, 111, 38, id(face_bg)); // mouth
if (dot_shown && !id(listening)) {
it.filled_rectangle(254, 2, 28, 28, id(face_bg));
}
}
dot_shown = id(listening);
// status bar (battery top-left, wifi top-right), redrawn only when something changes
if (!bar_drawn || wifi_bars != last_bars || batt != last_batt || charging != last_charging) {
bar_drawn = true;
last_bars = wifi_bars;
last_batt = batt;
last_charging = charging;
it.filled_rectangle(4, 4, 90, 22, id(face_bg));
it.filled_rectangle(288, 4, 28, 22, id(face_bg));
// battery: outline, terminal nub and fill proportional to the charge
if (batt >= 0) {
const Color c = charging ? id(status_ok) : batt <= 20 ? id(status_low) : id(status_dim);
it.rectangle(8, 8, 26, 13, c);
it.filled_rectangle(34, 12, 2, 5, c);
it.filled_rectangle(10, 10, 22 * batt / 100, 9, c);
it.printf(40, 6, id(font_status), c, "%d%%", batt);
}
// wifi: 4 bars, unused ones dimmed; red when disconnected
for (int i = 0; i < 4; i++) {
const int h = 4 + 3 * i;
const Color c = !wifi_ok ? id(status_low) : i < wifi_bars ? id(status_dim) : Color(40, 40, 40);
it.filled_rectangle(290 + i * 6, 21 - h, 4, h, c);
}
}
// Winamp style spectrum analyzer instead of the face while music plays.
// Only the segments that changed since the last frame are drawn or cleared, which keeps both the
// pixel work and the SPI transfer small.
if (music) {
static float peak[32];
static uint32_t peak_time[32];
static int prev_segs[32], prev_cap[32];
const uint32_t now = millis();
const int bands = std::min<int>(id(spectrum).get_bands(), 32);
const int x0 = 12, x1 = 308, top = 40, bottom = 228;
const int slot = (x1 - x0) / bands, bar_w = slot - 3;
const int seg_h = 5; // 4 px LED segment + 1 px gap
const int max_segs = (bottom - top) / seg_h;
if (spectrum_reset) {
spectrum_reset = false;
for (int b = 0; b < 32; b++) {
peak[b] = 0.0f;
prev_segs[b] = 0;
prev_cap[b] = -1;
}
}
auto seg_y = [&](int s) { return bottom - (s + 1) * seg_h + 1; };
for (int b = 0; b < bands; b++) {
const float level = id(spectrum).get_level(b);
const int x = x0 + b * slot + 1;
const int segs = std::min(max_segs, (int) (level * max_segs));
// peak caps hold for a moment, then fall slowly
if (level >= peak[b]) {
peak[b] = level;
peak_time[b] = now;
} else if (now - peak_time[b] > 400) {
peak[b] = std::max(0.0f, peak[b] - 0.03f);
}
const int cap = peak[b] > 0.0f ? std::min(max_segs - 1, std::max(segs, (int) (peak[b] * max_segs))) : -1;
// the old cap is cleared first; bar segments drawn below repaint anything it overlapped
if (prev_cap[b] >= 0 && prev_cap[b] != cap)
it.filled_rectangle(x, seg_y(prev_cap[b]), bar_w, seg_h - 1, id(face_bg));
if (segs > prev_segs[b]) {
for (int s = prev_segs[b]; s < segs; s++) {
// green at the bottom, through yellow to red at the top
const float f = float(s) / float(max_segs - 1);
const Color c = f < 0.5f ? Color((uint8_t) (510 * f), 230, 0) : Color(255, (uint8_t) (460 * (1.0f - f)), 0);
it.filled_rectangle(x, seg_y(s), bar_w, seg_h - 1, c);
}
} else if (segs < prev_segs[b]) {
it.filled_rectangle(x, seg_y(prev_segs[b] - 1), bar_w, (prev_segs[b] - segs) * seg_h, id(face_bg));
}
prev_segs[b] = segs;
if (cap >= segs && cap >= 0 && (cap != prev_cap[b] || cap == segs)) {
const int cy = seg_y(cap) + seg_h - 3;
it.filled_rectangle(x, cy, bar_w, 2, id(status_dim));
}
prev_cap[b] = cap;
}
return;
}
const int ex = id(gaze_x), ey = id(gaze_y);
const int eye_y = 93 + ey, eye_r = 8;
const int lx = 90 + ex, rx = 230 + ex;
if (love) {
// heart eyes, blush and a wide smile
for (int cx : {lx, rx}) {
it.filled_circle(cx - 6, eye_y - 3, 7, id(love_red));
it.filled_circle(cx + 6, eye_y - 3, 7, id(love_red));
it.filled_triangle(cx - 13, eye_y - 1, cx + 13, eye_y - 1, cx, eye_y + 14, id(love_red));
}
it.filled_circle(60 + ex, 130 + ey, 12, Color(90, 20, 40));
it.filled_circle(260 + ex, 130 + ey, 12, Color(90, 20, 40));
if (!id(speaking)) {
it.filled_circle(160 + ex / 2, 132 + ey / 2, 30, id(face_fg));
it.filled_circle(160 + ex / 2, 122 + ey / 2, 30, id(face_bg));
}
} else if (id(eyes_open)) {
it.filled_circle(lx, eye_y, eye_r, id(face_fg));
it.filled_circle(rx, eye_y, eye_r, id(face_fg));
} else {
it.filled_rectangle(lx - eye_r, eye_y - 2, eye_r * 2, 4, id(face_fg));
it.filled_rectangle(rx - eye_r, eye_y - 2, eye_r * 2, 4, id(face_fg));
}
const float m = id(mouth_open);
if (!love || id(speaking)) {
const int mw = 90 - (int) (40 * m);
const int mh = 4 + (int) (26 * m);
it.filled_rectangle(160 - mw / 2 + ex / 2, 148 - mh / 2 + ey / 2, mw, mh, id(face_fg));
}
if (id(listening)) {
it.image(254, 2, id(icon_listening), id(face_fg), id(face_bg)); // left of the wifi bars
}
touchscreen:
- platform: ft63x6
id: touch_lcd
i2c_id: i2c_bsp
reset_pin:
aw9523b_id: aw9523b_hub
number: 0
on_touch:
- voice_assistant.start:
esp32_camera:
id: camera_id
name: Camera
i2c_id: i2c_bsp
setup_priority: -200 # after power rails and IO expanders
pixel_format: RGB565
max_framerate: 15 fps
frame_buffer_count: 1
# GC0308 has no JPEG, convert RGB565 -> JPEG for HA (as in BSP); runs in the main loop, so no periodic frames
jpeg_quality: 12
idle_framerate: 0 fps
vsync_pin:
number: 46
ignore_strapping_warning: true
href_pin: 38
external_clock:
pin: 2
frequency: 20MHz
pixel_clock_pin:
number: 45
ignore_strapping_warning: true
data_pins: [39, 40, 41, 42, 15, 16, 48, 47]
resolution: 320x240
frame_buffer_location: PSRAM
sensor:
- platform: axp2101
id: axp2101_sensor
battery_level:
id: battery_level
name: Battery Level
filters:
- delta: 1.0
on_value:
- component.update: display_tft
battery_charging:
id: battery_charging
name: Battery Charging
on_state:
- component.update: display_tft
- platform: ltr_als_ps
i2c_id: i2c_bsp
address: 0x23
type: ALS_PS
ambient_light:
id: ambient_light
name: Ambient Light
ps_counts:
id: proximity_counts
name: Proximity Counts
- platform: ina226
i2c_id: i2c_bsp
address: 0x41
shunt_resistance: 0.01 ohm
max_current: 3.2A
current:
id: ina226_current
name: Current
power:
id: ina226_power
name: Power
bus_voltage:
id: ina226_bus_voltage
name: Bus Voltage
si12t:
id: touch_hub
i2c_id: i2c_bsp
# report low-strength touches too, not only medium/high
intr_level: OUTPUT_ALL
text_sensor:
- platform: si12t
id: head_touch_1
name: Head Touch 1
channel: CH_1
# lower level = lower touch threshold: SENS_LEVEL_1 = 0.7 % capacitance change (default level 3 is 1.2 %),
# SENS_LEVEL_0 made neighbouring pads trigger together
sensitivity: SENS_LEVEL_1
extend_threshold: false
update_interval: 100ms
internal: true
- platform: si12t
id: head_touch_2
name: Head Touch 2
channel: CH_2
sensitivity: SENS_LEVEL_1
extend_threshold: false
update_interval: 100ms
internal: true
- platform: si12t
id: head_touch_3
name: Head Touch 3
channel: CH_3
sensitivity: SENS_LEVEL_1
extend_threshold: false
update_interval: 100ms
internal: true
uart:
- id: uart_servo
tx_pin: 6
rx_pin: 7
baud_rate: 1000000
scs9009:
ftservo:
- platform: scs9009
id: x_servo
address: 1
- platform: scs9009
id: y_servo
address: 2
number:
- platform: ftservo
ftservo_id: x_servo
angle:
id: servo_x_angle
name: Servo X Angle
min_value: -164
max_value: 164
use_raw_angle: false
angle_offset: 164
step: 5
light:
- platform: m5ioe1
id: light_bar
name: Light Bar
num_led: 12
- platform: monochromatic
id: lcd_backlight
name: LCD Backlight
output: lcd_backlight_output
restore_mode: ALWAYS_ON
remote_transmitter:
id: ir_tx
pin: 5
carrier_duty_percent: 50%
non_blocking: true
remote_receiver:
id: ir_rx
pin: 10
time:
- platform: bm8563
id: rtc_time
i2c_id: i2c_bsp
update_interval: never
- !include common/time/homeassistant.yaml