Files
homeassistant-config/esphome/luxe_music.yaml
T

486 lines
12 KiB
YAML

substitutions:
startup_sound_file: https://github.com/RASPIAUDIO/esphomeLuxe/raw/refs/heads/main/wav/sounds_timer_finished.wav
device_description: "Raspiaudio Muse Luxe Music player for Music Assistant using sendspin"
name: "muse-luxe-music"
friendly_name: "Raspiaudio Muse Luxe Music"
esphome:
comment: ${device_description}
name: ${name}
friendly_name: ${friendly_name}
min_version: 2026.8.2
name_add_mac_suffix: true
on_boot:
priority: -100
then:
# GPIO21 commande la mise en sourdine de l'amplificateur de la Luxe.
- output.turn_off: dac_mute
# Laisse le mixer terminer une annonce avant la reprise d'un flux MA.
- speaker_source.set_playlist_delay:
id: external_media_player
pipeline: announcement
delay: 500ms
- speaker_source.set_playlist_delay:
id: external_media_player
pipeline: media
delay: 1500ms
- script.execute: led_red
project:
name: raspiaudio.music-assistant
version: "2026.9.3"
esp32:
variant: esp32
framework:
type: esp-idf
advanced:
include_builtin_idf_components:
- esp_http_client
sram1_as_iram: true
logger:
level: warn
api:
encryption:
key: !secret apikey
# La lecture Sendspin doit continuer même sans client Home Assistant connecté.
reboot_timeout: 0s
on_client_connected:
- if:
condition:
lambda: return !id(boot_sound_played);
then:
- lambda: id(boot_sound_played) = true;
# Laisse MA restaurer son groupe avant de décider si l'annonce peut jouer.
- delay: 5s
- if:
condition:
lambda: |-
using media_player::MediaPlayerState;
return id(startup_sound_switch).state &&
id(external_media_player).state != MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING &&
id(external_media_player).state != MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING;
then:
- script.execute:
id: play_sound
priority: true
sound_file: ready_sound
wifi:
ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
#use_address: ${name}.home
power_save_mode: high
fast_connect: on
min_auth_mode: WPA2
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Raspiaudio Fallback Hotspot"
password: !secret fallback_psk
psram:
mode: quad
speed: 80MHz
ota:
- platform: esphome
id: ota_esphome
encryption:
- platform: http_request
id: ota_http_request
http_request:
captive_portal:
improv_serial:
external_components:
- source:
type: local
path: components
components: [luxe_ip5306]
# Initialisation spécifique du codec de la museLuxe (sorties différentielles
# et horloge MCLK sur GPIO0). Sendspin et la chaîne audio sont désormais natifs.
- source:
type: git
url: https://github.com/RASPIAUDIO/esphomeLuxe
ref: 192372ff59b560a74aa547dc26af609918fcc9d6
components: [es8388]
i2c:
sda: GPIO18
scl: GPIO23
frequency: 100kHz
id: bus_a
es8388:
i2s_audio:
- id: i2s_audio_bus
i2s_lrclk_pin: GPIO25
i2s_bclk_pin: GPIO5
#####################
# Internal Components
output:
- platform: gpio
id: dac_mute
pin:
number: GPIO21
inverted: true
mode:
output: true
speaker:
- platform: i2s_audio
id: box_speaker
i2s_audio_id: i2s_audio_bus
i2s_dout_pin: GPIO26
dac_type: external
channel: stereo
sample_rate: 48000
buffer_duration: 300ms
- platform: mixer
id: mixing_speaker
output_speaker: box_speaker
num_channels: 2
source_speakers:
- id: announcement_mixer_input
- id: media_mixer_input
- platform: resampler
id: media_resampling_input
output_speaker: media_mixer_input
sample_rate: 48000
- platform: resampler
id: announcement_resampling_input
output_speaker: announcement_mixer_input
sample_rate: 48000
sendspin:
id: sendspin_hub
audio_file:
- id: ready_sound
file: ${startup_sound_file}
media_source:
- platform: sendspin
id: sendspin_source
buffer_size: 2000000
- platform: audio_file
id: file_source
media_player:
- platform: sendspin
id: sendspin_media_player
name: museLuxe
volume_increment: 2%
- platform: speaker_source
name: museLuxe Audio
id: external_media_player
internal: true
announcement_pipeline:
speaker: announcement_resampling_input
sources:
- file_source
format: FLAC
num_channels: 1
sample_rate: 48000
media_pipeline:
speaker: media_resampling_input
sources:
- sendspin_source
format: FLAC
num_channels: 2
sample_rate: 48000
volume_increment: 0.05
volume_min: 0.05
volume_max: 0.95
on_state:
then:
- lambda: |-
using media_player::MediaPlayerState;
auto state = id(external_media_player).state;
if (
state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING ||
state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING
) {
id(led_green).execute();
} else {
id(led_red).execute();
}
script:
- id: update_battery_led
mode: queued
max_runs: 2
then:
- lambda: |-
const bool critical = id(battery_critical).state;
if (critical && (!id(led).remote_values.is_on() || id(led).get_effect_name() != "Battery alert")) {
auto call = id(led).turn_on();
call.set_effect("Battery alert");
call.set_brightness(1.0f);
call.perform();
} else if (!critical && id(led).get_effect_name() == "Battery alert") {
auto call = id(led).turn_on();
call.set_effect("None");
call.perform();
}
- id: play_sound
parameters:
priority: bool
sound_file: string
then:
- if:
condition:
lambda: return priority;
then:
- media_player.stop:
id: external_media_player
announcement: true
- lambda: |-
if ( (id(external_media_player).state != media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING ) || priority) {
id(external_media_player)
->make_call()
.set_media_url("audio-file://" + sound_file)
.set_announcement(true)
.perform();
}
- id: led_red
then:
- light.turn_on:
id: led
red: 100%
green: 0%
blue: 0%
brightness: 100%
- id: led_green
then:
- light.turn_on:
id: led
red: 0%
green: 100%
blue: 0%
brightness: 100%
switch:
- platform: template
id: ip5306_charge_switch
name: "Allow charging"
entity_category: config
icon: mdi:battery-charging
restore_mode: DISABLED
optimistic: false
lambda: return id(battery_manager).charge_enabled();
turn_on_action:
- lambda: id(battery_manager).set_charge_enabled(true);
turn_off_action:
- lambda: id(battery_manager).set_charge_enabled(false);
- platform: template
id: startup_sound_switch
name: Startup sound
icon: "mdi:card-text-outline"
entity_category: config
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
globals:
- id: boot_sound_played
type: bool
restore_value: no
initial_value: "false"
binary_sensor:
- platform: template
id: battery_critical
name: Battery low
device_class: battery
entity_category: diagnostic
lambda: |-
if (!id(battery_percent).has_state() || !std::isfinite(id(battery_percent).state)) return {};
const float percent = id(battery_percent).state;
const float threshold = id(battery_alert_threshold).state;
if (percent < threshold) return true;
if (percent >= threshold + 5.0f) return false;
return {};
on_state:
- script.execute: update_battery_led
- platform: gpio
pin:
number: GPIO12
inverted: true
mode:
input: true
output: false
open_drain: false
pullup: true
pulldown: false
drive_strength: 20.0
name: Play or Pause
on_press:
then:
- media_player.toggle: sendspin_media_player
- platform: gpio
pin:
number: GPIO19
inverted: true
mode:
input: true
pullup: true
name: Volume Up
on_click:
- media_player.volume_up:
id: sendspin_media_player
- platform: gpio
pin:
number: GPIO32
inverted: true
mode:
input: true
pullup: true
name: Volume Down
on_click:
- media_player.volume_down:
id: sendspin_media_player
light:
- platform: esp32_rmt_led_strip
id: led
name: LED light
disabled_by_default: false
entity_category: config
pin: GPIO22
default_transition_length: 0s
chipset: WS2812
num_leds: 1
channel_colors: GRB
# Conserve l'alerte après une commande HA ou un changement lecture/arrêt.
on_state:
- script.execute: update_battery_led
effects:
- addressable_lambda:
name: "Battery alert"
update_interval: 500ms
lambda: |-
static bool lit = false;
if (initial_run) lit = false;
lit = !lit;
it.all() = lit ? current_color : Color::BLACK;
- pulse:
name: "Slow Pulse"
transition_length: 250ms
update_interval: 250ms
min_brightness: 50%
max_brightness: 100%
- pulse:
name: "Fast Pulse"
transition_length: 100ms
update_interval: 100ms
min_brightness: 50%
max_brightness: 100%
sensor:
- platform: adc
pin: GPIO33
id: battery_voltage
name: Battery voltage
icon: "mdi:battery-outline"
update_interval: 15s
accuracy_decimals: 3
attenuation: 12db
raw: true
filters:
- multiply: 0.00173913 # 2300 -> 4, for attenuation 11db, based on Olivier's code
- exponential_moving_average:
alpha: 0.2
send_every: 2
- delta: 0.002
on_value:
- sensor.template.publish:
id: battery_percent
state: !lambda return x;
- platform: template
id: battery_percent
name: Battery level
device_class: battery
unit_of_measurement: "%"
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
# Même estimation tension/charge que la Radio, après calibration Luxe.
filters:
- calibrate_polynomial:
degree: 3
datapoints:
- 4.20 -> 100.0
- 4.13 -> 97.1
- 4.10 -> 94.2
- 4.07 -> 88.4
- 4.05 -> 82.7
- 4.04 -> 76.9
- 4.04 -> 71.1
- 4.01 -> 65.3
- 3.99 -> 59.5
- 3.95 -> 53.8
- 3.93 -> 48.0
- 3.91 -> 42.2
- 3.88 -> 36.4
- 3.86 -> 30.6
- 3.84 -> 24.9
- 3.82 -> 19.1
- 3.76 -> 13.3
- 3.73 -> 10.4
- 3.70 -> 7.5
- 3.64 -> 4.6
- 3.50 -> 1.7
- 3.00 -> 0.0
- lambda: return clamp(x, 0.0f, 100.0f);
- platform: wifi_signal
name: "museLuxe WiFi signal"
update_interval: 10s
# Paramètre logiciel : conserve une hystérésis fixe de 5 points.
number:
- platform: template
id: battery_alert_threshold
name: "Low battery threshold"
entity_category: config
unit_of_measurement: "%"
min_value: 5
max_value: 50
step: 1
initial_value: 20
restore_value: true
optimistic: true
mode: box
luxe_ip5306:
id: battery_manager
i2c_id: bus_a
address: 0x75
update_interval: 10s
available:
name: "IP5306 available"
id: ip5306_available
charging:
name: "Charging"
full:
name: "Battery full"
charge_allowed:
name: "Charging enabled (hardware)"
level:
name: "IP5306 battery level"
current_limit:
name: "IP5306 charge current limit"