From e949ebacc7b26c27c2e2bffc0a51b4af35cb6a9a Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sun, 27 Sep 2026 18:59:01 +0200 Subject: [PATCH] Added Raspiaudio Muse Luxe again as a sendspin player. --- esphome/components/luxe_ip5306/__init__.py | 50 ++ esphome/components/luxe_ip5306/luxe_ip5306.h | 108 +++++ esphome/luxe_music.yaml | 485 +++++++++++++++++++ 3 files changed, 643 insertions(+) create mode 100644 esphome/components/luxe_ip5306/__init__.py create mode 100644 esphome/components/luxe_ip5306/luxe_ip5306.h create mode 100644 esphome/luxe_music.yaml diff --git a/esphome/components/luxe_ip5306/__init__.py b/esphome/components/luxe_ip5306/__init__.py new file mode 100644 index 0000000..f8f9dc0 --- /dev/null +++ b/esphome/components/luxe_ip5306/__init__.py @@ -0,0 +1,50 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import binary_sensor, i2c, sensor +from esphome.const import CONF_ID, ENTITY_CATEGORY_DIAGNOSTIC + +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["sensor", "binary_sensor"] + +ns = cg.esphome_ns.namespace("luxe_ip5306") +LuxeIP5306 = ns.class_("LuxeIP5306", cg.PollingComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LuxeIP5306), + cv.Required("available"): binary_sensor.binary_sensor_schema( + device_class="connectivity", entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Required("charging"): binary_sensor.binary_sensor_schema( + device_class="battery_charging" + ), + cv.Required("full"): binary_sensor.binary_sensor_schema(), + cv.Required("charge_allowed"): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Required("level"): sensor.sensor_schema( + unit_of_measurement="%", accuracy_decimals=0, device_class="battery", + state_class="measurement", entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Required("current_limit"): sensor.sensor_schema( + unit_of_measurement="mA", accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } + ) + .extend(cv.polling_component_schema("10s")) + .extend(i2c.i2c_device_schema(0x75)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + for key in ("available", "charging", "full", "charge_allowed"): + entity = await binary_sensor.new_binary_sensor(config[key]) + cg.add(getattr(var, f"set_{key}")(entity)) + for key in ("level", "current_limit"): + entity = await sensor.new_sensor(config[key]) + cg.add(getattr(var, f"set_{key}")(entity)) diff --git a/esphome/components/luxe_ip5306/luxe_ip5306.h b/esphome/components/luxe_ip5306/luxe_ip5306.h new file mode 100644 index 0000000..727de45 --- /dev/null +++ b/esphome/components/luxe_ip5306/luxe_ip5306.h @@ -0,0 +1,108 @@ +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::luxe_ip5306 { + +// Optional IP5306-I2C on recent Luxe boards. Never changes power settings at boot. +class LuxeIP5306 : public PollingComponent, public i2c::I2CDevice { + public: + void set_available(binary_sensor::BinarySensor *s) { available_ = s; } + void set_charging(binary_sensor::BinarySensor *s) { charging_ = s; } + void set_full(binary_sensor::BinarySensor *s) { full_ = s; } + void set_charge_allowed(binary_sensor::BinarySensor *s) { charge_allowed_ = s; } + void set_level(sensor::Sensor *s) { level_ = s; } + void set_current_limit(sensor::Sensor *s) { current_limit_ = s; } + float get_setup_priority() const override { return setup_priority::DATA; } + + void setup() override { update(); } + + void update() override { + uint8_t control, charge, full, limit, gauge; + // Address-only ACK probe, then individual register reads. No assumption + // that the chip supports register auto-increment. + if (write(nullptr, 0) != i2c::ERROR_OK || + !read_byte(0x00, &control) || !read_byte(0x70, &charge) || + !read_byte(0x71, &full) || !read_byte(0x24, &limit)) { + unavailable_(); + return; + } + valid_ = true; + charge_enabled_ = (control & 0x10) != 0; + available_->publish_state(true); + charge_allowed_->publish_state(charge_enabled_); + const bool full_flag = (full & 0x08) != 0; + full_->publish_state(full_flag); + charging_->publish_state(charge_enabled_ && (charge & 0x08) && !full_flag); + // Configured current, NOT measured current. Keep it separate from the + // charging state: 0x70 alone cannot establish USB presence in every mode. + current_limit_->publish_state(50 + 100 * (limit & 0x1F)); + // Gauge is optional: undocumented on some revisions. Failure/unknown + // codes must never be presented as an empty battery. + float percent = NAN; + if (read_byte(0x78, &gauge)) { + switch (gauge >> 4) { + case 0x0: percent = 100; break; + case 0x8: percent = 75; break; + case 0xC: percent = 50; break; + case 0xE: percent = 25; break; + case 0xF: percent = 0; break; + } + } + level_->publish_state(percent); + } + + optional charge_enabled() const { + if (!valid_) return {}; + return charge_enabled_; + } + + void set_charge_enabled(bool enabled) { + uint8_t before; + // Read afresh even if the last poll succeeded. Modify only bit 4, leaving + // boost, key behaviour and all reserved bits untouched. + if (write(nullptr, 0) != i2c::ERROR_OK || !read_byte(0x00, &before)) { + unavailable_(); + ESP_LOGW("luxe_ip5306", "Recharge command rejected: circuit unavailable"); + return; + } + const uint8_t after = enabled ? (before | 0x10) : (before & ~0x10); + if (after != before && !write_byte(0x00, after)) { + unavailable_(); + ESP_LOGW("luxe_ip5306", "Recharge command failed"); + return; + } + update(); // Read-back is authoritative; the switch is not optimistic. + if (valid_ && charge_enabled_ != enabled) + ESP_LOGW("luxe_ip5306", "Recharge command not confirmed by circuit"); + } + + protected: + void unavailable_() { + valid_ = false; + available_->publish_state(false); + charging_->invalidate_state(); + full_->invalidate_state(); + charge_allowed_->invalidate_state(); + level_->publish_state(NAN); + current_limit_->publish_state(NAN); + // No mark_failed(): an older board must still play audio, and a device + // temporarily asleep or disconnected must be detected on the next poll. + } + + binary_sensor::BinarySensor *available_{nullptr}; + binary_sensor::BinarySensor *charging_{nullptr}; + binary_sensor::BinarySensor *full_{nullptr}; + binary_sensor::BinarySensor *charge_allowed_{nullptr}; + sensor::Sensor *level_{nullptr}; + sensor::Sensor *current_limit_{nullptr}; + bool valid_{false}; + bool charge_enabled_{false}; +}; +} // namespace esphome::luxe_ip5306 diff --git a/esphome/luxe_music.yaml b/esphome/luxe_music.yaml new file mode 100644 index 0000000..67f333a --- /dev/null +++ b/esphome/luxe_music.yaml @@ -0,0 +1,485 @@ +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"