[pcf8574] Add reset option to preserve outputs across MCU reboots (#20589)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Ivan Kruglov
2026-10-13 08:12:28 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent 40271fb597
commit 5acbd44067
6 changed files with 290 additions and 16 deletions
+6
View File
@@ -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)))
+18 -15
View File
@@ -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_); }
+10 -1
View File
@@ -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_; }
+32
View File
@@ -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
+189
View File
@@ -0,0 +1,189 @@
#include <gtest/gtest.h>
#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<bool> {
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
@@ -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)