メインコンテンツまでスキップ

reSpeaker XVF3800 と Home Assistant を使ったスマートホーム音声制御

概要​

新しい ReSpeaker XMOS XVF3800 と XIAO ESP32S3 を使ってスマートスペースと会話し、音声で照明をオン・オフしたり、音楽の音量を上げたり、天気を尋ねたりできます。

この章では、ReSpeaker XMOS XVF3800 と XIAO ESP32S3 の HA Voice Assistant を使用して Sonoff スマートスイッチを接続し、照明スイッチの音声制御を実現します。

必要なハードウェア​

ReSpeaker XVF3800 with XIAO ESP32S3Home Assistant デバイスSonoff BASICR2

ファームウェアの更新​

最高の再生体験を得るために、XMOS ファームウェアを更新する必要があります。 ここからファームウェアをダウンロードしてください。PC に ReSpeaker XMOS XVF3800 with XIAO ESP32S3 を接続し、ガイドに従って実行します。

pir

注記

XVF3800 マイクアレイは動作に 12.288 MHz の MCLK を必要としますが、ESPHome(Home Assistant で使用)は API の制限によりこれを生成できません。このファームウェアでは XVF3800 を I2S マスターとして動作させることで、ESP32 から MCLK を受け取らなくても自前でクロックを生成できるようにしています。 このファームウェアによりこの制限が解消され、マイクは Home Assistant で正しく動作します。

ファームウェアはこちらからダウンロードできます。

インストールガイドはこちらです。

ReSpeaker XMOS XVF3800 と XIAO ESP32S3 の準備​

Home Assistant > Settings > Apps に移動します。

pir

Install app(通常は右下)をクリックします。

pir

App Store で ESPHome Device Builder を検索してインストールします。

pir

Install をクリックして ESPHome アドオンをインストールします。

pir

Home Assistant のサイドバーから ESPHome Builder に移動します。

+ NEW DEVICE をクリックします。

pir

新しいデバイスエントリを選択し、Import from file をクリックします。

pir

内容をカスタムの YAML 設定 に置き換えます。

important

YAML ファイルはこちらから入手できます。

YAML を保存したら、INSTALL をクリックします。

pir

Advanced opinions と Download firmware binary を選択します。

pir

ファームウェアのコンパイルが完了するまで待ちます。

生成された .bin ファームウェアファイルを PC にダウンロードします。

pir

ESP32-S3 ボード(XVF3800 接続済み)を USB Type-C ケーブル で PC に接続します。

Chrome または Edge で Web-ESPHome を開きます。

CONNECT をクリックし、適切なシリアルポートを選択します。

pir

接続されたら INSTALL をクリックします。

pir

先ほどダウンロードした .bin ファイルを選択します。

pir

インストールが完了するまで待ちます(数分かかる場合があります)。

成功すると、完了メッセージが表示されます。

pir

Home Assistant > Settings > Devices & Services に戻ります。

ESPHome が検出された統合として表示されているはずです。

CONFIGURE をクリックし、Submit を押してセットアップを完了します。

pir

スマートデバイスを追加する​

メーカーのユーザーマニュアルに従って Sonoff BASICR2 デバイスをセットアップします。eWELink アカウントを作成する必要があります。

HACS のインストール​

まだインストールしていない場合は、公式ガイドに従って Home Assistant Community Store をセットアップしてください。

サイドバーから HACS を開きます。 検索セクションで Sonoff LAN を検索します。

pir

Install をクリックして統合を追加します。 変更を反映するために Home Assistant を再起動します。

Sonoff​

Settings → Devices & Services に移動します。 Add Integration をクリックします。 Sonoff を検索して選択します。

pir

認証のために eWeLink アカウントの認証情報 を入力します。

pir

接続に成功すると、関連するエンティティが次の場所に一覧表示されます。 Settings → Devices & Services → Entities

pir

Nabu Cloud を使った Voice Assistant​

このデモでは、音声を使って Home Assistant Cloud(Nabu Casa) に接続する方法を紹介します。セットアップはシンプルで、1 か月間の無料トライアル を利用して、その機能を存分に試すことができます。 組み込みの ウェイクワード「Okay Nabu」により、音声コマンドを簡単にトリガーし、複雑な設定なしで、いつでもどこでも デバイスやサービスと シームレスに連携 できます。

pir

pir

YAML の説明​

WiFi​

このセクションでは、デバイスが Wi-Fi に接続する方法を設定します。

wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
  • ssid & password: パスワードが平文で表示されないように、secrets.yaml ファイルから取得されます。
  • イベント: Wi-Fi が接続または切断されたときにアクションを実行します:
    • on_connect:

      • BLE improv セットアップを停止します。
      • control_leds スクリプトを実行します(Wi-Fi 接続時の LED エフェクト)。
    • on_disconnect:

      • control_leds スクリプトを実行します(Wi-Fi 切断時の LED エフェクト)。

I²C バス​

I²C は、ESP32 がマイクやオーディオコーデックなどの他のチップと通信するための通信ラインです。

i2c:
id: internal_i2c
sda: GPIO5
scl: GPIO6
scan: true
frequency: 100kHz
  • id: 他の場所でこのバスを参照するための名前。
  • sda / scl: データとクロックに使用されるピン。
  • scan: 起動時に接続されたデバイスをチェックします。
  • frequency: 通信速度(100kHz が標準)。

スイッチ​

スイッチ
switch:
# Mute Sound Switch.
- platform: template
id: mute_sound
name: Mute/unmute sound
icon: "mdi:bullhorn"
entity_category: config
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
# Wake Word Sound Switch.
- platform: template
id: wake_sound
name: Wake sound
icon: "mdi:bullhorn"
entity_category: config
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
# Internal switch to track when a timer is ringing on the device.
- platform: template
id: timer_ringing
optimistic: true
internal: true
restore_mode: ALWAYS_OFF
on_turn_off:
# Disable stop wake word
- micro_wake_word.disable_model: stop
- script.execute: disable_repeat
# Stop any current annoucement (ie: stop the timer ring mid playback)
- if:
condition:
media_player.is_announcing:
then:
media_player.stop:
announcement: true
# Set back ducking ratio to zero
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 0
duration: 1.0s
# Refresh the LED ring
- script.execute: control_leds
on_turn_on:
# Duck audio
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 20
duration: 0.0s
# Enable stop wake word
- micro_wake_word.enable_model: stop
# Ring timer
- script.execute: ring_timer
# Refresh LED
- script.execute: control_leds
# If 15 minutes have passed and the timer is still ringing, stop it.
- delay: 15min
- switch.turn_off: timer_ringing
# Defines if alarm is active
- platform: template
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
id: alarm_on
icon: mdi:bell-badge
name: "Alarm on"
on_turn_on:
- script.execute: control_leds
on_turn_off:
- script.execute: control_leds

スイッチは、Home Assistant 内のソフトウェア制御の「ボタン」です。サウンド、タイマー、アラームなどの機能を制御します。

ウェイクワードサウンドスイッチ: ウェイクワードのオーディオを制御します。

# Wake Word Sound Switch.
- platform: template
id: wake_sound
name: Wake sound
icon: "mdi:bullhorn"
entity_category: config
optimistic: true
restore_mode: RESTORE_DEFAULT_ON

タイマー鳴動内部スイッチ:

  • タイマーがアクティブかどうかを追跡します。
  • on_turn_on: 他のオーディオを -20dB だけ下げ、タイマー音を開始し、LED を更新し、15 分後に自動停止します。
  • on_turn_off: タイマーを停止し、音量を元に戻し、LED を更新します。
# Internal switch to track when a timer is ringing on the device.
- platform: template
id: timer_ringing
optimistic: true
internal: true
restore_mode: ALWAYS_OFF
...
...

アラームオンスイッチ:

# Defines if alarm is active
- platform: template
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
id: alarm_on
...
...
  • アラームの状態を追跡します。
  • オン/オフ時に LED スクリプトを実行します。

センサー​

センサー
number:
- platform: template
id: led_ring_brightness
name: "LED Ring Brightness"
icon: mdi:brightness-6
entity_category: config
optimistic: true
restore_value: true
min_value: 0.4
max_value: 1.0
step: 0.05
initial_value: 0.8
mode: slider

sensor:
- platform: template
id: next_timer
name: "Next timer"
update_interval: never
disabled_by_default: true
device_class: duration
unit_of_measurement: s
icon: "mdi:timer"
accuracy_decimals: 0

text_sensor:
- platform: template
id: next_timer_name
name: "Next timer name"
icon: "mdi:timer"
disabled_by_default: true
- platform: template
name: "Alarm time"
id: alarm_time
icon: mdi:bell-ring
- platform: template
name: "Current device time"
id: current_time
icon: mdi:clock

この YAML ブロックは、タイマー、アラーム、LED 輝度用のコントロールとセンサーを追加します。LED リングの明るさをスライダーで調整し、次のタイマー(時刻+名前)を追跡し、Home Assistant 内でアラーム時刻とデバイスの現在時刻を表示できるようにします。

LED リング輝度:

number:
- platform: template
id: led_ring_brightness
name: "LED Ring Brightness"
icon: mdi:brightness-6
...
  • ユーザーがスライダーで明るさを調整できるようにします。
  • min_value / max_value で上限と下限を定義します。
  • restore_value により、再起動後も以前の設定を保持します。

次のタイマー

sensor:
- platform: template
id: next_timer
name: "Next timer"
update_interval: never
...
  • 次のタイマーの残り時間を表示します。
  • タイマーが変化したときのみ更新されます(リソースを節約)。

アラーム時刻 & デバイス時刻

text_sensor:
- platform: template
id: next_timer_name
name: "Next timer name"
icon: "mdi:timer"
...
  • 現在のアラームと ESP32 のシステム時刻を表示します。

一定間隔での LED エフェクト​

interval:
- interval: 50ms
id: led_animation_interval
then:
- lambda: |-
if (id(volume_display_active)) {
id(update_volume_display_effect).execute();
return;
}
std::string effect = id(current_led_effect);
if (effect == "off") {
return;
} else if (effect == "breathe") {
id(update_breathe_effect).execute();
} else if (effect == "rainbow") {
id(update_rainbow_effect).execute();
} else if (effect == "comet_cw") {
id(update_comet_cw_effect).execute();
} else if (effect == "comet_ccw") {
id(update_comet_ccw_effect).execute();
} else if (effect == "twinkle") {
id(update_twinkle_effect).execute();
} else if (effect == "timer_tick") {
id(update_timer_tick_effect).execute();
} else if (effect == "led_beam") {
id(update_led_beam_effect).execute();
}
  • interval はバックグラウンドでコードを繰り返し実行します。
  • 50ms: 1 秒間に 20 回コードを実行します。
  • システム状態や選択されたエフェクトに基づいて LED アニメーションを制御します。

LED 制御​

LED 制御
 script:
# =========================================================================
# == Centralized script to control all LED effects ==
# =========================================================================
- id: led_set_effect
mode: restart
parameters:
effect: std::string
r: float
g: float
b: float
speed: float
then:
- lambda: |-
// Update global variables with the new parameters
id(led_ring_color_r) = r;
id(led_ring_color_g) = g;
id(led_ring_color_b) = b;
id(led_ring_speed) = speed;
id(current_led_effect) = effect;

// Handle the two types of effects: Off and Animated
if (effect == "off") {
uint32_t colors[12] = {0};
id(respeaker).set_led_ring(colors);
} else {
id(last_led_update_time) = millis(); // Reset timer for smooth animation start
}

# Individual update scripts for each animated effect
- id: update_breathe_effect
then:
- lambda: |-
static float phase = 0.0f;
uint32_t now = millis();
float dt = (now - id(last_led_update_time)) / 1000.0f;
id(last_led_update_time) = now;

phase += dt * id(led_ring_speed);
while (phase >= 1.0f) phase -= 1.0f;

float master_brightness = id(led_ring_brightness).state;
float breath_brightness = 0.5f * (1.0f + sinf(phase * 2.0f * M_PI)) * master_brightness;

uint8_t r = (uint8_t)(id(led_ring_color_r) * 255.0f * breath_brightness);
uint8_t g = (uint8_t)(id(led_ring_color_g) * 255.0f * breath_brightness);
uint8_t b = (uint8_t)(id(led_ring_color_b) * 255.0f * breath_brightness);
uint32_t current_color = (r << 16) | (g << 8) | b;

uint32_t colors[12];
for (int i = 0; i < 12; i++) colors[i] = current_color;
id(respeaker).set_led_ring(colors);

- id: update_rainbow_effect
then:
- lambda: |-
static float hue_offset = 0.0f;
uint32_t now = millis();
float dt = (now - id(last_led_update_time)) / 1000.0f;
id(last_led_update_time) = now;

hue_offset += dt * id(led_ring_speed);
if (hue_offset >= 1.0f) hue_offset -= 1.0f;

constexpr int NUM_LEDS = 12;
constexpr float HUE_STEP = 1.0f / NUM_LEDS;
uint32_t colors[NUM_LEDS];
float brightness = id(led_ring_brightness).state;

float current_hue = hue_offset;
for (int i = 0; i < NUM_LEDS; i++) {
float r, g, b;
if (current_hue >= 1.0f) current_hue -= 1.0f;
hsv_to_rgb((int)(current_hue * 360.0f), 1.0f, brightness, r, g, b);
colors[i] = ((uint8_t)(r * 255.0f) << 16) | ((uint8_t)(g * 255.0f) << 8) | ((uint8_t)(b * 255.0f));
current_hue += HUE_STEP;
}
id(respeaker).set_led_ring(colors);

- id: update_comet_cw_effect
then:
- lambda: |-
static float comet_pos = 0.0f;
uint32_t now = millis();
float dt = (now - id(last_led_update_time)) / 1000.0f;
id(last_led_update_time) = now;

constexpr int NUM_LEDS = 12;
constexpr int BASE_TAIL = 3;

float leds_per_sec = id(led_ring_speed) * NUM_LEDS;
comet_pos += dt * leds_per_sec;
while (comet_pos >= NUM_LEDS) comet_pos -= NUM_LEDS;

int head_index = (int)comet_pos;
int tail_length = BASE_TAIL + (int)(id(led_ring_speed));
if (tail_length > NUM_LEDS - 1) tail_length = NUM_LEDS - 1;

uint32_t colors[NUM_LEDS] = {0};
float brightness = id(led_ring_brightness).state;

uint8_t head_r = (uint8_t)(id(led_ring_color_r) * 255.0f * brightness);
uint8_t head_g = (uint8_t)(id(led_ring_color_g) * 255.0f * brightness);
uint8_t head_b = (uint8_t)(id(led_ring_color_b) * 255.0f * brightness);

colors[head_index % NUM_LEDS] = (head_r << 16) | (head_g << 8) | head_b;

for (int i = 1; i <= tail_length; i++) {
float tail_factor = (float)i / (tail_length + 1);
float tail_brightness = (1.0f - tail_factor) * brightness;
uint8_t r = (uint8_t)(id(led_ring_color_r) * 255.0f * tail_brightness);
uint8_t g = (uint8_t)(id(led_ring_color_g) * 255.0f * tail_brightness);
uint8_t b = (uint8_t)(id(led_ring_color_b) * 255.0f * tail_brightness);
int tail_index = (head_index - i + NUM_LEDS) % NUM_LEDS;
colors[tail_index] = (r << 16) | (g << 8) | b;
}
id(respeaker).set_led_ring(colors);

- id: update_comet_ccw_effect
then:
- lambda: |-
static float comet_pos = 0.0f;
uint32_t now = millis();
float dt = (now - id(last_led_update_time)) / 1000.0f;
id(last_led_update_time) = now;

constexpr int NUM_LEDS = 12;
constexpr int BASE_TAIL = 3;

float leds_per_sec = id(led_ring_speed) * NUM_LEDS;
comet_pos -= dt * leds_per_sec;
while (comet_pos < 0.0f) comet_pos += NUM_LEDS;

int head_index = (int)comet_pos;
int tail_length = BASE_TAIL + (int)(id(led_ring_speed));
if (tail_length > NUM_LEDS - 1) tail_length = NUM_LEDS - 1;

uint32_t colors[NUM_LEDS] = {0};
float brightness = id(led_ring_brightness).state;

uint8_t head_r = (uint8_t)(id(led_ring_color_r) * 255.0f * brightness);
uint8_t head_g = (uint8_t)(id(led_ring_color_g) * 255.0f * brightness);
uint8_t head_b = (uint8_t)(id(led_ring_color_b) * 255.0f * brightness);

colors[head_index % NUM_LEDS] = (head_r << 16) | (head_g << 8) | head_b;

for (int i = 1; i <= tail_length; i++) {
float tail_factor = (float)i / (tail_length + 1);
float tail_brightness = (1.0f - tail_factor) * brightness;
uint8_t r = (uint8_t)(id(led_ring_color_r) * 255.0f * tail_brightness);
uint8_t g = (uint8_t)(id(led_ring_color_g) * 255.0f * tail_brightness);
uint8_t b = (uint8_t)(id(led_ring_color_b) * 255.0f * tail_brightness);
int tail_index = (head_index + i) % NUM_LEDS;
colors[tail_index] = (r << 16) | (g << 8) | b;
}
id(respeaker).set_led_ring(colors);

- id: update_twinkle_effect
then:
- lambda: |-
constexpr int NUM_LEDS = 12;
static float led_brightness[NUM_LEDS] = {0.0f};
static float led_fade_speed[NUM_LEDS] = {0.0f};

uint32_t now = millis();
float dt = (now - id(last_led_update_time)) / 1000.0f;
id(last_led_update_time) = now;

// Update existing twinkles
for (int i = 0; i < NUM_LEDS; i++) {
if (led_fade_speed[i] != 0.0f) {
led_brightness[i] += led_fade_speed[i] * dt;
if (led_fade_speed[i] > 0.0f && led_brightness[i] >= 1.0f) {
led_brightness[i] = 1.0f;
led_fade_speed[i] *= -1.0f;
} else if (led_fade_speed[i] < 0.0f && led_brightness[i] <= 0.0f) {
led_brightness[i] = 0.0f;
led_fade_speed[i] = 0.0f;
}
}
}

// Start new twinkles
float twinkle_chance = dt * id(led_ring_speed);
if (random_float() < twinkle_chance) {
int led_to_start = (int)(random_float() * NUM_LEDS);
if (led_fade_speed[led_to_start] == 0.0f) {
led_brightness[led_to_start] = 0.0f;
float min_speed = 1.5f, max_speed = 3.0f;
led_fade_speed[led_to_start] = min_speed + (random_float() * (max_speed - min_speed));
}
}

// Render colors
uint32_t colors[NUM_LEDS];
float master_brightness = id(led_ring_brightness).state;
uint8_t base_r = (uint8_t)(id(led_ring_color_r) * 255.0f);
uint8_t base_g = (uint8_t)(id(led_ring_color_g) * 255.0f);
uint8_t base_b = (uint8_t)(id(led_ring_color_b) * 255.0f);

for (int i = 0; i < NUM_LEDS; i++) {
float current_led_brightness = led_brightness[i] * master_brightness;
uint8_t r = (uint8_t)(base_r * current_led_brightness);
uint8_t g = (uint8_t)(base_g * current_led_brightness);
uint8_t b = (uint8_t)(base_b * current_led_brightness);
colors[i] = (r << 16) | (g << 8) | b;
}
id(respeaker).set_led_ring(colors);

- id: update_timer_tick_effect
then:
- lambda: |-
constexpr int NUM_LEDS = 12;
static int tick_index = 0;

uint32_t now = millis();
// Only update tick position every 100ms to reduce I2C traffic
static uint32_t last_tick_update = 0;
if (now - last_tick_update >= 100) {
tick_index = (tick_index - 1 + NUM_LEDS) % NUM_LEDS;
last_tick_update = now;
}

uint32_t colors[NUM_LEDS] = {0};
uint32_t seconds_left = id(first_active_timer).seconds_left;
uint32_t total_seconds = id(first_active_timer).total_seconds;

float timer_ratio = (float)NUM_LEDS * seconds_left / std::max(total_seconds, (uint32_t)1);

float master_brightness = id(led_ring_brightness).state;
uint8_t base_r = (uint8_t)(id(led_ring_color_r) * 255.0f);
uint8_t base_g = (uint8_t)(id(led_ring_color_g) * 255.0f);
uint8_t base_b = (uint8_t)(id(led_ring_color_b) * 255.0f);

for (int i = 0; i < NUM_LEDS; i++) {
float bar_brightness = clamp(timer_ratio - i, 0.0f, 1.0f);
if (bar_brightness > 0.0f) {
float tick_dip = (i == tick_index) ? 0.9f : 1.0f;
float final_brightness = bar_brightness * tick_dip * master_brightness;
uint8_t r = (uint8_t)(base_r * final_brightness);
uint8_t g = (uint8_t)(base_g * final_brightness);
uint8_t b = (uint8_t)(base_b * final_brightness);
colors[i] = (r << 16) | (g << 8) | b;
}
}
id(respeaker).set_led_ring(colors);

- id: update_volume_display_effect
then:
- lambda: |-
constexpr int NUM_LEDS = 12;
uint32_t colors[NUM_LEDS] = {0};

if (id(external_media_player).is_ready()) {
bool is_muted = id(external_media_player).is_muted();
float volume = id(external_media_player).volume;

if (is_muted || volume == 0.0f) {
uint32_t mute_color = (255 << 16); // Red
colors[0] = mute_color;
colors[6] = mute_color;
} else {
float num_leds_on = volume * NUM_LEDS;
float master_brightness = id(led_ring_brightness).state;
uint8_t base_r = (uint8_t)(id(led_ring_color_r) * 255.0f);
uint8_t base_g = (uint8_t)(id(led_ring_color_g) * 255.0f);
uint8_t base_b = (uint8_t)(id(led_ring_color_b) * 255.0f);

for (int i = 0; i < NUM_LEDS; i++) {
float brightness = clamp(num_leds_on - i, 0.0f, 1.0f);
if (brightness > 0.0f) {
uint8_t r = (uint8_t)(base_r * brightness * master_brightness);
uint8_t g = (uint8_t)(base_g * brightness * master_brightness);
uint8_t b = (uint8_t)(base_b * brightness * master_brightness);
colors[i] = (r << 16) | (g << 8) | b;
}
}
}
}
id(respeaker).set_led_ring(colors);

- id: update_led_beam_effect
then:
- lambda: |-
constexpr int NUM_LEDS = 12;
constexpr int FADE_LEDS = 3;
constexpr float TRANSITION_DURATION = 0.5f; // Duration of the smooth transition
uint32_t colors[NUM_LEDS] = {0};

uint32_t now = millis();
float dt = (now - id(last_led_update_time)) / 1000.0f;
id(last_led_update_time) = now;

if (id(beam_direction).has_state()) {
// CORRECTING THE OFFSET: add 5 from the sensor reading
float target_pos = ((int)id(beam_direction).state + 5) % NUM_LEDS;
float current_pos = id(animated_beam_position);

// Calculate the shortest path around the circle
float diff = target_pos - current_pos;
if (diff > NUM_LEDS / 2.0f) {
diff -= NUM_LEDS;
} else if (diff < -NUM_LEDS / 2.0f) {
diff += NUM_LEDS;
}

// Move current position towards target
if (abs(diff) > 0.01f) {
float move_speed = diff / TRANSITION_DURATION;
current_pos += move_speed * dt;
} else {
current_pos = target_pos;
}

// Handle wrap-around for the animated position
if (current_pos >= NUM_LEDS) current_pos -= NUM_LEDS;
if (current_pos < 0.0f) current_pos += NUM_LEDS;

id(animated_beam_position) = current_pos;

// Render the smoothed beam
float master_brightness = id(led_ring_brightness).state;
uint8_t base_r = (uint8_t)(id(led_ring_color_r) * 255.0f);
uint8_t base_g = (uint8_t)(id(led_ring_color_g) * 255.0f);
uint8_t base_b = (uint8_t)(id(led_ring_color_b) * 255.0f);

for (int i = 0; i < NUM_LEDS; i++) {
// Calculate circular distance from current LED to the animated position
float dist = abs(i - current_pos);
if (dist > NUM_LEDS / 2.0f) {
dist = NUM_LEDS - dist;
}

// Calculate brightness based on distance (linear falloff)
float brightness_factor = 1.0f - (dist / (FADE_LEDS + 1.0f));
brightness_factor = std::max(0.0f, brightness_factor);

if (brightness_factor > 0.0f) {
float final_brightness = brightness_factor * master_brightness;
uint8_t r = (uint8_t)(base_r * final_brightness);
uint8_t g = (uint8_t)(base_g * final_brightness);
uint8_t b = (uint8_t)(base_b * final_brightness);
colors[i] = (r << 16) | (g << 8) | b;
}
}
}
id(respeaker).set_led_ring(colors);

# Master script controlling the LEDs, based on different conditions : initialization in progress, wifi and api connected and voice assistant phase.
# For the sake of simplicity and re-usability, the script calls child scripts defined below.
# This script will be called every time one of these conditions is changing.
- id: control_leds
mode: single # Prevent multiple simultaneous executions
then:
- lambda: |
// Cache expensive component checks
static bool last_respeaker_failed = false;
static bool last_wifi_connected = false;
static bool last_api_connected = false;
static int last_voice_phase = -1;
static bool last_timer_ringing = false;
static bool last_timer_active = false;
static bool last_improv_ble = false;
static bool last_init_progress = false;

bool respeaker_failed = id(respeaker).is_failed();
bool wifi_connected = id(wifi_id).is_connected();
bool api_connected = id(api_id).is_connected();
int voice_phase = id(voice_assistant_phase);
bool new_timer_ringing = id(timer_ringing).state;
bool improv_ble = id(improv_ble_in_progress);
bool init_progress = id(init_in_progress);

// Only update if something actually changed
bool needs_update = (
respeaker_failed != last_respeaker_failed ||
wifi_connected != last_wifi_connected ||
api_connected != last_api_connected ||
voice_phase != last_voice_phase ||
new_timer_ringing != last_timer_ringing ||
improv_ble != last_improv_ble ||
init_progress != last_init_progress
);

if (!needs_update) return;

// Update cache
last_respeaker_failed = respeaker_failed;
last_wifi_connected = wifi_connected;
last_api_connected = api_connected;
last_voice_phase = voice_phase;
last_timer_ringing = new_timer_ringing;
last_improv_ble = improv_ble;
last_init_progress = init_progress;

if (respeaker_failed) {
id(control_leds_respeaker_startup_failed).execute();
return;
}

// Only check timers if we need to
id(check_if_timers_active).execute();
if (id(is_timer_active)){
id(fetch_first_active_timer).execute();
}

// Continue with existing logic...
if (improv_ble) {
id(control_leds_improv_ble_state).execute();
} else if (init_progress) {
id(control_leds_init_state).execute();
} else if (!wifi_connected || !api_connected){
id(control_leds_no_ha_connection_state).execute();
} else if (new_timer_ringing) {
id(control_leds_timer_ringing).execute();
} else if (voice_phase == ${voice_assist_waiting_for_command_phase_id}) {
id(control_leds_voice_assistant_waiting_for_command_phase).execute();
} else if (voice_phase == ${voice_assist_listening_for_command_phase_id}) {
id(control_leds_voice_assistant_listening_for_command_phase).execute();
} else if (voice_phase == ${voice_assist_thinking_phase_id}) {
id(control_leds_voice_assistant_thinking_phase).execute();
} else if (voice_phase == ${voice_assist_replying_phase_id}) {
id(control_leds_voice_assistant_replying_phase).execute();
} else if (voice_phase == ${voice_assist_error_phase_id}) {
id(control_leds_voice_assistant_error_phase).execute();
} else if (voice_phase == ${voice_assist_not_ready_phase_id}) {
id(control_leds_voice_assistant_not_ready_phase).execute();
} else if (id(is_timer_active)) {
id(control_leds_timer_ticking).execute();
} else if (voice_phase == ${voice_assist_idle_phase_id}) {
id(control_leds_voice_assistant_idle_phase).execute();
}

# Script executed if respeaker startup failed
- id: control_leds_respeaker_startup_failed
then:
- script.execute:
id: led_set_effect
effect: "breathe"
r: 0.7
g: 0.0
b: 0.0
speed: 0.5

# Script executed during Improv BLE
- id: control_leds_improv_ble_state
then:
- script.execute:
id: led_set_effect
effect: "twinkle"
r: 1.0
g: 0.89
b: 0.71
speed: 10.0

# Script executed during initialization
- id: control_leds_init_state
then:
- if:
condition:
wifi.connected:
then:
- script.execute:
id: led_set_effect
effect: "twinkle"
r: 0.09
g: 0.73
b: 0.95
speed: 20.0
else:
- script.execute:
id: led_set_effect
effect: "twinkle"
r: 0.09
g: 0.73
b: 0.95
speed: 4.0

# Script executed when the device has no connection to Home Assistant
- id: control_leds_no_ha_connection_state
then:
- script.execute:
id: led_set_effect
effect: "twinkle"
r: 1.0
g: 0.0
b: 0.0
speed: 10.0

# Script executed when the voice assistant is waiting for a command (After the wake word)
- id: control_leds_voice_assistant_waiting_for_command_phase
then:
- lambda: |
id(animated_beam_position) = id(beam_direction).state;
- script.execute:
id: led_set_effect
effect: "led_beam"
r: 0.5
g: 0.0
b: 0.5
speed: 0.0

# Script executed when the voice assistant is listening to a command
- id: control_leds_voice_assistant_listening_for_command_phase
then:
- script.execute:
id: led_set_effect
effect: "led_beam"
r: 0.8
g: 0.0
b: 0.8
speed: 0.0

# Script executed when the voice assistant is thinking to a command
- id: control_leds_voice_assistant_thinking_phase
then:
- script.execute:
id: led_set_effect
effect: "breathe"
r: 0.6
g: 0.0
b: 0.6
speed: 1.0

# Script executed when the voice assistant is replying to a command
- id: control_leds_voice_assistant_replying_phase
then:
- script.execute:
id: led_set_effect
effect: "comet_ccw"
r: 0.6
g: 0.0
b: 0.6
speed: 1.0

# Script executed when the voice assistant is in error
- id: control_leds_voice_assistant_error_phase
then:
- script.execute:
id: led_set_effect
effect: "breathe"
r: 1.0
g: 0.0
b: 0.0
speed: 3.0

# Script executed when the voice assistant is not ready
- id: control_leds_voice_assistant_not_ready_phase
then:
- script.execute:
id: led_set_effect
effect: "twinkle"
r: 1.0
g: 0.0
b: 0.0
speed: 5.0

# Script executed when the volume is changed
- id: control_leds_volume_changed
mode: restart
then:
- lambda: |-
id(volume_display_active) = true;
- delay: 2s
- lambda: |-
id(volume_display_active) = false;

# Script executed when the timer is ringing, to control the LEDs
- id: control_leds_timer_ringing
then:
- script.execute:
id: led_set_effect
effect: "breathe"
r: 0.6
g: 0.0
b: 0.6
speed: 5.0

# Script executed when the timer is ticking, to control the LEDs
- id: control_leds_timer_ticking
then:
- script.execute:
id: led_set_effect
effect: "timer_tick"
r: 0.6
g: 0.0
b: 0.6
speed: 1.0

# Script executed when the voice assistant is idle (waiting for a wake word)
- id: control_leds_voice_assistant_idle_phase
then:
- script.execute:
id: led_set_effect
effect: "off"
r: 0.0
g: 0.0
b: 0.0
speed: 0.0

# Script executed when the timer is ringing, to playback sounds.
- id: ring_timer
then:
- script.execute: enable_repeat_one
- script.execute:
id: play_sound
priority: true
sound_file: !lambda return id(timer_finished_sound);

# Script executed when the timer is ringing, to repeat the timer finished sound.
- id: enable_repeat_one
then:
# Turn on the repeat mode and pause for 500 ms between playlist items/repeats
- 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, 500);

# Script execute when the timer is done ringing, to disable repeat mode.
- id: disable_repeat
then:
# Turn off the repeat mode and pause for 0 ms between playlist items/repeats
- 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);

# Script executed when we want to play sounds on the device.
- id: play_sound
parameters:
priority: bool
sound_file: "audio::AudioFile*"
then:
- lambda: |-
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);
}

# Script used to fetch the first active timer (Stored in global first_active_timer)
- id: fetch_first_active_timer
mode: single
then:
- lambda: |
static uint32_t last_fetch_time = 0;
uint32_t now = millis();

// Only fetch every 500ms to reduce overhead
if (now - last_fetch_time < 500) return;
last_fetch_time = now;

const auto timers = id(va).get_timers();
if (timers.empty()) return;

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(first_active_timer) = output_timer;

# Script used to check if a timer is active (Stored in global is_timer_active)
- 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(is_timer_active) = output;

# Script used activate the stop word if the TTS step is long.
# Why is this wrapped on a script?
# Becasue we want to stop the sequence if the TTS step is faster than that.
# This allows us to prevent having the deactivation of the stop word before its own activation.
- id: activate_stop_word_once
then:
- delay: 1s
# Enable stop wake word
- if:
condition:
switch.is_off: timer_ringing
then:
- micro_wake_word.enable_model: stop
- wait_until:
not:
media_player.is_announcing:
- if:
condition:
switch.is_off: timer_ringing
then:
- micro_wake_word.disable_model: stop

- id: check_alarm
then:
- lambda: |-
id(publish_current_time).execute();
// Check alarm
if (id(alarm_on).state && id(alarm_time).has_state()) {
// Get the stored alarm time from the sensor
auto set_alarm_time = id(alarm_time).state;
if (set_alarm_time.length() == 5 &&
isdigit(set_alarm_time[0]) && isdigit(set_alarm_time[1]) &&
isdigit(set_alarm_time[3]) && isdigit(set_alarm_time[4])) {
auto alarm_hour = std::stoi(set_alarm_time.substr(0, 2));
auto alarm_minute = std::stoi(set_alarm_time.substr(3, 2));

// Trigger action if current time matches alarm time
auto time_now = id(homeassistant_time).now();
if (time_now.hour == alarm_hour && time_now.minute == alarm_minute) {
auto action = id(alarm_action).state;
if (action == "Play sound") {
id(timer_ringing).turn_on();
} else if (action == "Send event") {
id(send_alarm_event).execute();
} else if (action == "Sound and event") {
id(timer_ringing).turn_on();
id(send_alarm_event).execute();
}
}
} else {
ESP_LOGW("alarm", "Incorrect alarm time setting");
}
}
- id: send_alarm_event
then:
- homeassistant.event:
event: esphome.alarm_ringing
- id: send_tts_uri_event
parameters:
tts_uri: string
then:
- homeassistant.event:
event: esphome.tts_uri
data:
uri: !lambda return tts_uri;
- id: send_stt_text_event
parameters:
stt_text: string
then:
- homeassistant.event:
event: esphome.stt_text
data:
text: !lambda return stt_text;
- id: publish_current_time
mode: single
then:
- lambda: |-
static std::string last_time_string = "";
auto time_now = id(homeassistant_time).now();
std::string current_time_string = time_now.strftime("%H:%M");

// Only publish if time actually changed
if (current_time_string != last_time_string) {
id(current_time).publish_state(current_time_string);
last_time_string = current_time_string;
}

中央コントローラー(led_set_effect)

script:
# =========================================================================
# == Centralized script to control all LED effects ==
# =========================================================================
- id: led_set_effect
mode: restart
parameters:
effect: std::string
r: float
g: float
b: float
speed: float
....
  • すべてのLEDエフェクトを一元管理します。
  • エフェクトの種類、色(R/G/B)、速度を動的に設定できます。
  • スムーズな動きを実現するために更新スクリプトを使用します。
  • Offエフェクトは即座にLEDを消灯します。

個別スクリプト

  • 各アニメーション(breathe、rainbow、comet、twinkle、timer tick、volume display、LED beam)は専用のスクリプトを持ちます。
  • システムをモジュール化し、保守しやすくします。
  • led_animation_interval または中央コントローラーによって定期的にトリガーされます。
デバイス状態LEDエフェクト
起動失敗赤のbreathe
Improv BLEモード暖色のtwinkle
初期化中青のtwinkle
HA未接続赤のtwinkle
音声アシスタント待機中紫のbeam
音声アシスタントが聞き取り中明るい紫のbeam
音声アシスタントが思考中紫のbreathe
音声アシスタントが応答中紫のcomet
音声アシスタントエラー赤のbreathe
音声アシスタント待機(アイドル)LED消灯
タイマーが鳴っている紫の高速breathe
音量変更一時的な表示

LED制御の仕組み(フロー概要)​

  1. エフェクトのトリガー

    • 何かが発生したとき(例:起動失敗、音声アシスタントが聞き取り中、タイマーが鳴っている)、スクリプトが実行されます。

    • そのスクリプトが 中央LEDコントローラー(led_set_effect)を呼び出し、次の情報を渡します:

      • どのエフェクト を実行するか(例:breathe、rainbow、comet)
      • どの色 にするか(R、G、Bの値)
      • どのくらいの速さ(speed)。

    起動に失敗した場合 → led_set_effect が effect = breathe、color = red で呼び出されます。

  2. 中央コントローラー(intervalループ)

    • 50ms ごと(1秒間に20回)、led_animation_interval ループが 現在のエフェクト を確認します。

    • そのエフェクト名に基づいて、対応する更新スクリプトに 制御を転送 します。

      • effect = breathe の場合 → update_breathe_effect を実行。
      • effect = rainbow の場合 → update_rainbow_effect を実行。
      • そのほか twinkle、comet、timer tick、LED beam なども同様です。

このループは ディスパッチャ のように動作し、「次にどのアニメーションスクリプトを実行するか」を決定します。


interval:
- interval: 50ms
id: led_animation_interval
then:
- lambda: |-
if (id(volume_display_active)) {
id(update_volume_display_effect).execute();
return;
}
std::string effect = id(current_led_effect);
if (effect == "off") {
return;
} else if (effect == "breathe") {
id(update_breathe_effect).execute();
} else if (effect == "rainbow") {
id(update_rainbow_effect).execute();
} else if (effect == "comet_cw") {
id(update_comet_cw_effect).execute();
} else if (effect == "comet_ccw") {
id(update_comet_ccw_effect).execute();
} else if (effect == "twinkle") {
id(update_twinkle_effect).execute();
} else if (effect == "timer_tick") {
id(update_timer_tick_effect).execute();
} else if (effect == "led_beam") {
id(update_led_beam_effect).execute();
}

  1. エフェクト更新スクリプト

    • 各エフェクトには、LEDの色をフレームごとに計算する専用スクリプトがあります。

    • 例:breatheエフェクト

      • サイン波を使って、明るさを滑らかにフェードイン・フェードアウトさせます。
      • 明るさにLEDリングのグローバル設定(speed、明るさスライダー、R/G/Bカラー)を掛け合わせます。
      • 12個すべてのLED用のカラー配列を構築します。
      • その色をRespeaker LEDリングに送信します。

例:


# Individual update scripts for each animated effect
- id: update_breathe_effect
then:
- lambda: |-
static float phase = 0.0f;
uint32_t now = millis();
float dt = (now - id(last_led_update_time)) / 1000.0f;
id(last_led_update_time) = now;

phase += dt * id(led_ring_speed);
while (phase >= 1.0f) phase -= 1.0f;

float master_brightness = id(led_ring_brightness).state;
float breath_brightness = 0.5f * (1.0f + sinf(phase * 2.0f * M_PI)) * master_brightness;

uint8_t r = (uint8_t)(id(led_ring_color_r) * 255.0f * breath_brightness);
uint8_t g = (uint8_t)(id(led_ring_color_g) * 255.0f * breath_brightness);
uint8_t b = (uint8_t)(id(led_ring_color_b) * 255.0f * breath_brightness);
uint32_t current_color = (r << 16) | (g << 8) | b;

uint32_t colors[12];
for (int i = 0; i < 12; i++) colors[i] = current_color;
id(respeaker).set_led_ring(colors);

これにより、アニメーションは滑らかで動的かつカスタマイズ可能になります。

オーディオ設定​

I²S 入力 / 出力

I²S(Inter-IC Sound)は、ESP32がコーデック、マイク、スピーカーなどのチップと高品質なデジタルオーディオを送受信するためのデジタル接続です。

i2s_audio:
- id: i2s_output
i2s_lrclk_pin:
number: GPIO7
allow_other_uses: true
i2s_bclk_pin:
number: GPIO8
allow_other_uses: true
# i2s_mclk_pin:
# number: GPIO9
# allow_other_uses: true

- id: i2s_input
i2s_lrclk_pin:
number: GPIO7
allow_other_uses: true
i2s_bclk_pin:
number: GPIO8
allow_other_uses: true
# i2s_mclk_pin:
# number: GPIO9
# allow_other_uses: true

マイク

  • I²Sマイクアレイ / コーデックから音声を取得します。
  • GPIO43:マイク音声データが入力されるピン。
  • サンプリングレート48 kHz、32ビット:高品質録音(ステレオ、2チャンネル)。
  • adc_type: external:外部コーデックを使用します(ESP32内蔵ADCは使用しません)。
  • i2s_mode: secondary:ESP32がコーデックのクロックに追従します(スレーブモード)。
microphone:
- platform: i2s_audio
id: i2s_mics
i2s_din_pin: GPIO43
adc_type: external
pdm: false
sample_rate: 48000
bits_per_sample: 32bit
i2s_mode: secondary
i2s_audio_id: i2s_input
channel: stereo

スピーカー

  • コーデック(AIC3104 DAC)経由でスピーカーに音声を送信します。
  • GPIO44:デジタルオーディオが出力されるピン。
  • 48 kHz、32ビットステレオ:高品質な再生。
  • buffer_duration 100ms:音声を滑らかに保ちます(ノイズや途切れを防止)。
  • timeout: never:スピーカーチャンネルを維持します(自動で停止しません)。
speaker:
# Hardware speaker output
- platform: i2s_audio
id: i2s_audio_speaker
sample_rate: 48000
i2s_mode: secondary
i2s_dout_pin: GPIO44
bits_per_sample: 32bit
i2s_audio_id: i2s_output
dac_type: external
channel: stereo
timeout: never
buffer_duration: 100ms
audio_dac: aic3104_dac

ミキサー

  • 複数のオーディオストリーム(メディア+アナウンス)を1つの出力にミックスします。
  # Virtual speakers to combine the announcement and media streams together into one output
- platform: mixer
id: mixing_speaker
output_speaker: i2s_audio_speaker
num_channels: 2
source_speakers:
- id: announcement_mixing_input
timeout: never
- id: media_mixing_input
timeout: never

リサンプラー

  • すべての音声ソースのサンプリングレートとビット深度を一致させます。
  # Vritual speakers to resample each pipelines' audio, if necessary, as the mixer speaker requires the same sample rate
- platform: resampler
id: announcement_resampling_speaker
output_speaker: announcement_mixing_input
sample_rate: 48000
bits_per_sample: 16
- platform: resampler
id: media_resampling_speaker
output_speaker: media_mixing_input
sample_rate: 48000
bits_per_sample: 16

メディアプレーヤー

  • 音量、ミュート、再生、ダッキング(アナウンス中にメディア音量を下げる)を制御します。
  • イベント用のサウンド(タイマー、ウェイクワード、エラー)を事前に読み込みます。
media_player:
- platform: speaker
id: external_media_player
name: None
internal: False
volume_increment: 0.05
volume_min: 0.0
volume_max: 1.0
...
...

Respeaker XVF3800 連携​

  • i2cアドレス:0x2C
  • ID:respeaker
  • マイクミュートスイッチ:1秒ごとに更新し、切り替え時にサウンドを再生します。
  • DFUバージョン報告:120秒ごとにファームウェアを報告します。
  • ビーム方向センサー:ボイスビームを追跡します(内部のみ)。
  • ファームウェア管理:必要に応じてXVF3800ファームウェアを自動フラッシュします。
respeaker_xvf3800:
id: respeaker
address: 0x2C
mute_switch:
id: mic_mute_switch
name: "Microphone Mute"
update_interval: 1s
on_turn_on:
...
...

参考 / リポジトリ​

external_components:
- source:
type: git
url: https://github.com/formatBCE/esphome
ref: respeaker_microphone
components:
- i2s_audio
refresh: 0s
- source:
type: git
url: https://github.com/formatBCE/Respeaker-XVF3800-ESPHome-integration
ref: main
components:
- respeaker_xvf3800
- aic3104
refresh: 0s

  • formatBCE/esphome:カスタムI²Sオーディオコンポーネント。
  • formatBCE/Respeaker-XVF3800-ESPHome-integration:
  • XVF3800ドライバー
  • AIC3104オーディオコーデックドライバー
  • refresh: 0s:常にリポジトリから最新コードを取得します。

Micro Wake Word​

micro_wake_word:
id: mww
microphone:
microphone: i2s_mics
channels: 1
# gain_factor: 4
stop_after_detection: false
....
....

ウェイクワード(「Okay Nabu」など)を検出し、音声アシスタントを起動します。

  • id: mww → 参照名。
  • microphone: i2s_mics、1チャンネル。
  • stop_after_detection: false → 継続的に聞き取りを行います。
  • okay_nabu, kenobi, hey_jarvis, hey_mycroft, stop(内部停止コマンド。任意のコマンドを追加可能)。
  • vad probability_cutoff: 0.05 → 音声検出感度。

検出時(マイクがミュートされていない場合)

  • タイマー、アナウンス、またはアクティブな音声アシスタントを停止します。
  • ウェイクサウンドを再生します(有効な場合)。
  • コマンド用に音声アシスタントを起動します。

音声アシスタント​

voice_assistant:
id: va
microphone:
microphone: i2s_mics
channels: 0
media_player: external_media_player
micro_wake_word: mww
use_wake_word: false
noise_suppression_level: 0
....
....

音声アシスタント(VA)の動作とインタラクションを制御します。

  • マイクとメディア: i2s_mics と外部メディアプレーヤーを使用します。
  • ウェイクワード: mww にリンクされていますが、ウェイクワードは不要です(use_wake_word: false)。
  • オーディオ設定: ノイズ抑制オフ、オートゲイン 0 dB、通常の音量。

イベント / 動作内容​

  • on_client_connected: VA を開始し、LED を更新し、必要に応じてマイクのミュートを解除します。
  • on_client_disconnected: VA を停止し、LED をリセットします。
  • on_error: LED にエラー状態を表示し、クラウド認証に失敗した場合はローカルサウンドを再生します。
  • on_start: VA 開始時にメディア音量を下げます(ダッキング)。
  • on_listening / on_stt_vad_start / on_stt_vad_end: リスニングおよび思考中に VA のフェーズと LED を更新します。
  • on_intent_progress / on_tts_start / on_tts_end: 発話を処理し、LED を更新し、ストップワードスクリプトをトリガーできます。
  • on_stt_end: 認識されたテキストイベントを送信します。
  • on_end: VA を停止し、LED をリセットし、ダッキングを終了します。

タイマーイベント​

  • on_timer_started / on_timer_updated / on_timer_cancelled / on_timer_finished / on_timer_tick:
    • タイマーの状態と名前を更新します。
    • LED を更新します。
    • カウント中のタイマーでは LED の更新を 5 秒ごとに減らします。

謝辞​

Seeed Studio ReSpeaker XVF3800 用にこの素晴らしい YAML ファイルを作成してくれた FormatBCE に感謝いたします。 彼をサポートするには、GitHub をご覧ください。

技術サポート & 製品ディスカッション​

弊社製品をお選びいただきありがとうございます。弊社は、製品をできるだけスムーズにご利用いただけるよう、さまざまなサポートを提供しています。お好みやニーズに合わせて選べる複数のコミュニケーションチャネルをご用意しています。

Loading Comments...