diff --git a/esphome/components/hdmi_cec/__init__.py b/esphome/components/hdmi_cec/__init__.py index c718968e201..5c5306f78e0 100644 --- a/esphome/components/hdmi_cec/__init__.py +++ b/esphome/components/hdmi_cec/__init__.py @@ -1,6 +1,6 @@ from esphome import automation, pins import esphome.codegen as cg -from esphome.components.esp32 import include_builtin_idf_component +from esphome.components import uart import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -9,133 +9,177 @@ from esphome.const import ( CONF_ON_MESSAGE, CONF_PIN, CONF_SOURCE, - PLATFORM_BK72XX, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_LN882X, - PLATFORM_RP2, - PLATFORM_RTL87XX, + CONF_TRIGGER_ID, + CONF_UART_ID, ) -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" 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 + hdmi_cec_ns = cg.esphome_ns.namespace("hdmi_cec") HDMICEC = hdmi_cec_ns.class_("HDMICEC", cg.Component) -_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"), -] +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) -_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 _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_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, - ] - ), +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, + } + ), + } ) -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]) +async def to_code(config): + if config[CONF_DECODE_MESSAGES]: + cg.add_define("HDMI_CEC_USE_DECODER") + + var = cg.new_Pvariable(config[CONF_ID]) 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)) + + cec_pin_ = await cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(cec_pin_)) + + 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, []): - _request_message_callback_slot() - await automation.build_callback_automation( - var, - "add_on_message_callback", - _MESSAGE_ARGS, + 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"), + ], conf, - when=_message_filter(conf), ) -automation.register_apply_action( +@automation.register_action( "hdmi_cec.send", - 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)), - ), + 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, ) +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 82e354dbae7..e78bd5ed6de 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.cpp +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -1,450 +1,719 @@ +#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" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" -#ifdef USE_ESP8266 -#include -#endif -#ifdef USE_RP2040 -#include +// 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" #endif namespace esphome::hdmi_cec { -ESPHOME_LOG_TAG(TAG, "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 -#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) +// 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(); #endif -// 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 - -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); + return offset; } void HDMICEC::setup() { - // 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(); + xmit_.setup(pin_); + recv_.setup(pin_, address_); } +void HDMICEC::set_pin(InternalGPIOPin *pin) { pin_ = pin; } + void HDMICEC::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_); + 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(); } void HDMICEC::loop() { - 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 (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(); } - // 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 - this->frames_queue_.pop_front(); + if (!xmit_.is_idle()) { + xmit_.transmit_message(); } - // 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(); + // Need more frequent service to handle pending workload? + if (!recv_.frames_queue_.is_empty() || !xmit_.is_idle()) { + fast_loop_.start(); + } else { + fast_loop_.stop(); } } -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 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(); -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; - } - 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 { - 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); - } - - 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_) { + 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; } - 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())); - } +#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 - // 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); - } - // Otherwise the acknowledge result arrives through the interrupt -} -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 + 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; } - ESP_LOGW(TAG, "Frame not sent after %u attempts, dropped", this->transmit_attempts_); } - this->transmit_attempts_ = 0; - this->send_queue_.pop_front(); + + // 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); + } } -bool HDMICEC::transmit_frame_(const Frame *frame) { - if (!this->send_start_bit_()) { +// 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::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]; + } + 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()}); + 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; } - 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; - } - } - // 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); + + 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 +#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()' return true; } -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_); +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 { + 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_); + } + } 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; + } + } + + 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 + 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()); + } +#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; + } + 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 +} + +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); + } + } + // 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; +} + +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_); return false; } - this->set_pin_output_low_(); + // The CEC line was not yet occupied (pulled low) by someone else + // 1. pull low for the start-bit duration + set_pin_output_low(); delay_microseconds_safe(START_BIT_NOM_US); - // Release the line and check that nobody else keeps it low - this->set_pin_input_high_(); + + // 2. let the line go high again, but test if no one else keeps it low + set_pin_input_high(); delay_microseconds_safe(START_BIT_HIGH_US / 2); - bool value = this->isr_pin_.digital_read(); + value &= pin_->digital_read(); delay_microseconds_safe(START_BIT_HIGH_US / 2); - value &= this->isr_pin_.digital_read(); + value &= pin_->digital_read(); + if (!value) { - ESP_LOGD(TAG, "Bus collision on sending start bit on attempt %u", this->transmit_attempts_); + ESP_LOGD(TAG, "Bus collision on sending start-bit on attempt %d", transmit_attempts_); } return value; } -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(); +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); } - const uint32_t elapsed = micros() - start_us; + bool value = pin_->digital_read(); + + // ... and sleep for the rest of the bit period + elapsed = micros() - start_us; if (elapsed < TOTAL_BIT_US) { delay_microseconds_safe(TOTAL_BIT_US - elapsed); } - return clear; + + // 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; } -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_(); -} - -#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; +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)); } - 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 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 - } - 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); - this->set_pin_input_high_(); -} +#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::gpio_isr_s(HDMICEC *self) { self->gpio_isr_(); } - -void IRAM_ATTR HDMICEC::gpio_isr_() { - const uint32_t now = micros(); - 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 - } - this->prev_pin_level_ = level; - atomic_set(this->last_edge_us_, now); - - if (!level) { - // 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_(); - } - 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; - } - - // 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) { - 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_); - - switch (this->receiver_state_) { - case ReceiverState::RECEIVER_STATE_IDLE: - break; - - 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_; - } - 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::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; - } - this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK; - break; - - 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_++; - } - } - 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; +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]); } } -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; +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; + } +#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(); + delay_microseconds_safe(LOW_BIT_US); + set_pin_input_high(); +} + +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 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_() { + 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; + } + prev_pin_level_ = level; + + // 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_(); + } + + 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; + } + 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_); + 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 + return; + } + + bool value = (pulse_duration <= HIGH_BIT_MAX_US); // short low pulse represents '1' + + 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_); + } + recv_bit_counter_ = 0; + recv_byte_buffer_ = 0; + receiver_state_ = ReceiverState::WAITING_FOR_EOM; // expect 9th bit of block: 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; + } + bool is_eom = (value == 1); + receiver_state_ = is_eom ? ReceiverState::WAITING_FOR_EOM_ACK : ReceiverState::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()' + } + reset_state_variables_(); + 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; } } // namespace esphome::hdmi_cec diff --git a/esphome/components/hdmi_cec/hdmi_cec.h b/esphome/components/hdmi_cec/hdmi_cec.h index d001a7ae23b..7085e1d77ef 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.h +++ b/esphome/components/hdmi_cec/hdmi_cec.h @@ -1,218 +1,335 @@ #pragma once #include +#include #include #include -#include -#include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/automation.h" -#ifdef USE_ESP32 -#include "driver/gptimer.h" -#endif -#ifdef USE_RP2040 -#include -#endif - -namespace esphome::hdmi_cec { - -// 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); +namespace esphome { +namespace uart { +class UARTComponent; } +namespace hdmi_cec { -/// 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 MessageTrigger; + +// 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) + +/** + * 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. + */ class Frame { public: - static constexpr size_t MAX_LENGTH = 16; // HDMI-CEC 1.4 - static constexpr size_t MAX_PAYLOAD_LENGTH = MAX_LENGTH - 1; - 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); + 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()) {} - 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; + 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; } - 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}; }; -/// 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"); - +/** + * 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: - 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; } + 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: - ESPHOME_ALWAYS_INLINE uint8_t count_() const { - return static_cast(atomic_get(this->back_inx_) - atomic_get(this->front_inx_)); + 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; } - 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)); + 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, + }; + + 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); } + + /** + * 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_(); - std::array store_{}; - std::atomic front_inx_{0}; - std::atomic back_inx_{0}; + 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}; }; -/// 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: - HDMICEC(InternalGPIOPin *pin, uint8_t address) : pin_(pin), address_(address) {} + 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; } void setup() override; void dump_config() override; void loop() override; - void set_promiscuous_mode(bool promiscuous) { this->promiscuous_mode_ = promiscuous; } - void set_monitor_mode(bool monitor_mode) { this->monitor_mode_ = monitor_mode; } -#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 + protected: + void try_builtin_handler_(uint8_t source, uint8_t destination, const std::vector &data); + void handle_received_message_(const Frame *frame); - /// 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())); + 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()); } protected: - 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 - - // 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); - 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_; -#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}; - - uint8_t address_; // logical address, 0xf when only listening - bool promiscuous_mode_{false}; - bool monitor_mode_{false}; + HDMICEC *parent_; }; -} // namespace esphome::hdmi_cec +} // namespace hdmi_cec +} // namespace esphome diff --git a/tests/components/hdmi_cec/common_no_uart.yaml b/tests/components/hdmi_cec/common_no_uart.yaml new file mode 100644 index 00000000000..3d091793c46 --- /dev/null +++ b/tests/components/hdmi_cec/common_no_uart.yaml @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000000..465d28c9781 --- /dev/null +++ b/tests/components/hdmi_cec/common_with_uart.yaml @@ -0,0 +1,31 @@ +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-with-uart.esp32-c3-idf.yaml b/tests/components/hdmi_cec/test-with-uart.esp32-c3-idf.yaml new file mode 100644 index 00000000000..2398e25ac60 --- /dev/null +++ b/tests/components/hdmi_cec/test-with-uart.esp32-c3-idf.yaml @@ -0,0 +1,6 @@ +# 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 new file mode 100644 index 00000000000..46e52553fe3 --- /dev/null +++ b/tests/components/hdmi_cec/test-with-uart.esp8266-ard.yaml @@ -0,0 +1,9 @@ +# 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