[tca8418] Add TCA8418 keypad component (#18521)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Zebble
2026-10-12 12:39:11 -10:00
committed by GitHub
co-authored by Claude Jesse Hills J. Nick Koston J. Nick Koston
parent 3ddbf8b3ef
commit a68cf669aa
13 changed files with 928 additions and 0 deletions
+1
View File
@@ -581,6 +581,7 @@ esphome/components/t6615/* @tylermenezes
esphome/components/tas2780/* @remcom
esphome/components/tas58xx/* @mrtoy-me @remcom
esphome/components/tc74/* @sethgirvan
esphome/components/tca8418/* @zebble
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
+156
View File
@@ -0,0 +1,156 @@
import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import gpio_expander, i2c, key_provider
from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_KEY
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@zebble"]
DEPENDENCIES = ["i2c"]
DOMAIN = "tca8418"
AUTO_LOAD = ["key_provider", "gpio_expander"]
MULTI_CONF = True
# Each keypad keeps its listeners in a fixed size list; the define is the largest count any one keypad needs
_request_listener_slot = cg.slot_counter("TCA8418_LISTENER_COUNT")
async def register_listener(keypad: cg.MockObj, var: cg.MockObj) -> None:
_request_listener_slot(str(keypad))
cg.add(keypad.register_listener(var))
CONF_GPI_EVENTS = "gpi_events"
CONF_KEYPAD_ID = "keypad_id"
# Matrix keys are numbered row by row from 1 with ten columns per row; pins outside
# the matrix report as individual inputs 97..114 (ROW0..7, then COL0..9)
MATRIX_COLUMNS = 10
MATRIX_KEY_MAX = 80
GPI_KEY_MIN = 97
GPI_KEY_MAX = 114
def _matrix_key(row: int, col: int) -> int:
return row * MATRIX_COLUMNS + col + 1
tca8418_ns = cg.esphome_ns.namespace("tca8418")
TCA8418Component = tca8418_ns.class_(
"TCA8418Component", key_provider.KeyProvider, cg.Component, i2c.I2CDevice
)
def _ascii_string(value: str) -> str:
"""A string of single byte characters, so it lines up with the matrix."""
value = cv.string_strict(value)
if not value.isascii():
raise cv.Invalid(
"Only ASCII characters can be used, since each key is one character"
)
return value
def _validate(config: ConfigType) -> ConfigType:
rows = config.get(CONF_ROWS, 0)
columns = config.get(CONF_COLUMNS, 0)
if (keys := config.get(CONF_KEYS)) is not None:
if rows == 0:
raise cv.Invalid(
"'keys' maps the key matrix, so 'rows' and 'columns' are required",
path=[CONF_KEYS],
)
if len(keys) != rows * columns:
raise cv.Invalid(
f"'keys' must have exactly {rows * columns} characters "
f"({rows} rows x {columns} columns), got {len(keys)}",
path=[CONF_KEYS],
)
# Individual inputs report as 97..114, which are also the codes for the
# letters 'a' to 'r'. Both reach on_key and key collectors as a number.
if (
keys is not None
and config[CONF_GPI_EVENTS]
and (
clashing := sorted(
{k for k in keys if GPI_KEY_MIN <= ord(k) <= GPI_KEY_MAX}
)
)
):
_LOGGER.warning(
"In '%s', the key(s) %s cannot be told apart from an individual input "
"on the '%s' and key collector paths, because both are reported as the "
"same number. Binary sensors are unaffected. Use other characters, or "
"set '%s: false', if this matters.",
CONF_KEYS,
", ".join(f"'{k}'" for k in clashing),
CONF_ON_KEY,
CONF_GPI_EVENTS,
)
if rows == 0 and not config[CONF_GPI_EVENTS]:
raise cv.Invalid(
"Nothing to report: set 'rows' and 'columns' for a key matrix, "
"or leave 'gpi_events' enabled to report individual inputs"
)
return config
_MATRIX_MSG = (
"'rows' and 'columns' must both be set to use the key matrix, "
"or both left unset to use every pin as an individual input"
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(TCA8418Component),
# The key matrix occupies ROW0..ROW(rows-1) and COL0..COL(columns-1).
# Leave both unset to use every pin as an individual input instead.
cv.Inclusive(CONF_ROWS, "matrix", msg=_MATRIX_MSG): cv.int_range(
min=1, max=8
),
cv.Inclusive(CONF_COLUMNS, "matrix", msg=_MATRIX_MSG): cv.int_range(
min=1, max=10
),
cv.Optional(CONF_KEYS): _ascii_string,
# Report pins that are not part of the matrix as individual inputs.
cv.Optional(CONF_GPI_EVENTS, default=True): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.Optional(CONF_ON_KEY): automation.validate_automation({}),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x34)),
_validate,
)
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 (rows := config.get(CONF_ROWS)) is not None:
columns = config[CONF_COLUMNS]
cg.add(var.set_rows(rows))
cg.add(var.set_columns(columns))
if (keys := config.get(CONF_KEYS)) is not None:
# Indexed by key number - 1, with 0 where no key is assigned.
table = [0] * _matrix_key(rows - 1, columns - 1)
for index, char in enumerate(keys.encode()):
table[_matrix_key(*divmod(index, columns)) - 1] = char
array = cg.shared_progmem_array("tca8418_keys", cg.uint8, table)
cg.add(var.set_keys(array, len(table)))
cg.add(var.set_gpi_events(config[CONF_GPI_EVENTS]))
if (interrupt_pin := config.get(CONF_INTERRUPT_PIN)) is not None:
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
for conf in config.get(CONF_ON_KEY, []):
await automation.build_callback_automation(
var, "add_on_key_callback", [(cg.uint8, "x")], conf
)
@@ -0,0 +1,143 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS
import esphome.config_validation as cv
from esphome.const import CONF_COL, CONF_KEY, CONF_ROW
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import (
CONF_GPI_EVENTS,
CONF_KEYPAD_ID,
GPI_KEY_MAX,
GPI_KEY_MIN,
MATRIX_COLUMNS,
MATRIX_KEY_MAX,
TCA8418Component,
_ascii_string,
_matrix_key,
register_listener,
tca8418_ns,
)
DEPENDENCIES = ["tca8418"]
CONF_KEY_CODE = "key_code"
TCA8418BinarySensor = tca8418_ns.class_(
"TCA8418BinarySensor", binary_sensor.BinarySensorInitiallyOff
)
def _key_code(value: int) -> int:
value = cv.int_(value)
if 1 <= value <= MATRIX_KEY_MAX or GPI_KEY_MIN <= value <= GPI_KEY_MAX:
return value
raise cv.Invalid(
f"Key numbers run from 1 to {MATRIX_KEY_MAX} for the key matrix, "
f"and {GPI_KEY_MIN} to {GPI_KEY_MAX} for individual inputs"
)
_POSITION_MSG = f"'{CONF_ROW}' and '{CONF_COL}' must be given together"
CONFIG_SCHEMA = cv.All(
binary_sensor.binary_sensor_schema(TCA8418BinarySensor).extend(
{
cv.GenerateID(CONF_KEYPAD_ID): cv.use_id(TCA8418Component),
cv.Inclusive(CONF_ROW, "position", msg=_POSITION_MSG): cv.int_range(
min=0, max=7
),
cv.Inclusive(CONF_COL, "position", msg=_POSITION_MSG): cv.int_range(
min=0, max=9
),
cv.Optional(CONF_KEY): cv.All(
cv.string,
_ascii_string,
cv.Length(min=1, max=1, msg="Key must be a single character"),
),
cv.Optional(CONF_KEY_CODE): _key_code,
}
),
cv.has_exactly_one_key(CONF_ROW, CONF_KEY, CONF_KEY_CODE),
)
def _final_validate(config: ConfigType) -> None:
"""Resolve the key to the number the device reports, checked against the keypad's matrix."""
full_config = fv.full_config.get()
keypad_path = full_config.get_path_for_id(config[CONF_KEYPAD_ID])[:-1]
keypad = full_config.get_config_for_path(keypad_path)
rows = keypad.get(CONF_ROWS, 0)
columns = keypad.get(CONF_COLUMNS, 0)
if (row := config.get(CONF_ROW)) is not None:
col = config[CONF_COL]
if row >= rows or col >= columns:
raise cv.Invalid(
f"Position {row}/{col} is outside the {rows} x {columns} key "
"matrix of the keypad it belongs to",
path=[CONF_ROW],
)
config[CONF_KEY_CODE] = _matrix_key(row, col)
return
if (key_code := config.get(CONF_KEY_CODE)) is not None:
if key_code <= MATRIX_KEY_MAX:
row, col = divmod(key_code - 1, MATRIX_COLUMNS)
if row >= rows or col >= columns:
raise cv.Invalid(
f"Key number {key_code} is position {row}/{col}, which is "
f"outside the {rows} x {columns} key matrix of the keypad it "
"belongs to",
path=[CONF_KEY_CODE],
)
return
if not keypad[CONF_GPI_EVENTS]:
raise cv.Invalid(
f"Key number {key_code} is an individual input, but the keypad has "
f"'{CONF_GPI_EVENTS}' turned off",
path=[CONF_KEY_CODE],
)
# 97..104 are ROW0..ROW7 and 105..114 are COL0..COL9. A pin used by the
# matrix cannot also report on its own.
offset = key_code - GPI_KEY_MIN
if offset < 8:
in_matrix, pin = offset < rows, f"ROW{offset}"
else:
in_matrix, pin = offset - 8 < columns, f"COL{offset - 8}"
if in_matrix:
raise cv.Invalid(
f"Key number {key_code} is {pin}, which the key matrix is using, so "
"it cannot report as an individual input",
path=[CONF_KEY_CODE],
)
return
if (key := config.get(CONF_KEY)) is not None:
keys = keypad.get(CONF_KEYS)
if keys is None:
raise cv.Invalid(
f"'{CONF_KEY}' names a key from the keypad's 'keys', which is not set",
path=[CONF_KEY],
)
if (index := keys.find(key)) < 0:
raise cv.Invalid(
f"'{key}' is not one of the keypad's keys", path=[CONF_KEY]
)
if keys.count(key) > 1:
raise cv.Invalid(
f"'{key}' appears more than once in the keypad's keys, so it does "
f"not name one key. Use '{CONF_ROW}' and '{CONF_COL}' instead",
path=[CONF_KEY],
)
config[CONF_KEY_CODE] = _matrix_key(*divmod(index, columns))
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config, config[CONF_KEY_CODE])
keypad = await cg.get_variable(config[CONF_KEYPAD_ID])
await register_listener(keypad, var)
@@ -0,0 +1,26 @@
#pragma once
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "../tca8418.h"
namespace esphome::tca8418 {
/// One key of a TCA8418, matched by the key number codegen resolved from the config
class TCA8418BinarySensor : public binary_sensor::BinarySensorInitiallyOff, public TCA8418Listener {
public:
explicit TCA8418BinarySensor(uint8_t key) : key_(key) {}
void key_pressed(uint8_t key) override {
if (key == this->key_)
this->publish_state(true);
}
void key_released(uint8_t key) override {
if (key == this->key_)
this->publish_state(false);
}
protected:
const uint8_t key_;
};
} // namespace esphome::tca8418
+170
View File
@@ -0,0 +1,170 @@
#include "tca8418.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::tca8418 {
ESPHOME_LOG_TAG(TAG, "tca8418");
// Minimum gap between event queue reads; the ten entry queue holds anything arriving sooner.
static constexpr uint32_t POLL_INTERVAL_MS = 50;
void IRAM_ATTR TCA8418Component::interrupt_handler(TCA8418Component *arg) { arg->enable_loop_soon_any_context(); }
void TCA8418Component::setup() {
// The configuration register reads back, so use it to check the device is there.
uint8_t config;
if (!this->read_byte(TCA8418_REG_CFG, &config)) {
ESP_LOGE(TAG, "Failed to read from device - check wiring / address");
this->mark_failed();
return;
}
if (!this->configure_pins_()) {
ESP_LOGE(TAG, "Failed to configure pins");
this->mark_failed();
return;
}
// Report key events, and keep the interrupt asserted while events remain queued.
if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) {
ESP_LOGE(TAG, "Failed to write the configuration register");
this->mark_failed();
return;
}
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
// The interrupt output is active low, so a falling edge means an event was
// queued. The handler only wakes the loop; the queue is read from there.
this->interrupt_pin_->attach_interrupt(&TCA8418Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE);
}
// Discard anything the device queued before it was configured, and clear
// every interrupt flag: leaving one set holds the interrupt output asserted.
if (!this->drain_events_(false)) {
ESP_LOGE(TAG, "Failed to clear the event queue");
this->mark_failed();
return;
}
// With an interrupt pin there is nothing to do until it fires. Stay running
// if the device still has something queued, since that produces no new edge.
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read())
this->disable_loop();
}
bool TCA8418Component::configure_pins_() {
// Each of the three registers covers a group of pins: ROW0-7, COL0-7, COL8-9.
uint8_t matrix[3] = {0, 0, 0};
if (this->rows_ > 0) {
matrix[0] = static_cast<uint8_t>((1U << this->rows_) - 1U);
const uint8_t low_columns = this->columns_ > 8 ? 8 : this->columns_;
matrix[1] = static_cast<uint8_t>((1U << low_columns) - 1U);
if (this->columns_ > 8)
matrix[2] = static_cast<uint8_t>((1U << (this->columns_ - 8U)) - 1U);
}
// Pins outside the matrix report individually when they are in event mode.
// The device ignores these bits for pins that belong to the matrix.
const uint8_t gpi[3] = {
static_cast<uint8_t>(this->gpi_events_ ? ~matrix[0] : 0),
static_cast<uint8_t>(this->gpi_events_ ? ~matrix[1] : 0),
static_cast<uint8_t>(this->gpi_events_ ? (0x03 & ~matrix[2]) : 0),
};
for (uint8_t i = 0; i < 3; i++) {
// Enable the internal pull-ups (a 0 bit enables the pull-up) so that
// buttons can simply switch their pin to ground.
if (!this->write_byte(TCA8418_REG_GPIO_PULL1 + i, 0x00) || !this->write_byte(TCA8418_REG_KP_GPIO1 + i, matrix[i]) ||
!this->write_byte(TCA8418_REG_GPI_EM1 + i, gpi[i]) || !this->write_byte(TCA8418_REG_GPIO_INT_EN1 + i, gpi[i])) {
return false;
}
}
return true;
}
void TCA8418Component::loop() {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_poll_ < POLL_INTERVAL_MS)
return;
this->last_poll_ = now;
if (!this->drain_events_(true)) {
this->status_set_warning();
return;
}
this->status_clear_warning();
// Events that arrive during a read keep INT asserted without a new edge, so only stop once it is clear
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read())
this->disable_loop();
}
bool TCA8418Component::drain_events_(bool dispatch) {
uint8_t count;
if (!this->read_byte(TCA8418_REG_KEY_LCK_EC, &count))
return false;
count &= TCA8418_EVENT_COUNT_MASK;
// An idle poll has no flags to clear, unless INT is still asserted from a failed clear
if (count == 0 && dispatch && (this->interrupt_pin_ == nullptr || this->interrupt_pin_->digital_read()))
return true;
// The overflow flag stays clear with a full queue, so check the count
if (dispatch && count >= TCA8418_FIFO_DEPTH) {
ESP_LOGW(TAG, "Event queue is full - some key presses may have been lost");
}
uint8_t event;
for (uint8_t i = 0; i < count; i++) {
if (!this->read_byte(TCA8418_REG_KEY_EVENT_A, &event))
return false;
if (dispatch)
this->dispatch_(event & TCA8418_KEY_CODE_MASK, (event & TCA8418_KEY_PRESSED) != 0);
}
return this->write_byte(TCA8418_REG_INT_STAT, TCA8418_INT_STAT_ALL);
}
uint8_t TCA8418Component::key_char_(uint8_t key) const {
if (key == 0 || key > this->keys_size_)
return 0;
return progmem_read_byte(&this->keys_[key - 1]);
}
void TCA8418Component::dispatch_(uint8_t key, bool pressed) {
ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released"));
#ifdef TCA8418_LISTENER_COUNT
for (auto *listener : this->listeners_) {
if (pressed) {
listener->key_pressed(key);
} else {
listener->key_released(key);
}
}
#endif
if (!pressed)
return;
// Key collectors and automations receive the mapped character when a key map
// is configured, and the key number otherwise.
const uint8_t key_char = this->key_char_(key);
this->send_key_(key_char != 0 ? key_char : key);
}
void TCA8418Component::dump_config() {
ESP_LOGCONFIG(TAG,
"TCA8418:\n"
" Individual inputs: %s",
YESNO(this->gpi_events_));
if (this->rows_ > 0) {
ESP_LOGCONFIG(TAG, " Key matrix: %u rows x %u columns", this->rows_, this->columns_);
}
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
}
} // namespace esphome::tca8418
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "esphome/components/key_provider/key_provider.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
namespace esphome::tca8418 {
// Register map (see TI datasheet SCPS231)
static constexpr uint8_t TCA8418_REG_CFG = 0x01;
static constexpr uint8_t TCA8418_REG_INT_STAT = 0x02;
static constexpr uint8_t TCA8418_REG_KEY_LCK_EC = 0x03;
static constexpr uint8_t TCA8418_REG_KEY_EVENT_A = 0x04; // top of the event FIFO
static constexpr uint8_t TCA8418_REG_GPIO_INT_EN1 = 0x1A;
static constexpr uint8_t TCA8418_REG_KP_GPIO1 = 0x1D;
static constexpr uint8_t TCA8418_REG_GPI_EM1 = 0x20;
static constexpr uint8_t TCA8418_REG_GPIO_PULL1 = 0x2C;
// CFG
static constexpr uint8_t TCA8418_CFG_KEY_INT_EN = 0x01; // interrupt on key events
static constexpr uint8_t TCA8418_CFG_INT_CFG = 0x10; // re-assert INT while events remain
// INT_STAT
static constexpr uint8_t TCA8418_INT_STAT_ALL = 0x1F; // every interrupt flag
// KEY_LCK_EC: the low nibble is the number of queued events
static constexpr uint8_t TCA8418_EVENT_COUNT_MASK = 0x0F;
// Key events: bit 7 set = pressed, clear = released; bits 6-0 are the key number
static constexpr uint8_t TCA8418_KEY_PRESSED = 0x80;
static constexpr uint8_t TCA8418_KEY_CODE_MASK = 0x7F;
// The event queue holds ten entries, which bounds how many can be read at once.
static constexpr uint8_t TCA8418_FIFO_DEPTH = 10;
class TCA8418Listener {
public:
/// Called with the key number reported by the device (1..80 matrix, 97..114 individual inputs).
virtual void key_pressed(uint8_t key) {}
virtual void key_released(uint8_t key) {}
};
class TCA8418Component : public key_provider::KeyProvider, public Component, public i2c::I2CDevice {
public:
void setup() override;
void loop() override;
void dump_config() override;
void set_rows(uint8_t rows) { this->rows_ = rows; }
void set_columns(uint8_t columns) { this->columns_ = columns; }
void set_gpi_events(bool gpi_events) { this->gpi_events_ = gpi_events; }
void set_keys(const uint8_t *keys, uint8_t size) {
this->keys_ = keys;
this->keys_size_ = size;
}
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
#ifdef TCA8418_LISTENER_COUNT
void register_listener(TCA8418Listener *listener) { this->listeners_.push_back(listener); }
#endif
/// Wakes the loop so queued events are read. Runs in interrupt context.
static void IRAM_ATTR interrupt_handler(TCA8418Component *arg);
protected:
/// Write the matrix / individual-input pin configuration. Returns false on I2C failure.
bool configure_pins_();
/// Read every queued key event, dispatching them if asked, then clear the interrupt flags.
/// Returns false on I2C failure.
bool drain_events_(bool dispatch);
void dispatch_(uint8_t key, bool pressed);
/// The character mapped to a matrix key, or 0 when it has none.
uint8_t key_char_(uint8_t key) const;
const uint8_t *keys_{nullptr}; // PROGMEM, indexed by key number - 1
InternalGPIOPin *interrupt_pin_{nullptr};
uint32_t last_poll_{0};
#ifdef TCA8418_LISTENER_COUNT
StaticVector<TCA8418Listener *, TCA8418_LISTENER_COUNT> listeners_;
#endif
uint8_t rows_{0};
uint8_t columns_{0};
uint8_t keys_size_{0};
bool gpi_events_{true};
};
} // namespace esphome::tca8418
+1
View File
@@ -202,6 +202,7 @@
#define SYSTA_BUS_LISTENER_COUNT 2
#define SERIAL_PROXY_COUNT 2
#define SNTP_SERVER_COUNT 3
#define TCA8418_LISTENER_COUNT 1
#define USE_MEDIA_PLAYER
#define USE_MEDIA_SOURCE
#define USE_NETWORK
@@ -0,0 +1,156 @@
"""Tests for how the tca8418 binary sensor resolves its key against the keypad."""
from __future__ import annotations
import pytest
from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS
from esphome.components.tca8418 import CONF_GPI_EVENTS
from esphome.components.tca8418.binary_sensor import (
CONF_KEY_CODE,
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
)
from esphome.config import Config
import esphome.config_validation as cv
from esphome.const import (
CONF_COL,
CONF_ID,
CONF_KEY,
CONF_NAME,
CONF_ROW,
PlatformFramework,
)
from esphome.core import ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "keypad"
def _full_config(
*,
rows: int = 4,
columns: int = 3,
keys: str | None = "123456789*0#",
gpi_events: bool = True,
) -> Config:
"""A full config carrying one keypad, as the ID pass leaves it."""
hub: ConfigType = {
CONF_ID: ID(HUB_ID, is_declaration=True, type="tca8418"),
CONF_ROWS: rows,
CONF_COLUMNS: columns,
CONF_GPI_EVENTS: gpi_events,
}
if keys is not None:
hub[CONF_KEYS] = keys
full = Config()
full["tca8418"] = [hub]
full.declare_ids.append((hub[CONF_ID], ["tca8418", 0, CONF_ID]))
return full
def _sensor(**selector: object) -> ConfigType:
config: ConfigType = {
"keypad_id": ID(HUB_ID, is_declaration=False, type="tca8418"),
CONF_NAME: "Key",
}
config.update(selector)
return config
def _validated(config: ConfigType) -> ConfigType:
config = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(config)
return config
@pytest.fixture
def keypad(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config())
# A key number is worked out here so the sensor only compares numbers at run
# time. Matrix keys are row * 10 + column + 1.
@pytest.mark.parametrize(
("selector", "key_code"),
[
({CONF_ROW: 0, CONF_COL: 0}, 1),
({CONF_ROW: 3, CONF_COL: 2}, 33),
({CONF_KEY: "1"}, 1),
({CONF_KEY: "5"}, 12),
({CONF_KEY: "#"}, 33),
({CONF_KEY_CODE: 12}, 12),
],
)
def test_key_is_resolved(keypad: None, selector: ConfigType, key_code: int) -> None:
assert _validated(_sensor(**selector))[CONF_KEY_CODE] == key_code
def test_position_outside_the_matrix_is_rejected(keypad: None) -> None:
with pytest.raises(cv.Invalid, match="outside the 4 x 3 key matrix"):
_validated(_sensor(row=0, col=3))
def test_key_number_outside_the_matrix_is_rejected(keypad: None) -> None:
"""Key 5 is ROW0/COL4, which a three column matrix never reports."""
with pytest.raises(cv.Invalid, match="position 0/4, which is outside"):
_validated(_sensor(key_code=5))
def test_key_number_of_a_pin_the_matrix_uses_is_rejected(keypad: None) -> None:
"""97 is ROW0, which belongs to the matrix, so it cannot report on its own."""
with pytest.raises(cv.Invalid, match="ROW0, which the key matrix is using"):
_validated(_sensor(key_code=97))
def test_individual_input_outside_the_matrix_passes(keypad: None) -> None:
"""ROW5 is past the four rows of the matrix, so it reports on its own."""
assert _validated(_sensor(key_code=102))[CONF_KEY_CODE] == 102
def test_column_beyond_the_matrix_passes(keypad: None) -> None:
"""COL3 is past the three columns of the matrix; 105 is COL0."""
assert _validated(_sensor(key_code=108))[CONF_KEY_CODE] == 108
def test_individual_input_needs_gpi_events(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF, full_config=_full_config(gpi_events=False)
)
with pytest.raises(cv.Invalid, match="'gpi_events' turned off"):
_validated(_sensor(key_code=102))
def test_character_without_a_key_map_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config(keys=None))
with pytest.raises(cv.Invalid, match="which is not set"):
_validated(_sensor(key="5"))
def test_character_not_in_the_key_map_is_rejected(keypad: None) -> None:
with pytest.raises(cv.Invalid, match="not one of the keypad's keys"):
_validated(_sensor(key="Z"))
def test_repeated_character_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A character appearing twice does not name one key."""
set_core_config(
PlatformFramework.ESP32_IDF, full_config=_full_config(keys="1234567891*0")
)
with pytest.raises(cv.Invalid, match="appears more than once"):
_validated(_sensor(key="1"))
def test_key_given_as_a_number_is_accepted(keypad: None) -> None:
"""'key: 5' is a reasonable way to name the key labelled 5."""
assert _validated(_sensor(key=5))[CONF_KEY_CODE] == 12
@@ -0,0 +1,142 @@
"""Tests for the tca8418 keypad and binary sensor configuration schemas."""
from __future__ import annotations
import logging
import pytest
from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS
from esphome.components.tca8418 import (
CONF_GPI_EVENTS,
CONFIG_SCHEMA as HUB_SCHEMA,
_validate,
)
from esphome.components.tca8418.binary_sensor import (
CONF_KEY_CODE,
CONFIG_SCHEMA as SENSOR_SCHEMA,
)
import esphome.config_validation as cv
from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_NAME, CONF_ROW
from esphome.core import ID
from esphome.types import ConfigType
def _config(
*,
rows: int = 4,
columns: int = 3,
keys: str | None = None,
gpi_events: bool = True,
) -> ConfigType:
config: ConfigType = {CONF_GPI_EVENTS: gpi_events}
if rows:
config[CONF_ROWS] = rows
if columns:
config[CONF_COLUMNS] = columns
if keys is not None:
config[CONF_KEYS] = keys
return config
def test_a_matrix_with_a_key_map_passes() -> None:
_validate(_config(keys="123456789*0#"))
def test_individual_inputs_only_passes() -> None:
"""No matrix at all is fine as long as the pins report on their own."""
_validate(_config(rows=0, columns=0))
@pytest.mark.parametrize("half", [{CONF_ROWS: 4}, {CONF_COLUMNS: 3}])
def test_half_a_matrix_is_rejected(half: ConfigType) -> None:
config = {CONF_ID: ID("k", is_declaration=True), "i2c_id": ID("bus"), **half}
with pytest.raises(cv.Invalid, match="must both be set"):
HUB_SCHEMA(config)
def test_key_map_without_a_matrix_is_rejected() -> None:
with pytest.raises(cv.Invalid, match="'rows' and 'columns' are required"):
_validate(_config(rows=0, columns=0, keys="123"))
@pytest.mark.parametrize("keys", ["12345678901", "12345678"])
def test_key_map_of_the_wrong_length_is_rejected(keys: str) -> None:
"""A key map names every key of the matrix, so its length is fixed."""
with pytest.raises(cv.Invalid, match="must have exactly 12 characters"):
_validate(_config(keys=keys))
def test_a_keypad_that_reports_nothing_is_rejected() -> None:
with pytest.raises(cv.Invalid, match="Nothing to report"):
_validate(_config(rows=0, columns=0, gpi_events=False))
def test_letters_that_clash_with_individual_inputs_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
"""97 to 114 are individual inputs and also the codes for 'a' to 'r'."""
with caplog.at_level(logging.WARNING):
_validate(_config(keys="abc456789*0#"))
assert "cannot be told apart" in caplog.text
assert "'a', 'b', 'c'" in caplog.text
def test_other_characters_do_not_warn(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
_validate(_config(keys="123456789*0#"))
assert caplog.text == ""
def test_no_warning_without_individual_inputs(
caplog: pytest.LogCaptureFixture,
) -> None:
"""With the individual inputs off there is nothing for a letter to clash with."""
with caplog.at_level(logging.WARNING):
_validate(_config(keys="abc456789*0#", gpi_events=False))
assert caplog.text == ""
def _sensor(**selector: object) -> ConfigType:
return {
"keypad_id": ID("keypad", is_declaration=False, type="tca8418"),
CONF_NAME: "Key",
**selector,
}
# Checks made before the keypad is consulted: a key is named exactly one way,
# a position has both halves, a character is one ASCII character, and a key
# number is one the device uses (81 to 96 are unused, numbering stops at 114).
@pytest.mark.parametrize(
("selector", "error"),
[
({CONF_ROW: 0, CONF_COL: 0}, None),
({CONF_KEY: "5"}, None),
({CONF_KEY: 5}, None),
({CONF_KEY_CODE: 12}, None),
({CONF_KEY_CODE: 102}, None),
({CONF_ROW: 0, CONF_COL: 0, CONF_KEY: "5"}, "more than one of"),
({CONF_ROW: 0, CONF_COL: 0, CONF_KEY_CODE: 12}, "more than one of"),
({CONF_KEY: "5", CONF_KEY_CODE: 12}, "more than one of"),
({}, "exactly one of"),
({CONF_ROW: 0}, "must be given together"),
({CONF_COL: 0}, "must be given together"),
({CONF_KEY: "ab"}, "single character"),
({CONF_KEY: ""}, "single character"),
({CONF_KEY: "\u00e9"}, "Only ASCII characters"),
({CONF_KEY_CODE: 0}, "Key numbers run from"),
({CONF_KEY_CODE: 81}, "Key numbers run from"),
({CONF_KEY_CODE: 96}, "Key numbers run from"),
({CONF_KEY_CODE: 115}, "Key numbers run from"),
],
)
def test_sensor_schema(selector: ConfigType, error: str | None) -> None:
if error is None:
SENSOR_SCHEMA(_sensor(**selector))
return
with pytest.raises(cv.Invalid, match=error):
SENSOR_SCHEMA(_sensor(**selector))
+36
View File
@@ -0,0 +1,36 @@
tca8418:
# A 4x3 key matrix, with the remaining pins reporting as individual inputs.
- id: matrix_keypad_device
i2c_id: i2c_bus
address: 0x34
rows: 4
columns: 3
keys: "123456789*0#"
# The interrupt output is open drain, so it needs a pull-up. Use the
# internal one unless the board already has its own.
interrupt_pin:
number: ${interrupt_pin}
mode: INPUT_PULLUP
on_key:
- logger.log:
format: "Key: %c"
args: [x]
# Every pin reporting as an individual input, polled over I2C.
- id: inputs_only_device
i2c_id: i2c_bus
address: 0x35
binary_sensor:
- platform: tca8418
keypad_id: matrix_keypad_device
row: 0
col: 0
name: "Key top left"
- platform: tca8418
keypad_id: matrix_keypad_device
key: "5"
name: "Key five"
- platform: tca8418
keypad_id: inputs_only_device
key_code: 97
name: "Input ROW0"
@@ -0,0 +1,6 @@
substitutions:
interrupt_pin: GPIO13
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
tca8418: !include common.yaml
@@ -0,0 +1,6 @@
substitutions:
interrupt_pin: GPIO12
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
tca8418: !include common.yaml