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"