diff --git a/esphome/components/hdmi_cec/__init__.py b/esphome/components/hdmi_cec/__init__.py index 5c5306f78e0..8c766fc35a8 100644 --- a/esphome/components/hdmi_cec/__init__.py +++ b/esphome/components/hdmi_cec/__init__.py @@ -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 diff --git a/esphome/components/hdmi_cec/hdmi_cec.cpp b/esphome/components/hdmi_cec/hdmi_cec.cpp index e78bd5ed6de..11906b9ffbd 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.cpp +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -1,719 +1,579 @@ -#include "esphome/core/defines.h" -#include "esphome/core/log.h" - -#include -#include - -#ifdef HDMI_CEC_USE_UART -#include "esphome/components/uart/uart_component.h" -#endif #include "hdmi_cec.h" -// For now, remove the message decoder to reduce the amount of code in the PR review -#undef HDMI_CEC_USE_DECODER - -#ifdef HDMI_CEC_USE_DECODER -#include "cec_decoder.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#ifdef USE_HDMI_CEC_UART +#include "esphome/components/uart/uart_component.h" +#endif +#ifdef USE_ESP8266 +#include +#endif +#ifdef USE_RP2040 +#include #endif namespace esphome::hdmi_cec { -// CEC protocol constants as stated in standard: -static constexpr uint8_t MAX_FRAME_LENGTH_BYTES = 16; // max frame (message) length in bytes -static constexpr uint32_t START_BIT_MIN_US = 3500; // minimum duration of 'low' startbit -static constexpr uint32_t START_BIT_NOM_US = 3700; // nominal duration of 'low' startbit -static constexpr uint32_t START_BIT_HIGH_US = 800; -static constexpr uint32_t HIGH_BIT_MIN_US = 400; -static constexpr uint32_t HIGH_BIT_MAX_US = 800; -static constexpr uint32_t TOTAL_BIT_US = 2400; -static constexpr uint32_t HIGH_BIT_US = 600; -static constexpr uint32_t LOW_BIT_US = 1500; -static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_RECEIVE = (TOTAL_BIT_US * 5); -static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_FAIL = (TOTAL_BIT_US * 3); -static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS = (TOTAL_BIT_US * 7); -static constexpr uint8_t MAX_ATTEMPTS = 8; // CEC says 5, but we also count 'bus busy' occurences +ESPHOME_LOG_TAG(TAG, "hdmi_cec"); -// constants used for this implementation: -static const char *const TAG = "hdmi_cec"; - -Frame::Frame(uint8_t initiator_addr, uint8_t target_addr, const uint8_t *payload, unsigned int payload_size) { - this->set_header(initiator_addr, target_addr); - size_ = std::min(1 + payload_size, data_.size()); // size of header(==1) plus payload in frame - for (int i = 1; i < size_; i++) - data_[i] = *payload++; -} - -void Frame::set_header(uint8_t initiator_addr, uint8_t target_addr) { - data_[0] = ((initiator_addr & 0xf) << 4) | (target_addr & 0xf); - if (size_ < 1) - size_ = 1; -} - -int Frame::to_text(char *line, unsigned int size) const { - unsigned int offset = 0; - for (int i = 0; i < size_; i++) { - offset = buf_append_printf(line, size, offset, "%02X:", data_[i]); - } - // clear last printed ':' - if (offset > 0) - line[--offset] = '\0'; - -#ifdef HDMI_CEC_USE_DECODER - offset = buf_append_printf(line, size, offset, " => "); - Decoder decoder(*this, line + offset, size - offset); - offset += decoder.decode(); +#if defined(USE_ESP8266) || defined(USE_RP2040) +// The timer interrupt of these platforms carries no argument +static HDMICEC *ack_timer_owner = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif - return offset; +// Bit timing from the HDMI 1.4 CEC specification, in microseconds +static constexpr uint32_t START_BIT_MIN_US = 3500; // a low period at least this long is a start bit +static constexpr uint32_t START_BIT_NOM_US = 3700; +static constexpr uint32_t START_BIT_HIGH_US = 800; +static constexpr uint32_t HIGH_BIT_MIN_US = 400; // shortest low period of a logical 1 +static constexpr uint32_t HIGH_BIT_US = 600; // nominal low period of a logical 1 +static constexpr uint32_t LOW_BIT_US = 1500; // nominal low period of a logical 0 +static constexpr uint32_t SAFE_SAMPLE_US = 1050; // the line is sampled here; a longer low period is a logical 0 +static constexpr uint32_t TOTAL_BIT_US = 2400; +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_RECEIVE = TOTAL_BIT_US * 5; // also for a new initiator +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_FAIL = TOTAL_BIT_US * 3; +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS = TOTAL_BIT_US * 7; +static constexpr uint32_t ABORTED_FRAME_US = TOTAL_BIT_US * 15; // no edge for this long: our frame was cut short +static constexpr uint8_t MAX_ATTEMPTS = 8; // CEC allows 5, but a busy bus counts as an attempt here too + +// Opcodes of the built-in replies +static constexpr uint8_t OPCODE_FEATURE_ABORT = 0x00; +static constexpr uint8_t OPCODE_GIVE_OSD_NAME = 0x46; +static constexpr uint8_t OPCODE_SET_OSD_NAME = 0x47; +static constexpr uint8_t OPCODE_GIVE_PHYSICAL_ADDRESS = 0x83; +static constexpr uint8_t OPCODE_REPORT_PHYSICAL_ADDRESS = 0x84; +static constexpr uint8_t OPCODE_GET_CEC_VERSION = 0x9F; +static constexpr uint8_t OPCODE_CEC_VERSION = 0x9E; +static constexpr uint8_t CEC_VERSION_1_4 = 0x05; +static constexpr uint8_t BROADCAST_ADDRESS = 0xF; + +/// The CEC device type a logical address stands for, for "Report Physical Address" +static uint8_t device_type_of(uint8_t logical_address) { + switch (logical_address) { + case 0x0: + return 0x00; // TV + case 0x1: + case 0x2: + case 0x9: + return 0x01; // Recording Device + case 0x3: + case 0x6: + case 0x7: + case 0xA: + return 0x03; // Tuner + case 0x5: + return 0x05; // Audio System + default: + return 0x04; // Playback Device + } +} + +Frame::Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span payload) { + this->data_[0] = ((initiator_addr & 0xf) << 4) | (destination_addr & 0xf); + this->size_ = 1 + payload.size(); + std::copy(payload.begin(), payload.end(), this->data_.begin() + 1); } void HDMICEC::setup() { - xmit_.setup(pin_); - recv_.setup(pin_, address_); + // The line is open drain with a weak pull-up: driven low to send, released to read + this->pin_->setup(); + this->isr_pin_ = this->pin_->to_isr(); + this->set_pin_input_high_(); + this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; + if (!this->setup_ack_timer_()) { + ESP_LOGE(TAG, "Failed to set up the acknowledge timer"); + this->mark_failed(); + return; + } + this->pin_->attach_interrupt(HDMICEC::gpio_isr_s, this, gpio::INTERRUPT_ANY_EDGE); + // loop() only has work once the interrupt queues a frame or send() queues one + this->disable_loop(); } -void HDMICEC::set_pin(InternalGPIOPin *pin) { pin_ = pin; } - void HDMICEC::dump_config() { - ESP_LOGCONFIG(TAG, "HDMI-CEC"); - ESP_LOGCONFIG(TAG, " address: 0x%x", address_); - ESP_LOGCONFIG(TAG, " physical address: 0x%04x", physical_address_); - xmit_.dump_config(); - recv_.dump_config(); + ESP_LOGCONFIG(TAG, + "HDMI-CEC:\n" + " Address: 0x%X\n" + " Monitor mode: %s\n" + " Promiscuous mode: %s", + this->address_, YESNO(this->monitor_mode_), YESNO(this->promiscuous_mode_)); + LOG_PIN(" Pin: ", this->pin_); + if (this->physical_address_ != UNSET_PHYSICAL_ADDRESS) { + ESP_LOGCONFIG(TAG, " Physical address: %X.%X.%X.%X", this->physical_address_ >> 12, + (this->physical_address_ >> 8) & 0xF, (this->physical_address_ >> 4) & 0xF, + this->physical_address_ & 0xF); + } + if (this->osd_name_ != nullptr) { + char name[Frame::MAX_PAYLOAD_LENGTH]; + progmem_memcpy(name, this->osd_name_, this->osd_name_length_); + name[this->osd_name_length_] = '\0'; + ESP_LOGCONFIG(TAG, " OSD name: %s", name); + } +#ifdef USE_HDMI_CEC_UART + ESP_LOGCONFIG(TAG, " Transmit: UART"); +#endif } void HDMICEC::loop() { - if (const Frame *frame = recv_.frames_queue_.front()) { - // handle 1 inbound message per loop(), to avoid taking too much time - handle_received_message_(frame); - recv_.frames_queue_.pop_front(); + const uint8_t dropped = atomic_get(this->dropped_frames_); + if (dropped != this->reported_dropped_frames_) { + const uint8_t count = dropped - this->reported_dropped_frames_; + ESP_LOGW(TAG, "%u received frame%s dropped (queue full or oversized)", count, + count == 1 ? LOG_STR_LITERAL("") : LOG_STR_LITERAL("s")); + this->reported_dropped_frames_ = dropped; } - if (!xmit_.is_idle()) { - xmit_.transmit_message(); + // One inbound frame per pass keeps the time spent in the automations bounded + if (const Frame *frame = this->frames_queue_.front()) { +#ifdef ESPHOME_LOG_HAS_DEBUG + char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; + ESP_LOGD(TAG, "Received: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); +#endif +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload()); +#endif + if (!this->monitor_mode_ && frame->destination_addr() == this->address_ && + !this->is_claimed_(frame->payload()[0])) { + this->reply_builtin_(frame); + } + this->frames_queue_.pop_front(); } - // Need more frequent service to handle pending workload? - if (!recv_.frames_queue_.is_empty() || !xmit_.is_idle()) { - fast_loop_.start(); - } else { - fast_loop_.stop(); + // A frame being sent stays at the front of the queue until its attempts are over + if (!this->send_queue_.is_empty()) { + this->transmit_message_(); + } + if (this->frames_queue_.is_empty() && this->send_queue_.is_empty()) { + this->disable_loop(); } } -void HDMICEC::handle_received_message_(const Frame *frame) { - const uint8_t src_addr = frame->initiator_addr(); - const uint8_t dest_addr = frame->destination_addr(); - const uint8_t opcode = frame->opcode(); - - if (frame->size() == 1) { - // don't process pings. they're already dealt with by the acknowledgement mechanism - ESP_LOGV(TAG, "Received ping: 0x%01X -> 0x%01X", src_addr, dest_addr); - return; - } - -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG - std::array line; - frame->to_text(line.data(), (unsigned int) (line.size())); - ESP_LOGD(TAG, "Received: %s", line.data()); -#endif - - std::vector data(frame->size() - 1); - for (int i = 1; i < frame->size(); i++) - data[i - 1] = frame->at(i); - - // Process on_message triggers - bool handled_by_trigger = false; - for (auto *trigger : message_triggers_) { - bool can_trigger = - ((!trigger->source_.has_value() || (trigger->source_ == src_addr)) && - (!trigger->destination_.has_value() || (trigger->destination_ == dest_addr)) && - (!trigger->opcode_.has_value() || (trigger->opcode_ == opcode)) && - (!trigger->data_.has_value() || (data.size() >= trigger->data_->size() && - std::equal(trigger->data_->begin(), trigger->data_->end(), data.begin())))); - if (can_trigger) { - trigger->trigger(src_addr, dest_addr, data); - handled_by_trigger = true; +bool HDMICEC::is_claimed_(uint8_t opcode) const { + for (uint8_t i = 0; i < this->claimed_opcode_count_; i++) { + const uint8_t claimed = progmem_read_byte(&this->claimed_opcodes_[i]); + if (claimed >= opcode) { + return claimed == opcode; } } - - // If nothing in on_message handled this message, we try to run the built-in handlers - bool is_directly_addressed = (dest_addr != 0xF && dest_addr == address_); - if (is_directly_addressed && !handled_by_trigger) { - try_builtin_handler_(src_addr, dest_addr, data); - } + return false; } -// Derive CEC device type from 'logical address' -uint8_t HDMICEC::get_device_type() const { - switch (address_) { - // "TV" - case 0x0: - return 0x00; // "TV" - // "Audio System" - case 0x5: - return 0x05; // "Audio System" - // "Recording 1" - case 0x1: - // "Recording 2" - case 0x2: - // "Recording 3" - case 0x9: - return 0x01; // "Recording Device" - // "Tuner 1" - case 0x3: - // "Tuner 2" - case 0x6: - // "Tuner 3" - case 0x7: - // "Tuner 4" - case 0xA: - return 0x03; // "Tuner" - default: - return 0x04; // "Playback Device" +void HDMICEC::reply_builtin_(const Frame *frame) { + const uint8_t source = frame->initiator_addr(); + const uint8_t opcode = frame->payload()[0]; + if (opcode == OPCODE_FEATURE_ABORT || source == BROADCAST_ADDRESS) { + return; // an abort is never answered, and an unregistered device cannot be answered directly } -} - -void HDMICEC::try_builtin_handler_(uint8_t source, uint8_t destination, const std::vector &data) { - if (data.empty()) { - return; - } - - uint8_t opcode = data[0]; switch (opcode) { - // "Get CEC Version" request - case 0x9F: { - // reply with "CEC Version" (0x9E), "1.4" (0x5) - send(source, {0x9E, 0x05}); - break; - } - - // "Give Device Power Status" request - case 0x8F: { - // reply with "Report Power Status" (0x90) - send(source, {0x90, 0x00}); // "On" - break; - } - - // "Give OSD Name" request - case 0x46: { - // reply with "Set OSD Name" (0x47) - std::array payload = {0x47}; - unsigned int payload_size = std::min(payload.size(), osd_name_bytes_.size() + 1); - for (unsigned int i = 1; i < payload_size; i++) { - payload[i] = osd_name_bytes_[i - 1]; + case OPCODE_GET_CEC_VERSION: + this->send(source, {OPCODE_CEC_VERSION, CEC_VERSION_1_4}); + return; + case OPCODE_GIVE_PHYSICAL_ADDRESS: + if (this->physical_address_ != UNSET_PHYSICAL_ADDRESS) { + // Reported to everybody, with the device type the logical address stands for + this->send(BROADCAST_ADDRESS, + {OPCODE_REPORT_PHYSICAL_ADDRESS, static_cast(this->physical_address_ >> 8), + static_cast(this->physical_address_), device_type_of(this->address_)}); + return; } - send(address_, source, payload.data(), payload_size); break; - } - - // "Give Physical Address" request - case 0x83: { - // reply with "Report Physical Address" (0x84) - // Broadcast Physical Address and my device type - send(0xF, {0x84, (uint8_t) ((physical_address_ >> 8) & 0xff), (uint8_t) (physical_address_ & 0xff), - get_device_type()}); + case OPCODE_GIVE_OSD_NAME: + if (this->osd_name_ != nullptr) { + uint8_t reply[Frame::MAX_PAYLOAD_LENGTH] = {OPCODE_SET_OSD_NAME}; + progmem_memcpy(reply + 1, this->osd_name_, this->osd_name_length_); + this->send(source, std::span(reply, 1 + this->osd_name_length_)); + return; + } break; - } - - // Ignore "Feature Abort" opcode responses - case 0x00: - // no-op - break; - - // default case (no built-in handler + no on_message handler) => message not supported => send "Feature Abort" default: - send(source, {0x00, opcode, 0x00}); break; } -} - -bool HDMICEC::send(uint8_t source, uint8_t destination, const uint8_t *payload_bytes, unsigned int payload_size) { - if (recv_.get_monitor_mode()) { - // in 'monitor mode' no presence on the CEC bus is shown, so we don't send - return false; - } - - if (payload_size > Frame::MAX_PAYLOAD_LENGTH) { - ESP_LOGE("hdmi_cec", "Send abort %u->%u opcode=%u: Payload length %u exceeds max %u bytes", source, destination, - payload_bytes[0], payload_size, Frame::MAX_PAYLOAD_LENGTH); - return false; - } - - return xmit_.queue_for_send(source, destination, payload_bytes, payload_size); -} - -void CECTransmit::setup(InternalGPIOPin *pin) { - // This CEC software component optionally uses a UART to transmit (write) on the cec bus. - // When used, the uart output pin is connected to the GPIO CEC pin through a diode: cathode to uart pin. - // This makes sure the uart can only pull-down the CEC line, not pull-up. - // (It seems that specifying 'open-drain' mode for the uart pin has no effect!) - pin_ = pin; - set_pin_input_high(); - pin_->setup(); - set_pin_input_high(); - -#ifdef HDMI_CEC_USE_UART - if (uart_) { - uart_->set_baud_rate(2083); - uart_->set_data_bits(8); - uart_->set_stop_bits(1); - uart_->set_parity(uart::UART_CONFIG_PARITY_NONE); -#if defined(USE_ESP8266) || defined(USE_ESP32) - uart_->load_settings(false); -#endif // USE_ESP8266 || USE_ESP32 - ESP_LOGD(TAG, "Set uart configuration for CEC"); - } -#else - uart_ = nullptr; // probably superfluous, just to be safe and clear +#ifdef USE_HDMI_CEC_FEATURE_ABORT + // Nothing here or in an automation answers it: tell the initiator, as the bus expects + this->send(source, {OPCODE_FEATURE_ABORT, opcode, 0x00}); // reason: unrecognized opcode #endif } -void CECTransmit::dump_config() { - LOG_PIN(" pin: ", pin_); - ESP_LOGCONFIG(TAG, " has UART: %s", (uart_ ? "yes" : "no")); -} - -bool CECTransmit::queue_for_send(uint8_t source, uint8_t destination, const uint8_t *payload_bytes, - unsigned int payload_size) { - Frame *frame = send_queue_.back(); - if (!frame) - return false; // queue is (still) full - - new (frame) Frame(source, destination, payload_bytes, payload_size); - send_queue_.push_back(); // commit the frame obtained from the last 'back()' +bool HDMICEC::send(uint8_t source, uint8_t destination, std::span payload) { + if (this->monitor_mode_) { + ESP_LOGW(TAG, "Monitor mode, not sending 0x%X->0x%X", source, destination); + return false; + } + if (source > 0xf || destination > 0xf) { + ESP_LOGE(TAG, "Send abort: logical address 0x%X->0x%X out of range", source, destination); + return false; + } + if (payload.size() > Frame::MAX_PAYLOAD_LENGTH) { + ESP_LOGE(TAG, "Send abort 0x%X->0x%X: payload of %zu bytes exceeds %zu", source, destination, payload.size(), + Frame::MAX_PAYLOAD_LENGTH); + return false; + } + Frame *frame = this->send_queue_.back(); + if (frame == nullptr) { + ESP_LOGW(TAG, "Send queue full, frame 0x%X->0x%X dropped", source, destination); + return false; + } + *frame = Frame(source, destination, payload); + this->send_queue_.push_back(); + this->enable_loop(); return true; } -void CECTransmit::transmit_message() { - if (transmit_state_ != TransmitState::IDLE && send_queue_.is_empty()) { - // With a 'busy' transmit, the transmitted frame is always on the queue front - // Error state: this shall never occur: SW bug or HW line failure? - ESP_LOGE(TAG, "HDMICEC::transmit_message(): frame error status, force clear!"); - transmit_state_ = TransmitState::IDLE; - return; - } - - if (ATOMIC_GET(receiver_is_busy_) && (micros() - confirm_received_us_) > 15 * TOTAL_BIT_US) { - // protocol error on the bus between receiver and transmitter, this should never occur! - // The Receiver has stopped giving byte_eom_ack confirmations, which should occur after - // every received byte, which is 10x bit-period. So, >=15 bit periods is an error. - // (Normally, the sequence of bits ends with an 'eom' confirmation, which changes the state out of BUSY.) - // Force now leaving the BUSY state to avoid a deadlock by waiting indefinitely. - ATOMIC_SET(receiver_is_busy_, false); - if (transmit_state_ == TransmitState::BUSY) { - transmit_state_ = TransmitState::EOM_CONFIRMED; - } - } - - if (transmit_state_ == TransmitState::BUSY) { - // Transmit is busy, probably on the uart. - // Need to wait until this message transmit ends, from confirmation by receiver. - if (ATOMIC_GET(eom_received_)) { - transmit_state_ = TransmitState::EOM_CONFIRMED; - } else { +void HDMICEC::transmit_message_() { + const uint32_t now = micros(); + const uint32_t since_last_edge = now - atomic_get(this->last_edge_us_); + if (this->transmitting_) { + // The interrupt sees our own frame; wait for its end of message bit, or give up when the + // bus went quiet without one + if (!atomic_get(this->eom_received_) && since_last_edge < ABORTED_FRAME_US) { return; } - } - - if (transmit_state_ == TransmitState::EOM_CONFIRMED) { - // the Frame which is on the queue.front is confirmed to be fully sent out - const Frame *frame = send_queue_.front(); - uint8_t n_acks_expected = frame->is_broadcast() ? 0 : frame->size(); // for broadcast, acknowledge is bad - bool sent_ok = (n_bytes_received_ == frame->size()) && (n_acks_received_ == n_acks_expected); - // Create log message for debugging - if (!sent_ok) { - // last transmit had a byte count error, or ended without appropriate Acknowledge from recipient - if (n_bytes_received_ != frame->size()) { - ESP_LOGD(TAG, "Send frame incorrect on attempt %d, saw %d bytes but expected %d bytes", transmit_attempts_, - n_bytes_received_, frame->size()); - } else { - ESP_LOGD(TAG, "Send frame NOT acknowledged on attempt %d", transmit_attempts_); - } + this->transmitting_ = false; + atomic_set(this->eom_received_, false); // the end of our own frame is not a foreign one + const Frame *frame = this->send_queue_.front(); + // A broadcast is acknowledged by nobody; a directed frame on every byte + const bool bytes_ok = this->n_bytes_received_ == frame->size(); + const bool acks_ok = this->n_acks_received_ == (frame->is_broadcast() ? 0 : frame->size()); + if (bytes_ok && acks_ok) { + ESP_LOGD(TAG, "Send frame success in %u attempt%s", this->transmit_attempts_, + this->transmit_attempts_ > 1 ? LOG_STR_LITERAL("s") : LOG_STR_LITERAL("")); + } else if (!bytes_ok) { + ESP_LOGD(TAG, "Send frame incorrect on attempt %u, saw %u bytes but expected %zu", this->transmit_attempts_, + this->n_bytes_received_, frame->size()); } else { - // last transmit just ended successfully - ESP_LOGD(TAG, "Send frame success in %d attempt%s", transmit_attempts_, (transmit_attempts_ > 1 ? "s" : "")); - } - - // terminate working on the current frame? - if (sent_ok) { - required_idle_period_ = SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS; - transmit_attempts_ = 0; - send_queue_.pop_front(); - } else { - required_idle_period_ = SIGNAL_FREE_TIME_AFTER_XMIT_FAIL; + ESP_LOGD(TAG, "Send frame not acknowledged on attempt %u", this->transmit_attempts_); } + this->finish_attempt_(bytes_ok && acks_ok); + } else if (atomic_get(this->eom_received_)) { + // Another device finished a frame; the standard asks a new initiator to wait after it + this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; + atomic_set(this->eom_received_, false); } - transmit_state_ = TransmitState::IDLE; - if (transmit_attempts_ >= MAX_ATTEMPTS) { - ESP_LOGD(TAG, "frame was NOT sent correctly after %d attempts, drop frame", transmit_attempts_); - required_idle_period_ = SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS; - transmit_attempts_ = 0; - send_queue_.pop_front(); - } - - const Frame *frame = send_queue_.front(); // check for presence of next frame to send (null if queue is empty) - if (!frame || ATOMIC_GET(receiver_is_busy_)) { - // If the receiver is busy, the bus is occupied, and a transmit must wait until the bus is idle + const Frame *frame = this->send_queue_.front(); + // An edge within the signal free time means the bus is in use or not yet free for us; a start + // bit refused by a held line drives no edge, so that attempt is timed on its own + if (frame == nullptr || since_last_edge < this->required_idle_period_ || + now - this->last_attempt_us_ < this->required_idle_period_) { return; } - if (micros() - confirm_received_us_ < required_idle_period_) { - // It is too early for a transmit, to satisfy the CEC standard bus idle time - return; - } - - // Launch the transmit of the frame that is on the front of the queue - transmit_state_ = TransmitState::BUSY; - transmit_attempts_++; - -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG - if (transmit_attempts_ <= 1) { - std::array line; - frame->to_text(line.data(), (unsigned int) (line.size())); - ESP_LOGD(TAG, "Sending: %s", line.data()); + this->last_attempt_us_ = now; + this->transmit_attempts_++; +#ifdef ESPHOME_LOG_HAS_DEBUG + if (this->transmit_attempts_ == 1) { + char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; + ESP_LOGD(TAG, "Sending: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); } #endif - - // Reset info from receiver on progress of this transmit - // (The receiver will see this transmit and keep track of acknowledge bits.) - n_bytes_received_ = 0; - n_acks_received_ = 0; - ATOMIC_SET(eom_received_, false); - - // the 'start_bit' and the first 4 bits of the 'header block' are always sent by software on the GPIO - // pin to detect a bus collision and allow early termination of the frame transmit - if (!send_start_bit_() || !transmit_my_address_(frame->initiator_addr())) { - // sending these first bits caused a bus-collision with another initiator. - // further transmission is stopped immediatly, as the other initiator might not see the collision - confirm_received_us_ = micros(); // this would otherwise be set after seeing the (first) full byte - required_idle_period_ = SIGNAL_FREE_TIME_AFTER_XMIT_FAIL; - transmit_state_ = TransmitState::IDLE; - return; + // The interrupt counts the bytes and acknowledge bits of our own frame + this->n_bytes_received_ = 0; + this->n_acks_received_ = 0; + atomic_set(this->eom_received_, false); + this->transmitting_ = true; + if (!this->transmit_frame_(frame)) { + // The line was held, or another initiator sent at the same time + this->transmitting_ = false; + this->finish_attempt_(false); } - if (uart_) { - transmit_message_on_uart_(frame); - } else { - transmit_message_on_gpio_(frame); - } - // don't wait here on the transmision result (Acknowledge from destination) - // because (at least) the uart proceeds in the background + // Otherwise the acknowledge result arrives through the interrupt } -bool CECTransmit::transmit_my_address_(const uint8_t initiator_addr) { - // My (initiator) address is in the 4 MSB bits of the header byte (CEC transfers MSB first) - bool ok = true; - for (int i = 3; i >= 0 && ok; i--) { - bool bit_value = ((initiator_addr >> i) & 0x1); - if (bit_value) { - ok &= send_high_and_test_(); - } else { - send_bit_(false); +void HDMICEC::finish_attempt_(bool sent_ok) { + this->required_idle_period_ = sent_ok ? SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS : SIGNAL_FREE_TIME_AFTER_XMIT_FAIL; + if (!sent_ok) { + if (this->transmit_attempts_ < MAX_ATTEMPTS) { + return; // the signal free time has to pass before the retry } + ESP_LOGW(TAG, "Frame not sent after %u attempts, dropped", this->transmit_attempts_); } - // Check for bus collisions, which would make the received bus address different from the sent address - if (!ok) { - ESP_LOGD(TAG, "Bus collision on sending my initiator addr 0x%1x on attempt %d", initiator_addr, transmit_attempts_); - } - return ok; + this->transmit_attempts_ = 0; + this->send_queue_.pop_front(); } -void CECTransmit::transmit_message_on_gpio_(const Frame *frame) { - // for each byte of the frame: - for (int byte_inx = 0; byte_inx < frame->size(); byte_inx++) { - uint8_t current_byte = frame->at(byte_inx); - - // 1. send the current byte - bool partial_first_byte = (byte_inx == 0); - for (int8_t i = (partial_first_byte ? 3 : 7); i >= 0; i--) { - // send MSB (Most Significant Bit) first - bool bit_value = ((current_byte >> i) & 0b1); - send_bit_(bit_value); - } - - // 2. send EOM (End Of Frame) bit (logic 1 if this is the last byte of the frame) - bool is_eom = (byte_inx == frame->size() - 1); - send_bit_(is_eom); - - // 3. send ACK (Acknowledge) bit - send_bit_(true); // always send a 1, check elsewhere if our receiver sees a 1 or 0 - } -} - -bool CECTransmit::send_start_bit_() { - set_pin_input_high(); - bool value = pin_->digital_read(); - if (!value) { - ESP_LOGD(TAG, "Bus occupied before start-bit on attempt %d", transmit_attempts_); +bool HDMICEC::transmit_frame_(const Frame *frame) { + if (!this->send_start_bit_()) { return false; } - // The CEC line was not yet occupied (pulled low) by someone else - // 1. pull low for the start-bit duration - set_pin_output_low(); + const auto bytes = frame->bytes(); + for (size_t byte_inx = 0; byte_inx < bytes.size(); byte_inx++) { + const uint8_t current_byte = bytes[byte_inx]; + // Most significant bit first. The four initiator address bits watch for a collision with + // another initiator that started at the same moment; a lower address wins by holding the line + for (int8_t i = 7; i >= 0; i--) { + const bool bit_value = (current_byte >> i) & 0x1; + if (!this->send_bit_(bit_value) && byte_inx == 0 && i >= 4) { + ESP_LOGD(TAG, "Bus collision on sending initiator address 0x%X on attempt %u", frame->initiator_addr(), + this->transmit_attempts_); + return false; + } +#ifdef USE_HDMI_CEC_UART + if (this->uart_ != nullptr && byte_inx == 0 && i == 4) { + // The address is out and uncontested; the UART sends the rest while loop() goes on + this->transmit_on_uart_(frame); + return true; + } +#endif + } + // End of message bit, then the acknowledge bit which the receiver may pull low + this->send_bit_(byte_inx == bytes.size() - 1); + this->send_bit_(true); + } + return true; +} + +#ifdef USE_HDMI_CEC_UART +void HDMICEC::transmit_on_uart_(const Frame *frame) { + // At 2083 baud one UART byte (start bit, 8 data bits, stop bit) spans two CEC bits of five UART + // bits each. The UART start bit is low and its stop bit high, so a byte always begins the first + // CEC bit low and ends the second one high; the data bits, sent least significant first, fill in + // the rest: a logical 0 is low for three UART bits, a logical 1 for one. + static const uint8_t TWO_BITS_TO_UART_BYTE[4] PROGMEM = {0x8C, 0xEC, 0x8F, 0xEF}; // 00, 01, 10, 11 + uint8_t data[Frame::MAX_LENGTH * 5]; // ten CEC bits per byte, two per UART byte + size_t length = 0; + const auto bytes = frame->bytes(); + for (size_t byte_inx = 0; byte_inx < bytes.size(); byte_inx++) { + // The byte, its end of message bit and the acknowledge bit (sent high, the receiver pulls it low) + const uint16_t block = + (static_cast(bytes[byte_inx]) << 2) | (byte_inx == bytes.size() - 1 ? 0x2 : 0) | 0x1; + // The four initiator address bits of the header went out on the pin already + for (int8_t pair = (byte_inx == 0 ? 2 : 4); pair >= 0; pair--) { + data[length++] = progmem_read_byte(&TWO_BITS_TO_UART_BYTE[(block >> (2 * pair)) & 0x3]); + } + } + this->uart_->write_array(data, length); +} +#endif + +bool HDMICEC::send_start_bit_() { + if (!this->isr_pin_.digital_read()) { + ESP_LOGD(TAG, "Bus occupied before start bit on attempt %u", this->transmit_attempts_); + return false; + } + this->set_pin_output_low_(); delay_microseconds_safe(START_BIT_NOM_US); - - // 2. let the line go high again, but test if no one else keeps it low - set_pin_input_high(); + // Release the line and check that nobody else keeps it low + this->set_pin_input_high_(); delay_microseconds_safe(START_BIT_HIGH_US / 2); - value &= pin_->digital_read(); + bool value = this->isr_pin_.digital_read(); delay_microseconds_safe(START_BIT_HIGH_US / 2); - value &= pin_->digital_read(); - + value &= this->isr_pin_.digital_read(); if (!value) { - ESP_LOGD(TAG, "Bus collision on sending start-bit on attempt %d", transmit_attempts_); + ESP_LOGD(TAG, "Bus collision on sending start bit on attempt %u", this->transmit_attempts_); } return value; } -void IRAM_ATTR CECTransmit::send_bit_(bool bit_value) { - // total bit duration: - // logic 1: pull low for 600 us, then pull high for 1800 us - // logic 0: pull low for 1500 us, then pull high for 900 us - - const uint32_t low_duration_us = (bit_value ? HIGH_BIT_US : LOW_BIT_US); - const uint32_t high_duration_us = (TOTAL_BIT_US - low_duration_us); - - set_pin_output_low(); - delay_microseconds_safe(low_duration_us); - set_pin_input_high(); - delay_microseconds_safe(high_duration_us); -} - -bool IRAM_ATTR CECTransmit::send_high_and_test_() { - uint32_t start_us = micros(); - - // send a Logical 1 - set_pin_output_low(); - delay_microseconds_safe(HIGH_BIT_US); - set_pin_input_high(); - - // ...then wait up to the middle of the "Safe sample period" (CEC spec -> Signaling and Bit Timing -> Figure 5) - static constexpr uint32_t SAFE_SAMPLE_US = 1050; - uint32_t elapsed = micros() - start_us; - if (elapsed < SAFE_SAMPLE_US) { - delay_microseconds_safe(SAFE_SAMPLE_US - elapsed); +bool HDMICEC::send_bit_(bool bit_value) { + const uint32_t start_us = micros(); + this->set_pin_output_low_(); + delay_microseconds_safe(bit_value ? HIGH_BIT_US : LOW_BIT_US); + this->set_pin_input_high_(); + bool clear = true; + if (bit_value) { + // Sample in the middle of the safe sample period: a line still low means another initiator + // is sending a logical 0 at the same time + uint32_t elapsed = micros() - start_us; + if (elapsed < SAFE_SAMPLE_US) { + delay_microseconds_safe(SAFE_SAMPLE_US - elapsed); + } + clear = this->isr_pin_.digital_read(); } - bool value = pin_->digital_read(); - - // ... and sleep for the rest of the bit period - elapsed = micros() - start_us; + const uint32_t elapsed = micros() - start_us; if (elapsed < TOTAL_BIT_US) { delay_microseconds_safe(TOTAL_BIT_US - elapsed); } - - // If a 'high' value was read, the 'low' pulse was short, not lengthened by another driver. - // Such short pulse represents a 'high' bit. - return value; + return clear; } -void CECTransmit::transmit_message_on_uart_(const Frame *frame) { - std::vector uart_data; - uart_data.reserve(5 * frame->size()); // the UART is used with 5x oversampling (5 uart bytes per cec byte) - for (unsigned int i = 0; i < frame->size(); i++) { - convert_byte_to_uart_(uart_data, frame->at(i), i == 0, i == (frame->size() - 1)); - } -#ifdef HDMI_CEC_USE_UART - uart_->write_array(uart_data); // if not 'HDMI_CEC_USE_UART', the include file is missing and this cannot be compiled -#endif +void IRAM_ATTR HDMICEC::send_ack_() { + // The acknowledge has to start right at this falling edge; the timer releases the line + this->set_pin_output_low_(); + this->start_ack_timer_(); } -void CECTransmit::convert_byte_to_uart_(std::vector &uart_data, uint8_t byte, bool is_header, bool is_eom) { - // 5 uart-bits create the nominal 2.4ms cec bit period, with our baudrate of 2083 bits/sec. - // 10 uart-bits are made with an (always 0) uart start bit, then 8 data bit, and an (always 1) uart stop bit. - // transmitting a '0' data bit gets translated to a uart 3xlow, 2xhigh on the cec line - // transmitting a '1' data bit gets translated to a uart 1xlow, 4xhigh on the cec line - // Note that a CEC 'header/data block' byte is sent MSB (Most Significant Bit) first, - // whereas the bytes as given to the UART are sent out with LSB first. - // cec 2-bit data 10-bit pattern in transmission order stripped start/stop, swap lsb-first, uart data byte - // 00 0001100011 10001100 = 8c - // 01 0001101111 11101100 = ec - // 10 0111100011 10001111 = 8f - // 11 0111101111 11101111 = ef - static const std::array CEC2BIT_TO_UARTBYTE = {0x8c, 0xec, 0x8f, 0xef}; - uint16_t cec_block = ((uint16_t) byte) << 2; // expand byte to 10 bits - cec_block |= (is_eom ? 0x2 : 0); // insert eom (end-of-message) bit - cec_block |= 0x1; // insert ack bit, is always written as 1 - // send 10-bit block MSB-first, but skip initial 4 bits of header byte as those have already been sent - const uint8_t nbytes = is_header ? 3 : 5; - for (int i = nbytes - 1; i >= 0; i--) { - uint8_t twobits = (cec_block >> (2 * i)) & 0x3; // get most-signifiant bits first - uart_data.push_back(CEC2BIT_TO_UARTBYTE[twobits]); +#if defined(USE_ESP32) +bool HDMICEC::setup_ack_timer_() { + gptimer_config_t config = { + .clk_src = GPTIMER_CLK_SRC_DEFAULT, + .direction = GPTIMER_COUNT_UP, + .resolution_hz = 1000000, // one tick per microsecond + }; + if (gptimer_new_timer(&config, &this->ack_timer_) != ESP_OK) { + return false; } + gptimer_event_callbacks_t callbacks = {.on_alarm = HDMICEC::ack_timer_isr_s}; + // The alarm reloads the count to zero itself, so the interrupt only has to start the timer + gptimer_alarm_config_t alarm = { + .alarm_count = LOW_BIT_US, .reload_count = 0, .flags = {.auto_reload_on_alarm = true}}; + return gptimer_register_event_callbacks(this->ack_timer_, &callbacks, this) == ESP_OK && + gptimer_set_alarm_action(this->ack_timer_, &alarm) == ESP_OK && gptimer_enable(this->ack_timer_) == ESP_OK; } -void IRAM_ATTR CECTransmit::send_ack() { - // This method is called by the receiver (gpio_isr). When receiving a message, this transmitter - // is expected to be idle. The only exception to that would be the rather abnormal case - // where we transmit a message to ourselves (address_ == target_address). -#ifdef HDMI_CEC_USE_UART - if (transmit_state_ == TransmitState::IDLE) { - // transmit a '0' with 3 'low' uart bit periods, one of which is the uart start-bit. - // So, the uart byte to send has its 2 least-significant bits 0. - const uint8_t ack_byte = 0xfc; - uart_->write_byte(ack_byte); // issue on the uart, don't wait on completion - return; +void IRAM_ATTR HDMICEC::start_ack_timer_() { gptimer_start(this->ack_timer_); } + +bool IRAM_ATTR HDMICEC::ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg) { + auto *self = static_cast(arg); + self->set_pin_input_high_(); + gptimer_stop(timer); + return false; // no task to wake +} + +#elif defined(USE_ESP8266) +static constexpr uint32_t TIMER1_TICKS_PER_US = 5; // TIM_DIV16 of the 80 MHz clock + +bool HDMICEC::setup_ack_timer_() { + ack_timer_owner = this; + timer1_isr_init(); + timer1_attachInterrupt(HDMICEC::ack_timer_isr_s); + timer1_enable(TIM_DIV16, TIM_EDGE, TIM_SINGLE); // a write arms one shot + return true; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { timer1_write(LOW_BIT_US * TIMER1_TICKS_PER_US); } + +void IRAM_ATTR HDMICEC::ack_timer_isr_s() { ack_timer_owner->set_pin_input_high_(); } + +#elif defined(USE_RP2040) +bool HDMICEC::setup_ack_timer_() { + this->ack_alarm_ = hardware_alarm_claim_unused(false); + if (this->ack_alarm_ < 0) { + return false; // every hardware alarm is taken } -#endif - // send an ack bit by direct gpio pin manipulation - // This has the serious disadvantage that the receiver isr is kept - // busy for a serious amount of time. (Thus blocking other system interrupts.) - set_pin_output_low(); + ack_timer_owner = this; + hardware_alarm_set_callback(this->ack_alarm_, HDMICEC::ack_timer_isr_s); + return true; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { + hardware_alarm_set_target(this->ack_alarm_, make_timeout_time_us(LOW_BIT_US)); +} + +void IRAM_ATTR HDMICEC::ack_timer_isr_s(uint alarm_num) { ack_timer_owner->set_pin_input_high_(); } + +#else +bool HDMICEC::setup_ack_timer_() { return true; } + +// Without a one-shot timer the interrupt holds the line itself +void IRAM_ATTR HDMICEC::start_ack_timer_() { delay_microseconds_safe(LOW_BIT_US); - set_pin_input_high(); + this->set_pin_input_high_(); } +#endif -void IRAM_ATTR CECTransmit::got_start_of_activity() { - // bus is occupied, inhibit send - confirm_received_us_ = micros(); // initialize timing guard - ATOMIC_SET(receiver_is_busy_, true); -} +void IRAM_ATTR HDMICEC::gpio_isr_s(HDMICEC *self) { self->gpio_isr_(); } -void IRAM_ATTR CECTransmit::got_byte_eom_ack(bool eom, bool ack) { - confirm_received_us_ = micros(); // timepoint after receiving most recent byte - if (transmit_state_ == TransmitState::BUSY) { - // this received message was sent by myself, handle this confirmation to check acknowledgement - n_bytes_received_++; - // 'ack value == 0' means that the addressed device on the bus confirms receipt by pulling 'ack' low. - // (But for broadcast messages, 'ack == 0' indicates that some receiver denies the message.) - if (!ack) { - // 0 bit value means 'acknowledge' for an addressed message - n_acks_received_++; - } - } - if (eom) { - // some bus transfer ended. When the bus is free for a little while, we are allowed to transmit. - required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; - ATOMIC_SET(receiver_is_busy_, false); // unblock the transmitter - ATOMIC_SET(eom_received_, true); // atomic write to synchronize/commit above increments to the transmitter thread - } -} - -void CECReceive::setup(InternalGPIOPin *pin, uint8_t address) { - address_ = address; - pin->attach_interrupt(CECReceive::gpio_isr_s, this, gpio::INTERRUPT_ANY_EDGE); - isr_pin_ = pin->to_isr(); - frames_queue_.reset(); - reset_state_variables_(); -} - -void CECReceive::dump_config() { - ESP_LOGCONFIG(TAG, " monitor mode: %s", (monitor_mode_ ? "yes" : "no")); - ESP_LOGCONFIG(TAG, " promiscuous mode: %s", (promiscuous_mode_ ? "yes" : "no")); -} - -void IRAM_ATTR CECReceive::gpio_isr_s(CECReceive *self) { self->gpio_isr_(); } - -void IRAM_ATTR CECReceive::gpio_isr_() { +void IRAM_ATTR HDMICEC::gpio_isr_() { const uint32_t now = micros(); - const bool level = isr_pin_.digital_read(); - - if (level == prev_pin_level_) { - // spurious interrupt, probably triggered by a mode-change of the cec gpio - return; + const bool level = this->isr_pin_.digital_read(); + if (level == this->prev_pin_level_) { + return; // a mode change of the pin, not a bus edge } - prev_pin_level_ = level; + this->prev_pin_level_ = level; + atomic_set(this->last_edge_us_, now); - // on falling edge, store current time as the start of the low pulse if (!level) { - if (now - last_falling_edge_us_ > START_BIT_NOM_US + TOTAL_BIT_US) { - // there was a long period of silence on the bus: reset state for the start of a new message - reset_state_variables_(); + // Falling edge: the start of a low period + if (now - this->last_falling_edge_us_ > START_BIT_NOM_US + TOTAL_BIT_US) { + // The bus was quiet; whatever was gathered belongs to an aborted frame + this->reset_receiver_(); } - - last_falling_edge_us_ = now; - - if (receiver_state_ == ReceiverState::IDLE) { - // inform transmitter on new bus activity - xmit_.got_start_of_activity(); - } - - if (recv_ack_queued_ && !monitor_mode_) { - // create Acknowledge bit on the CEC bus by stretching the 'low' period - // Use the Transmitter to immediatly send this ack bit - xmit_.send_ack(); - recv_ack_queued_ = false; + this->last_falling_edge_us_ = now; + if (this->recv_ack_queued_) { + // Acknowledge by stretching this low period + this->send_ack_(); + this->recv_ack_queued_ = false; } return; } - // otherwise, it's a rising edge, so it's time to process the pulse length - const uint32_t pulse_duration = (now - last_falling_edge_us_); + // Rising edge: the low period tells the bit value + const uint32_t pulse_duration = now - this->last_falling_edge_us_; if (pulse_duration > START_BIT_MIN_US) { - // start bit detected. reset everything and start receiving - reset_state_variables_(); // abort any previously gathered (unfinished) state - receiver_state_ = ReceiverState::RECEIVING_BYTE; - recv_frame_buffer_ = frames_queue_.back(); - if (recv_frame_buffer_) - recv_frame_buffer_->clear(); - return; - } else if (pulse_duration < (HIGH_BIT_MIN_US / 4)) { - // short glitch on the line: ignore + this->reset_receiver_(); + this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE; + // Without a free slot the frame is neither acknowledged nor delivered, so its initiator retries + this->recv_frame_ = this->frames_queue_.back(); + if (this->recv_frame_ != nullptr) { + this->recv_frame_->clear(); + } return; } + if (pulse_duration < HIGH_BIT_MIN_US / 4) { + return; // glitch + } + const bool value = pulse_duration <= SAFE_SAMPLE_US; + // Our own frame is on the bus until its end of message; send() may give it any initiator address + const bool own_frame = this->transmitting_ && !atomic_get(this->eom_received_); - bool value = (pulse_duration <= HIGH_BIT_MAX_US); // short low pulse represents '1' + switch (this->receiver_state_) { + case ReceiverState::RECEIVER_STATE_IDLE: + break; - switch (receiver_state_) { - case ReceiverState::RECEIVING_BYTE: { - // write bit to the current byte - recv_byte_buffer_ = (recv_byte_buffer_ << 1) | (value & 0x1); - recv_bit_counter_++; - if (recv_bit_counter_ >= 8) { - // if we reached eight bits, push the current byte to the frame buffer - if (recv_frame_buffer_) { - recv_frame_buffer_->push_back(recv_byte_buffer_); + case ReceiverState::RECEIVER_STATE_RECEIVING_BYTE: + this->recv_byte_ = (this->recv_byte_ << 1) | value; + if (++this->recv_bit_counter_ == 8) { + if (this->recv_length_++ == 0) { + this->recv_header_ = this->recv_byte_; } - recv_bit_counter_ = 0; - recv_byte_buffer_ = 0; - receiver_state_ = ReceiverState::WAITING_FOR_EOM; // expect 9th bit of block: EOM + if (this->recv_frame_ != nullptr && !this->recv_frame_->push_back(this->recv_byte_)) { + this->recv_frame_ = nullptr; // longer than the bus allows: corrupt, do not deliver it + } + this->recv_bit_counter_ = 0; + this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM; } break; - } - case ReceiverState::WAITING_FOR_EOM: { - // check if we need to acknowledge this byte on the next 'ack' bit (10th bit of block) - if (recv_frame_buffer_ && !recv_frame_buffer_->is_broadcast() && - recv_frame_buffer_->destination_addr() == address_ && recv_frame_buffer_->initiator_addr() != address_) { - // Note: Do NOT acknowledge a message initiated by myself, - // to support dynamic 'Logical Address' allocation by sending 'polling' messages - recv_ack_queued_ = true; + case ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM: + this->recv_eom_ = value; + // A frame for us is acknowledged on the next bit, unless there is no room for it (so its + // initiator retries) or it is our own, which includes a polling message from a device + // claiming our logical address. A polling message for us (header only) needs no room, and + // goes unacknowledged only when nobody holds the address + if (!this->monitor_mode_ && !own_frame && (this->recv_frame_ != nullptr || (value && this->recv_length_ == 1)) && + (this->recv_header_ & 0xf) == this->address_ && (this->recv_header_ >> 4) != this->address_) { + this->recv_ack_queued_ = true; } - bool is_eom = (value == 1); - receiver_state_ = is_eom ? ReceiverState::WAITING_FOR_EOM_ACK : ReceiverState::WAITING_FOR_ACK; + this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK; break; - } - case ReceiverState::WAITING_FOR_ACK: { - xmit_.got_byte_eom_ack(false, value); // pass time of received byte, eom and ack, to transmitter - receiver_state_ = ReceiverState::RECEIVING_BYTE; // no EOM, so expect next byte for this frame - break; - } - - case ReceiverState::WAITING_FOR_EOM_ACK: { - xmit_.got_byte_eom_ack(true, value); // pass time of received byte, eom and ack, to transmitter - // Check if the application could be interested in this message - if (recv_frame_buffer_ && - (promiscuous_mode_ || - (recv_frame_buffer_->is_broadcast() && (recv_frame_buffer_->initiator_addr() != address_)) || - (recv_frame_buffer_->destination_addr() == address_))) { - frames_queue_.push_back(); // commit the received frame for later pick-up by 'front()' + case ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK: + if (own_frame) { + // A low acknowledge bit means the addressed device took the byte + // (for a broadcast it means some device refused it) + this->n_bytes_received_++; + if (!value) { + this->n_acks_received_++; + } } - reset_state_variables_(); + if (!this->recv_eom_) { + this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE; + break; + } + atomic_set(this->eom_received_, true); // publishes the tally above to loop() + // The application only gets frames that are not our own and not a polling message (header + // only), which the acknowledge answered already, and that are addressed to us unless + // promiscuous + const uint8_t destination = this->recv_header_ & 0xf; + const bool wanted = this->recv_length_ > 1 && !own_frame && (this->recv_header_ >> 4) != this->address_ && + (this->promiscuous_mode_ || destination == 0xf || destination == this->address_); + if (!wanted) { + // nothing to deliver + } else if (this->recv_frame_ == nullptr) { + atomic_set(this->dropped_frames_, atomic_get(this->dropped_frames_) + 1); + } else { + this->frames_queue_.push_back(); + this->enable_loop_soon_any_context(); + } + this->reset_receiver_(); break; - } - - default: { - break; - } } } -void IRAM_ATTR CECReceive::reset_state_variables_() { - receiver_state_ = ReceiverState::IDLE; - num_acks_ = 0; - recv_bit_counter_ = 0; - recv_byte_buffer_ = 0; - recv_ack_queued_ = false; - recv_frame_buffer_ = nullptr; +void IRAM_ATTR HDMICEC::reset_receiver_() { + this->receiver_state_ = ReceiverState::RECEIVER_STATE_IDLE; + this->recv_bit_counter_ = 0; + this->recv_length_ = 0; + this->recv_ack_queued_ = false; + this->recv_frame_ = nullptr; } } // namespace esphome::hdmi_cec diff --git a/esphome/components/hdmi_cec/hdmi_cec.h b/esphome/components/hdmi_cec/hdmi_cec.h index 7085e1d77ef..5bd12088636 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.h +++ b/esphome/components/hdmi_cec/hdmi_cec.h @@ -1,335 +1,258 @@ #pragma once #include -#include #include #include +#include -#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 +#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 ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic &atom, T value) { + atom.store(value, std::memory_order_release); +} +template ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic &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 &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 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 bytes() const { return {this->data_.data(), this->size_}; } + /// The bytes after the header, starting with the opcode + std::span 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 data_; + std::array 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 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; - // 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 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 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(atomic_get(this->back_inx_) - atomic_get(this->front_inx_)); + } + ESPHOME_ALWAYS_INLINE Frame *slot_(const std::atomic &inx) { return &this->store_[atomic_get(inx) % SIZE]; } + ESPHOME_ALWAYS_INLINE void advance_(std::atomic &inx) { + atomic_set(inx, static_cast(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 &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 eom_received_{false}; - std::atomic 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 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 store_{}; + std::atomic front_inx_{0}; + std::atomic 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 &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 &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 &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 void add_on_message_callback(F &&callback) { + this->message_callbacks_.add(std::forward(callback)); + } +#endif - HighFrequencyLoopRequester fast_loop_; - InternalGPIOPin *pin_{nullptr}; - uint8_t address_; // logical address, relates to device type - uint16_t physical_address_; - std::vector osd_name_bytes_; - std::vector message_triggers_; - - CECTransmit xmit_; - CECReceive recv_{xmit_}; -}; - -class MessageTrigger : public Trigger> { - 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 &data) { data_ = data; }; - - protected: - optional source_; - optional destination_; - optional opcode_; - optional> data_; -}; - -template class SendAction : public Action { - public: - SendAction(HDMICEC *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint8_t, source) - TEMPLATABLE_VALUE(uint8_t, destination) - TEMPLATABLE_VALUE(std::vector, 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 &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 payload); + bool send(uint8_t destination, std::span payload) { + return this->send(this->address_, destination, payload); + } + /// For lambdas: id(cec).send(0x0, {0x44, 0x41}); + bool send(uint8_t destination, std::initializer_list payload) { + return this->send(destination, std::span(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 frames_queue_; // received, interrupt to loop() + uint8_t recv_bit_counter_{0}; + uint8_t recv_byte_{0}; + FrameRingBuffer 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)> + 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 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 eom_received_{false}; // set for every frame, ours or not + std::atomic 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 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0e020975e84..9adf85971d6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/components/hdmi_cec/common-uart.yaml b/tests/components/hdmi_cec/common-uart.yaml new file mode 100644 index 00000000000..06d22566441 --- /dev/null +++ b/tests/components/hdmi_cec/common-uart.yaml @@ -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 diff --git a/tests/components/hdmi_cec/common.yaml b/tests/components/hdmi_cec/common.yaml index 5ee1764e2e9..7696de1ce47 100644 --- a/tests/components/hdmi_cec/common.yaml +++ b/tests/components/hdmi_cec/common.yaml @@ -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 diff --git a/tests/components/hdmi_cec/common_no_uart.yaml b/tests/components/hdmi_cec/common_no_uart.yaml deleted file mode 100644 index 3d091793c46..00000000000 --- a/tests/components/hdmi_cec/common_no_uart.yaml +++ /dev/null @@ -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" diff --git a/tests/components/hdmi_cec/common_with_uart.yaml b/tests/components/hdmi_cec/common_with_uart.yaml deleted file mode 100644 index 465d28c9781..00000000000 --- a/tests/components/hdmi_cec/common_with_uart.yaml +++ /dev/null @@ -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" diff --git a/tests/components/hdmi_cec/test-uart.esp32-idf.yaml b/tests/components/hdmi_cec/test-uart.esp32-idf.yaml new file mode 100644 index 00000000000..858b3b0eec8 --- /dev/null +++ b/tests/components/hdmi_cec/test-uart.esp32-idf.yaml @@ -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 diff --git a/tests/components/hdmi_cec/test-uart.esp8266-ard.yaml b/tests/components/hdmi_cec/test-uart.esp8266-ard.yaml new file mode 100644 index 00000000000..3d0276b3f9b --- /dev/null +++ b/tests/components/hdmi_cec/test-uart.esp8266-ard.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 diff --git a/tests/components/hdmi_cec/test-with-uart.esp32-c3-idf.yaml b/tests/components/hdmi_cec/test-with-uart.esp32-c3-idf.yaml deleted file mode 100644 index 2398e25ac60..00000000000 --- a/tests/components/hdmi_cec/test-with-uart.esp32-c3-idf.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# tested with Seeed Studio XIAO ESP32C3 -substitutions: - uart_pin: GPIO21 - cec_pin: GPIO10 - -<<: !include common_with_uart.yaml diff --git a/tests/components/hdmi_cec/test-with-uart.esp8266-ard.yaml b/tests/components/hdmi_cec/test-with-uart.esp8266-ard.yaml deleted file mode 100644 index 46e52553fe3..00000000000 --- a/tests/components/hdmi_cec/test-with-uart.esp8266-ard.yaml +++ /dev/null @@ -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 diff --git a/tests/test_build_components/common/uart_2083/esp32-idf.yaml b/tests/test_build_components/common/uart_2083/esp32-idf.yaml new file mode 100644 index 00000000000..5fc4fabb170 --- /dev/null +++ b/tests/test_build_components/common/uart_2083/esp32-idf.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 diff --git a/tests/test_build_components/common/uart_2083/esp8266-ard.yaml b/tests/test_build_components/common/uart_2083/esp8266-ard.yaml new file mode 100644 index 00000000000..9375e275ed7 --- /dev/null +++ b/tests/test_build_components/common/uart_2083/esp8266-ard.yaml @@ -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