PCA9554 I2c gpio expander custom component config error

I’m writing a custom component for the Texas Instruments PCA9554 I2C bus expander which I want to use for my sprinkler controller. I’m having some trouble getting it to configure properly in the yaml file, and I was wondering if someone might know what might be going on with either the init.py file or the esp32-sprinkler.yaml file.

I created the PCA9554 custom component using PCF8574 custom component source files already in the esphome component directory. They’re loaded as an external custom component in the esp32-sprinkler.yaml file.

The error I’m getting is:

srodgers@steverod ~/projects/esphome/esp32-sprinkler $ esphome compile esp32-sprinkler.yaml
INFO Reading configuration esp32-sprinkler.yaml...
Failed config

pca9554: [source esp32-sprinkler.yaml:44]
  
  expected a dictionary.
  - id: pca9554_master
    address: 32

*** All files are attached below ***

My yaml configuration is:

esphome:
  name: esp32-sprinkler
  platform: ESP32
  board: esp32doit-devkit-v1

wifi:
  networks:
    - ssid: !secret wifi_ssid
      password: !secret wifi_password
    - ssid: !secret sec_wifi_ssid
      password: !secret sec_wifi_password

web_server:
  port: 80
  # https://esphome.io/components/web_server.html


logger:
  # https://esphome.io/components/logger


api:
  password: !secret esphome_api_password
  # https://esphome.io/components/api


ota:
  password: !secret esphome_ota_password
  # https://esphome.io/components/ota

i2c:
  sda: 21
  scl: 22
  scan: true
  id: bus_a

external_components:
- source:
    type: local
    path: custom_components/pca9554
  
    
pca9554:
  - id: pca9554_stations_1_8
    address: 0x20
  

switch:
  - platform: gpio
    name: "Station 1"
    pin:
      pca9554: pca9554_stations_1_8
      number: 0
      mode:
        output: true
      inverted: false

The init.py file in the custom components directory is:

import esphome.codegen as cg
import esphome.config_validation as cv
from esphome import pins
from esphome.components import i2c
from esphome.const import (
    CONF_ID,
    CONF_INPUT,
    CONF_NUMBER,
    CONF_MODE,
    CONF_INVERTED,
    CONF_OUTPUT,
)

DEPENDENCIES = ["i2c"]
MULTI_CONF = False

pca9554_ns = cg.esphome_ns.namespace("pca9554")

PCA9554Component = pca9554_ns.class_("PCA9554Component", cg.Component, i2c.I2CDevice)
PCA9554GPIOPin = pca9554_ns.class_("PCA9554GPIOPin", cg.GPIOPin)

CONF_PCA9554 = "pca9554"
CONFIG_SCHEMA = (
    cv.Schema(
        {
            cv.Required(CONF_ID): cv.declare_id(PCA9554Component)
        }
    )
    .extend(cv.COMPONENT_SCHEMA)
    .extend(i2c.i2c_device_schema(0x20))
)


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)


def validate_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")
    return value


PCA9554_PIN_SCHEMA = cv.All(
    {
        cv.GenerateID(): cv.declare_id(PCA9554GPIOPin),
        cv.Required(CONF_PCA9554): cv.use_id(PCA9554Component),
        cv.Required(CONF_NUMBER): cv.int_range(min=0, max=8),
        cv.Optional(CONF_MODE, default={}): cv.All(
            {
                cv.Optional(CONF_INPUT, default=False): cv.boolean,
                cv.Optional(CONF_OUTPUT, default=False): cv.boolean,
            },
            validate_mode,
        ),
        cv.Optional(CONF_INVERTED, default=False): cv.boolean,
    }
)


@pins.PIN_SCHEMA_REGISTRY.register("pca9554", PCA9554_PIN_SCHEMA)
async def pca9554_pin_to_code(config):
    var = cg.new_Pvariable(config[CONF_ID])
    parent = await cg.get_variable(config[CONF_PCA9554])

    cg.add(var.set_parent(parent))

    num = config[CONF_NUMBER]
    cg.add(var.set_pin(num))
    cg.add(var.set_inverted(config[CONF_INVERTED]))
    cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE])))
    return var

The pca9554.cpp file contains:

#include "pca9554.h"
#include "esphome/core/log.h"


namespace esphome {
namespace pca9554 {

#define INPUT_REG 0
#define OUTPUT_REG 1
#define CONFIG_REG 3


static const char *const TAG = "pca9554";

void PCA9554Component::setup() {
  ESP_LOGCONFIG(TAG, "Setting up PCA9554...");
  // Test to see if device exists
  if (!this->read_inputs_()) {
    ESP_LOGE(TAG, "PCA9554 not available under 0x%02X", this->address_);
    this->mark_failed();
    return;
  }
  this->config_mask_ = 0; // All inputs at initialization
  this->write_config_();
  this->output_mask_ = 0; // All outputs low
  this->write_outputs_();
  this->read_inputs_(); // Read the inputs
}
void PCA9554Component::dump_config() {
  ESP_LOGCONFIG(TAG, "PCA9554:");
  LOG_I2C_DEVICE(this)
  if (this->is_failed()) {
    ESP_LOGE(TAG, "Communication with PCA9554 failed!");
  }
}


bool PCA9554Component::digital_read(uint8_t pin) {
  this->read_inputs_();
  return this->input_mask_ & (1 << pin);
}


void PCA9554Component::digital_write(uint8_t pin, bool value) {
  if (value) {
    this->output_mask_ |= (1 << pin);
  } else {
    this->output_mask_ &= ~(1 << pin);
  }
  this->write_outputs_();


}
void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) {
  if (flags == gpio::FLAG_INPUT) {
    // Clear mode mask bit
    this->config_mask_ &= ~(1 << pin);
    // Write GPIO to enable input mode
    this->write_config_();
  } else if (flags == gpio::FLAG_OUTPUT) {
    // Set mode mask bit
    this->config_mask_ |= 1 << pin;
  }
  this->write_config_();
}


bool PCA9554Component::read_inputs_() {
  if (this->is_failed())
    return false;
  bool success;
  uint8_t data[1];
  data[0] = INPUT_REG;
  success = this->write(data, 1, false) == esphome::i2c::ERROR_OK; // Write register address with no stop condition
  success = success && (this->read_bytes_raw(data, 1) == esphome::i2c::ERROR_OK);
  this->input_mask_ = data[0];
  if (!success) {
    this->status_set_warning();
    return false;
  }
  this->status_clear_warning();
  return true;
}

bool PCA9554Component::write_outputs_() {
 if (this->is_failed())
    return false;
  
  uint8_t data[2];
  data[0] = OUTPUT_REG;
  data[1] = this->output_mask_;

  if (this->write(data, 2) != esphome::i2c::ERROR_OK) {
    this->status_set_warning();
    return false;
  }

  this->status_clear_warning();
  return true;
}


bool PCA9554Component::write_config_() {
  if (this->is_failed())
    return false;
  
  uint8_t data[2];
  data[0] = CONFIG_REG;
  // Config bits: 1 is an output and 0 is an input 
  // These have to be inverted per PCA9554 the data sheet. PCA9554: 0 = Output and 1 = input.
  data[1] = ~this->config_mask_;


  if (this->write(data, 2) != esphome::i2c::ERROR_OK) {
    this->status_set_warning();
    return false;
  }

  this->status_clear_warning();
  return true;
}

float PCA9554Component::get_setup_priority() const { return setup_priority::IO; }

void PCA9554GPIOPin::setup() { pin_mode(flags_); }
void PCA9554GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); }
bool PCA9554GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; }
void PCA9554GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); }
std::string PCA9554GPIOPin::dump_summary() const {
  char buffer[32];
  snprintf(buffer, sizeof(buffer), "%u via PCA9554", pin_);
  return buffer;
}

}  // namespace pca9554
}  // namespace esphome

The pca9554.h file contains:


  /// Check i2c availability and setup masks
  void setup() override;
  /// Helper function to read the value of a pin.
  bool digital_read(uint8_t pin);
  /// Helper function to write the value of a pin.
  void digital_write(uint8_t pin, bool value);
  /// Helper function to set the pin mode of a pin.
  void pin_mode(uint8_t pin, gpio::Flags flags);


  float get_setup_priority() const override;

  void dump_config() override;

 protected:
  bool read_inputs_();

  bool write_outputs_();

  bool write_config_();

  /// Mask for the pin config - 1 means OUTPUT, 0 means INPUT
  uint8_t config_mask_{0x00};
  /// The mask to write as output state - 1 means HIGH, 0 means LOW
  uint8_t output_mask_{0x00};
  /// The state of the actual input pin states - 1 means HIGH, 0 means LOW
  uint8_t input_mask_{0x00};
};

/// Helper class to expose a PCA9554 pin as an internal input GPIO pin.
class PCA9554GPIOPin : public GPIOPin {
 public:
  void setup() override;
  void pin_mode(gpio::Flags flags) override;
  bool digital_read() override;
  void digital_write(bool value) override;
  std::string dump_summary() const override;

  void set_parent(PCA9554Component *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; }

 protected:
  PCA9554Component *parent_;
  uint8_t pin_;
  bool inverted_;
  gpio::Flags flags_;
};

}  // namespace pca9554
}  // namespace esphome


Solved by removing - from id in pca9554 stanza.

Updated yaml:

external_components:
- source:
    type: local
    path: custom_components/pca9554
  
    
pca9554:
    id: pca9554_stations_1_8
    address: 0x20


  

switch:
  - platform: gpio
    name: "Station 1"
    pin:
      pca9554: pca9554_stations_1_8
      number: 0
      mode:
        output: true
      inverted: false