mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[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:
co-authored by
Claude
Jesse Hills
J. Nick Koston
J. Nick Koston
parent
3ddbf8b3ef
commit
a68cf669aa
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user