diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 6c9ed3cd98b..5ede39d7145 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_MODE, CONF_NUMBER, CONF_OUTPUT, + CONF_RESET, ) from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -26,11 +27,14 @@ PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin) CONF_PCF8574 = "pcf8574" CONF_PCF8575 = "pcf8575" +# Keep in sync with PCF8574Component::reset_ in pcf8574.h. +DEFAULT_RESET = True CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, + cv.Optional(CONF_RESET, default=DEFAULT_RESET): cv.boolean, cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) @@ -44,6 +48,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) + if (reset := config[CONF_RESET]) != DEFAULT_RESET: + cg.add(var.set_reset(reset)) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 727e9a20265..ea1e894a530 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -12,8 +12,13 @@ void PCF8574Component::setup() { return; } - this->write_gpio_(); - this->read_gpio_(); + if (this->reset_) { + this->write_gpio_(); + this->read_gpio_(); + } else { + this->output_mask_ = this->input_mask_; + this->mode_mask_ = ~this->configured_input_pins_; + } if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); @@ -40,8 +45,9 @@ void PCF8574Component::loop() { void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:\n" - " Is PCF8575: %s", - YESNO(this->pcf8575_)); + " Is PCF8575: %s\n" + " Reset: %s", + YESNO(this->pcf8575_), YESNO(this->reset_)); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { @@ -82,20 +88,12 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { bool PCF8574Component::read_gpio_() { if (this->is_failed()) return false; - bool success; - uint8_t data[2]; - if (this->pcf8575_) { - success = this->read_bytes_raw(data, 2); - this->input_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); - } else { - success = this->read_bytes_raw(data, 1); - this->input_mask_ = data[0]; - } - - if (!success) { + uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. + if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) { this->status_set_warning(); return false; } + this->input_mask_ = (uint16_t(data[1]) << 8) | data[0]; this->status_clear_warning(); return true; } @@ -123,6 +121,11 @@ bool PCF8574Component::write_gpio_() { float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } void PCF8574GPIOPin::setup() { pin_mode(flags_); } +void PCF8574GPIOPin::set_flags(gpio::Flags flags) { + this->flags_ = flags; + if (flags & gpio::FLAG_INPUT) + this->parent_->register_input_pin(this->pin_); +} void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 9879d6a47eb..465bbd7a918 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -16,6 +16,9 @@ class PCF8574Component final : public Component, PCF8574Component() = default; void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; } + void set_reset(bool reset) { this->reset_ = reset; } + // Called by pin setters before any component's setup or GPIO writes. + void register_input_pin(uint8_t pin) { this->configured_input_pins_ |= 1 << pin; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } /// Check i2c availability and setup masks @@ -44,6 +47,11 @@ class PCF8574Component final : public Component, uint16_t output_mask_{0x00}; /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; + // Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins + // (including undeclared pins) are treated as outputs seeded from their observed levels. + uint16_t configured_input_pins_{0x00}; + // Keep in sync with DEFAULT_RESET in __init__.py; codegen omits the setter for the default. + bool reset_{true}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 InternalGPIOPin *interrupt_pin_{nullptr}; }; @@ -63,7 +71,8 @@ class PCF8574GPIOPin final : public GPIOPin { void set_parent(PCF8574Component *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } + // Call after set_parent() and set_pin(): input flags register the pin with the parent. + void set_flags(gpio::Flags flags); gpio::Flags get_flags() const override { return this->flags_; } diff --git a/tests/components/pcf8574/common.yaml b/tests/components/pcf8574/common.yaml index 8a26b930152..4613f4b8859 100644 --- a/tests/components/pcf8574/common.yaml +++ b/tests/components/pcf8574/common.yaml @@ -8,6 +8,16 @@ pcf8574: address: 0x22 pcf8575: false interrupt_pin: ${interrupt_pin} + reset: true + - id: pcf8574_preserved + i2c_id: i2c_bus + address: 0x23 + reset: false + - id: pcf8575_preserved + i2c_id: i2c_bus + address: 0x24 + pcf8575: true + reset: false binary_sensor: - platform: gpio @@ -18,6 +28,18 @@ binary_sensor: number: 1 mode: INPUT inverted: true + - platform: gpio + id: pcf8574_preserved_input + pin: + pcf8574: pcf8574_preserved + number: 1 + mode: INPUT + - platform: gpio + id: pcf8575_preserved_input + pin: + pcf8574: pcf8575_preserved + number: 15 + mode: INPUT output: - platform: gpio @@ -27,3 +49,13 @@ output: number: 0 mode: OUTPUT inverted: false + - platform: gpio + id: pcf8574_preserved_output + pin: + pcf8574: pcf8574_preserved + number: 0 + - platform: gpio + id: pcf8575_preserved_output + pin: + pcf8574: pcf8575_preserved + number: 8 diff --git a/tests/components/pcf8574/test_pcf8574.cpp b/tests/components/pcf8574/test_pcf8574.cpp new file mode 100644 index 00000000000..3500069ba10 --- /dev/null +++ b/tests/components/pcf8574/test_pcf8574.cpp @@ -0,0 +1,189 @@ +#include + +#include "esphome/components/pcf8574/pcf8574.h" + +namespace esphome::pcf8574::testing { + +// Model quasi-bidirectional readback separately from the output latch. +class PortBus : public i2c::I2CBus { + public: + 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, 0x21); + if (read_count != 0) { + EXPECT_EQ(read_count, this->width); + this->reads++; + if (this->fail_read) + return i2c::ERROR_TIMEOUT; + uint16_t levels = this->latch & ~this->external_low; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = levels >> (8 * i); + } + if (write_count != 0) { + EXPECT_EQ(write_count, this->width); + this->writes++; + this->latch = write_buffer[0]; + if (write_count == 2) + this->latch |= uint16_t(write_buffer[1]) << 8; + } + return i2c::ERROR_OK; + } + + size_t width{1}; + uint16_t latch{0}; + uint16_t external_low{0}; + unsigned reads{0}; + bool fail_read{false}; + unsigned writes{0}; +}; + +class PCF8574ResetTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + this->bus_.width = GetParam() ? 2 : 1; + this->mask_ = GetParam() ? 0xFFFF : 0xFF; + this->high_pin_ = GetParam() ? 15 : 7; + this->bus_.latch = 0xA55A & this->mask_; + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(0x21); + this->component_.set_pcf8575(GetParam()); + } + + void configure_input_(uint8_t number) { + PCF8574GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 0); + } + + PortBus bus_; + PCF8574Component component_; + uint16_t mask_; + uint8_t high_pin_; +}; + +TEST_P(PCF8574ResetTest, DefaultResetRetainsStartupAndPinModeBehavior) { + this->configure_input_(1); + + this->component_.setup(); + ASSERT_EQ(this->bus_.writes, 1); + EXPECT_EQ(this->bus_.latch, this->mask_); + EXPECT_EQ(this->bus_.reads, 2); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 1); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, this->mask_ & ~1); +} + +TEST_P(PCF8574ResetTest, PreservesOtherOutputsBeforeAndAfterTheirPinSetup) { + this->component_.set_reset(false); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 1); + EXPECT_EQ(this->bus_.latch, initial); + + // Consumers can write before calling their GPIO pin's setup(). + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + const uint16_t high_pin_mask = 1 << this->high_pin_; + this->component_.digital_write(this->high_pin_, (initial & high_pin_mask) == 0); + const uint16_t expected = (initial | 1) ^ high_pin_mask; + EXPECT_EQ(this->bus_.latch, expected); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, expected & ~1); + ASSERT_EQ(this->bus_.writes, 3); +} + +TEST_P(PCF8574ResetTest, MixedInputsAreReleasedBeforeTheirConsumersInitialize) { + const uint16_t inputs = (1 << 1) | (1 << this->high_pin_); + this->bus_.latch |= inputs; + const uint16_t initial = this->bus_.latch; + this->bus_.external_low = inputs; + + this->component_.set_reset(false); + this->configure_input_(1); + this->configure_input_(this->high_pin_); + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial | 1); + EXPECT_FALSE(this->component_.digital_read(1)); +} + +TEST_P(PCF8574ResetTest, InputSetupBeforeOutputSetupPreservesOutputs) { + this->component_.set_reset(false); + this->configure_input_(1); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + this->component_.pin_mode(1, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); +} + +TEST_P(PCF8574ResetTest, LoadedHighOutputIsLatchedLowOnFirstWrite) { + const uint16_t loaded_pin = 1 << this->high_pin_; + this->bus_.latch |= loaded_pin; + this->bus_.external_low = loaded_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~loaded_pin); +} + +TEST_P(PCF8574ResetTest, UndeclaredLowPinRemainsDrivenAfterExternalRelease) { + const uint16_t undeclared_pin = 1 << this->high_pin_; + this->bus_.latch |= undeclared_pin; + this->bus_.external_low = undeclared_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + this->bus_.external_low = 0; + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~undeclared_pin); + EXPECT_FALSE(this->component_.digital_read(this->high_pin_)); +} + +TEST_P(PCF8574ResetTest, InitialReadFailurePreventsAllWrites) { + this->bus_.fail_read = true; + + this->component_.set_reset(false); + this->component_.setup(); + EXPECT_TRUE(this->component_.is_failed()); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.writes, 0); +} + +INSTANTIATE_TEST_SUITE_P(PCF8574AndPCF8575, PCF8574ResetTest, ::testing::Bool()); + +} // namespace esphome::pcf8574::testing diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py new file mode 100644 index 00000000000..43751815358 --- /dev/null +++ b/tests/unit_tests/components/test_pcf8574.py @@ -0,0 +1,35 @@ +"""PCF8574 reset configuration and code generation.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from esphome.components import pcf8574 +from esphome.const import CONF_ID, CONF_RESET + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "options, expected", + [ + ({}, True), + ({"reset": True}, True), + ({"reset": False}, False), + ], +) +async def test_reset_configuration(options: dict, expected: bool) -> None: + config = pcf8574.CONFIG_SCHEMA({CONF_ID: "hub", **options}) + assert config[CONF_RESET] is expected + var = Mock() + with ( + patch.object(pcf8574.cg, "new_Pvariable", return_value=var), + patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock), + patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock), + patch.object(pcf8574.cg, "add"), + ): + await pcf8574.to_code(config) + + if expected: + var.set_reset.assert_not_called() + else: + var.set_reset.assert_called_once_with(False)