From d8ca4a9460129bcd9c6d528a222df283fe008951 Mon Sep 17 00:00:00 2001 From: Giovanni <105289353+GioMez@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:50:41 +0200 Subject: [PATCH] [audio_dac] Add ES9038Q2M Katana platform (#15017) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/es9038q2m_katana/__init__.py | 3 + .../components/es9038q2m_katana/audio_dac.py | 92 +++++++++ .../es9038q2m_katana/es9038q2m_katana.cpp | 176 ++++++++++++++++++ .../es9038q2m_katana/es9038q2m_katana.h | 62 ++++++ tests/components/es9038q2m_katana/common.yaml | 16 ++ .../es9038q2m_katana/test.esp32-idf.yaml | 3 + .../es9038q2m_katana/test.esp8266-ard.yaml | 3 + 8 files changed, 356 insertions(+) create mode 100644 esphome/components/es9038q2m_katana/__init__.py create mode 100644 esphome/components/es9038q2m_katana/audio_dac.py create mode 100644 esphome/components/es9038q2m_katana/es9038q2m_katana.cpp create mode 100644 esphome/components/es9038q2m_katana/es9038q2m_katana.h create mode 100644 tests/components/es9038q2m_katana/common.yaml create mode 100644 tests/components/es9038q2m_katana/test.esp32-idf.yaml create mode 100644 tests/components/es9038q2m_katana/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 56cb685ec53..6adf2a93594 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -183,6 +183,7 @@ esphome/components/es7243e/* @kbx81 esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT +esphome/components/es9038q2m_katana/* @GioMez esphome/components/esp32/* @esphome/core esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito esphome/components/esp32_ble_client/* @bdraco @jesserockz diff --git a/esphome/components/es9038q2m_katana/__init__.py b/esphome/components/es9038q2m_katana/__init__.py new file mode 100644 index 00000000000..5950268ad04 --- /dev/null +++ b/esphome/components/es9038q2m_katana/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@GioMez"] +DEPENDENCIES = ["i2c"] +DOMAIN = "es9038q2m_katana" diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py new file mode 100644 index 00000000000..f5c14d234c6 --- /dev/null +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -0,0 +1,92 @@ +import esphome.codegen as cg +from esphome.components import audio_dac, i2c +import esphome.config_validation as cv +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +CONF_DEEMPHASIS = "deemphasis" +CONF_DOP = "dop" +CONF_FILTER_SHAPE = "filter_shape" + +# Fields of the Katana format register: channel layout, word length and rate code. +FORMAT_STEREO = 0x80 +BITS_PER_SAMPLE_CODES = {16: 0x10, 24: 0x20, 32: 0x30} +SAMPLE_RATE_CODES = { + 44100: 0x04, + 48000: 0x05, + 88200: 0x06, + 96000: 0x07, + 176400: 0x08, + 192000: 0x09, + 352800: 0x0A, + 384000: 0x0B, +} + +es9038q2m_katana_ns = cg.esphome_ns.namespace("es9038q2m_katana") +ES9038Q2MKatana = es9038q2m_katana_ns.class_( + "ES9038Q2MKatana", + audio_dac.AudioDac, + cg.Component, + i2c.I2CDevice, +) + +FilterShape = es9038q2m_katana_ns.enum("FilterShape") +FILTER_SHAPES = { + "linear_phase_fast": FilterShape.FILTER_SHAPE_LINEAR_PHASE_FAST, + "linear_phase_slow": FilterShape.FILTER_SHAPE_LINEAR_PHASE_SLOW, + "minimum_phase_fast": FilterShape.FILTER_SHAPE_MIN_PHASE_FAST, + "minimum_phase_slow": FilterShape.FILTER_SHAPE_MIN_PHASE_SLOW, + "apodizing": FilterShape.FILTER_SHAPE_APODIZING, + "hybrid": FilterShape.FILTER_SHAPE_HYBRID, + "brick_wall": FilterShape.FILTER_SHAPE_BRICK_WALL, +} + +DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode") +DEEMPHASIS_MODES = { + "bypass": DeemphasisMode.DEEMPHASIS_BYPASS, + "32khz": DeemphasisMode.DEEMPHASIS_32KHZ, + "44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ, + "48khz": DeemphasisMode.DEEMPHASIS_48KHZ, +} + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ES9038Q2MKatana), + cv.Optional(CONF_FILTER_SHAPE, default="apodizing"): cv.enum( + FILTER_SHAPES, lower=True + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + cv.float_with_unit("bits", "bit"), + cv.one_of(*BITS_PER_SAMPLE_CODES, int=True), + ), + cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.one_of( + *SAMPLE_RATE_CODES, int=True + ), + cv.Optional(CONF_DEEMPHASIS, default="bypass"): cv.enum( + DEEMPHASIS_MODES, lower=True + ), + # Only switches the control logic; the source must already send DoP frames. + cv.Optional(CONF_DOP, default=False): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE])) + cg.add( + var.set_format( + FORMAT_STEREO + | BITS_PER_SAMPLE_CODES[config[CONF_BITS_PER_SAMPLE]] + | SAMPLE_RATE_CODES[config[CONF_SAMPLE_RATE]] + ) + ) + cg.add(var.set_deemphasis_mode(config[CONF_DEEMPHASIS])) + cg.add(var.set_dop_enabled(config[CONF_DOP])) diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp new file mode 100644 index 00000000000..464f971acef --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -0,0 +1,176 @@ +#include "es9038q2m_katana.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::es9038q2m_katana { + +ESPHOME_LOG_TAG(TAG, "es9038q2m_katana"); + +namespace { + +constexpr uint8_t KATANA_CHIP_ID = 0x30; + +constexpr uint8_t KATANA_REG_CHIP_ID = 0x00; +constexpr uint8_t KATANA_REG_RESET = 0x01; +constexpr uint8_t KATANA_REG_VOLUME_1 = 0x02; +constexpr uint8_t KATANA_REG_VOLUME_2 = 0x03; +constexpr uint8_t KATANA_REG_MUTE = 0x04; +constexpr uint8_t KATANA_REG_DSP_PROGRAM = 0x05; +constexpr uint8_t KATANA_REG_DEEMPHASIS = 0x06; +constexpr uint8_t KATANA_REG_DOP = 0x07; +constexpr uint8_t KATANA_REG_FORMAT = 0x08; +constexpr uint8_t KATANA_REG_COMMAND = 0x09; +constexpr uint8_t KATANA_REG_MUTE_STREAM = 0x0A; + +constexpr float KATANA_VOLUME_MIDPOINT = 0.5f; +constexpr float KATANA_VOLUME_MIDPOINT_STEPS = 50.0f; // -25 dB at 50% volume. +// Cubic amplitude gain: -20 * log10(volume^3) / 0.5 attenuation steps. +constexpr float KATANA_VOLUME_CUBIC_SCALE = 120.0f; +constexpr uint8_t KATANA_VOLUME_FULL_ATTENUATION = 255; + +// The control logic ignores writes for this long after a reset. +constexpr uint32_t RESET_SETTLE_DELAY_MS = 10; + +const LogString *filter_shape_to_str(FilterShape shape) { + switch (shape) { + case FILTER_SHAPE_LINEAR_PHASE_FAST: + return LOG_STR("linear_phase_fast"); + case FILTER_SHAPE_LINEAR_PHASE_SLOW: + return LOG_STR("linear_phase_slow"); + case FILTER_SHAPE_MIN_PHASE_FAST: + return LOG_STR("minimum_phase_fast"); + case FILTER_SHAPE_MIN_PHASE_SLOW: + return LOG_STR("minimum_phase_slow"); + case FILTER_SHAPE_APODIZING: + return LOG_STR("apodizing"); + case FILTER_SHAPE_HYBRID: + return LOG_STR("hybrid"); + default: + return LOG_STR("brick_wall"); + } +} + +const LogString *deemphasis_to_str(DeemphasisMode mode) { + switch (mode) { + case DEEMPHASIS_32KHZ: + return LOG_STR("32khz"); + case DEEMPHASIS_44_1KHZ: + return LOG_STR("44_1khz"); + case DEEMPHASIS_48KHZ: + return LOG_STR("48khz"); + default: + return LOG_STR("bypass"); + } +} + +} // namespace + +void ES9038Q2MKatana::setup() { + uint8_t chip_id = 0; + if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (chip_id != KATANA_CHIP_ID) { + ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); + this->mark_failed(LOG_STR("Unexpected chip ID")); + return; + } + if (!this->write_byte(KATANA_REG_RESET, 0x01)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + delay(RESET_SETTLE_DELAY_MS); + + // MUTE_STREAM is the gate the Linux driver closes between streams; ESPHome has no such + // notion, so it stays open and mute is only ever driven through MUTE. + if (!this->write_byte(KATANA_REG_FORMAT, this->format_) || !this->write_byte(KATANA_REG_COMMAND, 0x00) || + !this->write_byte(KATANA_REG_DSP_PROGRAM, this->filter_shape_) || + !this->write_byte(KATANA_REG_DEEMPHASIS, this->deemphasis_mode_) || + !this->write_byte(KATANA_REG_DOP, this->dop_enabled_ ? 1 : 0) || !this->write_volume_() || + !this->write_byte(KATANA_REG_MUTE, this->is_muted_ ? 1 : 0) || !this->write_byte(KATANA_REG_MUTE_STREAM, 0x00)) { + this->mark_failed(LOG_STR("Failed to configure the control logic")); + } +} + +void ES9038Q2MKatana::dump_config() { + ESP_LOGCONFIG(TAG, + "ES9038Q2M Katana:\n" + " Filter shape: %s\n" + " De-emphasis: %s\n" + " Format register: 0x%02X\n" + " DoP: %s", + LOG_STR_ARG(filter_shape_to_str(this->filter_shape_)), + LOG_STR_ARG(deemphasis_to_str(this->deemphasis_mode_)), this->format_, ONOFF(this->dop_enabled_)); + LOG_I2C_DEVICE(this); + if (this->is_failed()) + return; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + for (uint8_t reg = KATANA_REG_CHIP_ID; reg <= KATANA_REG_MUTE_STREAM; reg++) { + uint8_t value = 0; + if (this->read_byte(reg, &value)) { + ESP_LOGV(TAG, " Register 0x%02X: 0x%02X", reg, value); + } else { + ESP_LOGV(TAG, " Register 0x%02X: read failed", reg); + } + } +#endif +} + +// Both channel registers follow ESPHome's single master volume. +bool ES9038Q2MKatana::write_volume_() { + return this->write_byte(KATANA_REG_VOLUME_1, this->volume_reg_) && + this->write_byte(KATANA_REG_VOLUME_2, this->volume_reg_); +} + +// Records the outcome of a runtime write; the cached state only changes on success. +bool ES9038Q2MKatana::check_write_(bool ok) { + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } + return ok; +} + +bool ES9038Q2MKatana::set_volume(float volume) { + if (this->is_failed()) + return false; + volume = clamp(volume, 0.0f, 1.0f); + const uint8_t previous = this->volume_reg_; + float attenuation = KATANA_VOLUME_FULL_ATTENUATION; + if (volume >= KATANA_VOLUME_MIDPOINT) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS * (1.0f - volume) / (1.0f - KATANA_VOLUME_MIDPOINT); + } else if (volume > 0.0f) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS - KATANA_VOLUME_CUBIC_SCALE * log10f(volume / KATANA_VOLUME_MIDPOINT); + } + this->volume_reg_ = + static_cast(lroundf(clamp(attenuation, 0.0f, static_cast(KATANA_VOLUME_FULL_ATTENUATION)))); + if (this->check_write_(this->write_volume_())) + return true; + this->volume_reg_ = previous; + return false; +} + +float ES9038Q2MKatana::volume() { + if (this->volume_reg_ == KATANA_VOLUME_FULL_ATTENUATION) + return 0.0f; + if (this->volume_reg_ <= KATANA_VOLUME_MIDPOINT_STEPS) + return 1.0f - this->volume_reg_ * (1.0f - KATANA_VOLUME_MIDPOINT) / KATANA_VOLUME_MIDPOINT_STEPS; + return KATANA_VOLUME_MIDPOINT * + powf(10.0f, (KATANA_VOLUME_MIDPOINT_STEPS - this->volume_reg_) / KATANA_VOLUME_CUBIC_SCALE); +} + +bool ES9038Q2MKatana::set_mute_state_(bool mute_state) { + if (this->is_failed()) + return false; + if (!this->check_write_(this->write_byte(KATANA_REG_MUTE, mute_state ? 1 : 0))) + return false; + this->is_muted_ = mute_state; + return true; +} + +} // namespace esphome::es9038q2m_katana diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.h b/esphome/components/es9038q2m_katana/es9038q2m_katana.h new file mode 100644 index 00000000000..1d900f9bab4 --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::es9038q2m_katana { + +// DSP program numbers of the Katana control interface; there is no program 5. +enum FilterShape : uint8_t { + FILTER_SHAPE_LINEAR_PHASE_FAST = 0, + FILTER_SHAPE_LINEAR_PHASE_SLOW = 1, + FILTER_SHAPE_MIN_PHASE_FAST = 2, + FILTER_SHAPE_MIN_PHASE_SLOW = 3, + FILTER_SHAPE_APODIZING = 4, + FILTER_SHAPE_HYBRID = 6, + FILTER_SHAPE_BRICK_WALL = 7, +}; + +enum DeemphasisMode : uint8_t { + DEEMPHASIS_BYPASS = 0, + DEEMPHASIS_32KHZ = 1, + DEEMPHASIS_44_1KHZ = 2, + DEEMPHASIS_48KHZ = 3, +}; + +// Boards that expose an ES9038Q2M through the Allo Katana control logic at I2C address 0x30. +// Ported from the Linux driver sound/soc/bcm/allo-katana-codec.c; the DAC's own registers +// are never written, the control logic applies the configuration itself. +class ES9038Q2MKatana : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Cubic below 50%, then 0.5 dB per 1% above, meeting at -25 dB. + // Zero selects full attenuation (-127.5 dB). + bool set_volume(float volume) override; + float volume() override; + bool set_mute_off() override { return this->set_mute_state_(false); } + bool set_mute_on() override { return this->set_mute_state_(true); } + bool is_muted() override { return this->is_muted_; } + + void set_filter_shape(FilterShape shape) { this->filter_shape_ = shape; } + void set_format(uint8_t format) { this->format_ = format; } + void set_deemphasis_mode(DeemphasisMode mode) { this->deemphasis_mode_ = mode; } + void set_dop_enabled(bool enabled) { this->dop_enabled_ = enabled; } + + protected: + bool set_mute_state_(bool mute_state); + bool write_volume_(); + bool check_write_(bool ok); + + FilterShape filter_shape_{FILTER_SHAPE_APODIZING}; + DeemphasisMode deemphasis_mode_{DEEMPHASIS_BYPASS}; + uint8_t format_{0}; + // Attenuation in 0.5 dB steps: 0 is loudest, 255 quietest. Starts 20 dB down. + uint8_t volume_reg_{0x28}; + bool dop_enabled_{false}; +}; + +} // namespace esphome::es9038q2m_katana diff --git a/tests/components/es9038q2m_katana/common.yaml b/tests/components/es9038q2m_katana/common.yaml new file mode 100644 index 00000000000..074eb47eeaa --- /dev/null +++ b/tests/components/es9038q2m_katana/common.yaml @@ -0,0 +1,16 @@ +esphome: + on_boot: + then: + - audio_dac.mute_off: + - audio_dac.mute_on: + - audio_dac.set_volume: + volume: 50% + +audio_dac: + - platform: es9038q2m_katana + i2c_id: i2c_bus + filter_shape: hybrid + bits_per_sample: 24bit + sample_rate: 96000 + deemphasis: 48khz + dop: true diff --git a/tests/components/es9038q2m_katana/test.esp32-idf.yaml b/tests/components/es9038q2m_katana/test.esp32-idf.yaml new file mode 100644 index 00000000000..c5822be8f86 --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + es9038q2m_katana: !include common.yaml diff --git a/tests/components/es9038q2m_katana/test.esp8266-ard.yaml b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b2ff0d1aceb --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + es9038q2m_katana: !include common.yaml