diff --git a/CODEOWNERS b/CODEOWNERS index a770810490b..95d8c49c23e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -439,6 +439,7 @@ esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow esphome/components/pca6416a/* @Mat931 +esphome/components/pca9505/* @ajfriesen @ssieb esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py new file mode 100644 index 00000000000..7b0d5ac1732 --- /dev/null +++ b/esphome/components/pca9505/__init__.py @@ -0,0 +1,79 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INTERRUPT_PIN, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["gpio_expander"] +CODEOWNERS = ["@ssieb", "@ajfriesen"] +DEPENDENCIES = ["i2c"] +DOMAIN = "pca9505" +MULTI_CONF = True + +pca9505_ns = cg.esphome_ns.namespace("pca9505") + +PCA9505Component = pca9505_ns.class_("PCA9505Component", cg.Component, i2c.I2CDevice) +PCA9505GPIOPin = pca9505_ns.class_( + "PCA9505GPIOPin", cg.GPIOPin, cg.Parented.template(PCA9505Component) +) + +CONF_PCA9505 = "pca9505" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA9505Component), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x20)) +) + + +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) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + +def validate_mode(value: ConfigType) -> ConfigType: + 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") + return value + + +PCA9505_PIN_SCHEMA = pins.gpio_base_schema( + PCA9505GPIOPin, + cv.int_range(min=0, max=39), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, +).extend( + { + cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA9505, PCA9505_PIN_SCHEMA) +async def pca9505_pin_to_code(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCA9505]) + + 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 diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp new file mode 100644 index 00000000000..20f62c14721 --- /dev/null +++ b/esphome/components/pca9505/pca9505.cpp @@ -0,0 +1,165 @@ +#include "pca9505.h" +#include "esphome/core/log.h" + +namespace esphome::pca9505 { + +// Each register block holds one byte per port. Add the port number to reach a single +// port, or set the auto-increment bit to transfer the whole block in one go. +static constexpr uint8_t INPUT_REG = 0x00; +static constexpr uint8_t OUTPUT_REG = 0x08; +static constexpr uint8_t INVERT_REG = 0x10; +static constexpr uint8_t CONFIG_REG = 0x18; +static constexpr uint8_t MASK_REG = 0x20; +static constexpr uint8_t AUTO_INCREMENT = 0x80; + +ESPHOME_LOG_TAG(TAG, "pca9505"); + +void PCA9505Component::setup() { + // Test to see if device exists + if (!this->read_bank_(0)) { + ESP_LOGE(TAG, "PCA9505 not detected at 0x%02X", this->address_); + this->mark_failed(); + return; + } + + // No polarity inversion + this->write_all_banks_(INVERT_REG, 0x00); + // All outputs low, before anything is switched to being an output + this->write_all_banks_(OUTPUT_REG, 0x00); + // All inputs at initialization. The part sees a 1 as an input, so this is the + // inverse of config_, which is all zeroes here. + this->write_all_banks_(CONFIG_REG, 0xFF); + + // Mask every interrupt. This must be written, not assumed: the chip keeps its + // registers through an MCU reboot, so a previous run may have left pins unmasked. + for (uint8_t &bank : this->mask_) { + bank = 0xFF; + } + this->write_all_banks_(MASK_REG, 0xFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA9505Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read - only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + + // Nothing to poll until pin_mode() registers an input pin. + this->disable_loop(); +} + +void IRAM_ATTR PCA9505Component::gpio_intr(PCA9505Component *arg) { arg->enable_loop_soon_any_context(); } + +void PCA9505Component::loop() { + // Invalidate the cache so the next digital_read() triggers a fresh I2C read + this->reset_pin_cache_(); + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { + this->disable_loop(); + } +} + +void PCA9505Component::dump_config() { + ESP_LOGCONFIG(TAG, "PCA9505:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } + +bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin / 8] & (1 << (pin % 8)); } + +void PCA9505Component::digital_write_hw(uint8_t pin, bool value) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (value) { + this->output_[bank] |= mask; + } else { + this->output_[bank] &= ~mask; + } + this->write_bank_(OUTPUT_REG, bank, this->output_[bank]); +} + +void PCA9505Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (flags & gpio::FLAG_INPUT) { + this->config_[bank] &= ~mask; + // An input may raise INT; the polling loop is only needed when there is no interrupt pin. + this->mask_[bank] &= ~mask; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } + } else if (flags & gpio::FLAG_OUTPUT) { + this->config_[bank] |= mask; + // An output must never raise INT. + this->mask_[bank] |= mask; + } + // Invert as the part sees a 1 as an input + this->write_bank_(CONFIG_REG, bank, ~this->config_[bank]); + // Without an interrupt pin INT is unconnected, so the masks written at setup can stay + if (this->interrupt_pin_ != nullptr) { + this->write_bank_(MASK_REG, bank, this->mask_[bank]); + } +} + +bool PCA9505Component::read_bank_(uint8_t bank) { + if (this->is_failed()) + return false; + + if (this->read_register(INPUT_REG + bank, &this->input_[bank], 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_bank_(uint8_t reg, uint8_t bank, uint8_t value) { + if (this->is_failed()) + return false; + + if (this->write_register(reg + bank, &value, 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { + if (this->is_failed()) + return false; + + uint8_t values[BANK_COUNT]; + for (uint8_t &bank : values) { + bank = value; + } + if (this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } + +void PCA9505GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCA9505GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } + +bool PCA9505GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } + +void PCA9505GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } + +size_t PCA9505GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "%u via PCA9505", this->pin_); +} + +} // namespace esphome::pca9505 diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h new file mode 100644 index 00000000000..29c4f18361c --- /dev/null +++ b/esphome/components/pca9505/pca9505.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::pca9505 { + +/// Number of I/O pins on the PCA9505 and PCA9506. +static constexpr uint8_t PIN_COUNT = 40; +/// The pins are grouped into five ports of eight, each with its own set of registers. +static constexpr uint8_t BANK_COUNT = PIN_COUNT / 8; + +class PCA9505Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + + void setup() override; + void loop() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + protected: + static void IRAM_ATTR gpio_intr(PCA9505Component *arg); + + 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; + + bool read_bank_(uint8_t bank); + bool write_bank_(uint8_t reg, uint8_t bank, uint8_t value); + bool write_all_banks_(uint8_t reg, uint8_t value); + + /// The pin config per bank - 1 means OUTPUT, 0 means INPUT + uint8_t config_[BANK_COUNT]{}; + /// The output state per bank - 1 means HIGH, 0 means LOW + uint8_t output_[BANK_COUNT]{}; + /// The input pin states per bank - 1 means HIGH, 0 means LOW + uint8_t input_[BANK_COUNT]{}; + /// Which pins may pull INT low - 1 means masked, matching the chip + uint8_t mask_[BANK_COUNT]{}; + InternalGPIOPin *interrupt_pin_{nullptr}; +}; + +class PCA9505GPIOPin final : 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_{}; +}; + +} // namespace esphome::pca9505 diff --git a/tests/components/pca9505/common.yaml b/tests/components/pca9505/common.yaml new file mode 100644 index 00000000000..53614dbcdda --- /dev/null +++ b/tests/components/pca9505/common.yaml @@ -0,0 +1,46 @@ +pca9505: + - id: pca9505_hub + i2c_id: i2c_bus + address: 0x26 + - id: pca9505_hub_second + interrupt_pin: ${interrupt_pin} + i2c_id: i2c_bus + address: 0x27 + +binary_sensor: + # Pins 0 and 39 are the first and last pins, so between them they cover every + # bank index the five-port register maths has to get right. + - platform: gpio + id: pca9505_input_first + name: PCA9505 Binary Sensor First + pin: + pca9505: pca9505_hub + number: 0 + mode: INPUT + inverted: true + - platform: gpio + id: pca9505_input_last + name: PCA9505 Binary Sensor Last + pin: + pca9505: pca9505_hub_second + number: 39 + mode: INPUT + inverted: false + +switch: + - platform: gpio + id: pca9505_output_low + name: PCA9505 Switch Low + pin: + pca9505: pca9505_hub + number: 7 + mode: OUTPUT + inverted: false + - platform: gpio + id: pca9505_output_high + name: PCA9505 Switch High + pin: + pca9505: pca9505_hub + number: 32 + mode: OUTPUT + inverted: true diff --git a/tests/components/pca9505/test.esp32-idf.yaml b/tests/components/pca9505/test.esp32-idf.yaml new file mode 100644 index 00000000000..0dc4dcfd657 --- /dev/null +++ b/tests/components/pca9505/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.esp8266-ard.yaml b/tests/components/pca9505/test.esp8266-ard.yaml new file mode 100644 index 00000000000..84c7105a5a0 --- /dev/null +++ b/tests/components/pca9505/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.rp2040-ard.yaml b/tests/components/pca9505/test.rp2040-ard.yaml new file mode 100644 index 00000000000..9d79eb4f3bd --- /dev/null +++ b/tests/components/pca9505/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test_pca9505.cpp b/tests/components/pca9505/test_pca9505.cpp new file mode 100644 index 00000000000..4ee55fd805f --- /dev/null +++ b/tests/components/pca9505/test_pca9505.cpp @@ -0,0 +1,312 @@ +#include + +#include "esphome/components/pca9505/pca9505.h" + +namespace esphome::pca9505::testing { + +// Models the PCA9505 register map: five ports, each with its own input, output, +// polarity inversion and configuration byte. The command byte selects a register +// and its top bit asks the part to auto-increment through the block. +class FakeBus : public i2c::I2CBus { + public: + static constexpr uint8_t ADDRESS = 0x26; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, ADDRESS); + if (this->fail) + return i2c::ERROR_TIMEOUT; + + // Every transfer starts with the command byte. + EXPECT_GE(write_count, 1u); + const uint8_t command = write_buffer[0]; + const bool auto_increment = (command & 0x80) != 0; + uint8_t *block = this->block_for(command & 0x7F); + const uint8_t index = (command & 0x7F) % 8; + + if (read_count != 0) { + this->reads++; + // Without auto-increment the part keeps returning the same register. + EXPECT_TRUE(auto_increment || read_count == 1); + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = block[auto_increment ? index + i : index]; + return i2c::ERROR_OK; + } + + this->writes++; + EXPECT_TRUE(auto_increment || write_count == 2); + for (size_t i = 1; i < write_count; i++) + block[auto_increment ? index + i - 1 : index] = write_buffer[i]; + return i2c::ERROR_OK; + } + + uint8_t *block_for(uint8_t reg) { + if (reg < 0x08) + return this->input; + if (reg < 0x10) + return this->output; + if (reg < 0x18) + return this->invert; + if (reg < 0x20) + return this->config; + return this->mask; + } + + // Power-on reset defaults from the datasheet. + uint8_t input[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t output[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t invert[BANK_COUNT]{0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t config[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t mask[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + + unsigned reads{0}; + unsigned writes{0}; + bool fail{false}; +}; + +class PCA9505Test : public ::testing::Test { + protected: + void SetUp() override { + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(FakeBus::ADDRESS); + } + + PCA9505GPIOPin make_pin_(uint8_t number, gpio::Flags flags, bool inverted = false) { + PCA9505GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(flags); + pin.set_inverted(inverted); + return pin; + } + + FakeBus bus_; + PCA9505Component component_; + InternalGPIOPin int_pin_; // digital_read() returns the inverted flag on the host, so it doubles as the level +}; + +TEST_F(PCA9505Test, SetupLeavesEveryPinAnInputWithOutputsLow) { + this->component_.setup(); + + ASSERT_FALSE(this->component_.is_failed()); + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should still be an input"; + EXPECT_EQ(this->bus_.output[bank], 0x00) << "bank " << int(bank) << " should be driven low"; + EXPECT_EQ(this->bus_.invert[bank], 0x00) << "bank " << int(bank) << " should not be inverted"; + } +} + +TEST_F(PCA9505Test, SetupFailsWhenTheDeviceIsAbsent) { + this->bus_.fail = true; + this->component_.setup(); + + EXPECT_TRUE(this->component_.is_failed()); +} + +// The chip has 40 pins, so pin numbers reach past the width of an int shift. +// Every pin must land in its own bank and bit. +TEST_F(PCA9505Test, EachPinMapsToItsOwnBankAndBit) { + this->component_.setup(); + + for (uint8_t number = 0; number < PIN_COUNT; number++) { + PCA9505GPIOPin pin = this->make_pin_(number, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + const uint8_t want = (bank == number / 8) ? uint8_t(1 << (number % 8)) : 0x00; + ASSERT_EQ(this->bus_.output[bank], want) << "pin " << int(number) << " wrote outside its own bit"; + } + + pin.digital_write(false); + ASSERT_EQ(this->bus_.output[number / 8], 0x00) << "pin " << int(number) << " did not clear"; + } +} + +TEST_F(PCA9505Test, ConfiguringAnOutputOnlyTouchesItsOwnBank) { + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(35, gpio::FLAG_OUTPUT); + pin.setup(); + + // Pin 35 is bit 3 of bank 4. The part reads a 1 as an input, so that bit clears. + EXPECT_EQ(this->bus_.config[4], 0xFF & ~(1 << 3)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ReadsReportTheLevelOnTheHighestPin) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT); + pin.setup(); + + this->bus_.input[4] = 0x80; // pin 39 high + EXPECT_TRUE(pin.digital_read()); + + this->component_.loop(); // clears the cache so the next read hits the bus + this->bus_.input[4] = 0x00; + EXPECT_FALSE(pin.digital_read()); +} + +TEST_F(PCA9505Test, InvertedPinFlipsTheReportedLevel) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT, /*inverted=*/true); + pin.setup(); + + this->bus_.input[4] = 0x80; + EXPECT_FALSE(pin.digital_read()); + + this->component_.loop(); + this->bus_.input[4] = 0x00; + EXPECT_TRUE(pin.digital_read()); +} + +// Eight pins sharing a port must cost one transfer per loop, not eight. +TEST_F(PCA9505Test, PinsInOneBankShareASingleRead) { + this->component_.setup(); + + PCA9505GPIOPin pins[8]; + for (uint8_t i = 0; i < 8; i++) { + pins[i] = this->make_pin_(8 + i, gpio::FLAG_INPUT); + pins[i].setup(); + } + + this->bus_.input[1] = 0xA5; + const unsigned before = this->bus_.reads; + for (uint8_t i = 0; i < 8; i++) + EXPECT_EQ(pins[i].digital_read(), ((0xA5 >> i) & 1) != 0) << "pin " << int(8 + i); + EXPECT_EQ(this->bus_.reads - before, 1u); +} + +// Pins in different ports are cached separately, so each port costs its own read. +TEST_F(PCA9505Test, EachBankIsReadSeparately) { + this->component_.setup(); + + PCA9505GPIOPin pins[BANK_COUNT]; + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + pins[bank] = this->make_pin_(bank * 8, gpio::FLAG_INPUT); + pins[bank].setup(); + } + + const unsigned before = this->bus_.reads; + for (auto &pin : pins) + pin.digital_read(); + EXPECT_EQ(this->bus_.reads - before, BANK_COUNT); +} + +TEST_F(PCA9505Test, WritesAreSkippedOnceSetupHasFailed) { + this->bus_.fail = true; + this->component_.setup(); + ASSERT_TRUE(this->component_.is_failed()); + + this->bus_.fail = false; + const unsigned before = this->bus_.writes; + PCA9505GPIOPin pin = this->make_pin_(0, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + EXPECT_EQ(this->bus_.writes, before); +} + +// A 1 in a mask register blocks that pin from pulling INT low. +TEST_F(PCA9505Test, SetupMasksEveryInterrupt) { + // Pretend a previous run left port 2 unmasked. The chip keeps its registers + // through an MCU reboot, so setup must write them rather than assume the reset state. + this->bus_.mask[2] = 0x00; + this->component_.setup(); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be masked"; +} + +TEST_F(PCA9505Test, ConfiguringAnInputUnmasksOnlyThatPin) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + // Pin 34 is bit 2 of bank 4, and a 0 lets it through. + EXPECT_EQ(this->bus_.mask[4], 0xFF & ~(1 << 2)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ConfiguringAnOutputLeavesItMasked) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_OUTPUT); + pin.setup(); + + // An output must not be able to pull INT low. + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +TEST_F(PCA9505Test, WithoutAnInterruptPinTheMasksAreNotRewritten) { + this->component_.setup(); + const unsigned before = this->bus_.writes; + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + EXPECT_EQ(this->bus_.writes, before + 1); // the configuration register only + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +// Without an interrupt pin an input turns the polling loop on; with one it stays off. +TEST_F(PCA9505Test, AnInputEnablesPollingOnlyWithoutAnInterruptPin) { + this->component_.setup(); + ASSERT_TRUE(this->component_.is_idle()); + PCA9505GPIOPin polled = this->make_pin_(3, gpio::FLAG_INPUT); + polled.setup(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + PCA9505Component with_interrupt; + with_interrupt.set_i2c_bus(&this->bus_); + with_interrupt.set_i2c_address(FakeBus::ADDRESS); + with_interrupt.set_interrupt_pin(&this->int_pin_); + with_interrupt.setup(); + ASSERT_TRUE(with_interrupt.is_idle()); + PCA9505GPIOPin pin; + pin.set_parent(&with_interrupt); + pin.set_pin(3); + pin.set_flags(gpio::FLAG_INPUT); + pin.setup(); + EXPECT_TRUE(with_interrupt.is_idle()); +} + +// With an interrupt pin a read is served from the cache until loop() clears it. +TEST_F(PCA9505Test, InterruptPinKeepsTheCacheUntilTheLoopRuns) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(3, gpio::FLAG_INPUT); + pin.setup(); + + const unsigned before = this->bus_.reads; + pin.digital_read(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 1); + + this->component_.loop(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 2); +} + +// The loop only turns itself off once INT has gone high again, so a change that +// lands during the read cannot leave the cache stale with no edge to wake it. +TEST_F(PCA9505Test, LoopStaysOnUntilIntIsReleased) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + this->component_.enable_loop(); + ASSERT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(false); // the host pin reads back its inverted flag: INT still low + this->component_.loop(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(true); // INT released + this->component_.loop(); + EXPECT_TRUE(this->component_.is_idle()); +} + +} // namespace esphome::pca9505::testing