Rebuild on the merged bus core: flash tables, claimed opcodes, the UART after the address bits

This commit is contained in:
J. Nick Koston
2026-10-14 18:50:56 -10:00
parent 3c43717b8a
commit 4bfdd30f52
14 changed files with 890 additions and 1112 deletions
+157 -146
View File
@@ -1,6 +1,7 @@
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import uart
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
@@ -9,177 +10,187 @@ from esphome.const import (
CONF_ON_MESSAGE,
CONF_PIN,
CONF_SOURCE,
CONF_TRIGGER_ID,
CONF_UART_ID,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_RP2,
PLATFORM_RTL87XX,
)
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@JosVanEijndhoven"]
DOMAIN = "hdmi_cec"
MAX_LOGICAL_ADDRESS = 15
MAX_PAYLOAD_LENGTH = 15
MAX_OSD_NAME_LENGTH = MAX_PAYLOAD_LENGTH - 1 # 1 byte reserved for opcode
CONF_PHYSICAL_ADDRESS = "physical_address"
MAX_OSD_NAME_LENGTH = MAX_PAYLOAD_LENGTH - 1 # the opcode takes one byte
UART_BAUD_RATE = 2083 # five UART bits per 2.4 ms CEC bit
CONF_PROMISCUOUS_MODE = "promiscuous_mode"
CONF_MONITOR_MODE = "monitor_mode"
CONF_DECODE_MESSAGES = "decode_messages"
CONF_OSD_NAME = "osd_name"
CONF_DESTINATION = "destination"
CONF_OPCODE = "opcode"
CONF_PARENT = "parent"
def validate_data_array(value):
if isinstance(value, list):
if len(value) > MAX_PAYLOAD_LENGTH:
raise cv.Invalid(
f"HDMI-CEC data payload cannot exceed {MAX_PAYLOAD_LENGTH} bytes."
)
return cv.Schema([cv.hex_uint8_t])(value)
# If it's a lambda string, we can't inspect length in Python, let cv handle it
return cv.lambda_(value)
def validate_osd_name(value):
if not isinstance(value, str):
raise cv.Invalid("Must be a string")
if len(value) < 1:
raise cv.Invalid("Must be a non-empty string")
if len(value) > MAX_OSD_NAME_LENGTH:
raise cv.Invalid(f"Must not be more than {MAX_OSD_NAME_LENGTH}-characters long")
for char in value:
if not 0x20 <= ord(char) < 0x7E:
raise cv.Invalid(
f"character '{char}' ({ord(char)}) is outside of the supported character range (0x20..0x7e)"
)
return value
CONF_PHYSICAL_ADDRESS = "physical_address"
CONF_OSD_NAME = "osd_name"
hdmi_cec_ns = cg.esphome_ns.namespace("hdmi_cec")
HDMICEC = hdmi_cec_ns.class_("HDMICEC", cg.Component)
MessageTrigger = hdmi_cec_ns.class_(
"MessageTrigger",
automation.Trigger.template(cg.uint8, cg.uint8, cg.std_vector.template(cg.uint8)),
)
SendAction = hdmi_cec_ns.class_("SendAction", automation.Action)
_PAYLOAD_SPAN = cg.std_span.template(cg.uint8.operator("const"))
_PAYLOAD_VECTOR = cg.std_vector.template(cg.uint8)
_MESSAGE_ARGS = [
(cg.uint8, "source"),
(cg.uint8, "destination"),
(_PAYLOAD_SPAN, "data"),
]
CONFIG_SCHEMA = cv.COMPONENT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(HDMICEC),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_ADDRESS): cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS),
cv.Required(CONF_PHYSICAL_ADDRESS): cv.uint16_t,
cv.Optional(CONF_PROMISCUOUS_MODE, default=False): cv.boolean,
cv.Optional(CONF_MONITOR_MODE, default=False): cv.boolean,
cv.Optional(CONF_DECODE_MESSAGES, default=True): cv.boolean,
cv.Optional(CONF_OSD_NAME, default="esphome"): validate_osd_name,
cv.Optional(CONF_UART_ID): cv.use_id(uart.UARTComponent),
cv.Optional(CONF_ON_MESSAGE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MessageTrigger),
cv.Optional(CONF_SOURCE): cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS),
cv.Optional(CONF_DESTINATION): cv.int_range(
min=0, max=MAX_LOGICAL_ADDRESS
),
cv.Optional(CONF_OPCODE): cv.uint8_t,
cv.Optional(CONF_DATA): validate_data_array,
}
),
}
_request_message_callback_slot = cg.slot_counter("HDMI_CEC_MESSAGE_CALLBACK_COUNT")
logical_address = cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS)
payload = cv.All(cv.ensure_list(cv.hex_uint8_t), cv.Length(max=MAX_PAYLOAD_LENGTH))
payload_prefix = cv.All(payload, cv.Length(min=1))
def _osd_name(value: object) -> str:
# The name goes on the bus as plain ASCII, one byte per character
value = cv.All(cv.string_strict, cv.Length(min=1, max=MAX_OSD_NAME_LENGTH))(value)
if not all(0x20 <= ord(char) <= 0x7E for char in value):
raise cv.Invalid("osd_name may only contain printable ASCII characters")
return value
def _validate_message_filter(config: ConfigType) -> ConfigType:
# The opcode is the first payload byte: fold it into the data prefix
if (opcode := config.pop(CONF_OPCODE, None)) is not None:
data = config.setdefault(CONF_DATA, [opcode])
if data[0] != opcode:
raise cv.Invalid(
f"opcode 0x{opcode:02X} differs from the first data byte 0x{data[0]:02X}"
)
return config
def _message_filter(conf: ConfigType) -> str | None:
"""The C++ condition an on_message automation runs under, or None for every frame."""
checks = []
if (source := conf.get(CONF_SOURCE)) is not None:
checks.append(f"source == {source}")
if (destination := conf.get(CONF_DESTINATION)) is not None:
checks.append(f"destination == {destination}")
if data := conf.get(CONF_DATA):
checks.append(f"data.size() >= {len(data)}")
checks.extend(f"data[{i}] == 0x{byte:02X}" for i, byte in enumerate(data))
return " && ".join(checks) or None
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(HDMICEC),
cv.Required(CONF_PIN): pins.internal_gpio_input_pullup_pin_schema,
cv.Required(CONF_ADDRESS): logical_address,
cv.Optional(CONF_PHYSICAL_ADDRESS): cv.hex_uint16_t,
cv.Optional(CONF_OSD_NAME): _osd_name,
cv.Optional(CONF_UART_ID): cv.use_id(uart.UARTComponent),
cv.Optional(CONF_PROMISCUOUS_MODE, default=False): cv.boolean,
cv.Optional(CONF_MONITOR_MODE, default=False): cv.boolean,
cv.Optional(CONF_ON_MESSAGE): automation.validate_automation(
{
cv.Optional(CONF_SOURCE): logical_address,
cv.Optional(CONF_DESTINATION): logical_address,
cv.Optional(CONF_OPCODE): cv.hex_uint8_t,
cv.Optional(CONF_DATA): payload_prefix,
},
_validate_message_filter,
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on(
[
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2,
PLATFORM_BK72XX,
PLATFORM_RTL87XX,
PLATFORM_LN882X,
]
),
)
async def to_code(config):
if config[CONF_DECODE_MESSAGES]:
cg.add_define("HDMI_CEC_USE_DECODER")
def _final_validate(config: ConfigType) -> ConfigType:
if CONF_UART_ID in config:
# The UART only drives the line, through a diode; its settings make one CEC bit of five UART bits
uart.final_validate_device_schema(
DOMAIN,
baud_rate=UART_BAUD_RATE,
require_tx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)(config)
return config
var = cg.new_Pvariable(config[CONF_ID])
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
if CORE.is_esp32:
# The acknowledge bit is released from a general purpose timer, left out of the build by default
include_builtin_idf_component("esp_driver_gptimer")
pin = await cg.gpio_pin_expression(config[CONF_PIN])
var = cg.new_Pvariable(config[CONF_ID], pin, config[CONF_ADDRESS])
await cg.register_component(var, config)
if config[CONF_PROMISCUOUS_MODE]:
cg.add(var.set_promiscuous_mode(True))
if config[CONF_MONITOR_MODE]:
cg.add(var.set_monitor_mode(True))
if (physical_address := config.get(CONF_PHYSICAL_ADDRESS)) is not None:
cg.add(var.set_physical_address(physical_address))
if (osd_name := config.get(CONF_OSD_NAME)) is not None:
cg.add(var.set_osd_name(cg.shared_progmem_string(osd_name), len(osd_name)))
if (uart_id := config.get(CONF_UART_ID)) is not None:
cg.add_define("USE_HDMI_CEC_UART")
cg.add(var.set_uart(await cg.get_variable(uart_id)))
cec_pin_ = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(cec_pin_))
on_message = config.get(CONF_ON_MESSAGE, [])
# An automation with a data prefix claims its opcode: the built-in replies leave it alone. One
# without a prefix sees every frame, so nothing is unhandled and no Feature Abort is sent
claimed_opcodes = sorted(
{conf[CONF_DATA][0] for conf in on_message if CONF_DATA in conf}
)
if claimed_opcodes:
table = cg.shared_progmem_array(
"hdmi_cec_claimed_opcodes", cg.uint8, claimed_opcodes
)
cg.add(var.set_claimed_opcodes(table, len(claimed_opcodes)))
if all(CONF_DATA in conf for conf in on_message):
cg.add_define("USE_HDMI_CEC_FEATURE_ABORT")
if config.get(CONF_UART_ID) is not None:
uart_component = await cg.get_variable(config[CONF_UART_ID])
cg.add_define("HDMI_CEC_USE_UART")
cg.add(var.set_uart(uart_component))
cg.add(var.set_address(config[CONF_ADDRESS]))
cg.add(var.set_physical_address(config[CONF_PHYSICAL_ADDRESS]))
cg.add(var.set_promiscuous_mode(config[CONF_PROMISCUOUS_MODE]))
cg.add(var.set_monitor_mode(config[CONF_MONITOR_MODE]))
osd_name_bytes = bytes(
config[CONF_OSD_NAME], "ascii", "ignore"
) # convert string to ascii bytes
osd_name_bytes = list(osd_name_bytes) # convert bytes to ints
osd_name_bytes = cg.std_vector.template(cg.uint8)(osd_name_bytes)
cg.add(var.set_osd_name_bytes(osd_name_bytes))
for conf in config.get(CONF_ON_MESSAGE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
source = conf.get(CONF_SOURCE)
if source is not None:
cg.add(trigger.set_source(source))
destination = conf.get(CONF_DESTINATION)
if destination is not None:
cg.add(trigger.set_destination(destination))
opcode = conf.get(CONF_OPCODE)
if opcode is not None:
cg.add(trigger.set_opcode(opcode))
data = conf.get(CONF_DATA)
if data is not None:
cg.add(trigger.set_data(data))
await automation.build_automation(
trigger,
[
(cg.uint8, "source"),
(cg.uint8, "destination"),
(cg.std_vector.template(cg.uint8), "data"),
],
for conf in on_message:
_request_message_callback_slot()
await automation.build_callback_automation(
var,
"add_on_message_callback",
_MESSAGE_ARGS,
conf,
when=_message_filter(conf),
)
@automation.register_action(
automation.register_apply_action(
"hdmi_cec.send",
SendAction,
{
cv.GenerateID(CONF_PARENT): cv.use_id(HDMICEC),
cv.Optional(CONF_SOURCE): cv.templatable(
cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS)
),
cv.Required(CONF_DESTINATION): cv.templatable(
cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS)
),
cv.Required(CONF_DATA): cv.templatable(validate_data_array),
},
synchronous=True,
cv.Schema(
{
cv.GenerateID(): cv.use_id(HDMICEC),
cv.Required(CONF_DESTINATION): cv.templatable(logical_address),
cv.Required(CONF_DATA): cv.templatable(payload),
}
),
automation.ApplyCall(
"send({}, {})",
((CONF_DESTINATION, cg.uint8), (CONF_DATA, _PAYLOAD_VECTOR)),
),
)
async def send_action_to_code(config, action_id, template_args, args):
parent = await cg.get_variable(config[CONF_PARENT])
var = cg.new_Pvariable(action_id, template_args, parent)
source = config.get(CONF_SOURCE)
if source is not None:
source_template_ = await cg.templatable(source, args, cg.uint8)
cg.add(var.set_source(source_template_))
destination_template_ = await cg.templatable(
config.get(CONF_DESTINATION), args, cg.uint8
)
cg.add(var.set_destination(destination_template_))
data_vec_ = cg.std_vector.template(cg.uint8)
data_template_ = await cg.templatable(
config.get(CONF_DATA), args, data_vec_, data_vec_
)
cg.add(var.set_data(data_template_))
return var
File diff suppressed because it is too large Load Diff
+217 -294
View File
@@ -1,335 +1,258 @@
#pragma once
#include <array>
#include <vector>
#include <atomic>
#include <initializer_list>
#include <span>
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/automation.h"
#include "esphome/core/defines.h"
#include "esphome/core/gpio.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace uart {
#ifdef USE_ESP32
#include "driver/gptimer.h"
#endif
#ifdef USE_RP2040
#include <hardware/timer.h>
#endif
#ifdef USE_HDMI_CEC_UART
namespace esphome::uart {
class UARTComponent;
}
namespace hdmi_cec {
#endif
class MessageTrigger;
namespace esphome::hdmi_cec {
// For atomics, the '++' operator is explicitly avoided here, because that requires an atomic 'read-modify-write'
// which is not supported by all targets. (In particular not by the esp8266)
// The fall-back here to a non-atomic read-modify-write is OK for this application, as no other thread (or isr)
// will modify the same variable.
// The 'atomic' semantics is required here for its memory-barrier.
#define ATOMIC_SET(atom, value) (atom).store((value), std::memory_order_release)
#define ATOMIC_INCR(atom) ATOMIC_SET(atom, (atom) + 1)
#define ATOMIC_GET(atom) (atom).load(std::memory_order_acquire)
// Release/acquire store and load. The read-modify-write forms of std::atomic are not available on
// every target (the ESP8266 has none), so a writer computes the new value itself; that is safe here
// because every atomic in this component has exactly one writer.
template<typename T> ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
atom.store(value, std::memory_order_release);
}
template<typename T> ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
return atom.load(std::memory_order_acquire);
}
/**
* A Frame stores an HDMI-CEC bus message as sequence of bytes with variable length.
* Such bus message is either received or to be transmitted.
* The naming 'frame' corresponds to the nomenclature in the HDMI-CEC standard.
* A frame contains one 'header' byte and a variable number of 'payload' bytes.
* The HDMI CEC standard 1.4 specifies a maximum length of 16 for any frame.
* Filling a frame with 'push_back' in an ISR shall not cause dynamic memory allocation.
*/
/// One CEC bus message: a header byte with the initiator and destination addresses, then up to
/// 15 payload bytes of which the first is the opcode. Fixed storage, so the receive interrupt
/// never allocates.
class Frame {
public:
Frame() = default;
Frame(uint8_t initiator_addr, uint8_t target_addr, const uint8_t *payload, unsigned int payload_size);
Frame(uint8_t initiator_addr, uint8_t target_addr, const std::initializer_list<uint8_t> &payload)
: Frame(initiator_addr, target_addr, payload.begin(), payload.size()) {}
static constexpr size_t MAX_LENGTH = 16; // HDMI-CEC 1.4
static constexpr size_t MAX_PAYLOAD_LENGTH = MAX_LENGTH - 1;
void set_header(uint8_t initiator_addr, uint8_t target_addr);
uint8_t initiator_addr() const { return (data_[0] >> 4) & 0xf; }
uint8_t destination_addr() const { return data_[0] & 0xf; }
uint8_t opcode() const { return (this->size() >= 2) ? data_[1] : 0; }
bool is_broadcast() const { return this->destination_addr() == 0xf; }
uint8_t size() const { return size_; }
uint8_t at(unsigned int i) const { return (i < size_) ? data_[i] : 0; }
uint8_t operator[](unsigned int i) const { return at(i); }
void clear() { size_ = 0; }
void push_back(uint8_t data) {
if (size_ < data_.size()) {
data_[size_++] = data;
Frame() = default;
/// The payload is at most MAX_PAYLOAD_LENGTH bytes; send() checks that
Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span<const uint8_t> payload);
ESPHOME_ALWAYS_INLINE uint8_t initiator_addr() const { return this->data_[0] >> 4; }
ESPHOME_ALWAYS_INLINE uint8_t destination_addr() const { return this->data_[0] & 0xf; }
ESPHOME_ALWAYS_INLINE bool is_broadcast() const { return this->destination_addr() == 0xf; }
ESPHOME_ALWAYS_INLINE size_t size() const { return this->size_; }
std::span<const uint8_t> bytes() const { return {this->data_.data(), this->size_}; }
/// The bytes after the header, starting with the opcode
std::span<const uint8_t> payload() const { return this->bytes().subspan(1); }
ESPHOME_ALWAYS_INLINE void clear() { this->size_ = 0; }
/// False when the frame is full already; the bus allows no more
ESPHOME_ALWAYS_INLINE bool push_back(uint8_t data) {
if (this->size_ == MAX_LENGTH) {
return false;
}
this->data_[this->size_++] = data;
return true;
}
/**
* Create a one-line textual representation of the Frame content for logging and debugging
* @param line: a buffer in which the text is to be written
* @param size: The size of this buffer not to exceed with text
* @return the length of the written text, excluding the terminating null character
*/
int to_text(char *line, unsigned int size) const;
constexpr static unsigned int MAX_LENGTH = 16;
constexpr static unsigned int MAX_PAYLOAD_LENGTH = MAX_LENGTH - 1; // subtract 1 byte for the src&dst field
protected:
std::array<uint8_t, MAX_LENGTH> data_;
std::array<uint8_t, MAX_LENGTH> data_{};
uint8_t size_{0};
};
/**
* The FrameRingBuffer is a container for Frames to queue data in a consumer-producer
* application. The use of std::Atomics allows safe multi-thread operation when used with
* a single producer and single consumer thread, where each Atomic index is updated
* by one thread only.
* After initialization, it operates without dynamic memory allocation.
* This allows the gpio isr to safely and efficiently pick-up and pass Frames.
* Due to its fixed memory size, it might return NULL pointers in case the buffer is full or empty.
*/
template<unsigned int SIZE> class FrameRingBuffer {
public:
FrameRingBuffer() : front_inx_{0}, back_inx_{0}, store_{} {
for (auto &t : store_) {
t = new Frame;
}
}
~FrameRingBuffer() {
for (auto &t : store_) {
delete t;
}
}
// 'front' is used to access a Frame, and use its content until it is no longer needed
Frame *front() const { return is_empty() ? nullptr : store_[front_inx_]; }
// 'pop_front' recycles the storage area of the last 'front()' call.
// This invalidates further use of that ptr by the caller
void pop_front() { cyclic_incr_(front_inx_); }
// 'back' is used to fetch a free Frame, fill with data, and queue for later pick-up
// Note that its returned Frame likely contains 'dirty' data.
Frame *back() const { return is_full() ? nullptr : store_[back_inx_]; }
// 'push_back' commits the frame that was earlier obtained by 'back()', presumably it got filled with content
// This invalidates further use of this frame by the caller.
void push_back() { cyclic_incr_(back_inx_); }
bool is_empty() const { return count_() == 0; }
bool is_full() const { return count_() == SIZE; }
void reset() {
front_inx_ = 0;
back_inx_ = 0;
}
protected:
using Index = std::atomic<unsigned int>;
// this simple increment scheme is sufficiently 'atomic' if the front and back are each used by
// one thread only. (So, at most one reader thread and one writer thread in the application.)
int count_() const {
int n = (int) (ATOMIC_GET(back_inx_) - ATOMIC_GET(front_inx_));
if (n < 0)
n += SIZE + 1;
return n;
}
void cyclic_incr_(Index &inx) { ATOMIC_SET(inx, (inx == SIZE) ? 0 : (inx + 1)); }
Index front_inx_; // ranging 0 .. SIZE
Index back_inx_; // ranging 0 .. SIZE
// if front_inx_ == back_inx_ the store is considered empty, so it can hold at most SIZE elements
std::array<Frame *, SIZE + 1> store_;
};
/**
* class CECTransmit accepts bus messages ("frames") for transmission.
* These messages, taken from "queue_for_send" are buffered, because a 'send' on the bus
* might take a really long time, which should not be directly consumed in its calling thread:
* With the local buffering, 'send' retries are now handled in subsequent time slices.
* The 'send' queue has a limited depth, fairly shallow, because in practice an application
* is expected not to queue so many 'send's before requesting (waiting for) an
* inbound reply message.
*/
class CECTransmit {
enum class TransmitState : uint8_t {
IDLE,
BUSY,
EOM_CONFIRMED,
};
/// A queue of frames between one producer and one consumer, for instance the receive interrupt and
/// loop(). The indices run freely and each is written by one side only, so no lock is needed.
/// back() hands out a slot to fill and push_back() commits it; front() and pop_front() do the same
/// on the reading side.
template<size_t SIZE> class FrameRingBuffer {
static_assert((SIZE & (SIZE - 1)) == 0, "SIZE must be a power of two");
public:
void setup(InternalGPIOPin *pin);
void dump_config();
bool queue_for_send(uint8_t source, uint8_t destination, const uint8_t *payload_bytes, unsigned int payload_size);
bool is_idle() const { return send_queue_.is_empty() && (transmit_state_ == TransmitState::IDLE); }
void set_uart(uart::UARTComponent *uart) { uart_ = uart; }
bool has_uart() const { return uart_ != nullptr; }
// Note: 'ESPHOME_ALWAYS_INLINE' allows the following methods to be also used in 'IRAM_ATTR' methods.
ESPHOME_ALWAYS_INLINE void set_pin_input_high() { pin_->pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP); }
ESPHOME_ALWAYS_INLINE void set_pin_output_low() {
pin_->pin_mode(gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN);
pin_->digital_write(false);
ESPHOME_ALWAYS_INLINE Frame *front() { return this->is_empty() ? nullptr : this->slot_(this->front_inx_); }
ESPHOME_ALWAYS_INLINE void pop_front() { this->advance_(this->front_inx_); }
ESPHOME_ALWAYS_INLINE Frame *back() { return this->count_() == SIZE ? nullptr : this->slot_(this->back_inx_); }
ESPHOME_ALWAYS_INLINE void push_back() { this->advance_(this->back_inx_); }
ESPHOME_ALWAYS_INLINE bool is_empty() const { return this->count_() == 0; }
protected:
ESPHOME_ALWAYS_INLINE uint8_t count_() const {
return static_cast<uint8_t>(atomic_get(this->back_inx_) - atomic_get(this->front_inx_));
}
ESPHOME_ALWAYS_INLINE Frame *slot_(const std::atomic<uint8_t> &inx) { return &this->store_[atomic_get(inx) % SIZE]; }
ESPHOME_ALWAYS_INLINE void advance_(std::atomic<uint8_t> &inx) {
atomic_set<uint8_t>(inx, static_cast<uint8_t>(atomic_get(inx) + 1));
}
/**
* Transmit the message on the front of the send_queue out on the CEC line
*/
void transmit_message();
/**
* This method is called from within the receiver isr method after receiving each byte.
* So, the fields that it updates need to be 'atomic'.
* @param eom the value of the received 'eom' bit
* @param ack the value of the received 'ack' bit
*/
void got_byte_eom_ack(bool eom, bool ack);
/**
* This method is called from within the receiver isr method,
* when receiving a start-bit, to notify that the CEC bus line is busy
*/
void got_start_of_activity();
/**
* The transmitter immediatly sends an acknowledge bit on behalf of
* the receiver, during the receipt of a message.
* This is either done by activating the UART, if configured and available,
* otherwise by direct GPIO pin manipulation.
*/
void send_ack();
protected:
/**
* Send the CEC protocol frame start bit.
* While doing so, check if another initiator tries to do the same, and if so, abort.
* @return true: start bit was successful, false: bus collision is detected and abort
*/
bool send_start_bit_();
void send_bit_(bool bit_value);
bool send_high_and_test_();
void transmit_message_on_gpio_(const Frame *frame);
void transmit_message_on_uart_(const Frame *frame);
void convert_byte_to_uart_(std::vector<uint8_t> &uart_data, uint8_t byte, bool is_header, bool is_eom);
bool transmit_my_address_(uint8_t initiator_addr); // send 4 bits with my address and check for bus collision
FrameRingBuffer<5> send_queue_; // queue at most a handful of messages to send
uint8_t transmit_attempts_{0};
uint8_t n_bytes_received_{0};
uint8_t n_acks_received_{0};
TransmitState transmit_state_{TransmitState::IDLE};
std::atomic<bool> eom_received_{false};
std::atomic<bool> receiver_is_busy_{false};
uint32_t confirm_received_us_{0};
uint32_t required_idle_period_{0};
uart::UARTComponent *uart_{nullptr};
InternalGPIOPin *pin_{nullptr};
};
class CECReceive {
enum class ReceiverState : uint8_t { IDLE, RECEIVING_BYTE, WAITING_FOR_EOM, WAITING_FOR_ACK, WAITING_FOR_EOM_ACK };
public:
constexpr static int MAX_FRAMES_QUEUED = 4;
CECReceive(CECTransmit &xmit) : xmit_(xmit) {}
void setup(InternalGPIOPin *pin, uint8_t address);
void dump_config();
void set_promiscuous_mode(bool promiscuous) { promiscuous_mode_ = promiscuous; }
void set_monitor_mode(bool monitor_mode) { monitor_mode_ = monitor_mode; }
bool get_monitor_mode() const { return monitor_mode_; }
FrameRingBuffer<MAX_FRAMES_QUEUED> frames_queue_;
protected:
static void gpio_isr_s(CECReceive *self);
void gpio_isr_();
void reset_state_variables_();
CECTransmit &xmit_; // the bus 'Receiver' needs to sync with 'sent' messages
ISRInternalGPIOPin isr_pin_;
bool promiscuous_mode_{false};
bool monitor_mode_{false};
bool recv_ack_queued_{false};
bool prev_pin_level_{true};
uint8_t recv_bit_counter_{0};
uint8_t recv_byte_buffer_{0};
uint8_t num_acks_{0}; // numer of 'low' acknowledge bits received in the current message
uint8_t address_{0};
uint32_t last_falling_edge_us_{0};
ReceiverState receiver_state_{ReceiverState::IDLE};
Frame *recv_frame_buffer_{nullptr};
std::array<Frame, SIZE> store_{};
std::atomic<uint8_t> front_inx_{0};
std::atomic<uint8_t> back_inx_{0};
};
/// The HDMI-CEC bus on one GPIO pin. The receive interrupt decodes the edges of every frame,
/// including those this device sends, and queues complete frames for loop(); loop() runs the
/// on_message automations and transmits queued frames, one blocking bit-banged attempt per pass.
class HDMICEC : public Component {
public:
void set_pin(InternalGPIOPin *pin);
void set_address(uint8_t address) { address_ = address & 0xf; }
uint8_t address() const { return address_; }
void set_physical_address(uint16_t physical_address) { physical_address_ = physical_address; }
uint16_t get_physical_address() const { return physical_address_; }
void set_promiscuous_mode(bool promiscuous) { recv_.set_promiscuous_mode(promiscuous); }
void set_monitor_mode(bool monitor_mode) { recv_.set_monitor_mode(monitor_mode); }
void set_osd_name_bytes(const std::vector<uint8_t> &osd_name_bytes) { osd_name_bytes_ = osd_name_bytes; }
void set_uart(uart::UARTComponent *uart) { xmit_.set_uart(uart); }
void add_message_trigger(MessageTrigger *trigger) { message_triggers_.push_back(trigger); }
uint8_t get_device_type() const;
// Generic 'send' of a bus message, it pushes its message into a send buffer for later transmit.
bool send(uint8_t source, uint8_t destination, const uint8_t *payload_bytes, unsigned int payload_size);
// More compact 'send' wrappers for easier use:
// Preferred for most use cases, with a compile-time-fixed argument list:
bool send(uint8_t destination, const std::initializer_list<uint8_t> &payload) {
return send(address_, destination, payload.begin(), payload.size());
}
// For use when the payload is stored by the caller in an std::vector or std::array:
bool send(uint8_t destination, const uint8_t *payload_bytes, unsigned int payload_size) {
return send(address_, destination, payload_bytes, payload_size);
}
// Component overrides
float get_setup_priority() const override { return esphome::setup_priority::DATA; }
HDMICEC(InternalGPIOPin *pin, uint8_t address) : pin_(pin), address_(address) {}
void setup() override;
void dump_config() override;
void loop() override;
protected:
void try_builtin_handler_(uint8_t source, uint8_t destination, const std::vector<uint8_t> &data);
void handle_received_message_(const Frame *frame);
void set_promiscuous_mode(bool promiscuous) { this->promiscuous_mode_ = promiscuous; }
void set_monitor_mode(bool monitor_mode) { this->monitor_mode_ = monitor_mode; }
/// Answer "Give Physical Address" with this address and the device type of the logical address
void set_physical_address(uint16_t physical_address) { this->physical_address_ = physical_address; }
/// Answer "Give OSD Name" with this name; it lives in flash
void set_osd_name(const char *name, uint8_t length) {
this->osd_name_ = name;
this->osd_name_length_ = length;
}
/// Opcodes an on_message automation answers itself, sorted, in flash; no built-in reply for them
void set_claimed_opcodes(const uint8_t *opcodes, uint8_t count) {
this->claimed_opcodes_ = opcodes;
this->claimed_opcode_count_ = count;
}
#ifdef USE_HDMI_CEC_UART
/// Send the frame body from this UART (its TX pin on the line through a diode) instead of bit-banging it
void set_uart(uart::UARTComponent *uart) { this->uart_ = uart; }
#endif
#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT
/// Called with the initiator, the destination and the payload (opcode first) of every received
/// frame; the payload is only valid during the call
template<typename F> void add_on_message_callback(F &&callback) {
this->message_callbacks_.add(std::forward<F>(callback));
}
#endif
HighFrequencyLoopRequester fast_loop_;
InternalGPIOPin *pin_{nullptr};
uint8_t address_; // logical address, relates to device type
uint16_t physical_address_;
std::vector<uint8_t> osd_name_bytes_;
std::vector<MessageTrigger *> message_triggers_;
CECTransmit xmit_;
CECReceive recv_{xmit_};
};
class MessageTrigger : public Trigger<uint8_t, uint8_t, std::vector<uint8_t>> {
friend class HDMICEC;
public:
explicit MessageTrigger(HDMICEC *parent) { parent->add_message_trigger(this); };
void set_source(uint8_t source) { source_ = source; };
void set_destination(uint8_t destination) { destination_ = destination; };
void set_opcode(uint8_t opcode) { opcode_ = opcode; };
void set_data(const std::vector<uint8_t> &data) { data_ = data; };
protected:
optional<uint8_t> source_;
optional<uint8_t> destination_;
optional<uint8_t> opcode_;
optional<std::vector<uint8_t>> data_;
};
template<typename... Ts> class SendAction : public Action<Ts...> {
public:
SendAction(HDMICEC *parent) : parent_(parent) {}
TEMPLATABLE_VALUE(uint8_t, source)
TEMPLATABLE_VALUE(uint8_t, destination)
TEMPLATABLE_VALUE(std::vector<uint8_t>, data)
void play(const Ts &...x) override {
auto source_address = this->source_.has_value() ? this->source_.value(x...) : this->parent_->address();
auto destination_address = this->destination_.value(x...);
const std::vector<uint8_t> &payload = this->data_.value(x...);
this->parent_->send(source_address, destination_address, payload.data(), payload.size());
/// Queue a frame for transmission; false when the queue is full or in monitor mode
bool send(uint8_t source, uint8_t destination, std::span<const uint8_t> payload);
bool send(uint8_t destination, std::span<const uint8_t> payload) {
return this->send(this->address_, destination, payload);
}
/// For lambdas: id(cec).send(0x0, {0x44, 0x41});
bool send(uint8_t destination, std::initializer_list<uint8_t> payload) {
return this->send(destination, std::span<const uint8_t>(payload.begin(), payload.size()));
}
protected:
HDMICEC *parent_;
enum class ReceiverState : uint8_t {
RECEIVER_STATE_IDLE,
RECEIVER_STATE_RECEIVING_BYTE,
RECEIVER_STATE_WAITING_FOR_EOM,
RECEIVER_STATE_WAITING_FOR_ACK,
};
// Receive side, in the interrupt
static void gpio_isr_s(HDMICEC *self);
void gpio_isr_();
void reset_receiver_();
/// Pull the line low for the acknowledge bit of a frame addressed to us
void send_ack_();
// A hardware one-shot timer releases the line after the acknowledge bit (gptimer on the ESP32,
// timer 1 on the ESP8266, a hardware alarm on the RP2040), so the interrupt returns at once;
// without one the interrupt holds the line for the 1.5 ms itself
bool setup_ack_timer_();
void start_ack_timer_();
#ifdef USE_ESP32
static bool ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg);
#endif
#ifdef USE_ESP8266
static void ack_timer_isr_s();
#endif
#ifdef USE_RP2040
static void ack_timer_isr_s(uint alarm_num);
#endif
/// The built-in replies to a frame addressed to us that no automation claims
void reply_builtin_(const Frame *frame);
bool is_claimed_(uint8_t opcode) const;
// Transmit side, in loop()
void transmit_message_();
/// Keep the frame for another attempt, or drop it once the attempts are used up
void finish_attempt_(bool sent_ok);
/// Drive the frame onto the line; false when another initiator sent at the same time
bool transmit_frame_(const Frame *frame);
bool send_start_bit_();
/// Send one bit; false when a logical 1 found the line held low by another initiator
bool send_bit_(bool bit_value);
#ifdef USE_HDMI_CEC_UART
/// Hand the frame after the initiator address to the UART, five UART bits per CEC bit
void transmit_on_uart_(const Frame *frame);
#endif
ESPHOME_ALWAYS_INLINE void set_pin_input_high_() { this->isr_pin_.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP); }
ESPHOME_ALWAYS_INLINE void set_pin_output_low_() {
this->isr_pin_.pin_mode(gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN);
this->isr_pin_.digital_write(false);
}
static constexpr size_t QUEUE_SIZE = 4;
// Each queue is 70 bytes, so two single-byte fields follow each to fill the alignment gap
FrameRingBuffer<QUEUE_SIZE> frames_queue_; // received, interrupt to loop()
uint8_t recv_bit_counter_{0};
uint8_t recv_byte_{0};
FrameRingBuffer<QUEUE_SIZE> send_queue_; // to send, both ends on loop()
uint8_t transmit_attempts_{0};
uint8_t reported_dropped_frames_{0};
#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT
StaticCallbackManager<HDMI_CEC_MESSAGE_CALLBACK_COUNT, void(uint8_t, uint8_t, std::span<const uint8_t>)>
message_callbacks_;
#endif
InternalGPIOPin *pin_;
ISRInternalGPIOPin isr_pin_;
const char *osd_name_{nullptr}; // in flash
const uint8_t *claimed_opcodes_{nullptr}; // in flash, sorted
#ifdef USE_HDMI_CEC_UART
uart::UARTComponent *uart_{nullptr};
#endif
#ifdef USE_ESP32
gptimer_handle_t ack_timer_{nullptr};
#endif
#ifdef USE_RP2040
int ack_alarm_{-1};
#endif
// The word-sized fields come first and the single-byte ones form one run, so nothing is padded
std::atomic<uint32_t> last_edge_us_{0}; // the bus is busy until this is some time ago; interrupt writes
Frame *recv_frame_{nullptr}; // interrupt only; null when the queue was full or the frame too long
uint32_t last_falling_edge_us_{0}; // interrupt only
uint32_t last_attempt_us_{0}; // loop() only; a refused start bit drives no edge, so it is timed here
uint16_t required_idle_period_{0}; // loop() only
// Shared with the interrupt. The tally of our own frame is written by the interrupt while
// transmitting_ is set and no end of message has passed yet; loop() reads it once eom_received_
// says the frame is over
std::atomic<bool> eom_received_{false}; // set for every frame, ours or not
std::atomic<uint8_t> dropped_frames_{0};
uint8_t n_bytes_received_{0};
uint8_t n_acks_received_{0};
bool transmitting_{false};
// Receive state, interrupt only
uint8_t recv_header_{0}; // kept outside the frame so a dropped frame can still be judged
uint8_t recv_length_{0};
ReceiverState receiver_state_{ReceiverState::RECEIVER_STATE_IDLE};
bool recv_eom_{false};
bool recv_ack_queued_{false};
bool prev_pin_level_{true};
uint16_t physical_address_{UNSET_PHYSICAL_ADDRESS};
uint8_t osd_name_length_{0};
uint8_t claimed_opcode_count_{0};
uint8_t address_; // logical address, 0xf when only listening
bool promiscuous_mode_{false};
bool monitor_mode_{false};
static constexpr uint16_t UNSET_PHYSICAL_ADDRESS = 0xFFFF; // the bus reserves this value
};
} // namespace hdmi_cec
} // namespace esphome
} // namespace esphome::hdmi_cec
+2
View File
@@ -155,6 +155,8 @@
#define USE_MDNS
#define USE_MDNS_STORE_SERVICES
#define HDMI_CEC_MESSAGE_CALLBACK_COUNT 2
#define USE_HDMI_CEC_FEATURE_ABORT
#define USE_HDMI_CEC_UART
#define I2C_PORT_FREQUENCY_COUNT 1
#define MDNS_SERVICE_COUNT 3
#define USE_MDNS_DYNAMIC_TXT
@@ -0,0 +1,13 @@
hdmi_cec:
id: cec
pin: ${cec_pin} # HDMI pin 13, with HDMI pin 17 as ground
address: 0x5
physical_address: 0x4000
osd_name: ESPHome
uart_id: uart_bus # its tx_pin drives the same line through a diode
on_message:
- opcode: 0x8F # Give Device Power Status
then:
- hdmi_cec.send:
destination: !lambda return source;
data: [0x90, 0x01] # Report Power Status: standby
+2
View File
@@ -2,6 +2,8 @@ hdmi_cec:
id: cec
pin: ${cec_pin} # HDMI pin 13, with HDMI pin 17 as ground
address: 0x5
physical_address: 0x4000
osd_name: ESPHome
promiscuous_mode: true
on_message:
- opcode: 0x8F # Give Device Power Status
@@ -1,21 +0,0 @@
hdmi_cec:
id: cec
pin: # to HDMI pin 13 (CEC), with HDMI pin 17 (DDC/CEC ground)
number: ${cec_pin}
mode:
input: true
pullup: true
address: 0x5
physical_address: 0x4000
promiscuous_mode: true
on_message:
# Give Device Power Status
- opcode: 0x8F
then:
- lambda: !lambda |-
id(cec).send(source, {0x90, 0x01});
# Volume Up from TV remote
- data: [0x44, 0x41]
then:
- logger.log: "HDMI-CEC Volume Up"
@@ -1,31 +0,0 @@
uart:
id: cec_uart
tx_pin: ${uart_pin} # has diode to cec pin, with kathode here on uart
baud_rate: 38400
hdmi_cec:
id: cec
pin: # to HDMI pin 13 (CEC), with HDMI pin 17 (DDC/CEC ground)
number: ${cec_pin}
mode:
input: true
pullup: true
address: 0x5
physical_address: 0x4000
uart_id: cec_uart
promiscuous_mode: true
monitor_mode: false
on_message:
- opcode: 0x8F # Give Device Power Status
then:
hdmi_cec.send:
destination: 0 # answer to TV
data: [0x90, 0x01] # power is 'standby'
- opcode: 0x8C # Give Device VendorID
then:
- lambda: |-
id(cec).send(0xf, {0x87, 0x00, 0x09, 0xb0}); // broadcast myself as Onkyo
- data: [0x44, 0x41] # Volume Up from TV remote
then:
- logger.log: "HDMI-CEC Volume Up"
@@ -0,0 +1,6 @@
substitutions:
cec_pin: GPIO14
packages:
uart: !include ../../test_build_components/common/uart_2083/esp32-idf.yaml
hdmi_cec: !include common-uart.yaml
@@ -0,0 +1,6 @@
substitutions:
cec_pin: GPIO14
packages:
uart: !include ../../test_build_components/common/uart_2083/esp8266-ard.yaml
hdmi_cec: !include common-uart.yaml
@@ -1,6 +0,0 @@
# tested with Seeed Studio XIAO ESP32C3
substitutions:
uart_pin: GPIO21
cec_pin: GPIO10
<<: !include common_with_uart.yaml
@@ -1,9 +0,0 @@
# tested with LOLIN D1 mini which has an esp8266ex
substitutions:
uart_pin: GPIO2
cec_pin: GPIO14
# UART0 has Tx on GPIO1, and creates a print-message during boot
# UART1 has Tx on GPIO2, is a transmit-only uart, often used for logging
<<: !include common_with_uart.yaml
@@ -0,0 +1,11 @@
# Common UART configuration for ESP32 IDF tests - 2083 baud
substitutions:
tx_pin: GPIO17
rx_pin: GPIO16
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 2083
@@ -0,0 +1,11 @@
# Common UART configuration for ESP8266 Arduino tests - 2083 baud
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 2083