diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 4e30c47bdae..56d7842fb10 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -1,13 +1,23 @@ +from typing import NamedTuple + from esphome import pins import esphome.codegen as cg -from esphome.components import gpio_expander, i2c +from esphome.components import i2c +from esphome.components.const import CONF_DRIVE_STRENGTH +from esphome.components.gpio_expander import validate_interrupt_pin import esphome.config_validation as cv from esphome.const import ( + CONF_ADDRESS, + CONF_DEVICE, + CONF_I2C_ID, CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, + CONF_MODE, CONF_NUMBER, CONF_OUTPUT, + CONF_PULLDOWN, + CONF_PULLUP, ) from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -17,46 +27,106 @@ AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] DOMAIN = "pca9554" MULTI_CONF = True -CONF_PIN_COUNT = "pin_count" -pca9554_ns = cg.esphome_ns.namespace("pca9554") +CONF_LATCH = "latch" +CONF_PCA9554 = "pca9554" +CONF_PIN_COUNT = "pin_count" + +pca9554_ns = cg.esphome_ns.namespace("pca9554") PCA9554Component = pca9554_ns.class_("PCA9554Component", cg.Component, i2c.I2CDevice) PCA9554GPIOPin = pca9554_ns.class_( "PCA9554GPIOPin", cg.GPIOPin, cg.Parented.template(PCA9554Component) ) -CONF_PCA9554 = "pca9554" -CONFIG_SCHEMA = ( + +class _Device(NamedTuple): + pins: int + # Lowest I2C address, with all address pins low + address: int + # Has the PCAL registers: pull resistors, input latch, drive strength, interrupt mask + pcal: bool = False + + +DEVICES: dict[str, _Device] = { + "PCA9536": _Device(4, 0x41), + "PCA9534": _Device(8, 0x20), + "PCA9534A": _Device(8, 0x38), + "PCA9538": _Device(8, 0x70), + "PCA9554": _Device(8, 0x20), + "PCA9554A": _Device(8, 0x38), + "TCA9554": _Device(8, 0x20), + "PCA9535": _Device(16, 0x20), + "PCA9555": _Device(16, 0x20), + "TCA9535": _Device(16, 0x20), + "TCA9555": _Device(16, 0x20), + "PCAL9538": _Device(8, 0x70, True), + "PCAL9555": _Device(16, 0x20, True), +} +# The device a legacy `pin_count:` stands for +_LEGACY_DEVICES = {4: "PCA9536", 8: "PCA9554", 16: "PCA9555"} +# Datasheet drive levels and their register codes +DRIVE_STRENGTHS = {0.25: 0, 0.5: 1, 0.75: 2, 1.0: 3} + + +def _validate_hub(config: ConfigType) -> ConfigType: + count = config.get(CONF_PIN_COUNT) + if (device := config.get(CONF_DEVICE)) is None: + device = config[CONF_DEVICE] = _LEGACY_DEVICES[8 if count is None else count] + elif count is not None and count != DEVICES[device].pins: + raise cv.Invalid( + f"'{CONF_PIN_COUNT}' ({count}) does not match the {DEVICES[device].pins} " + f"pins of device {device}; remove '{CONF_PIN_COUNT}'" + ) + config.setdefault(CONF_ADDRESS, DEVICES[device].address) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), - cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, + cv.GenerateID(CONF_I2C_ID): cv.use_id(i2c.I2CBus), + # The default depends on the device, so _validate_hub fills it in + cv.Optional(CONF_ADDRESS): cv.i2c_address, + cv.Optional(CONF_DEVICE): cv.one_of(*DEVICES, upper=True), + cv.Optional(CONF_PIN_COUNT): cv.one_of(4, 8, 16), + cv.Optional(CONF_INTERRUPT_PIN): validate_interrupt_pin, } - ) - .extend(cv.COMPONENT_SCHEMA) - .extend( - i2c.i2c_device_schema(0x20) - ) # Note: 0x20 for the non-A part. The PCA9554A parts start at addess 0x38 + ).extend(cv.COMPONENT_SCHEMA), + _validate_hub, ) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + device = DEVICES[config[CONF_DEVICE]] + var = await i2c.new_i2c_component(config, device.pins) + if device.pcal: + # Only a build with a PCAL part carries the code for its extra registers + cg.add_define("USE_PCA9554_PCAL") + cg.add(var.set_pcal()) await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) +def validate_mode(value: ConfigType) -> ConfigType: + """Reject contradictory modes; whether the device supports a mode is checked in final validation.""" + pins.gpio_validate_input_xor_output(value) + if value[CONF_PULLDOWN] and value[CONF_PULLUP]: + raise cv.Invalid("Pull-up and pull-down are mutually exclusive") + return value + + PCA9554_PIN_SCHEMA = pins.gpio_base_schema( PCA9554GPIOPin, cv.int_range(min=0, max=15), - modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=pins.gpio_validate_input_xor_output, + modes=[CONF_INPUT, CONF_OUTPUT, CONF_PULLUP, CONF_PULLDOWN], + mode_validator=validate_mode, ).extend( { cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component), + cv.Optional(CONF_LATCH, default=False): cv.boolean, + cv.Optional(CONF_DRIVE_STRENGTH): cv.All( + cv.percentage, cv.one_of(*DRIVE_STRENGTHS) + ), } ) @@ -64,13 +134,36 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema( def pca9554_pin_final_validate( pin_config: ConfigType, parent_config: ConfigType ) -> None: - count = parent_config[CONF_PIN_COUNT] - if pin_config[CONF_NUMBER] >= count: - raise cv.Invalid(f"Pin number must be in range 0-{count - 1}") + device_name = parent_config[CONF_DEVICE] + device = DEVICES[device_name] + if pin_config[CONF_NUMBER] >= device.pins: + raise cv.Invalid(f"Pin number must be in range 0-{device.pins - 1}") + + mode = pin_config[CONF_MODE] + if pin_config[CONF_LATCH] and mode[CONF_OUTPUT]: + raise cv.Invalid(f"'{CONF_LATCH}' is not valid for an output pin") + if CONF_DRIVE_STRENGTH in pin_config and mode[CONF_INPUT]: + raise cv.Invalid(f"'{CONF_DRIVE_STRENGTH}' is not valid for an input pin") + if device.pcal: + return + for name in (CONF_PULLUP, CONF_PULLDOWN): + if mode[name]: + raise cv.Invalid(f"Pin mode '{name}' is not valid for device {device_name}") + for key in (CONF_LATCH, CONF_DRIVE_STRENGTH): + if pin_config.get(key): + raise cv.Invalid(f"'{key}' is not supported by device {device_name}") @pins.PIN_SCHEMA_REGISTRY.register( CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate ) async def pca9554_pin_to_code(config: ConfigType) -> MockObj: - return await pins.new_parented_pin(config, CONF_PCA9554) + var = await pins.new_parented_pin(config, CONF_PCA9554) + parent = await cg.get_variable(config[CONF_PCA9554]) + num = config[CONF_NUMBER] + # Final validation only lets these through on a PCAL hub + if config[CONF_LATCH]: + cg.add(parent.set_latch(num)) + if (drive_strength := config.get(CONF_DRIVE_STRENGTH)) is not None: + cg.add(parent.set_drive_strength(num, DRIVE_STRENGTHS[drive_strength])) + return var diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index add9c8d0109..404a23f3f93 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -4,15 +4,25 @@ namespace esphome::pca9554 { // for 16 bit expanders, these addresses will be doubled. -const uint8_t INPUT_REG = 0; -const uint8_t OUTPUT_REG = 1; -const uint8_t INVERT_REG = 2; -const uint8_t CONFIG_REG = 3; +static constexpr uint8_t INPUT_REG = 0; +static constexpr uint8_t OUTPUT_REG = 1; +static constexpr uint8_t INVERT_REG = 2; +static constexpr uint8_t CONFIG_REG = 3; + +#ifdef USE_PCA9554_PCAL +// PCAL registers, as offsets from PCAL_BASE spaced by the register width like the ones above. +static constexpr uint8_t PCAL_BASE = 0x40; +static constexpr uint8_t OUTPUT_DRIVE_0 = 0; +static constexpr uint8_t OUTPUT_DRIVE_1 = 1; +static constexpr uint8_t INPUT_LATCH = 2; +static constexpr uint8_t PUPD_ENABLE = 3; +static constexpr uint8_t PUPD_SEL = 4; +static constexpr uint8_t INTERRUPT_MASK = 5; +#endif ESPHOME_LOG_TAG(TAG, "pca9554"); void PCA9554Component::setup() { - this->reg_width_ = (this->pin_count_ + 7) / 8; // Test to see if device exists if (!this->read_inputs_()) { ESP_LOGE(TAG, "PCA95xx not detected at 0x%02X", this->address_); @@ -29,6 +39,13 @@ void PCA9554Component::setup() { // All outputs low this->output_mask_ = 0; this->write_register_(OUTPUT_REG, this->output_mask_); +#ifdef USE_PCA9554_PCAL + // The chip keeps these across an MCU reboot + if (this->pcal_ && !this->write_pcal_shadows_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } +#endif // Read the inputs this->read_inputs_(); ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), @@ -45,7 +62,9 @@ void PCA9554Component::setup() { // For polling mode, loop is re-enabled when pin_mode() registers an input pin this->disable_loop(); } + void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_loop_soon_any_context(); } + void PCA9554Component::loop() { // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); @@ -62,6 +81,9 @@ void PCA9554Component::dump_config() { "PCA9554:\n" " I/O Pins: %d", this->pin_count_); +#ifdef USE_PCA9554_PCAL + ESP_LOGCONFIG(TAG, " PCAL registers: %s", YESNO(this->pcal_)); +#endif LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) } @@ -82,11 +104,16 @@ void PCA9554Component::digital_write_hw(uint8_t pin, bool value) { } else { this->output_mask_ &= ~(1 << pin); } +#ifdef USE_PCA9554_PCAL + if (this->write_register_(OUTPUT_REG, this->output_mask_)) + this->retry_pcal_shadows_(); // an output-only hub never reads, so it retries here +#else this->write_register_(OUTPUT_REG, this->output_mask_); +#endif } void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) { - if (flags == gpio::FLAG_INPUT) { + if (flags & gpio::FLAG_INPUT) { // Clear mode mask bit this->config_mask_ &= ~(1 << pin); // Enable polling loop for input pins (not needed for interrupt-driven mode @@ -94,11 +121,24 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (this->interrupt_pin_ == nullptr) { this->enable_loop(); } - } else if (flags == gpio::FLAG_OUTPUT) { + } else if (flags & gpio::FLAG_OUTPUT) { // Set mode mask bit this->config_mask_ |= 1 << pin; } this->write_register_(CONFIG_REG, ~this->config_mask_); + +#ifdef USE_PCA9554_PCAL + if (this->pcal_) { + const uint16_t bit = 1U << pin; + // A masked pin never asserts INT, so only inputs are unmasked + this->update_pcal_register_(INTERRUPT_MASK, this->interrupt_mask_, bit, this->config_mask_ & bit); + const bool pull = flags & (gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN); + // The select bit means nothing while the pull is disabled + if (pull) + this->update_pcal_register_(PUPD_SEL, this->pupd_select_, bit, (flags & gpio::FLAG_PULLUP) ? bit : 0); + this->update_pcal_register_(PUPD_ENABLE, this->pupd_enable_, bit, pull ? bit : 0); + } +#endif } bool PCA9554Component::read_inputs_() { @@ -109,30 +149,64 @@ bool PCA9554Component::read_inputs_() { return false; } - this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_); - if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { - ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); + const i2c::ErrorCode err = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_); + if (!this->status_comm_result(err == i2c::ERROR_OK)) { + ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) err); return false; } this->input_mask_ = inputs[0]; if (this->reg_width_ == 2) { this->input_mask_ |= inputs[1] << 8; } +#ifdef USE_PCA9554_PCAL + // The bus answers again, so retry the settings a failed write left off the chip + this->retry_pcal_shadows_(); +#endif return true; } -bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { +bool PCA9554Component::write_address_(uint8_t address, uint16_t value) { uint8_t outputs[2]; outputs[0] = (uint8_t) value; outputs[1] = (uint8_t) (value >> 8); - this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_); - if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { - ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); + const i2c::ErrorCode err = this->write_register(address, outputs, this->reg_width_); + if (!this->status_comm_result(err == i2c::ERROR_OK)) { + ESP_LOGE(TAG, "write_register(): I2C I/O error: %d", (int) err); return false; } return true; } +#ifdef USE_PCA9554_PCAL +bool PCA9554Component::write_pcal_register_(uint8_t reg, uint16_t value) { + if (this->write_address_(PCAL_BASE + reg * this->reg_width_, value)) + return true; + this->pcal_dirty_ = true; + return false; +} + +bool PCA9554Component::write_pcal_shadows_() { + this->pcal_dirty_ = false; + // Every write runs so one failure does not hide the others; each failure sets pcal_dirty_ again + bool ok = this->write_pcal_register_(PUPD_ENABLE, this->pupd_enable_); + ok &= this->write_pcal_register_(PUPD_SEL, this->pupd_select_); + ok &= this->write_pcal_register_(INPUT_LATCH, this->latch_); + ok &= this->write_pcal_register_(INTERRUPT_MASK, this->interrupt_mask_); + ok &= this->write_pcal_register_(OUTPUT_DRIVE_0, static_cast(this->drive_)); + ok &= this->write_pcal_register_(OUTPUT_DRIVE_1, static_cast(this->drive_ >> (8 * this->reg_width_))); + return ok; +} + +void PCA9554Component::update_pcal_register_(uint8_t reg, uint16_t &shadow, uint16_t mask, uint16_t value) { + const uint16_t updated = (shadow & ~mask) | (value & mask); + // The shadows hold the wanted state; an unchanged value is already on the chip unless a write failed + if (updated == shadow && !this->pcal_dirty_) + return; + shadow = updated; + this->write_pcal_register_(reg, shadow); +} +#endif + float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } } // namespace esphome::pca9554 diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index b8d34e328eb..053139bc9e7 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -12,7 +12,7 @@ class PCA9554Component final : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { public: - PCA9554Component() = default; + explicit PCA9554Component(uint8_t pin_count) : pin_count_(pin_count), reg_width_((pin_count + 7) / 8) {} /// Check i2c availability and setup masks void setup() override; @@ -24,33 +24,70 @@ class PCA9554Component final : public Component, void dump_config() override; - void set_pin_count(size_t pin_count) { this->pin_count_ = pin_count; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } +#ifdef USE_PCA9554_PCAL + /// The PCAL parts add pull resistors, input latches, an interrupt mask and drive strength. + void set_pcal() { this->pcal_ = true; } + /// Only updates the shadow, which setup() writes. + void set_latch(uint8_t pin) { this->latch_ |= 1U << pin; } + /// Only updates the shadow, which setup() writes. 0 is a quarter of the full drive strength, 3 the full strength. + void set_drive_strength(uint8_t pin, uint8_t strength) { + const uint8_t shift = 2 * pin; + this->drive_ = (this->drive_ & ~(3UL << shift)) | (uint32_t(strength) << shift); + } +#endif protected: static void IRAM_ATTR gpio_intr(PCA9554Component *arg); bool read_inputs_(); - bool write_register_(uint8_t reg, uint16_t value); + bool write_register_(uint8_t reg, uint16_t value) { return this->write_address_(reg * this->reg_width_, value); } + bool write_address_(uint8_t address, uint16_t value); +#ifdef USE_PCA9554_PCAL + bool write_pcal_register_(uint8_t reg, uint16_t value); + /// Write every PCAL shadow to the chip; false when any write failed + bool write_pcal_shadows_(); + /// Retry the shadows when an earlier PCAL write failed + void retry_pcal_shadows_() { + if (this->pcal_dirty_) + this->write_pcal_shadows_(); + } + /// Changes the masked bits of a shadow and writes the register only when it changed. + void update_pcal_register_(uint8_t reg, uint16_t &shadow, uint16_t mask, uint16_t value); +#endif // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; - /// number of bits the expander has - size_t pin_count_{8}; - /// width of registers - size_t reg_width_{1}; + InternalGPIOPin *interrupt_pin_{nullptr}; +#ifdef USE_PCA9554_PCAL + /// Drive strength shadow, two bits per pin; the low reg_width_ bytes go to OUTPUT_DRIVE_0 + uint32_t drive_{0xFFFFFFFF}; +#endif /// Mask for the pin config - 1 means OUTPUT, 0 means INPUT uint16_t config_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint16_t output_mask_{0x00}; - /// The state of the actual input pin states - 1 means HIGH, 0 means LOW + /// The state of the actual input pins - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; - /// Storage for last I2C error seen - esphome::i2c::ErrorCode last_error_; - InternalGPIOPin *interrupt_pin_{nullptr}; +#ifdef USE_PCA9554_PCAL + // Shadows of the PCAL registers. setup() writes them, so every part starts with the pulls disconnected + // whatever its power-on state is (the PCAL9555 powers up with pull-ups enabled). + uint16_t pupd_enable_{0x0000}; + uint16_t pupd_select_{0xFFFF}; + uint16_t latch_{0x0000}; + uint16_t interrupt_mask_{0xFFFF}; +#endif + /// number of bits the expander has + const uint8_t pin_count_; + /// width of registers + const uint8_t reg_width_; +#ifdef USE_PCA9554_PCAL + bool pcal_{false}; + bool pcal_dirty_{false}; // a PCAL write failed; retried after the next good read or output write +#endif }; using PCA9554GPIOPin = gpio_expander::ExpanderGPIOPin; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 70838d897c8..d687273dca3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -257,6 +257,7 @@ #define USE_OTA_STATE_LISTENER #define USE_OUTPUT #define USE_OUTPUT_FLOAT_POWER_SCALING +#define USE_PCA9554_PCAL #define USE_POWER_SUPPLY // Only defined by key-lookup preference backends; the slot-based platforms // (esp8266, rp2040) never set it in generated builds, and their preferences diff --git a/tests/component_tests/pca9554/__init__.py b/tests/component_tests/pca9554/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/pca9554/config/pca9554.yaml b/tests/component_tests/pca9554/config/pca9554.yaml new file mode 100644 index 00000000000..5674d541c71 --- /dev/null +++ b/tests/component_tests/pca9554/config/pca9554.yaml @@ -0,0 +1,68 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +pca9554: + - id: plain_hub + address: 0x20 + device: PCA9554 + - id: pcal_hub + address: 0x70 + device: PCAL9555 + - id: pca9538_hub + device: PCA9538 + +binary_sensor: + - platform: gpio + id: plain_input + name: Plain input + pin: + pca9554: plain_hub + number: 1 + mode: INPUT + - platform: gpio + id: pcal_input + name: PCAL input + pin: + pca9554: pcal_hub + number: 9 + mode: + input: true + pullup: true + latch: true + +switch: + - platform: gpio + id: pcal_weak_output + pin: + pca9554: pcal_hub + number: 10 + mode: OUTPUT + drive_strength: 25% + - platform: gpio + id: pcal_full_output + pin: + pca9554: pcal_hub + number: 11 + mode: OUTPUT + drive_strength: 100% + - platform: gpio + id: pcal_mid_output + pin: + pca9554: pcal_hub + number: 12 + mode: OUTPUT + drive_strength: 75% + - platform: gpio + id: pca9538_output + pin: + pca9554: pca9538_hub + number: 7 + mode: OUTPUT diff --git a/tests/component_tests/pca9554/test_pca9554.py b/tests/component_tests/pca9554/test_pca9554.py new file mode 100644 index 00000000000..885fdb06155 --- /dev/null +++ b/tests/component_tests/pca9554/test_pca9554.py @@ -0,0 +1,126 @@ +"""Tests for the PCA9554 device table and its pin validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.pca9554 import ( + CONFIG_SCHEMA, + PCA9554_PIN_SCHEMA, + pca9554_pin_final_validate, +) +from esphome.types import ConfigType + + +def _hub(**overrides: object) -> ConfigType: + return CONFIG_SCHEMA({"id": "hub", **overrides}) + + +def _pin(number: int, mode: dict, **overrides: object) -> ConfigType: + return PCA9554_PIN_SCHEMA( + {"pca9554": "hub", "number": number, "mode": mode, **overrides} + ) + + +def test_pin_count_must_match_the_device() -> None: + """A stale pin_count next to a device would address every register wrongly.""" + with pytest.raises( + cv.Invalid, match="does not match the 8 pins of device PCAL9538" + ): + _hub(device="PCAL9538", pin_count=16) + + +@pytest.mark.parametrize( + ("overrides", "device"), + [ + ({}, "PCA9554"), + ({"pin_count": 4}, "PCA9536"), + ({"pin_count": 8}, "PCA9554"), + ({"pin_count": 16}, "PCA9555"), + ], +) +def test_legacy_pin_count_maps_to_a_device(overrides: dict, device: str) -> None: + assert _hub(**overrides)["device"] == device + + +@pytest.mark.parametrize( + ("device", "address"), + [ + ("PCA9554", 0x20), + ("PCA9554A", 0x38), + ("PCA9536", 0x41), + ("PCA9538", 0x70), + ("PCAL9538", 0x70), + ], +) +def test_address_defaults_to_the_device_base(device: str, address: int) -> None: + assert _hub(device=device)["address"] == address + assert _hub(device=device, address=0x27)["address"] == 0x27 + + +def test_pin_number_follows_the_device() -> None: + with pytest.raises(cv.Invalid, match="range 0-3"): + pca9554_pin_final_validate(_pin(4, {"input": True}), _hub(device="PCA9536")) + pca9554_pin_final_validate(_pin(15, {"input": True}), _hub(device="PCAL9555")) + + +@pytest.mark.parametrize( + ("mode", "overrides", "message"), + [ + ({"input": True, "pullup": True}, {}, "Pin mode 'pullup' is not valid"), + ({"input": True, "pulldown": True}, {}, "Pin mode 'pulldown' is not valid"), + ({"input": True}, {"latch": True}, "'latch' is not supported"), + ({"output": True}, {"drive_strength": "100%"}, "'drive_strength' is not"), + ], +) +def test_pcal_only_settings_are_rejected_on_a_plain_part( + mode: dict, overrides: dict, message: str +) -> None: + pin = _pin(1, mode, **overrides) + with pytest.raises(cv.Invalid, match=f"{message}.*device PCA9554"): + pca9554_pin_final_validate(pin, _hub(device="PCA9554")) + pca9554_pin_final_validate(pin, _hub(device="PCAL9538")) + + +def test_outputs_cannot_be_latched() -> None: + with pytest.raises(cv.Invalid, match="'latch' is not valid for an output pin"): + pca9554_pin_final_validate( + _pin(1, {"output": True}, latch=True), _hub(device="PCAL9538") + ) + + +def test_inputs_take_no_drive_strength() -> None: + with pytest.raises(cv.Invalid, match="'drive_strength' is not valid for an input"): + pca9554_pin_final_validate( + _pin(1, {"input": True}, drive_strength="50%"), _hub(device="PCAL9538") + ) + + +@pytest.mark.parametrize("value", ["30%", "0%", 2]) +def test_drive_strength_must_be_a_datasheet_level(value: object) -> None: + with pytest.raises(cv.Invalid): + _pin(1, {"output": True}, drive_strength=value) + + +def test_pcal_code_is_only_generated_for_pcal_hubs( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The define and the hub setters exist only where a PCAL part is configured.""" + main_cpp = generate_main(component_config_path("pca9554.yaml")) + + for call in ( + "pcal_hub->set_pcal();", + "pcal_hub->set_latch(9);", + "pcal_hub->set_drive_strength(12, 2);", + "pcal_hub->set_drive_strength(10, 0);", + "pcal_hub->set_drive_strength(11, 3);", + ): + assert main_cpp.count(call) == 1 + for call in ("set_pcal(", "set_latch(", "set_drive_strength("): + assert f"plain_hub->{call}" not in main_cpp + assert f"pca9538_hub->{call}" not in main_cpp + # 0x70, the PCA9538 default + assert "pca9538_hub->set_i2c_address(112);" in main_cpp diff --git a/tests/components/pca9554/common.yaml b/tests/components/pca9554/common.yaml index 82a88b90aae..5d586b6084a 100644 --- a/tests/components/pca9554/common.yaml +++ b/tests/components/pca9554/common.yaml @@ -8,6 +8,14 @@ pca9554: pin_count: 8 address: 0x3E interrupt_pin: ${interrupt_pin} + - id: pcal9538 + i2c_id: i2c_bus + address: 0x70 + device: PCAL9538 + - id: pcal9555 + i2c_id: i2c_bus + address: 0x71 + device: PCAL9555 binary_sensor: - platform: gpio @@ -25,3 +33,20 @@ binary_sensor: number: 0 mode: OUTPUT inverted: false + - platform: gpio + id: pcal9538_input + pin: + pca9554: pcal9538 + number: 3 + mode: + input: true + pullup: true + latch: true + # A pin on the second port of a 16 bit PCAL part, with a non-default drive strength + - platform: gpio + id: pcal9555_output + pin: + pca9554: pcal9555 + number: 12 + mode: OUTPUT + drive_strength: 50%