diff --git a/CODEOWNERS b/CODEOWNERS index 6a81cc1d40..c5beba8c0b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -379,6 +379,7 @@ esphome/components/pca6416a/* @Mat931 esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman +esphome/components/pcm5122/* @remcom esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 diff --git a/esphome/components/pcm5122/__init__.py b/esphome/components/pcm5122/__init__.py new file mode 100644 index 0000000000..81e00ca74b --- /dev/null +++ b/esphome/components/pcm5122/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@remcom"] diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py new file mode 100644 index 0000000000..0017a1ef5a --- /dev/null +++ b/esphome/components/pcm5122/audio_dac.py @@ -0,0 +1,98 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.audio_dac import AudioDac +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] + +pcm5122_ns = cg.esphome_ns.namespace("pcm5122") +PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice) +CONF_PCM5122 = "pcm5122" + +pcm5122_bits_per_sample = pcm5122_ns.enum("PCM5122BitsPerSample") +PCM5122_BITS_PER_SAMPLE_ENUM = { + 16: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_16, + 24: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_24, + 32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32, +} + +_validate_bits = cv.float_with_unit("bits", "bit") + + +PCM5122GPIOPin = pcm5122_ns.class_( + "PCM5122GPIOPin", + cg.GPIOPin, + cg.Parented.template(PCM5122), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PCM5122), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + _validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM) + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x4D)) +) + + +def _validate_pin_mode(value): + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output, not both") + return value + + +def _validate_pin(value): + if value[CONF_MODE][CONF_INPUT] and value[CONF_NUMBER] == 6: + raise cv.Invalid("GPIO6 cannot be used as input on the PCM5122") + return value + + +PIN_SCHEMA = cv.All( + pins.gpio_base_schema( + PCM5122GPIOPin, + cv.int_range(min=3, max=6), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=_validate_pin_mode, + ).extend( + { + cv.Required(CONF_PCM5122): cv.use_id(PCM5122), + } + ), + _validate_pin, +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) +async def pcm5122_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCM5122]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var + + +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) + + cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp new file mode 100644 index 0000000000..68bbd50e4f --- /dev/null +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -0,0 +1,140 @@ +#include "pcm5122.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::pcm5122 { + +static const char *const TAG = "pcm5122"; + +void PCM5122::setup() { + // Select page 0 and verify chip presence via I2C ACK + if (!this->select_page_(0)) { + ESP_LOGE(TAG, "Write failed"); + this->status_set_error(LOG_STR("Write failed")); + this->mark_failed(); + return; + } + + // Reset audio modules + this->reg(PCM5122_REG_RESET) = PCM5122_RESET_MODULES; + delay(20); + this->reg(PCM5122_REG_RESET) = 0x00; + + // Ignore clock halt detection; enable clock divider autoset + optional err_detect = this->read_byte(PCM5122_REG_ERROR_DETECT); + if (!err_detect.has_value()) { + ESP_LOGE(TAG, "Failed to read ERROR_DETECT"); + this->mark_failed(); + return; + } + uint8_t err_detect_val = err_detect.value(); + err_detect_val |= PCM5122_ERROR_DETECT_IGNORE_CLKHALT; + err_detect_val &= ~PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET; + this->reg(PCM5122_REG_ERROR_DETECT) = err_detect_val; + + // I2S format with the configured word length + uint8_t alen; + switch (this->bits_per_sample_) { + case PCM5122_BITS_PER_SAMPLE_16: + alen = PCM5122_AUDIO_FORMAT_ALEN_16BIT; + break; + case PCM5122_BITS_PER_SAMPLE_24: + alen = PCM5122_AUDIO_FORMAT_ALEN_24BIT; + break; + case PCM5122_BITS_PER_SAMPLE_32: + default: + alen = PCM5122_AUDIO_FORMAT_ALEN_32BIT; + break; + } + this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen; + + // PLL reference clock: BCK + optional pll_ref = this->read_byte(PCM5122_REG_PLL_REF); + if (!pll_ref.has_value()) { + ESP_LOGE(TAG, "Failed to read PLL_REF"); + this->mark_failed(); + return; + } + uint8_t pll_ref_val = pll_ref.value(); + pll_ref_val &= ~PCM5122_PLL_REF_MASK; + pll_ref_val |= PCM5122_PLL_REF_SOURCE_BCK; + this->reg(PCM5122_REG_PLL_REF) = pll_ref_val; + + if (!this->set_mute_on() || !this->set_volume(this->volume_)) { + this->mark_failed(); + return; + } +} + +void PCM5122::dump_config() { + ESP_LOGCONFIG(TAG, "Audio DAC:"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, + " Bits per sample: %u\n" + " Muted: %s", + this->bits_per_sample_, YESNO(this->is_muted_)); +} + +bool PCM5122::set_mute_off() { + this->is_muted_ = false; + return this->write_mute_(); +} + +bool PCM5122::set_mute_on() { + this->is_muted_ = true; + return this->write_mute_(); +} + +bool PCM5122::set_volume(float volume) { + this->volume_ = clamp(volume, 0.0f, 1.0f); + return this->write_volume_(); +} + +bool PCM5122::is_muted() { return this->is_muted_; } + +float PCM5122::volume() { return this->volume_; } + +bool PCM5122::select_page_(uint8_t page) { + if (this->current_page_ == page) + return true; + if (!this->write_byte(PCM5122_REG_PAGE_SELECT, page)) { + this->current_page_ = -1; + return false; + } + this->current_page_ = page; + return true; +} + +bool PCM5122::write_mute_() { + uint8_t mute_byte = this->is_muted() ? 0x11 : 0x00; + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_MUTE, mute_byte)) { + ESP_LOGE(TAG, "Writing mute failed"); + return false; + } + return true; +} + +bool PCM5122::write_volume_() { + // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step). + // Note: volume=0.0 maps to -52.5 dB (still audible), not true silence. + // Use set_mute_on() for silence. + const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale + const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum + + const uint8_t volume_byte = + dvol_max_volume + static_cast(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume))); + + ESP_LOGV(TAG, "Setting volume to 0x%.2x", volume_byte); + + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DVOL_LEFT, volume_byte) || + !this->write_byte(PCM5122_REG_DVOL_RIGHT, volume_byte)) { + ESP_LOGE(TAG, "Writing volume failed"); + return false; + } + return true; +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h new file mode 100644 index 0000000000..f86b096c82 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::pcm5122 { + +// Page 0 register addresses +static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00; +static const uint8_t PCM5122_REG_RESET = 0x01; +static const uint8_t PCM5122_REG_MUTE = 0x03; +static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08; +static const uint8_t PCM5122_REG_PLL_REF = 0x0D; +static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25; +static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28; +static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D; +static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E; +static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1 +static const uint8_t PCM5122_GPIO_OUTPUT_SELECT_REGISTER = 0x02; // GPIO driven by GPIO_OUTPUT register (reg 0x56) +static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56; +static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57; +static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77; + +// Register values for init sequence +static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules +static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00) +// ALEN (word length) occupies bits [1:0] of the audio format register +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_16BIT = 0x00; +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_24BIT = 0x02; +static const uint8_t PCM5122_AUDIO_FORMAT_ALEN_32BIT = 0x03; +static const uint8_t PCM5122_ERROR_DETECT_IGNORE_CLKHALT = (1 << 3); +static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1); +static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4] +static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK) + +enum PCM5122BitsPerSample : uint8_t { + PCM5122_BITS_PER_SAMPLE_16 = 16, + PCM5122_BITS_PER_SAMPLE_24 = 24, + PCM5122_BITS_PER_SAMPLE_32 = 32, +}; + +class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + + void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + + bool set_mute_off() override; + bool set_mute_on() override; + bool set_volume(float volume) override; + + bool is_muted() override; + float volume() override; + + friend class PCM5122GPIOPin; + + protected: + bool select_page_(uint8_t page); + bool write_mute_(); + bool write_volume_(); + + float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) + int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes + bool is_muted_{false}; + PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16}; +}; + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.cpp b/esphome/components/pcm5122/pcm5122_gpio.cpp new file mode 100644 index 0000000000..1aef130457 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122_gpio.cpp @@ -0,0 +1,69 @@ +#include "pcm5122_gpio.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::pcm5122 { + +static const char *const TAG = "pcm5122.gpio"; + +void PCM5122GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCM5122GPIOPin::pin_mode(gpio::Flags flags) { + this->flags_ = flags; + if (!this->parent_->select_page_(0)) { + ESP_LOGE(TAG, "Failed to select page 0"); + return; + } + optional curr = this->parent_->read_byte(PCM5122_REG_GPIO_ENABLE); + if (!curr.has_value()) { + ESP_LOGE(TAG, "Failed to read GPIO_ENABLE"); + return; + } + if (flags & gpio::FLAG_INPUT) { + this->parent_->reg(PCM5122_REG_GPIO_ENABLE) = curr.value() & ~(1 << (this->pin_ - 1)); + } else if (flags & gpio::FLAG_OUTPUT) { + this->parent_->reg(PCM5122_REG_GPIO_ENABLE) = curr.value() | (1 << (this->pin_ - 1)); + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT_SELECT + (this->pin_ - 1)) = PCM5122_GPIO_OUTPUT_SELECT_REGISTER; + optional invert = this->parent_->read_byte(PCM5122_REG_GPIO_INVERT); + if (!invert.has_value()) { + ESP_LOGE(TAG, "Failed to read GPIO_INVERT"); + return; + } + if (this->inverted_) { + this->parent_->reg(PCM5122_REG_GPIO_INVERT) = invert.value() | (1 << (this->pin_ - 1)); + } else { + this->parent_->reg(PCM5122_REG_GPIO_INVERT) = invert.value() & ~(1 << (this->pin_ - 1)); + } + } +} + +void PCM5122GPIOPin::digital_write(bool value) { + if (!this->parent_->select_page_(0)) + return; + optional curr = this->parent_->read_byte(PCM5122_REG_GPIO_OUTPUT); + if (!curr.has_value()) + return; + if (value) { + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT) = curr.value() | (1 << (this->pin_ - 1)); + } else { + this->parent_->reg(PCM5122_REG_GPIO_OUTPUT) = curr.value() & ~(1 << (this->pin_ - 1)); + } +} + +bool PCM5122GPIOPin::digital_read() { + if (!this->parent_->select_page_(0)) + return this->value_; + optional read = this->parent_->read_byte(PCM5122_REG_GPIO_INPUT); + if (read.has_value()) { + // GPIO input register has RSV at bit 0; GPIN_N is at bit N (unlike other GPIO registers) + this->value_ = !!(read.value() & (1 << this->pin_)) != this->inverted_; + } + return this->value_; +} + +size_t PCM5122GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "PCM5122 GPIO%u", this->pin_); +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.h b/esphome/components/pcm5122/pcm5122_gpio.h new file mode 100644 index 0000000000..8edaa6d3e8 --- /dev/null +++ b/esphome/components/pcm5122/pcm5122_gpio.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/gpio.h" + +#include "pcm5122.h" + +namespace esphome::pcm5122 { + +class PCM5122GPIOPin : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{gpio::FLAG_NONE}; + bool value_{false}; +}; + +} // namespace esphome::pcm5122 diff --git a/tests/components/pcm5122/common.yaml b/tests/components/pcm5122/common.yaml new file mode 100644 index 0000000000..cf96f57464 --- /dev/null +++ b/tests/components/pcm5122/common.yaml @@ -0,0 +1,24 @@ +audio_dac: + - platform: pcm5122 + id: pcm5122_dac + i2c_id: i2c_bus + address: 0x4D + bits_per_sample: 32bit + +output: + - platform: gpio + id: pcm5122_amp_enable + pin: + pcm5122: pcm5122_dac + number: 3 + mode: + output: true + +binary_sensor: + - platform: gpio + id: pcm5122_gpio_input + pin: + pcm5122: pcm5122_dac + number: 4 + mode: + input: true diff --git a/tests/components/pcm5122/test.esp32-ard.yaml b/tests/components/pcm5122/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/pcm5122/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.esp32-idf.yaml b/tests/components/pcm5122/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/pcm5122/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.esp8266-ard.yaml b/tests/components/pcm5122/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/pcm5122/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/pcm5122/test.rp2040-ard.yaml b/tests/components/pcm5122/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/pcm5122/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml