diff --git a/README.md b/README.md index 5c822bb..3c1618c 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,25 @@ The product payload decodes to: The exact-PID `tuya-local` profile maps DP132 color strings to white/red/orange/yellow/lime/cyan/blue/magenta and also uses the same DP for an effect field. Treat that as reference information until this hardware revision is tested directly. +## Using this from an ESPHome server + +Copy `server-example.yaml` to the server and add `wifi_ssid`, `wifi_password`, `ap_password` and `api_encryption_key` to its `secrets.yaml`. The patched UART in `components/uart` loads automatically, so the server's ESPHome install needs no patch. It's a copy from ESPHome 2026.10.2, so recheck it when updating ESPHome. To release a change, update `ref` in `wake-up-light-ii.yaml`, tag that commit, then update `ref` in the server's YAML. + +## Corrections from Tuya's data model + +Tuya's data model for this family was posted in [tuya-local issue #3821](https://github.com/make-all/tuya-local/issues/3821) for the sibling product `rslo8vqc1xjtaoy9`. It matches what this unit reports, and it replaces the older notes below where they differ. + +- **DP127** is 48 bytes on the unit tested here: 12 bytes per alarm, 4 alarms. Logs cut it off at 16 bytes, which likely explains the older 16-byte notes. Bytes: 0 enabled; 1-2 minutes since midnight; 3 repeat days (bit 0 = Monday, 0 = one time); 4 ring duration; 5 mode (0 sound, 1 sound + light, 2 radio, 3 radio + light); 6 sound; 7 volume; 8 radio station; 9 sunrise minutes; 10 brightness after sunrise; 11 snooze (0 allowed, 1 not). +- **DP128** is 10 bytes: 0 unused; 1 mode, as above; 2 sound 1-4; 3 volume; 4 sound duration; 5 radio station; 6 radio duration; 7 start brightness; 8 sunset duration; 9 snooze. Bytes 4, 6 and 8 should match. +- **DP107** is stations found by a scan, then the current station. **DP126** starts (1) or ends (0) a channel search. +- **DP117** and **DP132** are enums on this unit, so values count from 0. DP132 runs cycle, red, orange, yellow, green, cyan, blue, purple. +- **DP125** is online time synchronization. **DP130** is snooze. + +Observed behaviour on this unit: a mode without light sets brightness to 0, and a light mode with brightness 0 falls back to no light. Alarm stations were reset to 1 while no stations had been scanned. DP127 writes are ignored while an alarm is snoozed. DP130 stays on after snoozing a ringing alarm and returns to off at once otherwise. Default light transitions flood the MCU with writes, so keep them at 0. + ## DP127 alarm structure -DP127 is 16 bytes on this unit. The safest implementation is to **cache the MCU-provided packet and modify only decoded bytes**. +Older notes, see the corrections above. DP127 is 16 bytes on this unit. The safest implementation is to **cache the MCU-provided packet and modify only decoded bytes**. | Byte(s) | Meaning | Confidence | |---|---|---| diff --git a/alarm_block.yaml b/alarm_block.yaml new file mode 100644 index 0000000..194a2e4 --- /dev/null +++ b/alarm_block.yaml @@ -0,0 +1,260 @@ +############################################################################### +# One alarm's controls. Included once per alarm from wake-up-light-ii.yaml. +# n = alarm number +# o = offset of this alarm's 12-byte block inside DP127 +# dp = this alarm's on/off datapoint +# Byte layout is documented next to the DP127 handler in the main file. +############################################################################### + +# Bit 0 = Monday, per Tuya's definition and a ringing test. No days set = one time. +packages: + mon: !include { file: alarm_day.yaml, vars: { day: "Mon", bit: "0", w: "11" } } + tue: !include { file: alarm_day.yaml, vars: { day: "Tue", bit: "1", w: "12" } } + wed: !include { file: alarm_day.yaml, vars: { day: "Wed", bit: "2", w: "13" } } + thu: !include { file: alarm_day.yaml, vars: { day: "Thu", bit: "3", w: "14" } } + fri: !include { file: alarm_day.yaml, vars: { day: "Fri", bit: "4", w: "15" } } + sat: !include { file: alarm_day.yaml, vars: { day: "Sat", bit: "5", w: "16" } } + sun: !include { file: alarm_day.yaml, vars: { day: "Sun", bit: "6", w: "17" } } + +web_server: + sorting_groups: + - id: group_alarm_${n} + name: "Alarm ${n}" + sorting_weight: 3${n} + +switch: + - platform: tuya + id: alarm_${n}_enable + name: "Alarm ${n}" + switch_datapoint: ${dp} + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 1 + + # Stored inverted: 0 = snooze allowed, 1 = not allowed. + - platform: template + id: alarm_${n}_snooze_allowed + name: "Alarm ${n} Snooze Allowed" + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 10 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return id(alarm_raw)[${o} + 11] == 0; + turn_on_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 11] = 0; + - script.execute: send_alarm_settings + turn_off_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 11] = 1; + - script.execute: send_alarm_settings + +datetime: + - platform: template + id: alarm_${n}_time + name: "Alarm ${n} Time" + type: time + update_interval: 2s + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 2 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + uint16_t mins = (static_cast(id(alarm_raw)[${o} + 1]) << 8) | id(alarm_raw)[${o} + 2]; + if (mins >= 1440) return {}; + ESPTime t{}; + t.hour = mins / 60; + t.minute = mins % 60; + t.second = 0; + return t; + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + uint16_t mins = static_cast(x.hour) * 60 + x.minute; + id(alarm_raw)[${o} + 1] = (mins >> 8) & 0xFF; + id(alarm_raw)[${o} + 2] = mins & 0xFF; + - script.execute: send_alarm_settings + +select: + - platform: template + id: alarm_${n}_mode + name: "Alarm ${n} Mode" + options: + - "Sound" + - "Sound + Light" + - "Radio" + - "Radio + Light" + update_interval: 2s + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 2.5 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + switch (id(alarm_raw)[${o} + 5]) { + case 0: return std::string("Sound"); + case 1: return std::string("Sound + Light"); + case 2: return std::string("Radio"); + case 3: return std::string("Radio + Light"); + default: return {}; + } + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + uint8_t value = 0; + if (x == "Sound + Light") value = 1; + else if (x == "Radio") value = 2; + else if (x == "Radio + Light") value = 3; + id(alarm_raw)[${o} + 5] = value; + // The MCU drops a light mode if brightness is 0, so give it a level. + if ((value == 1 || value == 3) && id(alarm_raw)[${o} + 10] == 0) id(alarm_raw)[${o} + 10] = 10; + - script.execute: send_alarm_settings + + - platform: template + id: alarm_${n}_sound + name: "Alarm ${n} Sound" + options: + - "Off" + - "Sound 1" + - "Sound 2" + - "Sound 3" + - "Sound 4" + - "Sound 5" + - "Sound 6" + - "Sound 7" + - "FM Radio" + update_interval: 2s + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 3 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + uint8_t s = id(alarm_raw)[${o} + 6]; + if (s == 0) return std::string("Off"); + if (s == 8) return std::string("FM Radio"); + if (s >= 1 && s <= 7) return std::string("Sound ") + std::to_string(s); + return {}; + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + uint8_t value = 0; + if (x == "Sound 1") value = 1; + else if (x == "Sound 2") value = 2; + else if (x == "Sound 3") value = 3; + else if (x == "Sound 4") value = 4; + else if (x == "Sound 5") value = 5; + else if (x == "Sound 6") value = 6; + else if (x == "Sound 7") value = 7; + else if (x == "FM Radio") value = 8; + id(alarm_raw)[${o} + 6] = value; + - script.execute: send_alarm_settings + +number: + - platform: template + id: alarm_${n}_volume + name: "Alarm ${n} Volume" + min_value: 1 + max_value: 16 + step: 1 + update_interval: 2s + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 4 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return static_cast(id(alarm_raw)[${o} + 7]); + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 7] = static_cast(x); + - script.execute: send_alarm_settings + + - platform: template + id: alarm_${n}_wake_brightness + name: "Alarm ${n} Wake Light" + min_value: 1 + max_value: 20 + step: 1 + update_interval: 2s + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 5 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return static_cast(id(alarm_raw)[${o} + 10]); + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 10] = static_cast(x); + - script.execute: send_alarm_settings + + # Tuya's definition says 10-20, default 15, but this lamp reports 30. + - platform: template + id: alarm_${n}_ring_duration + name: "Alarm ${n} Ring Duration" + unit_of_measurement: "min" + min_value: 10 + max_value: 60 + step: 5 + update_interval: 2s + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 8 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return static_cast(id(alarm_raw)[${o} + 4]); + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 4] = static_cast(x); + - script.execute: send_alarm_settings + + - platform: template + id: alarm_${n}_radio_station + name: "Alarm ${n} Radio Station" + min_value: 1 + max_value: 60 + step: 1 + update_interval: 2s + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 9 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return static_cast(id(alarm_raw)[${o} + 8]); + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 8] = static_cast(x); + - script.execute: send_alarm_settings + + - platform: template + id: alarm_${n}_sunrise_duration + name: "Alarm ${n} Sunrise Duration" + unit_of_measurement: "min" + min_value: 10 + max_value: 60 + step: 10 + update_interval: 2s + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 6 + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return static_cast(id(alarm_raw)[${o} + 9]); + set_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 9] = static_cast(x); + - script.execute: send_alarm_settings + +text_sensor: + - platform: template + id: alarm_${n}_summary + name: "Alarm ${n} Summary" + update_interval: never + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: 7 diff --git a/alarm_day.yaml b/alarm_day.yaml new file mode 100644 index 0000000..09380b6 --- /dev/null +++ b/alarm_day.yaml @@ -0,0 +1,27 @@ +############################################################################### +# One weekday switch for one alarm. Included per day from alarm_block.yaml. +# n, o come from alarm_block.yaml; day = label, bit = bit in the weekday mask, +# w = sort position in the web UI. +############################################################################### + +switch: + - platform: template + id: alarm_${n}_day_${bit} + name: "Alarm ${n} ${day}" + icon: "mdi:calendar" + web_server: + sorting_group_id: group_alarm_${n} + sorting_weight: ${w} + lambda: |- + if (!id(alarm_raw_valid)) return {}; + return (id(alarm_raw)[${o} + 3] >> ${bit}) & 1; + turn_on_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 3] |= (1 << ${bit}); + - script.execute: send_alarm_settings + turn_off_action: + - lambda: |- + if (!id(alarm_raw_valid)) return; + id(alarm_raw)[${o} + 3] &= ~(1 << ${bit}); + - script.execute: send_alarm_settings diff --git a/components/uart/__init__.py b/components/uart/__init__.py new file mode 100644 index 0000000..25fbfb7 --- /dev/null +++ b/components/uart/__init__.py @@ -0,0 +1,611 @@ +from logging import getLogger +import math +import re + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + variant_filtered_enum, +) +from esphome.config_helpers import ( + filter_source_files_from_defines, + filter_source_files_from_platform, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_AFTER, + CONF_BAUD_RATE, + CONF_BYTES, + CONF_DATA, + CONF_DEBUG, + CONF_DELIMITER, + CONF_DIRECTION, + CONF_DUMMY_RECEIVER, + CONF_DUMMY_RECEIVER_ID, + CONF_FLOW_CONTROL_PIN, + CONF_ID, + CONF_LAMBDA, + CONF_NUMBER, + CONF_PORT, + CONF_RX_BUFFER_SIZE, + CONF_RX_PIN, + CONF_SEQUENCE, + CONF_TIMEOUT, + CONF_TRIGGER_ID, + CONF_TX_BUFFER_SIZE, + CONF_TX_PIN, + CONF_UART_ID, + PLATFORM_HOST, + PlatformFramework, +) +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +import esphome.final_validate as fv +from esphome.yaml_util import make_data_base + +_LOGGER = getLogger(__name__) + +CODEOWNERS = ["@esphome/core"] +DOMAIN = "uart" + + +uart_ns = cg.esphome_ns.namespace("uart") +UARTComponent = uart_ns.class_("UARTComponent") + +IDFUARTComponent = uart_ns.class_("IDFUARTComponent", UARTComponent, cg.Component) +ESP8266UartComponent = uart_ns.class_( + "ESP8266UartComponent", UARTComponent, cg.Component +) +RP2UartComponent = uart_ns.class_("RP2UartComponent", UARTComponent, cg.Component) +LibreTinyUARTComponent = uart_ns.class_( + "LibreTinyUARTComponent", UARTComponent, cg.Component +) +HostUartComponent = uart_ns.class_("HostUartComponent", UARTComponent, cg.Component) + + +NATIVE_UART_CLASSES = ( + str(IDFUARTComponent), + str(ESP8266UartComponent), + str(RP2UartComponent), + str(LibreTinyUARTComponent), +) + +HOST_BAUD_RATES = [ + 50, + 75, + 110, + 134, + 150, + 200, + 300, + 600, + 1200, + 1800, + 2400, + 4800, + 9600, + 19200, + 38400, + 57600, + 115200, + 230400, + 460800, + 500000, + 576000, + 921600, + 1000000, + 1152000, + 1500000, + 2000000, + 2500000, + 3000000, + 3500000, + 4000000, +] + +UARTDevice = uart_ns.class_("UARTDevice") +UARTWriteAction = uart_ns.class_("UARTWriteAction", automation.Action) +UARTDebugger = uart_ns.class_("UARTDebugger", cg.Component, automation.Action) +UARTDummyReceiver = uart_ns.class_("UARTDummyReceiver", cg.Component) +MULTI_CONF = True +MULTI_CONF_NO_DEFAULT = True + + +def validate_raw_data(value): + if isinstance(value, str): + return value.encode("utf-8") + if isinstance(value, str): + return value + if isinstance(value, list): + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + "data must either be a string wrapped in quotes or a list of bytes" + ) + + +def validate_rx_pin(value): + value = pins.internal_gpio_input_pin_schema(value) + if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: + raise cv.Invalid("Pins GPIO16 and GPIO17 cannot be used as RX pins on ESP8266.") + return value + + +def validate_host_config(config): + if CORE.is_host: + if CONF_TX_PIN in config or CONF_RX_PIN in config: + raise cv.Invalid( + "TX and RX pins are not supported for UART on host platform." + ) + if config[CONF_BAUD_RATE] not in HOST_BAUD_RATES: + raise cv.Invalid( + f"Host platform doesn't support baud rate {config[CONF_BAUD_RATE]}", + path=[CONF_BAUD_RATE], + ) + return config + + +def validate_rx_buffer_size(config): + if CORE.is_esp32: + # ESP32 UART hardware FIFO is 128 bytes (LP UART is 16 bytes, but we use 128 as safe minimum) + # rx_buffer_size must be greater than the hardware FIFO length + min_buffer_size = 128 + if config[CONF_RX_BUFFER_SIZE] <= min_buffer_size: + _LOGGER.warning( + "UART rx_buffer_size (%d bytes) is too small and must be greater than the hardware " + "FIFO size (%d bytes). The buffer size will be automatically adjusted at runtime.", + config[CONF_RX_BUFFER_SIZE], + min_buffer_size, + ) + return config + + +def _uart_declare_type(value): + if CORE.is_esp8266: + return cv.declare_id(ESP8266UartComponent)(value) + if CORE.is_esp32: + return cv.declare_id(IDFUARTComponent)(value) + if CORE.is_rp2: + return cv.declare_id(RP2UartComponent)(value) + if CORE.is_libretiny: + return cv.declare_id(LibreTinyUARTComponent)(value) + if CORE.is_host: + return cv.declare_id(HostUartComponent)(value) + raise NotImplementedError + + +UARTParityOptions = uart_ns.enum("UARTParityOptions") +UART_PARITY_OPTIONS = { + "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, + "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, + "ODD": UARTParityOptions.UART_CONFIG_PARITY_ODD, +} + +CONF_FLUSH_TIMEOUT = "flush_timeout" +CONF_RX_FULL_THRESHOLD = "rx_full_threshold" +CONF_RX_TIMEOUT = "rx_timeout" +CONF_CLOCK_SOURCE = "clock_source" + +UARTClockSource = cg.global_ns.enum("uart_sclk_t") +UART_CLOCK_SOURCES = { + "DEFAULT": UARTClockSource.UART_SCLK_DEFAULT, + "APB": UARTClockSource.UART_SCLK_APB, + "XTAL": UARTClockSource.UART_SCLK_XTAL, + "RTC": UARTClockSource.UART_SCLK_RTC, + "REF_TICK": UARTClockSource.UART_SCLK_REF_TICK, +} + +# Keep in sync with SOC_UART_SUPPORT_* in ESP-IDF's per-variant soc_caps.h. +UART_CLOCK_SOURCES_BY_VARIANT = { + VARIANT_ESP32: ["DEFAULT", "APB", "REF_TICK"], + VARIANT_ESP32C2: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C3: ["DEFAULT", "APB", "XTAL", "RTC"], + VARIANT_ESP32C5: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C6: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C61: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H2: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H4: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H21: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32P4: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32S2: ["DEFAULT", "APB", "REF_TICK"], + VARIANT_ESP32S3: ["DEFAULT", "APB", "XTAL", "RTC"], + VARIANT_ESP32S31: ["DEFAULT", "XTAL", "RTC"], +} + +UARTDirection = uart_ns.enum("UARTDirection") +UART_DIRECTIONS = { + "RX": UARTDirection.UART_DIRECTION_RX, + "TX": UARTDirection.UART_DIRECTION_TX, + "BOTH": UARTDirection.UART_DIRECTION_BOTH, +} + +# The reason for having CONF_BYTES at 150 by default: +# +# The log message buffer size is 512 bytes by default. About 35 bytes are +# used for the log prefix. That leaves us with 477 bytes for logging data. +# The default log output is hex, which uses 3 characters per represented +# byte (2 hex chars + 1 separator). That means that 477 / 3 = 159 bytes +# can be represented in a single log line. Using 150, because people love +# round numbers. +AFTER_DEFAULTS = {CONF_BYTES: 150, CONF_TIMEOUT: "100ms"} + +CONF_DEBUG_PREFIX = "debug_prefix" + +# By default, log in hex format when no specific sequence is provided. +DEFAULT_DEBUG_OUTPUT = "UARTDebug::log_hex(direction, bytes, ':', debug_prefix);" +DEFAULT_SEQUENCE = [{CONF_LAMBDA: make_data_base(DEFAULT_DEBUG_OUTPUT)}] + + +def maybe_empty_debug(value): + if value is None: + value = {} + return DEBUG_SCHEMA(value) + + +def validate_port(value): + if not re.match(r"^/(?:[^/]+/)[^/]+$", value): + raise cv.Invalid("Port must be a valid device path") + return value + + +DEBUG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UARTDebugger), + cv.Optional(CONF_DIRECTION, default="BOTH"): cv.enum( + UART_DIRECTIONS, upper=True + ), + cv.Optional(CONF_AFTER, default=AFTER_DEFAULTS): cv.Schema( + { + cv.Optional( + CONF_BYTES, default=AFTER_DEFAULTS[CONF_BYTES] + ): cv.validate_bytes, + cv.Optional( + CONF_TIMEOUT, default=AFTER_DEFAULTS[CONF_TIMEOUT] + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_DELIMITER): cv.templatable(validate_raw_data), + } + ), + cv.Optional( + CONF_SEQUENCE, default=DEFAULT_SEQUENCE + ): automation.validate_automation(), + cv.Optional(CONF_DUMMY_RECEIVER, default=False): cv.boolean, + cv.GenerateID(CONF_DUMMY_RECEIVER_ID): cv.declare_id(UARTDummyReceiver), + cv.Optional(CONF_DEBUG_PREFIX, default=""): cv.string, + } +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): _uart_declare_type, + cv.Required(CONF_BAUD_RATE): cv.int_range(min=1), + cv.Optional(CONF_TX_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_RX_PIN): validate_rx_pin, + cv.Optional(CONF_FLOW_CONTROL_PIN): cv.All( + cv.only_on_esp32, pins.internal_gpio_output_pin_schema + ), + cv.Optional(CONF_PORT): cv.All(validate_port, cv.only_on(PLATFORM_HOST)), + cv.Optional(CONF_RX_BUFFER_SIZE, default=256): cv.validate_bytes, + cv.Optional(CONF_TX_BUFFER_SIZE): cv.All( + cv.only_on_esp32, cv.validate_bytes, cv.int_range(min=129) + ), + cv.Optional(CONF_RX_FULL_THRESHOLD): cv.All( + cv.only_on_esp32, cv.validate_bytes, cv.int_range(min=1, max=120) + ), + cv.SplitDefault(CONF_RX_TIMEOUT, esp32=2): cv.All( + cv.only_on_esp32, cv.validate_bytes, cv.int_range(min=0, max=92) + ), + cv.Optional(CONF_FLUSH_TIMEOUT): cv.All( + cv.only_on_esp32, cv.positive_time_period_milliseconds + ), + cv.Optional(CONF_CLOCK_SOURCE): cv.All( + cv.only_on_esp32, + variant_filtered_enum(UART_CLOCK_SOURCES_BY_VARIANT, upper=True), + ), + cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), + cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), + cv.Optional(CONF_PARITY, default="NONE"): cv.enum( + UART_PARITY_OPTIONS, upper=True + ), + cv.Optional(CONF_DEBUG): maybe_empty_debug, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.has_at_least_one_key(CONF_TX_PIN, CONF_RX_PIN, CONF_PORT), + validate_host_config, + validate_rx_buffer_size, +) + + +async def debug_to_code(config, parent): + trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], parent) + await cg.register_component(trigger, config) + for action in config[CONF_SEQUENCE]: + await automation.build_automation( + trigger, + [ + (UARTDirection, "direction"), + (cg.std_vector.template(cg.uint8), "bytes"), + (cg.StringRef, "debug_prefix"), + ], + action, + ) + cg.add(trigger.set_direction(config[CONF_DIRECTION])) + after = config[CONF_AFTER] + cg.add(trigger.set_after_bytes(after[CONF_BYTES])) + cg.add(trigger.set_after_timeout(after[CONF_TIMEOUT])) + if CONF_DELIMITER in after: + data = after[CONF_DELIMITER] + if isinstance(data, bytes): + data = list(data) + for byte in after[CONF_DELIMITER]: + cg.add(trigger.add_delimiter_byte(byte)) + if config[CONF_DUMMY_RECEIVER]: + dummy = cg.new_Pvariable(config[CONF_DUMMY_RECEIVER_ID], parent) + await cg.register_component(dummy, {}) + if debug_prefix := config[CONF_DEBUG_PREFIX]: + cg.add(trigger.set_debug_prefix(debug_prefix)) + cg.add_define("USE_UART_DEBUGGER") + + +async def to_code(config): + cg.add_global(uart_ns.using) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) + + if CONF_TX_PIN in config: + tx_pin = await cg.gpio_pin_expression(config[CONF_TX_PIN]) + cg.add(var.set_tx_pin(tx_pin)) + if CONF_RX_PIN in config: + rx_pin = await cg.gpio_pin_expression(config[CONF_RX_PIN]) + cg.add(var.set_rx_pin(rx_pin)) + if CONF_FLOW_CONTROL_PIN in config: + flow_control_pin = await cg.gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) + cg.add(var.set_flow_control_pin(flow_control_pin)) + if CONF_PORT in config: + cg.add(var.set_name(config[CONF_PORT])) + cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) + if CORE.is_esp32: + if CONF_RX_FULL_THRESHOLD not in config: + # Calculate rx_full_threshold to be 10ms + bytelength = config[CONF_DATA_BITS] + config[CONF_STOP_BITS] + 1 + if config[CONF_PARITY] != "NONE": + bytelength += 1 + config[CONF_RX_FULL_THRESHOLD] = max( + 1, + min( + 120, + math.floor((config[CONF_BAUD_RATE] / (bytelength * 1000 / 10)) - 1), + ), + ) + cg.add(var.set_rx_full_threshold(config[CONF_RX_FULL_THRESHOLD])) + cg.add(var.set_rx_timeout(config[CONF_RX_TIMEOUT])) + if CONF_FLUSH_TIMEOUT in config: + cg.add(var.set_flush_timeout(config[CONF_FLUSH_TIMEOUT])) + if (tx_buffer_size := config.get(CONF_TX_BUFFER_SIZE)) is not None: + cg.add(var.set_tx_buffer_size(tx_buffer_size)) + # The member already defaults to UART_SCLK_DEFAULT, so only emit a real choice + if (clock_source := config.get(CONF_CLOCK_SOURCE, "DEFAULT")) != "DEFAULT": + cg.add(var.set_clock_source(UART_CLOCK_SOURCES[clock_source])) + cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) + cg.add(var.set_data_bits(config[CONF_DATA_BITS])) + # Skip the setter when the config matches the C++ initializer (UART_CONFIG_PARITY_NONE). + if (parity := config[CONF_PARITY]) != "NONE": + cg.add(var.set_parity(parity)) + + if CONF_DEBUG in config: + await debug_to_code(config[CONF_DEBUG], var) + + # ESP8266: Enable the Arduino Serial objects that might be used based on pin config + # The C++ code selects hardware serial at runtime based on these pin combinations: + # - Serial (UART0): TX=1 or null, RX=3 or null + # - Serial (UART0 swap): TX=15 or null, RX=13 or null + # - Serial1: TX=2 or null, RX=8 or null + if CORE.is_esp8266: + from esphome.components.esp8266.const import enable_serial, enable_serial1 + + tx_num = config[CONF_TX_PIN][CONF_NUMBER] if CONF_TX_PIN in config else None + rx_num = config[CONF_RX_PIN][CONF_NUMBER] if CONF_RX_PIN in config else None + + # Check if this config could use Serial (UART0 regular or swap) + if (tx_num is None or tx_num in (1, 15)) and ( + rx_num is None or rx_num in (3, 13) + ): + enable_serial() + cg.add_define("USE_ESP8266_UART_SERIAL") + # Check if this config could use Serial1 + if (tx_num is None or tx_num == 2) and (rx_num is None or rx_num == 8): + enable_serial1() + cg.add_define("USE_ESP8266_UART_SERIAL1") + + CORE.add_job(final_step) + + +# A schema to use for all UART devices, all UART integrations must extend this! +UART_DEVICE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_UART_ID): cv.use_id(UARTComponent), + } +) + +KEY_UART_DEVICES = "uart_devices" + + +def final_validate_device_schema( + name: str, + *, + uart_bus: str = CONF_UART_ID, + baud_rate: int | None = None, + require_tx: bool = False, + require_rx: bool = False, + data_bits: int | None = None, + parity: str | None = None, + stop_bits: int | None = None, +): + def validate_baud_rate(value): + if value != baud_rate: + raise cv.Invalid( + f"Component {name} requires baud rate {baud_rate} for the uart referenced by {uart_bus}" + ) + return value + + def validate_pin(opt, device): + def validator(value): + if opt in device and not CORE.testing_mode: + raise cv.Invalid( + f"The uart {opt} is used both by {name} and {device[opt]}, " + f"but can only be used by one. Please create a new uart bus for {name}." + ) + device[opt] = name + return value + + return validator + + def validate_data_bits(value): + if value != data_bits: + raise cv.Invalid( + f"Component {name} requires {data_bits} data bits for the uart referenced by {uart_bus}" + ) + return value + + def validate_parity(value): + if value != parity: + raise cv.Invalid( + f"Component {name} requires parity {parity} for the uart referenced by {uart_bus}" + ) + return value + + def validate_stop_bits(value): + # usb_uart channels store stop bits as strings ("1", "1.5", "2"). + if float(value) != stop_bits: + raise cv.Invalid( + f"Component {name} requires {stop_bits} stop bits for the uart referenced by {uart_bus}" + ) + return value + + def validate_hub(hub_config): + hub_schema = {} + uart_id = hub_config[CONF_ID] + uart_id_type_str = str(uart_id.type) + devices = fv.full_config.get().data.setdefault(KEY_UART_DEVICES, {}) + device = devices.setdefault(uart_id, {}) + + if require_tx and uart_id_type_str in NATIVE_UART_CLASSES: + hub_schema[ + cv.Required( + CONF_TX_PIN, + msg=f"Component {name} requires uart referenced by {uart_bus} to declare a tx_pin", + ) + ] = validate_pin(CONF_TX_PIN, device) + if require_rx and uart_id_type_str in NATIVE_UART_CLASSES: + hub_schema[ + cv.Required( + CONF_RX_PIN, + msg=f"Component {name} requires uart referenced by {uart_bus} to declare a rx_pin", + ) + ] = validate_pin(CONF_RX_PIN, device) + if baud_rate is not None: + hub_schema[cv.Required(CONF_BAUD_RATE)] = validate_baud_rate + if data_bits is not None: + hub_schema[cv.Required(CONF_DATA_BITS)] = validate_data_bits + if parity is not None: + hub_schema[cv.Required(CONF_PARITY)] = validate_parity + if stop_bits is not None: + hub_schema[cv.Required(CONF_STOP_BITS)] = validate_stop_bits + return cv.Schema(hub_schema, extra=cv.ALLOW_EXTRA)(hub_config) + + return cv.Schema( + {cv.Required(uart_bus): fv.id_declaration_match_schema(validate_hub)}, + extra=cv.ALLOW_EXTRA, + ) + + +async def register_uart_device(var, config): + """Register a UART device, setting up all the internal values. + + This is a coroutine, you need to await it with an 'await' expression! + """ + parent = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_uart_parent(parent)) + + +@automation.register_action( + "uart.write", + UARTWriteAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(UARTComponent), + cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + }, + key=CONF_DATA, + ), + synchronous=True, +) +async def uart_write_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + data = config[CONF_DATA] + if isinstance(data, bytes): + data = list(data) + + if cg.is_template(data): + templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) + cg.add(var.set_data_template(templ)) + else: + # Generate static array in flash to avoid RAM copy + arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) + cg.add(var.set_data_static(arr, len(data))) + return var + + +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """Final code generation step to configure optional UART features.""" + if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking: + # Wake-on-RX is essentially free (just an ISR function pointer + # registration on ESP32, an inline flag set on ESP8266 software + # serial) — enable by default to reduce RX buffer overflow risk by + # waking the main loop immediately when data arrives. + cg.add_define("USE_UART_WAKE_LOOP_ON_RX") + + +_platform_filter = filter_source_files_from_platform( + { + "uart_component_esp_idf.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, + "uart_component_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, + "uart_component_host.cpp": {PlatformFramework.HOST_NATIVE}, + "uart_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, + "uart_component_libretiny.cpp": { + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + } +) + +# uart_debugger.cpp is fully #ifdef'd on USE_UART_DEBUGGER, set only when a +# debug block is configured. +_define_filter = filter_source_files_from_defines( + {"uart_debugger.cpp": "USE_UART_DEBUGGER"} +) + + +def FILTER_SOURCE_FILES() -> list[str]: + return _platform_filter() + _define_filter() diff --git a/components/uart/automation.h b/components/uart/automation.h new file mode 100644 index 0000000..e5a9fa7 --- /dev/null +++ b/components/uart/automation.h @@ -0,0 +1,43 @@ +#pragma once + +#include "uart.h" +#include "esphome/core/automation.h" + +#include + +namespace esphome::uart { + +template class UARTWriteAction final : public Action, public Parented { + public: + void set_data_template(std::vector (*func)(Ts...)) { + // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers + this->code_.func = func; + this->len_ = -1; // Sentinel value indicates template mode + } + + // Store pointer to static data in flash (no RAM copy) + void set_data_static(const uint8_t *data, size_t len) { + this->code_.data = data; + this->len_ = len; // Length >= 0 indicates static mode + } + + void play(const Ts &...x) override { + if (this->len_ >= 0) { + // Static mode: use pointer and length + this->parent_->write_array(this->code_.data, static_cast(this->len_)); + } else { + // Template mode: call function + auto val = this->code_.func(x...); + this->parent_->write_array(val); + } + } + + protected: + ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length + union Code { + std::vector (*func)(Ts...); // Function pointer (stateless lambdas) + const uint8_t *data; // Pointer to static data in flash + } code_; +}; + +} // namespace esphome::uart diff --git a/components/uart/button/__init__.py b/components/uart/button/__init__.py new file mode 100644 index 0000000..95fe212 --- /dev/null +++ b/components/uart/button/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import button, uart +import esphome.config_validation as cv +from esphome.const import CONF_DATA +from esphome.core import HexInt + +from .. import uart_ns, validate_raw_data + +CODEOWNERS = ["@ssieb"] + +DEPENDENCIES = ["uart"] + +UARTButton = uart_ns.class_("UARTButton", button.Button, uart.UARTDevice, cg.Component) + + +CONFIG_SCHEMA = ( + button.button_schema(UARTButton) + .extend( + { + cv.Required(CONF_DATA): validate_raw_data, + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await button.new_button(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + data = config[CONF_DATA] + if isinstance(data, bytes): + data = [HexInt(x) for x in data] + cg.add(var.set_data(cg.ArrayInitializer(*data))) diff --git a/components/uart/button/uart_button.cpp b/components/uart/button/uart_button.cpp new file mode 100644 index 0000000..809ceaa --- /dev/null +++ b/components/uart/button/uart_button.cpp @@ -0,0 +1,15 @@ +#include "uart_button.h" +#include "esphome/core/log.h" + +namespace esphome::uart { + +static const char *const TAG = "uart.button"; + +void UARTButton::press_action() { + ESP_LOGD(TAG, "'%s': Sending data", this->get_name().c_str()); + this->write_array(this->data_.data(), this->data_.size()); +} + +void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } + +} // namespace esphome::uart diff --git a/components/uart/button/uart_button.h b/components/uart/button/uart_button.h new file mode 100644 index 0000000..47f45d4 --- /dev/null +++ b/components/uart/button/uart_button.h @@ -0,0 +1,23 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/button/button.h" + +#include + +namespace esphome::uart { + +class UARTButton final : public button::Button, public UARTDevice, public Component { + public: + void set_data(std::vector &&data) { this->data_ = std::move(data); } + void set_data(std::initializer_list data) { this->data_ = std::vector(data); } + + void dump_config() override; + + protected: + void press_action() override; + std::vector data_; +}; + +} // namespace esphome::uart diff --git a/components/uart/event/__init__.py b/components/uart/event/__init__.py new file mode 100644 index 0000000..64af318 --- /dev/null +++ b/components/uart/event/__init__.py @@ -0,0 +1,90 @@ +import esphome.codegen as cg +from esphome.components import event, uart +import esphome.config_validation as cv +from esphome.const import CONF_EVENT_TYPES, CONF_ID +from esphome.core import ID +from esphome.types import ConfigType + +from .. import uart_ns + +CODEOWNERS = ["@eoasmxd"] + +DEPENDENCIES = ["uart"] + +UARTEvent = uart_ns.class_("UARTEvent", event.Event, uart.UARTDevice, cg.Component) + + +def validate_event_types(value) -> list[tuple[str, str | list[int]]]: + if not isinstance(value, list): + raise cv.Invalid("Event type must be a list of key-value mappings.") + + processed: list[tuple[str, str | list[int]]] = [] + for item in value: + if not isinstance(item, dict): + raise cv.Invalid(f"Event type item must be a mapping (dictionary): {item}") + if len(item) != 1: + raise cv.Invalid( + f"Event type item must be a single key-value mapping: {item}" + ) + + # Get the single key-value pair + event_name, match_data = next(iter(item.items())) + + if not isinstance(event_name, str): + raise cv.Invalid(f"Event name (key) must be a string: {event_name}") + + try: + # Try to validate as list of hex bytes + match_data_bin = cv.ensure_list(cv.hex_uint8_t)(match_data) + processed.append((event_name, match_data_bin)) + continue + except cv.Invalid: + pass # Not binary, try string + + try: + # Try to validate as string + match_data_str = cv.string_strict(match_data) + processed.append((event_name, match_data_str)) + continue + except cv.Invalid: + pass # Not string either + + # If neither validation passed + raise cv.Invalid( + f"Event match data for '{event_name}' must be a string or a list of hex bytes. Invalid data: {match_data}" + ) + + if not processed: + raise cv.Invalid("event_types must contain at least one event mapping.") + + return processed + + +CONFIG_SCHEMA = ( + event.event_schema(UARTEvent) + .extend( + { + cv.Required(CONF_EVENT_TYPES): validate_event_types, + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + event_names = [item[0] for item in config[CONF_EVENT_TYPES]] + var = await event.new_event(config, event_types=event_names) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + for i, (event_name, match_data) in enumerate(config[CONF_EVENT_TYPES]): + if isinstance(match_data, str): + match_data = [ord(c) for c in match_data] + + match_data_var_id = ID( + f"match_data_{config[CONF_ID]}_{i}", is_declaration=True, type=cg.uint8 + ) + match_data_var = cg.static_const_array( + match_data_var_id, cg.ArrayInitializer(*match_data) + ) + cg.add(var.add_event_matcher(event_name, match_data_var, len(match_data))) diff --git a/components/uart/event/uart_event.cpp b/components/uart/event/uart_event.cpp new file mode 100644 index 0000000..02c5f2e --- /dev/null +++ b/components/uart/event/uart_event.cpp @@ -0,0 +1,48 @@ +#include "uart_event.h" +#include "esphome/core/log.h" +#include + +namespace esphome::uart { + +static const char *const TAG = "uart.event"; + +void UARTEvent::setup() {} + +void UARTEvent::dump_config() { LOG_EVENT("", "UART Event", this); } + +void UARTEvent::loop() { this->read_data_(); } + +void UARTEvent::add_event_matcher(const char *event_name, const uint8_t *match_data, size_t match_data_len) { + this->matchers_.push_back({event_name, match_data, match_data_len}); + if (match_data_len > this->max_matcher_len_) { + this->max_matcher_len_ = match_data_len; + } +} + +void UARTEvent::read_data_() { + while (this->available()) { + uint8_t data; + this->read_byte(&data); + this->buffer_.push_back(data); + + bool match_found = false; + for (const auto &matcher : this->matchers_) { + if (this->buffer_.size() < matcher.data_len) { + continue; + } + + if (std::equal(matcher.data, matcher.data + matcher.data_len, this->buffer_.end() - matcher.data_len)) { + this->trigger(matcher.event_name); + this->buffer_.clear(); + match_found = true; + break; + } + } + + if (!match_found && this->max_matcher_len_ > 0 && this->buffer_.size() > this->max_matcher_len_) { + this->buffer_.erase(this->buffer_.begin()); + } + } +} + +} // namespace esphome::uart diff --git a/components/uart/event/uart_event.h b/components/uart/event/uart_event.h new file mode 100644 index 0000000..3960ffd --- /dev/null +++ b/components/uart/event/uart_event.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/event/event.h" +#include "esphome/components/uart/uart.h" +#include + +namespace esphome::uart { + +class UARTEvent final : public event::Event, public UARTDevice, public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void add_event_matcher(const char *event_name, const uint8_t *match_data, size_t match_data_len); + + protected: + struct EventMatcher { + const char *event_name; + const uint8_t *data; + size_t data_len; + }; + + void read_data_(); + std::vector matchers_; + std::vector buffer_; + size_t max_matcher_len_ = 0; +}; + +} // namespace esphome::uart diff --git a/components/uart/packet_transport/__init__.py b/components/uart/packet_transport/__init__.py new file mode 100644 index 0000000..58c6296 --- /dev/null +++ b/components/uart/packet_transport/__init__.py @@ -0,0 +1,20 @@ +from esphome.components.packet_transport import ( + PacketTransport, + new_packet_transport, + transport_schema, +) +from esphome.cpp_types import PollingComponent + +from .. import UART_DEVICE_SCHEMA, register_uart_device, uart_ns + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["uart"] + +UARTTransport = uart_ns.class_("UARTTransport", PacketTransport, PollingComponent) + +CONFIG_SCHEMA = transport_schema(UARTTransport).extend(UART_DEVICE_SCHEMA) + + +async def to_code(config): + var, _ = await new_packet_transport(config) + await register_uart_device(var, config) diff --git a/components/uart/packet_transport/uart_transport.cpp b/components/uart/packet_transport/uart_transport.cpp new file mode 100644 index 0000000..6b8eae6 --- /dev/null +++ b/components/uart/packet_transport/uart_transport.cpp @@ -0,0 +1,81 @@ +#include "esphome/core/log.h" +#include "esphome/core/application.h" +#include "uart_transport.h" + +namespace esphome::uart { + +static const char *const TAG = "uart_transport"; + +void UARTTransport::loop() { + PacketTransport::loop(); + + while (this->parent_->available()) { + uint8_t byte; + if (!this->parent_->read_byte(&byte)) { + ESP_LOGW(TAG, "Failed to read byte from UART"); + return; + } + if (byte == FLAG_BYTE) { + if (this->rx_started_ && this->receive_buffer_.size() > 6) { + auto len = this->receive_buffer_.size(); + auto crc = crc16(this->receive_buffer_.data(), len - 2); + if (crc != (this->receive_buffer_[len - 2] | (this->receive_buffer_[len - 1] << 8))) { + ESP_LOGD(TAG, "CRC mismatch, discarding packet"); + this->rx_started_ = false; + this->receive_buffer_.clear(); + continue; + } + this->receive_buffer_.resize(len - 2); + this->process_(this->receive_buffer_); + this->rx_started_ = false; + } else { + this->rx_started_ = true; + } + this->receive_buffer_.clear(); + this->rx_control_ = false; + continue; + } + if (!this->rx_started_) + continue; + if (byte == CONTROL_BYTE) { + this->rx_control_ = true; + continue; + } + if (this->rx_control_) { + byte ^= 0x20; + this->rx_control_ = false; + } + if (this->receive_buffer_.size() == MAX_PACKET_SIZE) { + ESP_LOGD(TAG, "Packet too large, discarding"); + this->rx_started_ = false; + this->receive_buffer_.clear(); + continue; + } + this->receive_buffer_.push_back(byte); + } +} + +/** + * Write a byte to the UART bus. If the byte is a flag or control byte, it will be escaped. + * @param byte The byte to write. + */ +void UARTTransport::write_byte_(uint8_t byte) const { + if (byte == FLAG_BYTE || byte == CONTROL_BYTE) { + this->parent_->write_byte(CONTROL_BYTE); + byte ^= 0x20; + } + this->parent_->write_byte(byte); +} + +void UARTTransport::send_packet(const std::vector &buf) const { + this->parent_->write_byte(FLAG_BYTE); + for (uint8_t byte : buf) { + this->write_byte_(byte); + } + auto crc = crc16(buf.data(), buf.size()); + this->write_byte_(crc & 0xFF); + this->write_byte_(crc >> 8); + this->parent_->write_byte(FLAG_BYTE); +} + +} // namespace esphome::uart diff --git a/components/uart/packet_transport/uart_transport.h b/components/uart/packet_transport/uart_transport.h new file mode 100644 index 0000000..b1ce8ac --- /dev/null +++ b/components/uart/packet_transport/uart_transport.h @@ -0,0 +1,38 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/packet_transport/packet_transport.h" +#include +#include "../uart.h" + +namespace esphome::uart { + +/** + * A transport protocol for sending and receiving packets over a UART connection. + * The protocol is based on Asynchronous HDLC framing. (https://en.wikipedia.org/wiki/High-Level_Data_Link_Control) + * There are two special bytes: FLAG_BYTE and CONTROL_BYTE. + * A 16-bit CRC is appended to the packet, then + * the protocol wraps the resulting data between FLAG_BYTEs. + * Any occurrence of FLAG_BYTE or CONTROL_BYTE in the data is escaped by emitting CONTROL_BYTE followed by the byte + * XORed with 0x20. + */ +static const uint16_t MAX_PACKET_SIZE = 508; +static const uint8_t FLAG_BYTE = 0x7E; +static const uint8_t CONTROL_BYTE = 0x7D; + +class UARTTransport final : public packet_transport::PacketTransport, public UARTDevice { + public: + void loop() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + protected: + void write_byte_(uint8_t byte) const; + void send_packet(const std::vector &buf) const override; + bool should_send() override { return true; }; + size_t get_max_packet_size() override { return MAX_PACKET_SIZE; } + std::vector receive_buffer_{}; + bool rx_started_{}; + bool rx_control_{}; +}; + +} // namespace esphome::uart diff --git a/components/uart/software_serial_rx_decoder.h b/components/uart/software_serial_rx_decoder.h new file mode 100644 index 0000000..d80dbf1 --- /dev/null +++ b/components/uart/software_serial_rx_decoder.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +/// Software serial RX decoder: on_edge() counts bits from the time between edges; +/// finalize() completes a byte whose idle high tail has no closing edge. +/// Times are in any steady tick unit. +class SoftwareSerialRxDecoder { + public: + static constexpr uint8_t RX_IDLE = 0xFF; + + /// Configure framing and buffer; drops all state. Follow with reset(). + void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer, + size_t buffer_size) { + this->bit_ticks_ = bit_ticks; + this->data_bits_ = data_bits; + this->stop_bit_ = data_bits + (parity ? 1 : 0); + this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2); + this->buffer_ = buffer; + this->buffer_size_ = buffer_size; + this->in_pos_ = 0; + this->out_pos_ = 0; + this->reset(0, true); + } + + /// Forget any partial frame; `level` is the current line level. + void reset(uint32_t now, bool level) { + this->bit_ = RX_IDLE; + this->cur_byte_ = 0; + this->last_level_ = level; + this->last_edge_ = now; + } + + /// ISR. True to wake the loop: a byte was pushed, or the line went high in an + /// open frame (a data 1 and the idle tail look alike) so finalize() may be needed. + bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) { + const bool last_level = this->last_level_; + // Collapsed edge + if (level == last_level) + return false; + // LX106 has no divider, so count + uint32_t delta = now - this->last_edge_; + if (delta > this->max_run_ticks_) + delta = this->max_run_ticks_; + delta += this->bit_ticks_ / 2; + uint32_t bits = 0; + while (delta >= this->bit_ticks_) { + delta -= this->bit_ticks_; + bits++; + } + const bool pushed = this->consume_run_(bits, last_level); + this->last_edge_ = now; + this->last_level_ = level; + return pushed || (level && this->bit_ != RX_IDLE); + } + + bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; } + + bool finalize_due(uint32_t now) const { + const uint8_t bit = this->bit_; + if (bit == RX_IDLE) + return false; + return now - this->last_edge_ >= this->tail_ticks_(bit); + } + + /// Call with the ISR masked. + void finalize(uint32_t now) { + if (!this->last_level_ || !this->finalize_due(now)) + return; + // Remaining bits are all ones + const uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + if (bit < this->data_bits_) + cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1); + this->push_byte(cur); + this->bit_ = RX_IDLE; + } + + /// Drops the byte when full; holds buffer_size - 1 bytes. + bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) { + size_t in = this->in_pos_; + size_t next = in + 1; + if (next == this->buffer_size_) + next = 0; + if (next == this->out_pos_) + return false; + this->buffer_[in] = data; + this->in_pos_ = next; + return true; + } + + uint8_t *buffer() const { return this->buffer_; } + size_t buffer_size() const { return this->buffer_size_; } + + size_t available() const { + // Read volatile in_pos_ once to avoid TOCTOU race with ISR. + size_t in = this->in_pos_; + if (in >= this->out_pos_) + return in - this->out_pos_; + return this->buffer_size_ - this->out_pos_ + in; + } + uint8_t peek_byte() const { + if (this->in_pos_ == this->out_pos_) + return 0; + return this->buffer_[this->out_pos_]; + } + uint8_t read_byte() { + if (this->in_pos_ == this->out_pos_) + return 0; + uint8_t data = this->buffer_[this->out_pos_]; + size_t next = this->out_pos_ + 1; + this->out_pos_ = next == this->buffer_size_ ? 0 : next; + return data; + } + + protected: + uint32_t tail_ticks_(uint8_t bit) const { + return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2; + } + + bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) { + uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + bool pushed = false; + while (bits > 0) { + if (bit == RX_IDLE) { + if (level) + break; + // Start bit + bit = 0; + cur = 0; + bits--; + } else if (bit < this->data_bits_) { + uint8_t n = this->data_bits_ - bit; + if (n > bits) + n = bits; + if (level) + cur |= ((1U << n) - 1) << bit; + bit += n; + bits -= n; + } else if (bit < this->stop_bit_) { + // Parity, not checked + bit++; + bits--; + } else { + // Stop bit; low is a framing error + if (level) + pushed = this->push_byte(cur); + bit = RX_IDLE; + break; + } + } + this->bit_ = bit; + this->cur_byte_ = cur; + return pushed; + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_ticks_{0}; + uint32_t max_run_ticks_{0}; + volatile uint32_t last_edge_{0}; + uint8_t *buffer_{nullptr}; + size_t buffer_size_{0}; + volatile size_t in_pos_{0}; + size_t out_pos_{0}; // reader only + /// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE. + volatile uint8_t bit_{RX_IDLE}; + volatile uint8_t cur_byte_{0}; + volatile bool last_level_{true}; + uint8_t data_bits_{8}; + uint8_t stop_bit_{8}; +}; + +} // namespace esphome::uart diff --git a/components/uart/switch/__init__.py b/components/uart/switch/__init__.py new file mode 100644 index 0000000..290bbed --- /dev/null +++ b/components/uart/switch/__init__.py @@ -0,0 +1,59 @@ +import esphome.codegen as cg +from esphome.components import switch, uart +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_SEND_EVERY +from esphome.core import HexInt + +from .. import uart_ns, validate_raw_data + +DEPENDENCIES = ["uart"] + +UARTSwitch = uart_ns.class_("UARTSwitch", switch.Switch, uart.UARTDevice, cg.Component) + +CONF_TURN_OFF = "turn_off" +CONF_TURN_ON = "turn_on" + +CONFIG_SCHEMA = ( + switch.switch_schema(UARTSwitch, block_inverted=True) + .extend( + { + cv.Required(CONF_DATA): cv.Any( + validate_raw_data, + cv.Schema( + { + cv.Optional(CONF_TURN_OFF): validate_raw_data, + cv.Optional(CONF_TURN_ON): validate_raw_data, + } + ), + ), + cv.Optional(CONF_SEND_EVERY): cv.positive_time_period_milliseconds, + }, + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await switch.new_switch(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + data = config[CONF_DATA] + if isinstance(data, dict): + if data_on := data.get(CONF_TURN_ON): + if isinstance(data_on, bytes): + data_on = [HexInt(x) for x in data_on] + cg.add(var.set_data_on(cg.ArrayInitializer(*data_on))) + if data_off := data.get(CONF_TURN_OFF): + if isinstance(data_off, bytes): + data_off = [HexInt(x) for x in data_off] + cg.add(var.set_data_off(cg.ArrayInitializer(*data_off))) + else: + data = config[CONF_DATA] + if isinstance(data, bytes): + data = [HexInt(x) for x in data] + cg.add(var.set_data_on(cg.ArrayInitializer(*data))) + cg.add(var.set_single_state(True)) + if CONF_SEND_EVERY in config: + cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/components/uart/switch/uart_switch.cpp b/components/uart/switch/uart_switch.cpp new file mode 100644 index 0000000..642bd19 --- /dev/null +++ b/components/uart/switch/uart_switch.cpp @@ -0,0 +1,60 @@ +#include "uart_switch.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +namespace esphome::uart { + +static const char *const TAG = "uart.switch"; + +void UARTSwitch::loop() { + if (this->send_every_) { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_transmission_ > this->send_every_) { + this->write_command_(this->state); + this->last_transmission_ = now; + } + } +} + +void UARTSwitch::write_command_(bool state) { + if (state && !this->data_on_.empty()) { + ESP_LOGD(TAG, "'%s': Sending on data", this->get_name().c_str()); + this->write_array(this->data_on_.data(), this->data_on_.size()); + } + if (!state && !this->data_off_.empty()) { + ESP_LOGD(TAG, "'%s': Sending off data", this->get_name().c_str()); + this->write_array(this->data_off_.data(), this->data_off_.size()); + } +} + +void UARTSwitch::write_state(bool state) { + if (!this->single_state_) { + this->publish_state(state); + this->write_command_(state); + this->last_transmission_ = millis(); + return; + } + + if (!state) { + this->publish_state(false); + return; + } + + this->publish_state(true); + this->write_command_(true); + + if (this->send_every_ == 0) { + this->publish_state(false); + } else { + this->last_transmission_ = millis(); + } +} + +void UARTSwitch::dump_config() { + LOG_SWITCH("", "UART Switch", this); + if (this->send_every_) { + ESP_LOGCONFIG(TAG, " Send Every: %" PRIu32, this->send_every_); + } +} + +} // namespace esphome::uart diff --git a/components/uart/switch/uart_switch.h b/components/uart/switch/uart_switch.h new file mode 100644 index 0000000..c924c7d --- /dev/null +++ b/components/uart/switch/uart_switch.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/switch/switch.h" + +#include +#include + +namespace esphome::uart { + +class UARTSwitch final : public switch_::Switch, public UARTDevice, public Component { + public: + void loop() override; + + void set_data_on(std::vector &&data) { this->data_on_ = std::move(data); } + void set_data_on(std::initializer_list data) { this->data_on_ = std::vector(data); } + void set_data_off(std::vector &&data) { this->data_off_ = std::move(data); } + void set_data_off(std::initializer_list data) { this->data_off_ = std::vector(data); } + void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } + void set_single_state(bool single) { this->single_state_ = single; } + + void dump_config() override; + + protected: + void write_command_(bool state); + void write_state(bool state) override; + std::vector data_on_; + std::vector data_off_; + bool single_state_{false}; + uint32_t send_every_; + uint32_t last_transmission_; +}; + +} // namespace esphome::uart diff --git a/components/uart/uart.cpp b/components/uart/uart.cpp new file mode 100644 index 0000000..44bc99a --- /dev/null +++ b/components/uart/uart.cpp @@ -0,0 +1,40 @@ +#include "uart.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include + +namespace esphome::uart { + +static const char *const TAG = "uart"; + +// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE +PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN"); + +void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity, + uint8_t data_bits) { + if (this->parent_->get_baud_rate() != baud_rate) { + ESP_LOGE(TAG, " Invalid baud_rate: Integration requested baud_rate %" PRIu32 " but you have %" PRIu32 "!", + baud_rate, this->parent_->get_baud_rate()); + } + if (this->parent_->get_stop_bits() != stop_bits) { + ESP_LOGE(TAG, " Invalid stop bits: Integration requested stop_bits %u but you have %u!", stop_bits, + this->parent_->get_stop_bits()); + } + if (this->parent_->get_data_bits() != data_bits) { + ESP_LOGE(TAG, " Invalid number of data bits: Integration requested %u data bits but you have %u!", data_bits, + this->parent_->get_data_bits()); + } + if (this->parent_->get_parity() != parity) { + ESP_LOGE(TAG, " Invalid parity: Integration requested parity %s but you have %s!", + LOG_STR_ARG(parity_to_str(parity)), LOG_STR_ARG(parity_to_str(this->parent_->get_parity()))); + } +} + +const LogString *parity_to_str(UARTParityOptions parity) { + return UARTParityStrings::get_log_str(static_cast(parity), UARTParityStrings::LAST_INDEX); +} + +} // namespace esphome::uart diff --git a/components/uart/uart.h b/components/uart/uart.h new file mode 100644 index 0000000..d1c6773 --- /dev/null +++ b/components/uart/uart.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "uart_component.h" + +namespace esphome::uart { + +class UARTDevice { + public: + UARTDevice() = default; + UARTDevice(UARTComponent *parent) : parent_(parent) {} + + void set_uart_parent(UARTComponent *parent) { this->parent_ = parent; } + + void write_byte(uint8_t data) { this->parent_->write_byte(data); } + + void set_rx_full_threshold(size_t rx_full_threshold) { this->parent_->set_rx_full_threshold(rx_full_threshold); } + void set_rx_full_threshold_ms(size_t time) { this->parent_->set_rx_full_threshold_ms(time); } + size_t get_rx_full_threshold() { return this->parent_->get_rx_full_threshold(); } + void set_rx_timeout(size_t rx_timeout) { this->parent_->set_rx_timeout(rx_timeout); } + size_t get_rx_timeout() { return this->parent_->get_rx_timeout(); } + + void write_array(const uint8_t *data, size_t len) { this->parent_->write_array(data, len); } + void write_array(const std::vector &data) { this->parent_->write_array(data); } + template void write_array(const std::array &data) { + this->parent_->write_array(data.data(), data.size()); + } + + void write_str(const char *str) { this->parent_->write_str(str); } + + bool read_byte(uint8_t *data) { return this->parent_->read_byte(data); } + bool peek_byte(uint8_t *data) { return this->parent_->peek_byte(data); } + + bool read_array(uint8_t *data, size_t len) { return this->parent_->read_array(data, len); } + template optional> read_array() { // NOLINT + // One object returned on both paths; a separate empty optional trips GCC's -Wmaybe-uninitialized. + optional> res(std::in_place); + if (!this->read_array(res->data(), N)) { + res.reset(); + } + return res; + } + + size_t available() { return this->parent_->available(); } + + UARTFlushResult flush() { return this->parent_->flush(); } + + // Compat APIs + int read() { + uint8_t data; + if (!this->read_byte(&data)) + return -1; + return data; + } + size_t write(uint8_t data) { + this->write_byte(data); + return 1; + } + int peek() { + uint8_t data; + if (!this->peek_byte(&data)) + return -1; + return data; + } + + /// Check that the configuration of the UART bus matches the provided values and otherwise print a warning + ESPDEPRECATED("Use uart.final_validate_device_schema() in Python instead. Removed in 2027.3.0", "2026.9.0") + void check_uart_settings(uint32_t baud_rate, uint8_t stop_bits = 1, + UARTParityOptions parity = UART_CONFIG_PARITY_NONE, uint8_t data_bits = 8); + + protected: + UARTComponent *parent_{nullptr}; +}; + +} // namespace esphome::uart diff --git a/components/uart/uart_component.cpp b/components/uart/uart_component.cpp new file mode 100644 index 0000000..762e56c --- /dev/null +++ b/components/uart/uart_component.cpp @@ -0,0 +1,30 @@ +#include "uart_component.h" + +namespace esphome::uart { + +static const char *const TAG = "uart"; + +bool UARTComponent::check_read_timeout_(size_t len) { + if (this->available() >= len) + return true; + + uint32_t start_time = millis(); + while (this->available() < len) { + if (millis() - start_time > 100) { + ESP_LOGE(TAG, "Reading from UART timed out at byte %zu!", this->available()); + return false; + } + yield(); + } + return true; +} + +void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { + uint8_t bytelength = this->data_bits_ + this->stop_bits_ + 1; + if (this->parity_ != UARTParityOptions::UART_CONFIG_PARITY_NONE) + bytelength += 1; + int32_t val = clamp((this->baud_rate_ / (bytelength * 1000 / time)) - 1, 1, 120); + this->set_rx_full_threshold(val); +} + +} // namespace esphome::uart diff --git a/components/uart/uart_component.h b/components/uart/uart_component.h new file mode 100644 index 0000000..2dfbf9c --- /dev/null +++ b/components/uart/uart_component.h @@ -0,0 +1,229 @@ +#pragma once + +#include +#include +#include +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#ifdef USE_UART_DEBUGGER +#include "esphome/core/automation.h" +#endif + +namespace esphome::uart { + +enum UARTParityOptions { + UART_CONFIG_PARITY_NONE, + UART_CONFIG_PARITY_EVEN, + UART_CONFIG_PARITY_ODD, + // Only USB serial adapters (usb_uart) support these + UART_CONFIG_PARITY_MARK, + UART_CONFIG_PARITY_SPACE, +}; + +#ifdef USE_UART_DEBUGGER +enum UARTDirection { + UART_DIRECTION_RX, + UART_DIRECTION_TX, + UART_DIRECTION_BOTH, +}; +#endif + +const LogString *parity_to_str(UARTParityOptions parity); + +/// Result of a flush() call. +enum class UARTFlushResult { + UART_FLUSH_RESULT_SUCCESS, ///< Confirmed: all bytes left the TX FIFO. + UART_FLUSH_RESULT_TIMEOUT, ///< Confirmed: timed out before TX completed. + UART_FLUSH_RESULT_FAILED, ///< Confirmed: driver or hardware error. + UART_FLUSH_RESULT_ASSUMED_SUCCESS, ///< Platform cannot report result; success is assumed. +}; + +class UARTComponent { + public: + static constexpr size_t RX_FULL_THRESHOLD_UNSET = 0; + + // Writes an array of bytes to the UART bus. + // @param data A vector of bytes to be written. + void write_array(const std::vector &data) { this->write_array(&data[0], data.size()); } + + // Writes a single byte to the UART bus. + // @param data The byte to be written. + void write_byte(uint8_t data) { this->write_array(&data, 1); }; + + // Writes a null-terminated string to the UART bus. + // @param str Pointer to the null-terminated string. + void write_str(const char *str) { + const auto *data = reinterpret_cast(str); + this->write_array(data, strlen(str)); + }; + + // Pure virtual method to write an array of bytes to the UART bus. + // @param data Pointer to the array of bytes. + // @param len Length of the array. + virtual void write_array(const uint8_t *data, size_t len) = 0; + + // Reads a single byte from the UART bus. + // @param data Pointer to the byte where the read data will be stored. + // @return True if a byte was successfully read, false otherwise. + bool read_byte(uint8_t *data) { return this->read_array(data, 1); }; + + // Pure virtual method to peek the next byte in the UART buffer without removing it. + // @param data Pointer to the byte where the peeked data will be stored. + // @return True if a byte is available to peek, false otherwise. + virtual bool peek_byte(uint8_t *data) = 0; + + // Pure virtual method to read an array of bytes from the UART bus. + // @param data Pointer to the array where the read data will be stored. + // @param len Number of bytes to read. + // @return True if the specified number of bytes were successfully read, false otherwise. + virtual bool read_array(uint8_t *data, size_t len) = 0; + + // Pure virtual method to return the number of bytes available for reading. + // @return Number of available bytes. + virtual size_t available() = 0; + + // Returns how many bytes write_array() accepts right now without blocking. + // Platforms that cannot tell return SIZE_MAX: write_array() takes everything and may block. + virtual size_t available_for_write() { return SIZE_MAX; } + + // Pure virtual method to block until all bytes have been written to the UART bus. + // @return UARTFlushResult indicating whether the flush was confirmed, timed out, failed, or assumed successful. + virtual UARTFlushResult flush() = 0; + + // Returns true if the underlying transport is connected and operational. + // Hardware UARTs always return true. USB-backed UARTs override to reflect actual connection state. + virtual bool is_connected() { return true; } + + // Sets the maximum time to wait for TX to drain during flush(). + // Only meaningful on ESP32 (IDF). Other platforms ignore this value. + // @param flush_timeout_ms Timeout in milliseconds; 0 means wait indefinitely. + virtual void set_flush_timeout(uint32_t flush_timeout_ms) {} + + // Sets the TX (transmit) pin for the UART bus. + // @param tx_pin Pointer to the internal GPIO pin used for transmission. + void set_tx_pin(InternalGPIOPin *tx_pin) { this->tx_pin_ = tx_pin; } + + // Sets the RX (receive) pin for the UART bus. + // @param rx_pin Pointer to the internal GPIO pin used for reception. + void set_rx_pin(InternalGPIOPin *rx_pin) { this->rx_pin_ = rx_pin; } + + // Sets the flow control pin for the UART bus. + // @param flow_control_pin Pointer to the internal GPIO pin used for flow control. + void set_flow_control_pin(InternalGPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } + + // Sets the size of the RX buffer. + // @param rx_buffer_size Size of the RX buffer in bytes. + void set_rx_buffer_size(size_t rx_buffer_size) { this->rx_buffer_size_ = rx_buffer_size; } + + // Gets the size of the RX buffer. + // @return Size of the RX buffer in bytes. + size_t get_rx_buffer_size() { return this->rx_buffer_size_; } + + // Sets the RX FIFO full interrupt threshold. + // @param rx_full_threshold RX full interrupt threshold in bytes. + virtual void set_rx_full_threshold(size_t rx_full_threshold) {} + + // Sets the RX FIFO full interrupt threshold. + // @param time RX full interrupt threshold in ms. + void set_rx_full_threshold_ms(uint8_t time); + + // Gets the RX FIFO full interrupt threshold. + // @return RX full interrupt threshold in bytes. + size_t get_rx_full_threshold() { return this->rx_full_threshold_; } + + // Sets the RX timeout interrupt threshold. + // @param rx_timeout RX timeout interrupt threshold (unit: time of sending one byte). + virtual void set_rx_timeout(size_t rx_timeout) {} + + // Gets the RX timeout interrupt threshold. + // @return RX timeout interrupt threshold (unit: time of sending one byte). + size_t get_rx_timeout() { return this->rx_timeout_; } + + // Sets the number of stop bits used in UART communication. + // @param stop_bits Number of stop bits. + void set_stop_bits(uint8_t stop_bits) { this->stop_bits_ = stop_bits; } + + // Gets the number of stop bits used in UART communication. + // @return Number of stop bits. + uint8_t get_stop_bits() const { return this->stop_bits_; } + + // Set the number of data bits used in UART communication. + // @param data_bits Number of data bits. + void set_data_bits(uint8_t data_bits) { this->data_bits_ = data_bits; } + + // Get the number of data bits used in UART communication. + // @return Number of data bits. + uint8_t get_data_bits() const { return this->data_bits_; } + + // Set the parity used in UART communication. + // @param parity Parity option. + void set_parity(UARTParityOptions parity) { this->parity_ = parity; } + + // Get the parity used in UART communication. + // @return Parity option. + UARTParityOptions get_parity() const { return this->parity_; } + + // Set the baud rate for UART communication. + // @param baud_rate Baud rate in bits per second. + void set_baud_rate(uint32_t baud_rate) { baud_rate_ = baud_rate; } + + // Get the baud rate for UART communication. + // @return Baud rate in bits per second. + uint32_t get_baud_rate() const { return baud_rate_; } + +#if defined(USE_ESP8266) || defined(USE_ESP32) + /** + * Load the UART settings. + * @param dump_config If true (default), output the new settings to logs; otherwise, change settings quietly. + * + * Example: + * ```cpp + * id(uart1).load_settings(false); + * ``` + * + * This will load the current UART interface with the latest settings (baud_rate, parity, etc). + */ + virtual void load_settings(bool dump_config) = 0; + + /** + * Load the UART settings. + * + * Example: + * ```cpp + * id(uart1).load_settings(); + * ``` + * + * This will load the current UART interface with the latest settings (baud_rate, parity, etc). + */ + void load_settings() { this->load_settings(true); } +#endif // USE_ESP8266 || USE_ESP32 + +#ifdef USE_UART_DEBUGGER + template void add_debug_callback(F &&callback) { this->debug_callback_.add(std::forward(callback)); } +#endif + + protected: + virtual void check_logger_conflict() = 0; + bool check_read_timeout_(size_t len = 1); + + InternalGPIOPin *tx_pin_{}; + InternalGPIOPin *rx_pin_{}; + InternalGPIOPin *flow_control_pin_{}; + size_t rx_buffer_size_{}; + // ESP32 (both Arduino and ESP-IDF) always sets this at codegen time via set_rx_full_threshold(). + // Other platforms (USB UART, Arduino, etc.) leave it unset. + size_t rx_full_threshold_{RX_FULL_THRESHOLD_UNSET}; + size_t rx_timeout_{0}; + uint32_t baud_rate_{0}; + uint8_t stop_bits_{0}; + uint8_t data_bits_{0}; + UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; // Must match the parity check in __init__.py +#ifdef USE_UART_DEBUGGER + CallbackManager debug_callback_{}; +#endif +}; + +} // namespace esphome::uart diff --git a/components/uart/uart_component_esp8266.cpp b/components/uart/uart_component_esp8266.cpp new file mode 100644 index 0000000..0819a1b --- /dev/null +++ b/components/uart/uart_component_esp8266.cpp @@ -0,0 +1,416 @@ +#ifdef USE_ESP8266 +#include "uart_component_esp8266.h" +#include +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/wake.h" +#endif + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +namespace esphome::uart { + +ESPHOME_LOG_TAG(TAG, "uart"); +// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes. +static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200; +// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a +// CpuFrequencyBoost; the cycle counter does not. +static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000; +__attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; } +bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +uint32_t ESP8266UartComponent::get_config() { + uint32_t config = 0; + + if (this->parity_ == UART_CONFIG_PARITY_NONE) { + config |= UART_PARITY_NONE; + } else if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + config |= UART_PARITY_EVEN; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + config |= UART_PARITY_ODD; + } + + switch (this->data_bits_) { + case 5: + config |= UART_NB_BIT_5; + break; + case 6: + config |= UART_NB_BIT_6; + break; + case 7: + config |= UART_NB_BIT_7; + break; + case 8: + config |= UART_NB_BIT_8; + break; + } + + if (this->stop_bits_ == 1) { + config |= UART_NB_STOP_BIT_1; + } else { + config |= UART_NB_STOP_BIT_2; + } + + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) + config |= BIT(22); + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) + config |= BIT(19); + + return config; +} + +void ESP8266UartComponent::setup() { + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); + } + + // Use Arduino HardwareSerial UARTs if all used pins match the ones + // preconfigured by the platform. For example if RX disabled but TX pin + // is 1 we still want to use Serial. + SerialConfig config = static_cast(get_config()); + +#ifdef USE_ESP8266_UART_SERIAL + if (!ESP8266UartComponent::serial0_in_use && (tx_pin_ == nullptr || tx_pin_->get_pin() == 1) && + (rx_pin_ == nullptr || rx_pin_->get_pin() == 3) +#ifdef USE_LOGGER + // we will use UART0 if logger isn't using it in swapped mode + && (logger::global_logger->get_hw_serial() == nullptr || + logger::global_logger->get_uart() != logger::UART_SELECTION_UART0_SWAP) +#endif + ) { + this->hw_serial_ = &Serial; + this->hw_serial_->begin(this->baud_rate_, config); + this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); + ESP8266UartComponent::serial0_in_use = true; + } else if (!ESP8266UartComponent::serial0_in_use && (tx_pin_ == nullptr || tx_pin_->get_pin() == 15) && + (rx_pin_ == nullptr || rx_pin_->get_pin() == 13) +#ifdef USE_LOGGER + // we will use UART0 swapped if logger isn't using it in regular mode + && (logger::global_logger->get_hw_serial() == nullptr || + logger::global_logger->get_uart() != logger::UART_SELECTION_UART0) +#endif + ) { + this->hw_serial_ = &Serial; + this->hw_serial_->begin(this->baud_rate_, config); + this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); + this->hw_serial_->swap(); + ESP8266UartComponent::serial0_in_use = true; + } else +#endif // USE_ESP8266_UART_SERIAL +#ifdef USE_ESP8266_UART_SERIAL1 + if ((tx_pin_ == nullptr || tx_pin_->get_pin() == 2) && (rx_pin_ == nullptr || rx_pin_->get_pin() == 8)) { + this->hw_serial_ = &Serial1; + this->hw_serial_->begin(this->baud_rate_, config); + this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); + } else +#endif // USE_ESP8266_UART_SERIAL1 + { + this->sw_serial_ = new ESP8266SoftwareSerial(); // NOLINT + this->sw_serial_->setup(tx_pin_, rx_pin_, this->baud_rate_, this->stop_bits_, this->data_bits_, this->parity_, + this->rx_buffer_size_); + } +} + +void ESP8266UartComponent::load_settings(bool dump_config) { + ESP_LOGCONFIG(TAG, "Loading UART bus settings"); + if (this->hw_serial_ != nullptr) { + SerialConfig config = static_cast(get_config()); + this->hw_serial_->begin(this->baud_rate_, config); + this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); + } else { + this->sw_serial_->setup(this->tx_pin_, this->rx_pin_, this->baud_rate_, this->stop_bits_, this->data_bits_, + this->parity_, this->rx_buffer_size_); + } + if (dump_config) { + ESP_LOGCONFIG(TAG, "UART bus was reloaded."); + this->dump_config(); + } +} + +void ESP8266UartComponent::dump_config() { + ESP_LOGCONFIG(TAG, "UART Bus:"); + LOG_PIN(" TX Pin: ", this->tx_pin_); + LOG_PIN(" RX Pin: ", this->rx_pin_); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); // NOLINT + } + ESP_LOGCONFIG(TAG, + " Baud Rate: %u baud\n" + " Data Bits: %u\n" + " Parity: %s\n" + " Stop bits: %u", + this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); + if (this->hw_serial_ != nullptr) { + ESP_LOGCONFIG(TAG, " Using hardware serial interface."); + } else { + ESP_LOGCONFIG(TAG, " Using software serial" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + ); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX decoder: %s", + this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge") + : LOG_STR_LITERAL("start bit sampler")); + } + } + this->check_logger_conflict(); +} + +void ESP8266UartComponent::check_logger_conflict() { +#ifdef USE_LOGGER + if (this->hw_serial_ == nullptr || logger::global_logger->get_baud_rate() == 0) { + return; + } + + if (this->hw_serial_ == logger::global_logger->get_hw_serial()) { + ESP_LOGW(TAG, " You're using the same serial port for logging and the UART component. Please " + "disable logging over the serial port by setting logger->baud_rate to 0."); + } +#endif +} + +void ESP8266UartComponent::write_array(const uint8_t *data, size_t len) { + if (this->hw_serial_ != nullptr) { + this->hw_serial_->write(data, len); + } else { + for (size_t i = 0; i < len; i++) + this->sw_serial_->write_byte(data[i]); + } +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif +} +bool ESP8266UartComponent::peek_byte(uint8_t *data) { + if (!this->check_read_timeout_()) + return false; + if (this->hw_serial_ != nullptr) { + *data = this->hw_serial_->peek(); + } else { + *data = this->sw_serial_->peek_byte(); + } + return true; +} +bool ESP8266UartComponent::read_array(uint8_t *data, size_t len) { + if (!this->check_read_timeout_(len)) + return false; + if (this->hw_serial_ != nullptr) { + this->hw_serial_->readBytes(data, len); + } else { + for (size_t i = 0; i < len; i++) + data[i] = this->sw_serial_->read_byte(); + } +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return true; +} +size_t ESP8266UartComponent::available() { + if (this->hw_serial_ != nullptr) { + return this->hw_serial_->available(); + } else { + return this->sw_serial_->available(); + } +} +size_t ESP8266UartComponent::available_for_write() { + if (this->hw_serial_ != nullptr) { + return this->hw_serial_->availableForWrite(); + } + return SIZE_MAX; // software serial bit-bangs each byte synchronously; there is no buffer to fill +} +UARTFlushResult ESP8266UartComponent::flush() { + ESP_LOGVV(TAG, " Flushing"); + if (this->hw_serial_ != nullptr) { + this->hw_serial_->flush(); + } else { + this->sw_serial_->flush(); + } + return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; +} +void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, + uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity, + size_t rx_buffer_size) { + // load_settings() re-enters here + if (this->gpio_rx_pin_ != nullptr) + this->gpio_rx_pin_->detach_interrupt(); + this->bit_time_ = F_CPU / baud_rate; + this->stop_bits_ = stop_bits; + this->data_bits_ = data_bits; + this->parity_ = parity; + // to_isr() allocates, so call it only for a new or changed pin + if (tx_pin != nullptr) { + if (tx_pin != this->gpio_tx_pin_) + this->tx_pin_ = tx_pin->to_isr(); + gpio_tx_pin_ = tx_pin; + gpio_tx_pin_->setup(); + tx_pin_.digital_write(true); + } + if (rx_pin != nullptr) { + if (rx_pin != this->gpio_rx_pin_) + this->rx_pin_ = rx_pin->to_isr(); + gpio_rx_pin_ = rx_pin; + gpio_rx_pin_->setup(); + uint8_t *buffer = this->rx_.buffer(); + if (this->rx_.buffer_size() != rx_buffer_size) { + delete[] buffer; // NOLINT + buffer = new uint8_t[rx_buffer_size]; // NOLINT + } + this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE, + stop_bits, buffer, rx_buffer_size); + this->rx_.reset(rx_now(), this->rx_pin_.digital_read()); + if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE); + } else { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + } + } +} +// A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles +// the bit time then. The whole byte is read inside the ISR, so the clock cannot change partway through. +__attribute__((always_inline)) static inline uint32_t rx_bit_time(uint32_t bit_time) { +#if F_CPU != 160000000L + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- CPU2X is MMIO at a fixed address + return bit_time << (CPU2X & 1); +#else + return bit_time; +#endif +} + +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { + const uint32_t bit_time = rx_bit_time(arg->bit_time_); + uint32_t wait = bit_time + bit_time / 3 - 500; + const uint32_t start = arch_get_cpu_cycle_count(); + uint8_t rec = 0; + // Manually unroll the loop + for (int i = 0; i < arg->data_bits_; i++) + rec |= arg->read_bit_(&wait, start, bit_time) << i; + + /* If parity is enabled, just read it and ignore it. */ + /* TODO: Should we check parity? Or is it too slow for nothing added..*/ + if (arg->parity_ == UART_CONFIG_PARITY_EVEN || arg->parity_ == UART_CONFIG_PARITY_ODD) + arg->read_bit_(&wait, start, bit_time); + + // Stop bit + arg->wait_(&wait, start, bit_time); + if (arg->stop_bits_ == 2) + arg->wait_(&wait, start, bit_time); + + arg->rx_.push_byte(rec); + // Clear RX pin so that the interrupt doesn't re-trigger right away again. + arg->rx_pin_.clear_interrupt(); +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Wake the main loop so the consuming component drains the byte promptly + // instead of waiting for the next loop_interval_ tick. Important for timing + // sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow). + wake_loop_isrsafe(); +#endif +} +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) { + const uint32_t now = rx_now(); + const bool level = arg->rx_pin_.digital_read(); + [[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level); +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (wake) + wake_loop_isrsafe(); +#endif +} +void ESP8266SoftwareSerial::rx_finalize_pending_() { + if (!this->rx_.finalize_due(rx_now())) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Tail not elapsed; wake again only once drained + if (this->rx_.available() == 0) + wake_loop_threadsafe(); +#endif + return; + } + InterruptLock lock; + this->rx_.finalize(rx_now()); +} +void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { + if (this->gpio_tx_pin_ == nullptr) { + ESP_LOGE(TAG, "UART doesn't have TX pins set!"); + return; + } + bool parity_bit = false; + bool need_parity_bit = true; + if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + parity_bit = false; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + parity_bit = true; + } else { + need_parity_bit = false; + } + + { + // Transmit runs from the main loop and never overlaps a CpuFrequencyBoost + InterruptLock lock; + const uint32_t bit_time = this->bit_time_; + uint32_t wait = bit_time; + const uint32_t start = arch_get_cpu_cycle_count(); + // Start bit + this->write_bit_(false, &wait, start, bit_time); + for (int i = 0; i < this->data_bits_; i++) { + bool bit = data & (1 << i); + this->write_bit_(bit, &wait, start, bit_time); + if (need_parity_bit) + parity_bit ^= bit; + } + if (need_parity_bit) + this->write_bit_(parity_bit, &wait, start, bit_time); + // Stop bit + this->write_bit_(true, &wait, start, bit_time); + if (this->stop_bits_ == 2) + this->wait_(&wait, start, bit_time); + } +} +void IRAM_ATTR ESP8266SoftwareSerial::wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time) { + while (arch_get_cpu_cycle_count() - start < *wait) + ; + *wait += bit_time; +} +bool IRAM_ATTR ESP8266SoftwareSerial::read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time) { + this->wait_(wait, start, bit_time); + return this->rx_pin_.digital_read(); +} +void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time) { + this->tx_pin_.digital_write(bit); + this->wait_(wait, start, bit_time); +} +uint8_t ESP8266SoftwareSerial::read_byte() { + this->rx_sync_(); + return this->rx_.read_byte(); +} +uint8_t ESP8266SoftwareSerial::peek_byte() { + this->rx_sync_(); + return this->rx_.peek_byte(); +} +void ESP8266SoftwareSerial::flush() { + // Flush is a NO-OP with software serial, all bytes are written immediately. +} +size_t ESP8266SoftwareSerial::available() { + this->rx_sync_(); + return this->rx_.available(); +} + +} // namespace esphome::uart +#endif // USE_ESP8266 diff --git a/components/uart/uart_component_esp8266.h b/components/uart/uart_component_esp8266.h new file mode 100644 index 0000000..3d4a2ba --- /dev/null +++ b/components/uart/uart_component_esp8266.h @@ -0,0 +1,99 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "software_serial_rx_decoder.h" +#include "uart_component.h" + +namespace esphome::uart { + +class ESP8266SoftwareSerial { + public: + void setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, uint8_t stop_bits, + uint32_t data_bits, UARTParityOptions parity, size_t rx_buffer_size); + + uint8_t read_byte(); + uint8_t peek_byte(); + + void flush(); + + void write_byte(uint8_t data); + + size_t available(); + + protected: + /// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR. + static void gpio_intr(ESP8266SoftwareSerial *arg); + static void gpio_intr_edge(ESP8266SoftwareSerial *arg); + + void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); + bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); + + void rx_finalize_pending_(); + void ESPHOME_ALWAYS_INLINE rx_sync_() { + if (this->rx_.pending()) + this->rx_finalize_pending_(); + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_time_{0}; + InternalGPIOPin *gpio_tx_pin_{nullptr}; + ISRInternalGPIOPin tx_pin_; + InternalGPIOPin *gpio_rx_pin_{nullptr}; + ISRInternalGPIOPin rx_pin_; + SoftwareSerialRxDecoder rx_; + UARTParityOptions parity_; + uint8_t stop_bits_; + uint8_t data_bits_; +}; + +class ESP8266UartComponent final : public UARTComponent, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void write_array(const uint8_t *data, size_t len) override; + + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + + size_t available() override; + size_t available_for_write() override; + UARTFlushResult flush() override; + + uint32_t get_config(); + + /** + * Load the UART with the current settings. + * @param dump_config (Optional, default `true`): True for displaying new settings or + * false to change it quitely + * + * Example: + * ```cpp + * id(uart1).load_settings(); + * ``` + * + * This will load the current UART interface with the latest settings (baud_rate, parity, etc). + */ + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience + + protected: + void check_logger_conflict() override; + + HardwareSerial *hw_serial_{nullptr}; + ESP8266SoftwareSerial *sw_serial_{nullptr}; + + private: + static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +}; + +} // namespace esphome::uart +#endif // USE_ESP8266 diff --git a/components/uart/uart_component_esp_idf.cpp b/components/uart/uart_component_esp_idf.cpp new file mode 100644 index 0000000..5d90fc1 --- /dev/null +++ b/components/uart/uart_component_esp_idf.cpp @@ -0,0 +1,520 @@ +#ifdef USE_ESP32 + +#include "uart_component_esp_idf.h" +#include +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/gpio.h" +#include "driver/gpio.h" +#include "hal/uart_ll.h" +#include "esp_private/gpio.h" +#include "soc/gpio_num.h" +#include "soc/soc_caps.h" +#include "soc/uart_pins.h" + +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/application.h" +#endif + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +namespace esphome::uart { + +static const char *const TAG = "uart"; + +/// Check if a pin number matches one of the default UART0 GPIO pins. +/// These pins may have residual IOMUX state from the ROM bootloader that +/// must be cleared before UART reconfiguration. +/// +/// ESP-IDF's uart_set_pin() has an asymmetry: when routing TX via GPIO matrix, +/// it calls gpio_func_sel(PIN_FUNC_GPIO) to clear IOMUX, but for RX it only +/// calls gpio_input_enable() which does NOT clear the IOMUX function select. +/// If a default UART0 TX pin (configured as TX via IOMUX during boot) is later +/// reassigned as RX via GPIO matrix, the old IOMUX TX function remains active, +/// causing TX data to loop back into RX on the same pin. +static constexpr bool is_default_uart0_pin(int8_t pin_num) { + return pin_num == U0TXD_GPIO_NUM || pin_num == U0RXD_GPIO_NUM; +} + +// clock_source_ is stored in a byte; every uart_sclk_t value is a soc_module_clk_t below SOC_MOD_CLK_INVALID +static_assert(SOC_MOD_CLK_INVALID <= UINT8_MAX, "uart_sclk_t no longer fits in uint8_t clock_source_"); + +static const LogString *clock_source_to_str(uart_sclk_t clock_source) { + switch (clock_source) { +#if SOC_UART_SUPPORT_APB_CLK + case UART_SCLK_APB: + return LOG_STR("APB"); +#endif +#if SOC_UART_SUPPORT_XTAL_CLK + case UART_SCLK_XTAL: + return LOG_STR("XTAL"); +#endif +#if SOC_UART_SUPPORT_RTC_CLK + case UART_SCLK_RTC: + return LOG_STR("RTC"); +#endif +#if SOC_UART_SUPPORT_REF_TICK + case UART_SCLK_REF_TICK: + return LOG_STR("REF_TICK"); +#endif + default: + return clock_source == UART_SCLK_DEFAULT ? LOG_STR("DEFAULT") : LOG_STR("UNKNOWN"); + } +} + +uart_config_t IDFUARTComponent::get_config_() { + uart_parity_t parity = UART_PARITY_DISABLE; + if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + parity = UART_PARITY_EVEN; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + parity = UART_PARITY_ODD; + } + + uart_word_length_t data_bits; + switch (this->data_bits_) { + case 5: + data_bits = UART_DATA_5_BITS; + break; + case 6: + data_bits = UART_DATA_6_BITS; + break; + case 7: + data_bits = UART_DATA_7_BITS; + break; + case 8: + data_bits = UART_DATA_8_BITS; + break; + default: + data_bits = UART_DATA_BITS_MAX; + break; + } + + uart_config_t uart_config{}; + uart_config.baud_rate = this->baud_rate_; + uart_config.data_bits = data_bits; + uart_config.parity = parity; + uart_config.stop_bits = this->stop_bits_ == 1 ? UART_STOP_BITS_1 : UART_STOP_BITS_2; + uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; + uart_config.source_clk = static_cast(this->clock_source_); + uart_config.rx_flow_ctrl_thresh = 122; + + return uart_config; +} + +void IDFUARTComponent::setup() { + static uint8_t next_uart_num = 0; + +#ifdef USE_LOGGER + bool logger_uses_hardware_uart = true; + +#ifdef USE_LOGGER_USB_CDC + if (logger::global_logger->get_uart() == logger::UART_SELECTION_USB_CDC) { + // this is not a hardware UART, ignore it + logger_uses_hardware_uart = false; + } +#endif // USE_LOGGER_USB_CDC + +#ifdef USE_LOGGER_USB_SERIAL_JTAG + if (logger::global_logger->get_uart() == logger::UART_SELECTION_USB_SERIAL_JTAG) { + // this is not a hardware UART, ignore it + logger_uses_hardware_uart = false; + } +#endif // USE_LOGGER_USB_SERIAL_JTAG + + if (logger_uses_hardware_uart && logger::global_logger->get_baud_rate() > 0 && + logger::global_logger->get_uart_num() == next_uart_num) { + next_uart_num++; + } +#endif // USE_LOGGER + + if (next_uart_num >= SOC_UART_NUM) { + ESP_LOGW(TAG, "Maximum number of UART components created already"); + this->mark_failed(); + return; + } + this->uart_num_ = static_cast(next_uart_num++); + +#if (SOC_UART_LP_NUM >= 1) + size_t fifo_len = ((this->uart_num_ < SOC_UART_HP_NUM) ? SOC_UART_FIFO_LEN : SOC_LP_UART_FIFO_LEN); +#else + size_t fifo_len = SOC_UART_FIFO_LEN; +#endif + if (this->rx_buffer_size_ <= fifo_len) { + ESP_LOGW(TAG, "rx_buffer_size is too small, must be greater than %zu", fifo_len); + this->rx_buffer_size_ = fifo_len * 2; + } + + this->load_settings(false); +} + +void IDFUARTComponent::load_settings(bool dump_config) { + esp_err_t err; + + if (uart_is_driver_installed(this->uart_num_)) { + err = uart_driver_delete(this->uart_num_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + } + err = uart_driver_install(this->uart_num_, // UART number + this->rx_buffer_size_, // RX ring buffer size + this->tx_buffer_size_, // TX ring buffer size; 0 makes uart_write_bytes() block until + // the FIFO has taken everything + 0, // event queue size/depth + nullptr, // event queue + 0 // Flags used to allocate the interrupt + ); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + // uart_param_config must be called after uart_driver_install and before any + // other uart_set_*() calls. The driver installation resets the UART peripheral + // registers to their default state, overwriting any previously configured baud + // rate or framing settings. Calling uart_param_config here ensures the requested + // settings are applied after the reset and before pin routing, inversion, and + // threshold configuration. + uart_config_t uart_config = this->get_config_(); + err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + this->last_good_framing_ = this->framing_(); + + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; + int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; + int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; + + // Clear residual IOMUX function on UART0 default pins left by the ROM bootloader. + // See is_default_uart0_pin() comment for details on the ESP-IDF uart_set_pin() bug. + if (is_default_uart0_pin(tx)) { + gpio_func_sel(static_cast(tx), PIN_FUNC_GPIO); + } + if (is_default_uart0_pin(rx)) { + gpio_func_sel(static_cast(rx), PIN_FUNC_GPIO); + } + + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); + } + + // Must precede uart_set_pin() so an inverted TX line never shows the wrong idle + // level; apply_line_settings_() repeats it later for the reset registers. + err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + err = uart_set_pin(this->uart_num_, tx, rx, flow_control, UART_PIN_NO_CHANGE); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_pin failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + if (this->apply_line_settings_() != ESP_OK) { + this->mark_failed(); + return; + } + +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Register ISR callback to wake the main loop when UART data arrives. + // The callback runs in ISR context and uses vTaskNotifyGiveFromISR() to + // wake the main loop task directly — no queue or FreeRTOS task needed. + uart_set_select_notif_callback(this->uart_num_, IDFUARTComponent::uart_rx_isr_callback); +#endif // USE_UART_WAKE_LOOP_ON_RX + + if (dump_config) { + ESP_LOGCONFIG(TAG, "Reloaded UART %u", this->uart_num_); + this->dump_config(); + } +} + +uint32_t IDFUARTComponent::line_inversion_mask_() { + uint32_t invert = 0; + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { + invert |= UART_SIGNAL_TXD_INV; + } + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { + invert |= UART_SIGNAL_RXD_INV; + } + if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { + invert |= UART_SIGNAL_RTS_INV; + } + return invert; +} + +esp_err_t IDFUARTComponent::apply_line_settings_() { + // uart_param_config() resets these; call after every use of it. + esp_err_t err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); + return err; + } + + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). + auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; + err = uart_set_mode(this->uart_num_, mode); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + return err; + } + + return ESP_OK; +} + +void IDFUARTComponent::set_framing_(const Framing &framing) { + this->baud_rate_ = framing.baud_rate; + this->data_bits_ = framing.data_bits; + this->stop_bits_ = framing.stop_bits; + this->parity_ = framing.parity; + this->rx_full_threshold_ = framing.rx_full_threshold; +} + +esp_err_t IDFUARTComponent::apply_settings_live() { + if (this->is_failed()) { + return ESP_ERR_INVALID_STATE; + } + // No driver yet: nothing to reconfigure in place. + if (!uart_is_driver_installed(this->uart_num_)) { + this->load_settings(false); + return this->is_failed() ? ESP_FAIL : ESP_OK; + } + // Keeps the driver ring buffers; flushes both hardware FIFOs (in-flight bytes lost). + uart_config_t uart_config = this->get_config_(); + esp_err_t err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + // Failure leaves the registers reset; put back the last accepted framing so the + // getters still describe the hardware. + if (this->last_good_framing_.baud_rate == 0) { + ESP_LOGE(TAG, "uart_param_config (live) failed: %s; no previous framing to restore", esp_err_to_name(err)); + this->mark_failed(); + return err; + } + ESP_LOGW(TAG, "uart_param_config (live) failed: %s; restoring %" PRIu32 " baud", esp_err_to_name(err), + this->last_good_framing_.baud_rate); + this->set_framing_(this->last_good_framing_); + uart_config = this->get_config_(); + esp_err_t restore_err = uart_param_config(this->uart_num_, &uart_config); + if (restore_err != ESP_OK) { + ESP_LOGE(TAG, "UART left unconfigured after failed live reconfigure: %s", esp_err_to_name(restore_err)); + this->mark_failed(); + return err; + } + // Previous framing is live again; report the refusal (line-setting errors log). + this->apply_line_settings_(); + return err; + } + this->last_good_framing_ = this->framing_(); + // The new framing is live; a line-setting failure here only logs. + this->apply_line_settings_(); + return ESP_OK; +} + +void IDFUARTComponent::dump_config() { + ESP_LOGCONFIG(TAG, "UART Bus %u:", this->uart_num_); + LOG_PIN(" TX Pin: ", this->tx_pin_); + LOG_PIN(" RX Pin: ", this->rx_pin_); + LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, + " RX Buffer Size: %u\n" + " RX Full Threshold: %u\n" + " RX Timeout: %u", + this->rx_buffer_size_, this->rx_full_threshold_, this->rx_timeout_); + } + if (this->tx_buffer_size_ > 0) { + ESP_LOGCONFIG(TAG, " TX Buffer Size: %zu", this->tx_buffer_size_); + } + if (this->flush_timeout_ms_ > 0) { + ESP_LOGCONFIG(TAG, " Flush Timeout: %" PRIu32 " ms", this->flush_timeout_ms_); + } + ESP_LOGCONFIG(TAG, + " Baud Rate: %" PRIu32 " baud\n" + " Data Bits: %u\n" + " Parity: %s\n" + " Stop bits: %u\n" + " Clock Source: %s" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + , + this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_, + LOG_STR_ARG(clock_source_to_str(static_cast(this->clock_source_)))); + this->check_logger_conflict(); +} + +void IDFUARTComponent::set_rx_full_threshold(size_t rx_full_threshold) { + if (this->is_ready()) { + esp_err_t err = uart_set_rx_full_threshold(this->uart_num_, rx_full_threshold); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); + return; + } + } + this->rx_full_threshold_ = rx_full_threshold; +} + +void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) { + if (this->is_ready()) { + esp_err_t err = uart_set_rx_timeout(this->uart_num_, rx_timeout); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); + return; + } + } + this->rx_timeout_ = rx_timeout; +} + +size_t IDFUARTComponent::available_for_write() { + if (this->uart_num_ == UART_NUM_MAX || !uart_is_driver_installed(this->uart_num_)) + return 0; + if (this->tx_buffer_size_ == 0) { + return uart_ll_get_txfifo_len(UART_LL_GET_HW(this->uart_num_)); + } + // The driver's figure already deducts its ring item headers, so a write of this size fits + size_t free = 0; + uart_get_tx_buffer_free_size(this->uart_num_, &free); + return free; +} + +void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { + int32_t write_len = uart_write_bytes(this->uart_num_, data, len); + if (write_len != (int32_t) len) { + ESP_LOGW(TAG, "uart_write_bytes failed: %" PRId32 " != %zu", write_len, len); + this->mark_failed(); + } +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif +} + +bool IDFUARTComponent::peek_byte(uint8_t *data) { + if (!this->check_read_timeout_()) + return false; + if (this->has_peek_) { + *data = this->peek_byte_; + } else { + int len = uart_read_bytes(this->uart_num_, data, 1, 20 / portTICK_PERIOD_MS); + if (len == 0) { + *data = 0; + } else { + this->has_peek_ = true; + this->peek_byte_ = *data; + } + } + return true; +} + +bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return false; + } + size_t length_to_read = len; + int32_t read_len = 0; + if (!this->check_read_timeout_(len)) + return false; + if (this->has_peek_) { + length_to_read--; + *data = this->peek_byte_; + this->has_peek_ = false; + } + if (length_to_read > 0) + read_len = uart_read_bytes(this->uart_num_, data + (len - length_to_read), length_to_read, 20 / portTICK_PERIOD_MS); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return read_len == (int32_t) length_to_read; +} + +size_t IDFUARTComponent::available() { + size_t available = 0; + esp_err_t err; + + err = uart_get_buffered_data_len(this->uart_num_, &available); + + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_get_buffered_data_len failed: %s", esp_err_to_name(err)); + this->mark_failed(); + } + if (this->has_peek_) { + available++; + } + return available; +} + +UARTFlushResult IDFUARTComponent::flush() { + ESP_LOGVV(TAG, " Flushing"); + TickType_t ticks = this->flush_timeout_ms_ == 0 ? portMAX_DELAY : pdMS_TO_TICKS(this->flush_timeout_ms_); + esp_err_t err = uart_wait_tx_done(this->uart_num_, ticks); + if (err == ESP_OK) + return UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + if (err == ESP_ERR_TIMEOUT) + return UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; + return UARTFlushResult::UART_FLUSH_RESULT_FAILED; +} + +void IDFUARTComponent::check_logger_conflict() {} + +#ifdef USE_UART_WAKE_LOOP_ON_RX +// ISR callback invoked by the ESP-IDF UART driver when data arrives. +// Wakes the main loop directly via vTaskNotifyGiveFromISR() — no queue or task needed. +void IRAM_ATTR IDFUARTComponent::uart_rx_isr_callback(uart_port_t uart_num, uart_select_notif_t uart_select_notif, + BaseType_t *task_woken) { + if (uart_select_notif == UART_SELECT_READ_NOTIF) { + Application::wake_loop_isrsafe(task_woken); + } +} +#endif // USE_UART_WAKE_LOOP_ON_RX + +void IDFUARTComponent::on_shutdown() { + if (this->uart_num_ == UART_NUM_MAX || !uart_is_driver_installed(this->uart_num_)) + return; + uart_wait_tx_done(this->uart_num_, pdMS_TO_TICKS(100)); + // Keep the peripheral quiet across a soft reset so ROM output does not reach the attached device (#15472) + esp_err_t err = uart_driver_delete(this->uart_num_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); + } +} + +} // namespace esphome::uart +#endif // USE_ESP32 diff --git a/components/uart/uart_component_esp_idf.h b/components/uart/uart_component_esp_idf.h new file mode 100644 index 0000000..7c93b74 --- /dev/null +++ b/components/uart/uart_component_esp_idf.h @@ -0,0 +1,126 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include "esphome/core/component.h" +#include "uart_component.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include +#endif + +namespace esphome::uart { + +/// ESP-IDF UART driver wrapper. +/// +/// Thread safety: All public methods must only be called from the main loop. +/// The ESP-IDF UART driver API does not guarantee thread safety, and ESPHome's +/// peek byte state (has_peek_/peek_byte_) is not synchronized. +class IDFUARTComponent final : public UARTComponent, public Component { + public: + // User provided, not "= default": `new(p) IDFUARTComponent()` would zero-fill .bss that is already zero. + IDFUARTComponent() {} + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void set_rx_full_threshold(size_t rx_full_threshold) override; + void set_rx_timeout(size_t rx_timeout) override; + + void write_array(const uint8_t *data, size_t len) override; + + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + + size_t available() override; + size_t available_for_write() override; + UARTFlushResult flush() override; + + void set_flush_timeout(uint32_t flush_timeout_ms) override { this->flush_timeout_ms_ = flush_timeout_ms; } + + /// TX ring buffer size for the driver; 0 leaves TX unbuffered so write_array() blocks until + /// the FIFO has taken everything. + void set_tx_buffer_size(size_t tx_buffer_size) { this->tx_buffer_size_ = tx_buffer_size; } + + void set_clock_source(uart_sclk_t clock_source) { this->clock_source_ = static_cast(clock_source); } + + uint8_t get_hw_serial_number() { return this->uart_num_; } + + /// Discard everything received so far: the peek cache and the driver's RX buffer. + void flush_input() { + this->has_peek_ = false; + uart_flush_input(this->uart_num_); + } + + /** + * Load the UART with the current settings. + * @param dump_config (Optional, default `true`): True for displaying new settings or + * false to change it quitely + * + * Example: + * ```cpp + * id(uart1).load_settings(); + * ``` + * + * This will load the current UART interface with the latest settings (baud_rate, parity, etc). + */ + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience + + /** + * Apply the current framing (baud rate, parity, data/stop bits) to the installed + * driver in place, without the delete/reinstall of load_settings(). Tasks blocked in + * the driver survive and the ring buffers are kept, but both hardware FIFOs are + * flushed: a frame in flight reaches the peer truncated and bytes not yet out of the + * RX FIFO are dropped. No lock is taken: quiesce writers first if that matters. + * rx_full_threshold is not rescaled (call set_rx_full_threshold_ms() first if it + * should follow the baud rate); a rollback restores the value from the last accepted + * configuration, undoing a standalone set_rx_full_threshold() made since. Without an + * installed driver this is a full load_settings(false) instead. + * + * @return ESP_OK once the new framing is live (a line-setting error after that only + * logs). On rejection (unreachable baud rate) the previous framing is restored and + * the driver's error returned; if the restore fails too the component is marked + * failed. ESP_ERR_INVALID_STATE if already failed; ESP_FAIL if the fallback + * load_settings() fails. + */ + esp_err_t apply_settings_live(); + + void on_shutdown() override; + + protected: + void check_logger_conflict() override; + uint32_t line_inversion_mask_(); + // Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode. + esp_err_t apply_line_settings_(); + uart_port_t uart_num_{UART_NUM_MAX}; + uart_config_t get_config_(); + + struct Framing { + uint32_t baud_rate; + uint8_t data_bits; + uint8_t stop_bits; + UARTParityOptions parity; + size_t rx_full_threshold; // sized for the baud rate, so rolled back with it + }; + Framing framing_() const { + return {this->baud_rate_, this->data_bits_, this->stop_bits_, this->parity_, this->rx_full_threshold_}; + } + void set_framing_(const Framing &framing); + // Last framing the driver accepted; baud_rate 0 means none yet. + Framing last_good_framing_{}; + + bool has_peek_{false}; + uint8_t peek_byte_{0}; + uint8_t clock_source_{UART_SCLK_DEFAULT}; ///< uart_sclk_t stored in a byte; the IDF values are all small. + uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + size_t tx_buffer_size_{0}; + +#ifdef USE_UART_WAKE_LOOP_ON_RX + // ISR callback for UART RX data notification — wakes the main loop directly. + static void uart_rx_isr_callback(uart_port_t uart_num, uart_select_notif_t uart_select_notif, BaseType_t *task_woken); +#endif // USE_UART_WAKE_LOOP_ON_RX +}; + +} // namespace esphome::uart +#endif // USE_ESP32 diff --git a/components/uart/uart_component_host.cpp b/components/uart/uart_component_host.cpp new file mode 100644 index 0000000..bf9ab2d --- /dev/null +++ b/components/uart/uart_component_host.cpp @@ -0,0 +1,297 @@ +#ifdef USE_HOST +#include "uart_component_host.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#if !(defined(__linux__) || defined(__APPLE__)) +#error This HostUartComponent implementation is not supported on this host OS +#endif + +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +namespace { + +speed_t get_baud(int baud) { +#ifdef __APPLE__ + return baud; +#else + switch (baud) { + case 50: + return B50; + case 75: + return B75; + case 110: + return B110; + case 134: + return B134; + case 150: + return B150; + case 200: + return B200; + case 300: + return B300; + case 600: + return B600; + case 1200: + return B1200; + case 1800: + return B1800; + case 2400: + return B2400; + case 4800: + return B4800; + case 9600: + return B9600; + case 19200: + return B19200; + case 38400: + return B38400; + case 57600: + return B57600; + case 115200: + return B115200; + case 230400: + return B230400; + case 460800: + return B460800; + case 500000: + return B500000; + case 576000: + return B576000; + case 921600: + return B921600; + case 1000000: + return B1000000; + case 1152000: + return B1152000; + case 1500000: + return B1500000; + case 2000000: + return B2000000; + case 2500000: + return B2500000; + case 3000000: + return B3000000; + case 3500000: + return B3500000; + case 4000000: + return B4000000; + default: + return B0; + } +#endif +} + +} // namespace + +namespace esphome::uart { + +static const char *const TAG = "uart"; + +HostUartComponent::~HostUartComponent() { + if (this->file_descriptor_ != -1) { + close(this->file_descriptor_); + this->file_descriptor_ = -1; + } +} + +void HostUartComponent::setup() { + ESP_LOGCONFIG(TAG, "Opening UART port"); + speed_t baud = get_baud(this->baud_rate_); + if (baud == B0) { + ESP_LOGE(TAG, "Unsupported baud rate: %d", this->baud_rate_); + this->mark_failed(); + return; + } + this->file_descriptor_ = ::open(this->port_name_.c_str(), O_RDWR | O_NOCTTY | O_NDELAY); + if (this->file_descriptor_ == -1) { + this->update_error_(strerror(errno)); + this->mark_failed(); + return; + } + fcntl(this->file_descriptor_, F_SETFL, 0); + struct termios options; + tcgetattr(this->file_descriptor_, &options); + cfmakeraw(&options); + options.c_cflag &= ~CRTSCTS; + options.c_cflag |= CREAD | CLOCAL; + options.c_iflag &= ~(IXOFF | IXANY); + // Set data bits + options.c_cflag &= ~CSIZE; // Mask the character size bits + switch (this->data_bits_) { + case 5: + options.c_cflag |= CS5; + break; + case 6: + options.c_cflag |= CS6; + break; + case 7: + options.c_cflag |= CS7; + break; + case 8: + default: + options.c_cflag |= CS8; + break; + } + // Set parity + switch (this->parity_) { + case UART_CONFIG_PARITY_NONE: + options.c_cflag &= ~PARENB; + break; + case UART_CONFIG_PARITY_EVEN: + options.c_cflag |= PARENB; + options.c_cflag &= ~PARODD; + break; + case UART_CONFIG_PARITY_ODD: + options.c_cflag |= PARENB; + options.c_cflag |= PARODD; + break; + default: + break; + }; + // Set stop bits + if (this->stop_bits_ == 2) { + options.c_cflag |= CSTOPB; + } else { + options.c_cflag &= ~CSTOPB; + } + cfsetispeed(&options, baud); + cfsetospeed(&options, baud); + tcsetattr(this->file_descriptor_, TCSANOW, &options); +} + +void HostUartComponent::dump_config() { + ESP_LOGCONFIG(TAG, "UART:"); + ESP_LOGCONFIG(TAG, " Port: %s", this->port_name_.c_str()); + if (this->file_descriptor_ == -1) { + ESP_LOGCONFIG(TAG, " Port status: Not opened"); + if (!this->first_error_.empty()) { + ESP_LOGCONFIG(TAG, " Error: %s", this->first_error_.c_str()); + } + return; + } + ESP_LOGCONFIG(TAG, + " Port status: opened\n" + " Baud Rate: %d\n" + " Data Bits: %d\n" + " Parity: %s\n" + " Stop Bits: %d", + this->baud_rate_, this->data_bits_, + this->parity_ == UART_CONFIG_PARITY_NONE ? "None" + : this->parity_ == UART_CONFIG_PARITY_EVEN ? "Even" + : "Odd", + this->stop_bits_); + this->check_logger_conflict(); +} + +void HostUartComponent::write_array(const uint8_t *data, size_t len) { + if (this->file_descriptor_ == -1) { + return; + } + size_t written = ::write(this->file_descriptor_, data, len); + if (written != len) { + this->update_error_(strerror(errno)); + return; + } +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif + return; +} + +bool HostUartComponent::peek_byte(uint8_t *data) { + if (this->file_descriptor_ == -1) { + return false; + } + if (!this->has_peek_) { + if (!this->check_read_timeout_()) { + return false; + } + if (::read(this->file_descriptor_, &this->peek_byte_, 1) != 1) { + this->update_error_(strerror(errno)); + return false; + } + this->has_peek_ = true; + } + *data = this->peek_byte_; + return true; +} + +bool HostUartComponent::read_array(uint8_t *data, size_t len) { + if ((this->file_descriptor_ == -1) || (len == 0)) { + return false; + } + if (!this->check_read_timeout_(len)) + return false; + size_t length_to_read = len; + if (this->has_peek_) { + length_to_read--; + *data = this->peek_byte_; + this->has_peek_ = false; + } + if (length_to_read > 0) { + int sz = ::read(this->file_descriptor_, data + (len - length_to_read), length_to_read); + if (sz == -1) { + this->update_error_(strerror(errno)); + return false; + } + } +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return true; +} + +size_t HostUartComponent::available() { + if (this->file_descriptor_ == -1) { + return 0; + } + int available; + int res = ioctl(this->file_descriptor_, FIONREAD, &available); + if (res == -1) { + this->update_error_(strerror(errno)); + return 0; + } + size_t result = available; + if (this->has_peek_) + result++; + return result; +}; + +UARTFlushResult HostUartComponent::flush() { + if (this->file_descriptor_ == -1) { + return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; + } + ESP_LOGV(TAG, " Flushing"); + if (tcdrain(this->file_descriptor_) == -1) { + this->update_error_(strerror(errno)); + return UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + return UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; +} + +void HostUartComponent::update_error_(const std::string &error) { + if (this->first_error_.empty()) { + this->first_error_ = error; + } + ESP_LOGE(TAG, "Port error: %s", error.c_str()); +} + +} // namespace esphome::uart +#endif // USE_HOST diff --git a/components/uart/uart_component_host.h b/components/uart/uart_component_host.h new file mode 100644 index 0000000..bca62de --- /dev/null +++ b/components/uart/uart_component_host.h @@ -0,0 +1,35 @@ +#pragma once + +#ifdef USE_HOST + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "uart_component.h" + +namespace esphome::uart { + +class HostUartComponent final : public UARTComponent, public Component { + public: + virtual ~HostUartComponent(); + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + size_t available() override; + UARTFlushResult flush() override; + void set_name(std::string port_name) { port_name_ = port_name; }; + + protected: + void update_error_(const std::string &error); + void check_logger_conflict() override {} + std::string port_name_; + std::string first_error_; + int file_descriptor_ = -1; + bool has_peek_{false}; + uint8_t peek_byte_; +}; + +} // namespace esphome::uart +#endif // USE_HOST diff --git a/components/uart/uart_component_libretiny.cpp b/components/uart/uart_component_libretiny.cpp new file mode 100644 index 0000000..280bafc --- /dev/null +++ b/components/uart/uart_component_libretiny.cpp @@ -0,0 +1,208 @@ +#ifdef USE_LIBRETINY + +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "uart_component_libretiny.h" + +#include "esphome/components/libretiny/core.h" + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +#if LT_ARD_HAS_SOFTSERIAL +#include +#endif + +namespace esphome::uart { + +static const char *const TAG = "uart"; + +static const char *const UART_TYPE[] = { + "hardware", + "software", +}; + +uint16_t LibreTinyUARTComponent::get_config() { + uint16_t config = 0; + + switch (this->parity_) { + case UART_CONFIG_PARITY_NONE: + config |= SERIAL_PARITY_NONE; + break; + case UART_CONFIG_PARITY_EVEN: + config |= SERIAL_PARITY_EVEN; + break; + case UART_CONFIG_PARITY_ODD: + config |= SERIAL_PARITY_ODD; + break; + default: + break; + } + + config |= (this->data_bits_ - 4) << 8; + config |= 0x10 + (this->stop_bits_ - 1) * 0x20; + + return config; +} + +void LibreTinyUARTComponent::setup() { + int16_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); + int16_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); + + auto should_fallback_to_software_serial = [&]() -> bool { + auto has_flags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { + return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE; + }; + if (has_flags(this->tx_pin_, + gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || + has_flags(this->rx_pin_, + gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { +#if LT_ARD_HAS_SOFTSERIAL + ESP_LOGI(TAG, "Pins have flags set. Using Software Serial"); + return true; +#else + ESP_LOGW(TAG, "Pin flags are set but not supported for hardware serial. Ignoring"); +#endif + } + return false; + }; + + if (false) { // NOLINT(readability-simplify-boolean-expr) + return; + } +#if LT_HW_UART0 + else if ((((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && + (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX)) || + (tx_pin == 14 && rx_pin == 13)) && + !should_fallback_to_software_serial()) { + this->serial_ = &Serial0; + this->hardware_idx_ = 0; + } +#endif +#if LT_HW_UART1 + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL1_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL1_RX) && + !should_fallback_to_software_serial()) { + this->serial_ = &Serial1; + this->hardware_idx_ = 1; + } +#endif +#if LT_HW_UART2 + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL2_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL2_RX) && + !should_fallback_to_software_serial()) { + this->serial_ = &Serial2; + this->hardware_idx_ = 2; + } +#endif + else { +#if LT_ARD_HAS_SOFTSERIAL + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); + bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); + this->serial_ = new SoftwareSerial(rx_pin, tx_pin, rx_inverted || tx_inverted); +#else + this->serial_ = &Serial; + ESP_LOGE(TAG, " SoftwareSerial is not implemented for this chip. Only hardware pins are supported:"); +#if LT_HW_UART0 + ESP_LOGE(TAG, " TX=%u, RX=%u", PIN_SERIAL0_TX, PIN_SERIAL0_RX); +#endif +#if LT_HW_UART1 + ESP_LOGE(TAG, " TX=%u, RX=%u", PIN_SERIAL1_TX, PIN_SERIAL1_RX); +#endif +#if LT_HW_UART2 + ESP_LOGE(TAG, " TX=%u, RX=%u", PIN_SERIAL2_TX, PIN_SERIAL2_RX); +#endif + this->mark_failed(LOG_STR("SoftwareSerial is not implemented for this chip.")); + return; +#endif + } + + if (this->hardware_idx_ >= 0) + libretiny::ensure_serial_handle(this->hardware_idx_); + if (this->hardware_idx_ == 0 && tx_pin == 14 && rx_pin == 13) { + ESP_LOGI(TAG, "Using alternate UART0 pins RX=PA13 TX=PA14"); + Serial0.setPins(rx_pin, tx_pin); + } + this->serial_->begin(this->baud_rate_, get_config()); +} + +void LibreTinyUARTComponent::dump_config() { + bool is_software = this->hardware_idx_ == -1; + ESP_LOGCONFIG(TAG, + "UART Bus:\n" + " Type: %s", + UART_TYPE[is_software]); + if (!is_software) { + ESP_LOGCONFIG(TAG, " Port number: %d", this->hardware_idx_); + } + LOG_PIN(" TX Pin: ", tx_pin_); + LOG_PIN(" RX Pin: ", rx_pin_); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); + } + ESP_LOGCONFIG(TAG, + " Baud Rate: %" PRIu32 " baud\n" + " Data Bits: %u\n" + " Parity: %s\n" + " Stop bits: %u", + this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); + this->check_logger_conflict(); +} + +void LibreTinyUARTComponent::write_array(const uint8_t *data, size_t len) { + this->serial_->write(data, len); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif +} + +bool LibreTinyUARTComponent::peek_byte(uint8_t *data) { + if (!this->check_read_timeout_()) + return false; + *data = this->serial_->peek(); + return true; +} + +bool LibreTinyUARTComponent::read_array(uint8_t *data, size_t len) { + if (!this->check_read_timeout_(len)) + return false; + this->serial_->readBytes(data, len); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return true; +} + +size_t LibreTinyUARTComponent::available() { return this->serial_->available(); } +UARTFlushResult LibreTinyUARTComponent::flush() { + ESP_LOGVV(TAG, " Flushing"); + this->serial_->flush(); + return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; +} + +void LibreTinyUARTComponent::check_logger_conflict() { +#ifdef USE_LOGGER + if (this->hardware_idx_ == -1 || logger::global_logger->get_baud_rate() == 0) { + return; + } + + if (this->serial_ == logger::global_logger->get_hw_serial()) { + ESP_LOGW(TAG, " You're using the same serial port for logging and the UART component. Please " + "disable logging over the serial port by setting logger->baud_rate to 0."); + } +#endif +} + +} // namespace esphome::uart +#endif // USE_LIBRETINY diff --git a/components/uart/uart_component_libretiny.h b/components/uart/uart_component_libretiny.h new file mode 100644 index 0000000..aa13a01 --- /dev/null +++ b/components/uart/uart_component_libretiny.h @@ -0,0 +1,40 @@ +#pragma once + +#ifdef USE_LIBRETINY + +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "uart_component.h" + +namespace esphome::uart { + +class LibreTinyUARTComponent final : public UARTComponent, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void write_array(const uint8_t *data, size_t len) override; + + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + + size_t available() override; + UARTFlushResult flush() override; + + uint16_t get_config(); + + HardwareSerial *get_hw_serial() { return this->serial_; } + int8_t get_hw_serial_number() { return this->hardware_idx_; } + + protected: + void check_logger_conflict() override; + + HardwareSerial *serial_{nullptr}; + int8_t hardware_idx_{-1}; +}; + +} // namespace esphome::uart +#endif // USE_LIBRETINY diff --git a/components/uart/uart_component_rp2.cpp b/components/uart/uart_component_rp2.cpp new file mode 100644 index 0000000..ffb9bc0 --- /dev/null +++ b/components/uart/uart_component_rp2.cpp @@ -0,0 +1,218 @@ +#ifdef USE_RP2 +#include "uart_component_rp2.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif + +namespace esphome::uart { + +static const char *const TAG = "uart"; + +uint16_t RP2UartComponent::get_config() { + uint16_t config = 0; + + if (this->parity_ == UART_CONFIG_PARITY_NONE) { + config |= UART_PARITY_NONE; + } else if (this->parity_ == UART_CONFIG_PARITY_EVEN) { + config |= UART_PARITY_EVEN; + } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { + config |= UART_PARITY_ODD; + } + + switch (this->data_bits_) { + case 5: + config |= SERIAL_DATA_5; + break; + case 6: + config |= SERIAL_DATA_6; + break; + case 7: + config |= SERIAL_DATA_7; + break; + case 8: + config |= SERIAL_DATA_8; + break; + } + + if (this->stop_bits_ == 1) { + config |= SERIAL_STOP_BIT_1; + } else { + config |= SERIAL_STOP_BIT_2; + } + + return config; +} + +void RP2UartComponent::setup() { + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); + } + + uint16_t config = get_config(); + + constexpr uint32_t valid_tx_uart_0 = __bitset({0, 12, 16, 28}); + constexpr uint32_t valid_tx_uart_1 = __bitset({4, 8, 20, 24}); + + constexpr uint32_t valid_rx_uart_0 = __bitset({1, 13, 17, 29}); + constexpr uint32_t valid_rx_uart_1 = __bitset({5, 9, 21, 25}); + + int8_t tx_hw = -1; + int8_t rx_hw = -1; + + if (this->tx_pin_ != nullptr) { + if (this->tx_pin_->is_inverted()) { + ESP_LOGD(TAG, "An inverted TX pin %u can only be used with SerialPIO", this->tx_pin_->get_pin()); + } else { + if (((1 << this->tx_pin_->get_pin()) & valid_tx_uart_0) != 0) { + tx_hw = 0; + } else if (((1 << this->tx_pin_->get_pin()) & valid_tx_uart_1) != 0) { + tx_hw = 1; + } else { + ESP_LOGD(TAG, "TX pin %u can only be used with SerialPIO", this->tx_pin_->get_pin()); + } + } + } + + if (this->rx_pin_ != nullptr) { + if (this->rx_pin_->is_inverted()) { + ESP_LOGD(TAG, "An inverted RX pin %u can only be used with SerialPIO", this->rx_pin_->get_pin()); + } else { + if (((1 << this->rx_pin_->get_pin()) & valid_rx_uart_0) != 0) { + rx_hw = 0; + } else if (((1 << this->rx_pin_->get_pin()) & valid_rx_uart_1) != 0) { + rx_hw = 1; + } else { + ESP_LOGD(TAG, "RX pin %u can only be used with SerialPIO", this->rx_pin_->get_pin()); + } + } + } + + // Determine which hardware UART to use. A pin that is not specified + // should not prevent hardware UART selection — one-way UART is valid. + // When both pins are configured, both must be HW-capable and agree on UART number. + // When only one pin is configured (nullptr other), use that pin's HW UART. + // If a pin is configured but not HW-capable (inverted/invalid), fall back to SerialPIO. + int8_t hw_uart = -1; + const bool tx_configured = (this->tx_pin_ != nullptr); + const bool rx_configured = (this->rx_pin_ != nullptr); + + if (tx_configured && rx_configured) { + // Both pins configured — both must map to the same hardware UART + if (tx_hw != -1 && rx_hw != -1 && tx_hw == rx_hw) { + hw_uart = tx_hw; + } + } else if (tx_configured) { + hw_uart = tx_hw; + } else if (rx_configured) { + hw_uart = rx_hw; + } + +#ifdef USE_LOGGER + if (hw_uart != -1 && logger::global_logger->get_uart() == hw_uart) { + ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", hw_uart); + hw_uart = -1; + } +#endif + + if (hw_uart == -1) { + ESP_LOGV(TAG, "Using SerialPIO"); + pin_size_t tx = this->tx_pin_ == nullptr ? NOPIN : this->tx_pin_->get_pin(); + pin_size_t rx = this->rx_pin_ == nullptr ? NOPIN : this->rx_pin_->get_pin(); + auto *serial = new SerialPIO(tx, rx, this->rx_buffer_size_); // NOLINT(cppcoreguidelines-owning-memory) + serial->begin(this->baud_rate_, config); + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) + gpio_set_outover(tx, GPIO_OVERRIDE_INVERT); + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) + gpio_set_inover(rx, GPIO_OVERRIDE_INVERT); + this->serial_ = serial; + } else { + ESP_LOGV(TAG, "Using Hardware Serial"); + SerialUART *serial; + if (hw_uart == 0) { + serial = &Serial1; + } else { + serial = &Serial2; + } + if (this->tx_pin_ != nullptr) + serial->setTX(this->tx_pin_->get_pin()); + if (this->rx_pin_ != nullptr) + serial->setRX(this->rx_pin_->get_pin()); + serial->setFIFOSize(this->rx_buffer_size_); + serial->begin(this->baud_rate_, config); + this->serial_ = serial; + this->hw_serial_ = true; + } +} + +void RP2UartComponent::dump_config() { + ESP_LOGCONFIG(TAG, "UART Bus:"); + LOG_PIN(" TX Pin: ", tx_pin_); + LOG_PIN(" RX Pin: ", rx_pin_); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); + } + ESP_LOGCONFIG(TAG, + " Baud Rate: %u baud\n" + " Data Bits: %u\n" + " Parity: %s\n" + " Stop bits: %u", + this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); + if (this->hw_serial_) { + ESP_LOGCONFIG(TAG, " Using hardware serial"); + } else { + ESP_LOGCONFIG(TAG, " Using SerialPIO"); + } +} + +void RP2UartComponent::write_array(const uint8_t *data, size_t len) { + this->serial_->write(data, len); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_TX, data[i]); + } +#endif +} +bool RP2UartComponent::peek_byte(uint8_t *data) { + if (!this->check_read_timeout_()) + return false; + *data = this->serial_->peek(); + return true; +} +bool RP2UartComponent::read_array(uint8_t *data, size_t len) { + if (!this->check_read_timeout_(len)) + return false; + this->serial_->readBytes(data, len); +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(UART_DIRECTION_RX, data[i]); + } +#endif + return true; +} +size_t RP2UartComponent::available() { return this->serial_->available(); } +UARTFlushResult RP2UartComponent::flush() { + ESP_LOGVV(TAG, " Flushing"); + this->serial_->flush(); + return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; +} + +} // namespace esphome::uart +#endif // USE_RP2 diff --git a/components/uart/uart_component_rp2.h b/components/uart/uart_component_rp2.h new file mode 100644 index 0000000..734bc60 --- /dev/null +++ b/components/uart/uart_component_rp2.h @@ -0,0 +1,43 @@ +#pragma once + +#ifdef USE_RP2 + +#include +#include + +#include +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "uart_component.h" + +namespace esphome::uart { + +class RP2UartComponent final : public UARTComponent, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void write_array(const uint8_t *data, size_t len) override; + + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + + size_t available() override; + UARTFlushResult flush() override; + + uint16_t get_config(); + + bool is_hw_serial() { return this->hw_serial_; } + HardwareSerial *get_hw_serial() { return this->serial_; } + + protected: + void check_logger_conflict() override {} + bool hw_serial_{false}; + + HardwareSerial *serial_{nullptr}; +}; + +} // namespace esphome::uart +#endif // USE_RP2 diff --git a/components/uart/uart_debugger.cpp b/components/uart/uart_debugger.cpp new file mode 100644 index 0000000..e0be599 --- /dev/null +++ b/components/uart/uart_debugger.cpp @@ -0,0 +1,202 @@ +#include "esphome/core/defines.h" +#ifdef USE_UART_DEBUGGER + +#include +#include "uart_debugger.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::uart { + +static const char *const TAG = "uart_debug"; + +UARTDebugger::UARTDebugger(UARTComponent *parent) { + parent->add_debug_callback([this](UARTDirection direction, uint8_t byte) { + if (!this->is_my_direction_(direction) || this->is_recursive_()) { + return; + } + this->trigger_after_direction_change_(direction); + this->store_byte_(direction, byte); + this->trigger_after_delimiter_(byte); + this->trigger_after_bytes_(); + }); +} + +void UARTDebugger::loop() { this->trigger_after_timeout_(); } + +bool UARTDebugger::is_my_direction_(UARTDirection direction) { + return this->for_direction_ == UART_DIRECTION_BOTH || this->for_direction_ == direction; +} + +bool UARTDebugger::is_recursive_() { return this->is_triggering_; } + +void UARTDebugger::trigger_after_direction_change_(UARTDirection direction) { + if (this->has_buffered_bytes_() && this->for_direction_ == UART_DIRECTION_BOTH && + this->last_direction_ != direction) { + this->fire_trigger_(); + } +} + +void UARTDebugger::store_byte_(UARTDirection direction, uint8_t byte) { + this->bytes_.push_back(byte); + this->last_direction_ = direction; + this->last_time_ = millis(); +} + +void UARTDebugger::trigger_after_delimiter_(uint8_t byte) { + if (this->after_delimiter_.empty() || !this->has_buffered_bytes_()) { + return; + } + if (this->after_delimiter_[this->after_delimiter_pos_] != byte) { + this->after_delimiter_pos_ = 0; + return; + } + this->after_delimiter_pos_++; + if (this->after_delimiter_pos_ == this->after_delimiter_.size()) { + this->fire_trigger_(); + this->after_delimiter_pos_ = 0; + } +} + +void UARTDebugger::trigger_after_bytes_() { + if (this->has_buffered_bytes_() && this->after_bytes_ > 0 && this->bytes_.size() >= this->after_bytes_) { + this->fire_trigger_(); + } +} + +void UARTDebugger::trigger_after_timeout_() { + if (this->has_buffered_bytes_() && this->after_timeout_ > 0 && millis() - this->last_time_ >= this->after_timeout_) { + this->fire_trigger_(); + } +} + +bool UARTDebugger::has_buffered_bytes_() { return !this->bytes_.empty(); } + +void UARTDebugger::fire_trigger_() { + this->is_triggering_ = true; + trigger(this->last_direction_, this->bytes_, this->debug_prefix_); + this->bytes_.clear(); + this->is_triggering_ = false; +} + +void UARTDummyReceiver::loop() { + // Reading up to a limited number of bytes, to make sure that this loop() + // won't lock up the system on a continuous incoming stream of bytes. + uint8_t data; + int count = 50; + while (this->available() && count--) { + this->read_byte(&data); + } +} + +// In the upcoming log functions, a delay was added after all log calls. +// This is done to allow the system to ship the log lines via the API +// TCP connection(s). Without these delays, debug log lines could go +// missing when UART devices block the main loop for too long. + +void UARTDebug::log_hex(UARTDirection direction, std::vector bytes, uint8_t separator, StringRef prefix) { + std::string res; + if (direction == UART_DIRECTION_RX) { + res += "<<< "; + } else { + res += ">>> "; + } + size_t len = bytes.size(); + char buf[5]; + for (size_t i = 0; i < len; i++) { + if (i > 0) { + res += separator; + } + buf_append_printf(buf, sizeof(buf), 0, "%02X", bytes[i]); + res += buf; + } + ESP_LOGD(TAG, "%s%s", prefix.c_str(), res.c_str()); + delay(10); +} + +void UARTDebug::log_string(UARTDirection direction, std::vector bytes, StringRef prefix) { + std::string res; + if (direction == UART_DIRECTION_RX) { + res += "<<< \""; + } else { + res += ">>> \""; + } + size_t len = bytes.size(); + char buf[5]; + for (size_t i = 0; i < len; i++) { + if (bytes[i] == 7) { + res += "\\a"; + } else if (bytes[i] == 8) { + res += "\\b"; + } else if (bytes[i] == 9) { + res += "\\t"; + } else if (bytes[i] == 10) { + res += "\\n"; + } else if (bytes[i] == 11) { + res += "\\v"; + } else if (bytes[i] == 12) { + res += "\\f"; + } else if (bytes[i] == 13) { + res += "\\r"; + } else if (bytes[i] == 27) { + res += "\\e"; + } else if (bytes[i] == 34) { + res += "\\\""; + } else if (bytes[i] == 39) { + res += "\\'"; + } else if (bytes[i] == 92) { + res += "\\\\"; + } else if (bytes[i] < 32 || bytes[i] > 127) { + buf_append_printf(buf, sizeof(buf), 0, "\\x%02X", bytes[i]); + res += buf; + } else { + res += bytes[i]; + } + } + res += '"'; + ESP_LOGD(TAG, "%s%s", prefix.c_str(), res.c_str()); + delay(10); +} + +void UARTDebug::log_int(UARTDirection direction, std::vector bytes, uint8_t separator, StringRef prefix) { + std::string res; + size_t len = bytes.size(); + if (direction == UART_DIRECTION_RX) { + res += "<<< "; + } else { + res += ">>> "; + } + char buf[4]; // max 3 digits for uint8_t (255) + null + for (size_t i = 0; i < len; i++) { + if (i > 0) { + res += separator; + } + buf_append_printf(buf, sizeof(buf), 0, "%u", bytes[i]); + res += buf; + } + ESP_LOGD(TAG, "%s%s", prefix.c_str(), res.c_str()); + delay(10); +} + +void UARTDebug::log_binary(UARTDirection direction, std::vector bytes, uint8_t separator, StringRef prefix) { + std::string res; + size_t len = bytes.size(); + if (direction == UART_DIRECTION_RX) { + res += "<<< "; + } else { + res += ">>> "; + } + char buf[20]; + for (size_t i = 0; i < len; i++) { + if (i > 0) { + res += separator; + } + buf_append_printf(buf, sizeof(buf), 0, "0b" BYTE_TO_BINARY_PATTERN " (0x%02X)", BYTE_TO_BINARY(bytes[i]), bytes[i]); + res += buf; + } + ESP_LOGD(TAG, "%s%s", prefix.c_str(), res.c_str()); + delay(10); +} + +} // namespace esphome::uart +#endif diff --git a/components/uart/uart_debugger.h b/components/uart/uart_debugger.h new file mode 100644 index 0000000..b69dcf0 --- /dev/null +++ b/components/uart/uart_debugger.h @@ -0,0 +1,106 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_UART_DEBUGGER + +#include +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/core/string_ref.h" +#include "uart.h" +#include "uart_component.h" + +namespace esphome::uart { + +/// The UARTDebugger class adds debugging support to a UART bus. +/// +/// It accumulates bytes that travel over the UART bus and triggers one or +/// more actions that can log the data at an appropriate time. What +/// 'appropriate time' means exactly, is determined by a number of +/// configurable constraints. E.g. when a given number of bytes is gathered +/// and/or when no more data has been seen for a given time interval. +class UARTDebugger final : public Component, public Trigger, StringRef> { + public: + explicit UARTDebugger(UARTComponent *parent); + void loop() override; + + /// Set the direction in which to inspect the bytes: incoming, outgoing + /// or both. When debugging in both directions, logging will be triggered + /// when the direction of the data stream changes. + void set_direction(UARTDirection direction) { this->for_direction_ = direction; } + + /// Set the maximum number of bytes to accumulate. When the number of bytes + /// is reached, logging will be triggered. + void set_after_bytes(size_t size) { this->after_bytes_ = size; } + + /// Set a timeout for the data stream. When no new bytes are seen during + /// this timeout, logging will be triggered. + void set_after_timeout(uint32_t timeout) { this->after_timeout_ = timeout; } + + /// Add a delimiter byte. This can be called multiple times to setup a + /// multi-byte delimiter (a typical example would be '\r\n'). + /// When the constructed byte sequence is found in the data stream, + /// logging will be triggered. + void add_delimiter_byte(uint8_t byte) { this->after_delimiter_.push_back(byte); } + + void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } + + protected: + UARTDirection for_direction_; + UARTDirection last_direction_{}; + std::vector bytes_{}; + size_t after_bytes_; + uint32_t after_timeout_; + uint32_t last_time_{}; + std::vector after_delimiter_{}; + size_t after_delimiter_pos_{}; + bool is_triggering_{false}; + StringRef debug_prefix_{}; + + bool is_my_direction_(UARTDirection direction); + bool is_recursive_(); + void store_byte_(UARTDirection direction, uint8_t byte); + void trigger_after_direction_change_(UARTDirection direction); + void trigger_after_delimiter_(uint8_t byte); + void trigger_after_bytes_(); + void trigger_after_timeout_(); + bool has_buffered_bytes_(); + void fire_trigger_(); +}; + +/// This UARTDevice is used by the serial debugger to read data from a +/// serial interface when the 'dummy_receiver' option is enabled. +/// The data are not stored, nor processed. This is most useful when the +/// debugger is used to reverse engineer a serial protocol, for which no +/// specific UARTDevice implementation exists (yet), but for which the +/// incoming bytes must be read to drive the debugger. +class UARTDummyReceiver final : public Component, public UARTDevice { + public: + UARTDummyReceiver(UARTComponent *parent) : UARTDevice(parent) {} + void loop() override; +}; + +/// This class contains some static methods, that can be used to easily +/// create a logging action for the debugger. +class UARTDebug { + public: + /// Log the bytes as hex values, separated by the provided separator + /// character. + static void log_hex(UARTDirection direction, std::vector bytes, uint8_t separator, + StringRef prefix = StringRef()); + + /// Log the bytes as string values, escaping unprintable characters. + static void log_string(UARTDirection direction, std::vector bytes, StringRef prefix = StringRef()); + + /// Log the bytes as integer values, separated by the provided separator + /// character. + static void log_int(UARTDirection direction, std::vector bytes, uint8_t separator, + StringRef prefix = StringRef()); + + /// Log the bytes as ' ()' values, separated by the provided + /// separator. + static void log_binary(UARTDirection direction, std::vector bytes, uint8_t separator, + StringRef prefix = StringRef()); +}; + +} // namespace esphome::uart +#endif diff --git a/server-example.yaml b/server-example.yaml new file mode 100644 index 0000000..5b6d3b3 --- /dev/null +++ b/server-example.yaml @@ -0,0 +1,12 @@ +# Copy this to your ESPHome server's config folder. Everything else comes from +# the repo, and the values for the secrets below come from the server's own +# secrets.yaml: wifi_ssid, wifi_password, ap_password, api_encryption_key. +substitutions: + timezone: "Europe/Amsterdam" + +packages: + lamp: + url: https://mangavalk.com/mangavalk/esphome-wake-up-light-ii.git + ref: v1.0.0 + files: [wake-up-light-ii.yaml] + refresh: 1d diff --git a/wake-up-light-ii.yaml b/wake-up-light-ii.yaml index 3a49d4a..87e6fc5 100644 --- a/wake-up-light-ii.yaml +++ b/wake-up-light-ii.yaml @@ -6,24 +6,44 @@ # WBR3 PA13 = RX0 (from appliance MCU) # WBR3 PA14 = TX0 (to appliance MCU) # -# IMPORTANT: stock ESPHome/LibreTiny UART selection may not route UART0 to -# PA13/PA14 on WBR3. Apply patches/wbr3-pa13-pa14-uart.patch (or the helper -# script in patches/) before compiling if Tuya stays at init_state 0. +# Stock ESPHome/LibreTiny UART selection may not route UART0 to PA13/PA14 on +# WBR3, so this config loads the patched UART component in components/uart +# (patches/wbr3-pa13-pa14-uart.patch applied to ESPHome 2026.10.2). ############################################################################### substitutions: device_name: wake-up-light-ii friendly_name: "Wake Up Light II" ap_ssid: "Wake-Up-Light-II" - ap_password: "wakeup1234" # Change before publishing/regular use. timezone: "Europe/Berlin" # Change to your IANA timezone. +# Patched UART component. Keep ref equal to the tag this file is released under. +external_components: + - source: + type: git + url: https://mangavalk.com/mangavalk/esphome-wake-up-light-ii.git + ref: v1.0.0 + components: [uart] + refresh: 1d + +# Controls for each alarm live in alarm_block.yaml. +packages: + alarm_1: !include { file: alarm_block.yaml, vars: { n: "1", o: "0", dp: "109" } } + alarm_2: !include { file: alarm_block.yaml, vars: { n: "2", o: "12", dp: "122" } } + alarm_3: !include { file: alarm_block.yaml, vars: { n: "3", o: "24", dp: "123" } } + alarm_4: !include { file: alarm_block.yaml, vars: { n: "4", o: "36", dp: "124" } } + esphome: name: ${device_name} friendly_name: ${friendly_name} rtl87xx: board: wbr3 + # v1.14.0 fixes the restart hang after OTA (LibreTiny issue #396). Drop this + # once ESPHome's default framework version is 1.14.0 or newer. + framework: + version: 1.14.0 + source: https://github.com/libretiny-eu/libretiny.git#v1.14.0 # Disable serial logging: PA13/PA14 belong to the Tuya MCU link. logger: @@ -48,10 +68,10 @@ uart: ############################################################################### globals: - # DP127 is a 16-byte alarm structure on this unit. Never build it from - # scratch: cache what the appliance MCU reported and only edit known bytes. + # DP127 is 48 bytes on this unit: 4 alarm blocks of 12 bytes. Never build it + # from scratch: cache what the appliance MCU reported and only edit known bytes. - id: alarm_raw - type: uint8_t[16] + type: uint8_t[48] restore_value: no - id: alarm_raw_valid @@ -79,6 +99,49 @@ globals: restore_value: no initial_value: 'false' + # DP128: sunset settings, 10 bytes. Cached and edited like DP127. + - id: sunset_raw + type: uint8_t[10] + restore_value: no + + - id: sunset_raw_valid + type: bool + restore_value: no + initial_value: 'false' + + # DP107: stations found by a scan, and the current station. + - id: radio_total + type: uint8_t + restore_value: no + initial_value: '0' + + - id: radio_current + type: uint8_t + restore_value: no + initial_value: '0' + + - id: radio_valid + type: bool + restore_value: no + initial_value: 'false' + + # Knocks counted in the current pattern window. + - id: knock_count + type: int + restore_value: no + initial_value: '0' + + - id: bedtime_start_brightness + type: float + restore_value: no + initial_value: '0' + + # Last display state the schedule applied: -1 none, 0 day, 1 night. + - id: display_schedule_last + type: int + restore_value: no + initial_value: '-1' + ############################################################################### # Optional network time # @@ -100,12 +163,16 @@ time: then: - delay: 2s - script.execute: sync_lamp_clock + - script.execute: apply_display_schedule on_time: - seconds: 0 minutes: 0 then: - script.execute: sync_lamp_clock + - seconds: 0 + then: + - script.execute: apply_display_schedule ############################################################################### # Tuya MCU @@ -137,12 +204,17 @@ tuya: snprintf(buf, sizeof(buf), "%02u:%02u:%02u", hh, mm, ss); id(clock_time_text).publish_state(buf); - # DP107: FM station/preset data. Encoding is not decoded yet. + # DP107: [0] stations found by a scan (0 = none yet), [1] current station. - sensor_datapoint: 107 datapoint_type: raw then: - lambda: |- id(radio_station_raw).publish_state(format_hex_pretty(x)); + if (x.size() != 2) return; + id(radio_total) = x[0]; + id(radio_current) = x[1]; + id(radio_valid) = true; + id(radio_stations_found).publish_state(x[0]); # Keep cached DP127 enable bytes coherent when the dedicated enable DPs move. - sensor_datapoint: 109 @@ -150,88 +222,203 @@ tuya: then: - lambda: |- if (id(alarm_raw_valid)) id(alarm_raw)[0] = x ? 1 : 0; + id(publish_alarm_summaries).execute(); - sensor_datapoint: 122 datapoint_type: bool then: - lambda: |- if (id(alarm_raw_valid)) id(alarm_raw)[12] = x ? 1 : 0; + id(publish_alarm_summaries).execute(); - # DP127: alarm configuration. Confirmed 16-byte layout on this unit: - # [00] Alarm 1 enabled - # [01:02] Alarm 1 time = minutes since midnight, big-endian - # [03] Alarm 1 weekday mask (0x7F observed = all days; bit order unknown) - # [04] unknown (0x1E observed) - # [05] Alarm 1 wake-light enable (0/1) - # [06] Alarm 1 sound: 0=off, 1..7=built-in, 8=FM - # [07] Alarm 1 volume 1..16 - # [08] unknown (0x04 observed) - # [09] sunrise lead/duration in minutes (10..60 according to manual) - # [10] wake-light brightness 0..20 - # [11] unknown - # [12] Alarm 2 enabled - # [13:14] Alarm 2 time = minutes since midnight, big-endian - # [15] Alarm 2 weekday mask - # Alarm 2 sound/volume/light/duration changes did NOT change DP127. + - sensor_datapoint: 123 + datapoint_type: bool + then: + - lambda: |- + if (id(alarm_raw_valid)) id(alarm_raw)[24] = x ? 1 : 0; + id(publish_alarm_summaries).execute(); + + - sensor_datapoint: 124 + datapoint_type: bool + then: + - lambda: |- + if (id(alarm_raw_valid)) id(alarm_raw)[36] = x ? 1 : 0; + id(publish_alarm_summaries).execute(); + + # DP127: alarm configuration, 48 bytes on this unit: 4 blocks of 12 bytes, + # Alarm 1 at offset 0, 2 at 12, 3 at 24, 4 at 36. Layout per Tuya's data + # model for this family (tuya-local issue #3821): + # [00] enabled + # [01:02] time = minutes since midnight, big-endian + # [03] repeat days: bit 0 = Monday ... bit 6 = Sunday, 0 = one time + # [04] ring duration (Tuya says 10-20, this lamp reports 30) + # [05] mode: 0=sound, 1=sound+light, 2=radio, 3=radio+light + # [06] sound (this project also observed 8=FM) + # [07] sound volume 1..16 + # [08] radio station 1..60 + # [09] sunrise lead time in minutes, 10..60 + # [10] brightness after sunrise, 1..20 + # [11] snooze: 0=allowed, 1=not allowed - sensor_datapoint: 127 datapoint_type: raw then: - lambda: |- id(alarm_settings_raw).publish_state(format_hex_pretty(x)); - if (x.size() != 16) { + if (x.size() != 48) { ESP_LOGW("alarm", "Unexpected DP127 size: %u", (unsigned) x.size()); return; } - for (size_t i = 0; i < 16; i++) id(alarm_raw)[i] = x[i]; + for (size_t i = 0; i < 48; i++) id(alarm_raw)[i] = x[i]; id(alarm_raw_valid) = true; + id(publish_alarm_summaries).execute(); - uint16_t a1 = (static_cast(x[1]) << 8) | x[2]; - uint16_t a2 = (static_cast(x[13]) << 8) | x[14]; - unsigned a1_h = a1 / 60, a1_m = a1 % 60; - unsigned a2_h = a2 / 60, a2_m = a2 % 60; - - char sound[20]; - if (x[6] == 0) snprintf(sound, sizeof(sound), "Sound off"); - else if (x[6] == 8) snprintf(sound, sizeof(sound), "FM"); - else snprintf(sound, sizeof(sound), "Sound %u", (unsigned) x[6]); - - char buf[256]; - snprintf(buf, sizeof(buf), - "A1 %s %02u:%02u days=0x%02X | %s vol=%u | light=%s %u/20 | sunrise=%u min | A2 %s %02u:%02u days=0x%02X", - x[0] ? "ON" : "OFF", a1_h, a1_m, x[3], sound, x[7], - x[5] ? "ON" : "OFF", x[10], x[9], - x[12] ? "ON" : "OFF", a2_h, a2_m, x[15]); - id(alarm_summary).publish_state(buf); - - # DP128: sleep/sunset structure, not fully decoded yet. + # DP128: sunset settings, 10 bytes, per Tuya's data model: + # [0] switch (unused) [1] mode: 0=sound, 1=sound+light, 2=radio, 3=radio+light + # [2] sound 1..4 [3] sound volume 1..16 [4] sound duration 10..120 + # [5] radio station 1..60 [6] radio duration 10..120 + # [7] starting brightness 1..20 [8] sunset duration 10..120 + # [9] snooze: 0=allowed, 1=not allowed + # Bytes 4, 6 and 8 are kept equal, as Tuya's definition asks. - sensor_datapoint: 128 datapoint_type: raw then: - lambda: |- id(sunset_settings_raw).publish_state(format_hex_pretty(x)); + if (x.size() != 10) { + ESP_LOGW("sunset", "Unexpected DP128 size: %u", (unsigned) x.size()); + return; + } + for (size_t i = 0; i < 10; i++) id(sunset_raw)[i] = x[i]; + id(sunset_raw_valid) = true; - # DP132 exists in the exact-PID tuya-local definition as ambient color/effect. - # Keep it read-only until this hardware revision is mapped directly. - - sensor_datapoint: 132 - datapoint_type: string - then: - - lambda: |- - id(ambient_mode_raw).publish_state(x); ############################################################################### # Helper scripts ############################################################################### script: + # A knock asks the lamp's MCU to snooze (DP130). Forced because the MCU may + # leave DP130 on, and a normal write is skipped when the value looks unchanged. + # Mode single plus the delay ignores repeat knocks for 3 seconds. + - id: knock_snooze + mode: single + then: + - lambda: |- + ESP_LOGI("knock", "Sending snooze (DP130)"); + id(tuya_mcu).force_set_boolean_datapoint_value(130, true); + - delay: 3s + + # Restarted by every knock, so this runs once the knocking has stopped. + # Exactly two knocks toggle the light; any other count does nothing here. + - id: knock_pattern + mode: restart + then: + - delay: 800ms + - lambda: |- + int count = id(knock_count); + id(knock_count) = 0; + ESP_LOGI("knock", "Knock pattern finished: %d", count); + if (count == 2 && id(double_knock_light).state) { + id(main_light).toggle().perform(); + } + + # Writes the display mode only when the schedule crosses into day or night, + # so changing Clock Display by hand holds until the next crossing. + - id: apply_display_schedule + mode: restart + then: + - lambda: |- + if (!id(display_schedule_enabled).state) return; + auto now = id(network_time).now(); + if (!now.is_valid()) return; + int start = id(display_night_start).hour * 60 + id(display_night_start).minute; + int end = id(display_night_end).hour * 60 + id(display_night_end).minute; + int cur = now.hour * 60 + now.minute; + bool night; + if (start == end) night = false; + else if (start < end) night = cur >= start && cur < end; + else night = cur >= start || cur < end; + int state = night ? 1 : 0; + if (state == id(display_schedule_last)) return; + id(display_schedule_last) = state; + // DP104: 0=off, 1=day, 2=night, 3=auto. + int value; + if (night) value = id(display_night_level).active_index().value_or(1) == 0 ? 0 : 2; + else value = id(display_day_level).active_index().value_or(0) == 0 ? 1 : 3; + ESP_LOGI("display", "Schedule: %s, setting DP104 to %d", night ? "night" : "day", value); + id(tuya_mcu).set_integer_datapoint_value(104, value); + + # 30 steps over the chosen length. Mode restart means pressing start again + # begins a new fade from the current brightness. + - id: bedtime_fade + mode: restart + then: + - lambda: |- + float b = 0.0f; + id(main_light).remote_values.as_brightness(&b); + id(bedtime_start_brightness) = b; + - repeat: + count: 30 + then: + - delay: !lambda 'return static_cast(id(bedtime_fade_minutes).state) * 2000;' + - lambda: |- + if (!id(main_light).remote_values.is_on()) return; + float b = id(bedtime_start_brightness) * (29 - iteration) / 30.0f; + auto call = id(main_light).make_call(); + if (iteration >= 29 || b < 0.02f) call.set_state(false); + else call.set_brightness(b); + call.set_transition_length(0); + call.perform(); + + - id: radio_sleep + mode: restart + then: + - delay: !lambda 'return static_cast(id(radio_sleep_minutes).state) * 60000;' + - switch.turn_off: fm_radio + + - id: publish_alarm_summaries + mode: restart + then: + - lambda: |- + if (!id(alarm_raw_valid)) return; + text_sensor::TextSensor *summaries[4] = { + id(alarm_1_summary), id(alarm_2_summary), id(alarm_3_summary), id(alarm_4_summary)}; + static const char *const modes[4] = {"sound", "sound+light", "radio", "radio+light"}; + for (int n = 0; n < 4; n++) { + const uint8_t *b = id(alarm_raw) + n * 12; + uint16_t mins = (static_cast(b[1]) << 8) | b[2]; + char buf[120]; + snprintf(buf, sizeof(buf), + "%s %02u:%02u days=0x%02X %s sound=%u vol=%u station=%u sunrise=%u min light=%u/20 ring=%u min", + b[0] ? "ON" : "OFF", mins / 60, mins % 60, b[3], b[5] < 4 ? modes[b[5]] : "?", b[6], b[7], b[8], + b[9], b[10], b[4]); + summaries[n]->publish_state(buf); + } + + # The MCU echoes every write and that echo refreshes the cache, so wait for + # quick changes to finish and send them together. + - id: send_sunset_settings + mode: restart + then: + - delay: 500ms + - lambda: |- + if (!id(sunset_raw_valid)) { + ESP_LOGW("sunset", "DP128 has not been received yet; refusing to overwrite it"); + return; + } + std::vector data(id(sunset_raw), id(sunset_raw) + 10); + id(tuya_mcu).set_raw_datapoint_value(128, data); + - id: send_alarm_settings mode: restart then: + - delay: 500ms - lambda: |- if (!id(alarm_raw_valid)) { ESP_LOGW("alarm", "DP127 has not been received yet; refusing to overwrite it"); return; } - std::vector data(id(alarm_raw), id(alarm_raw) + 16); + std::vector data(id(alarm_raw), id(alarm_raw) + 48); ESP_LOGD("alarm", "Writing DP127: %s", format_hex_pretty(data).c_str()); id(tuya_mcu).set_raw_datapoint_value(127, data); @@ -262,10 +449,15 @@ light: min_value: 10 max_value: 1000 gamma_correct: 1.0 + # Keep at 0: a transition here writes to the MCU on every loop, about 50 + # commands a second. Bedtime fade uses its own slow steps instead. default_transition_length: 0s + web_server: + sorting_group_id: group_light + sorting_weight: 1 ############################################################################### -# Writable clock/alarm times +# Clock and display schedule times ############################################################################### datetime: @@ -274,6 +466,9 @@ datetime: name: "Lamp Clock" type: time update_interval: 2s + web_server: + sorting_group_id: group_clock + sorting_weight: 1 lambda: |- if (!id(lamp_clock_valid)) return {}; ESPTime t{}; @@ -289,54 +484,98 @@ datetime: id(tuya_mcu).set_string_datapoint_value(103, std::string(buf)); - platform: template - id: alarm_1_time - name: "Alarm 1 Time" + id: display_night_start + name: "Display Night Start" type: time - update_interval: 2s - lambda: |- - if (!id(alarm_raw_valid)) return {}; - uint16_t mins = (static_cast(id(alarm_raw)[1]) << 8) | id(alarm_raw)[2]; - if (mins >= 1440) return {}; - ESPTime t{}; - t.hour = mins / 60; - t.minute = mins % 60; - t.second = 0; - return t; - set_action: - - lambda: |- - if (!id(alarm_raw_valid)) return; - uint16_t mins = static_cast(x.hour) * 60 + x.minute; - id(alarm_raw)[1] = (mins >> 8) & 0xFF; - id(alarm_raw)[2] = mins & 0xFF; - - script.execute: send_alarm_settings + optimistic: true + restore_value: true + initial_value: "22:30:00" + web_server: + sorting_group_id: group_clock + sorting_weight: 11 + on_value: + - lambda: id(display_schedule_last) = -1; + - script.execute: apply_display_schedule - platform: template - id: alarm_2_time - name: "Alarm 2 Time" + id: display_night_end + name: "Display Night End" type: time - update_interval: 2s - lambda: |- - if (!id(alarm_raw_valid)) return {}; - uint16_t mins = (static_cast(id(alarm_raw)[13]) << 8) | id(alarm_raw)[14]; - if (mins >= 1440) return {}; - ESPTime t{}; - t.hour = mins / 60; - t.minute = mins % 60; - t.second = 0; - return t; - set_action: - - lambda: |- - if (!id(alarm_raw_valid)) return; - uint16_t mins = static_cast(x.hour) * 60 + x.minute; - id(alarm_raw)[13] = (mins >> 8) & 0xFF; - id(alarm_raw)[14] = mins & 0xFF; - - script.execute: send_alarm_settings + optimistic: true + restore_value: true + initial_value: "07:00:00" + web_server: + sorting_group_id: group_clock + sorting_weight: 12 + on_value: + - lambda: id(display_schedule_last) = -1; + - script.execute: apply_display_schedule ############################################################################### # Selects ############################################################################### select: + - platform: template + id: sunset_mode_select + name: "Sunset Type" + options: + - "Sound" + - "Sound + Light" + - "Radio" + - "Radio + Light" + update_interval: 2s + web_server: + sorting_group_id: group_sunset + sorting_weight: 2 + lambda: |- + if (!id(sunset_raw_valid)) return {}; + switch (id(sunset_raw)[1]) { + case 0: return std::string("Sound"); + case 1: return std::string("Sound + Light"); + case 2: return std::string("Radio"); + case 3: return std::string("Radio + Light"); + default: return {}; + } + set_action: + - lambda: |- + if (!id(sunset_raw_valid)) return; + uint8_t value = 0; + if (x == "Sound + Light") value = 1; + else if (x == "Radio") value = 2; + else if (x == "Radio + Light") value = 3; + id(sunset_raw)[1] = value; + // The MCU drops a light mode if brightness is 0, so give it a level. + if ((value == 1 || value == 3) && id(sunset_raw)[7] == 0) id(sunset_raw)[7] = 15; + - script.execute: send_sunset_settings + + - platform: template + id: sunset_sound + name: "Sunset Sound" + options: + - "Sound 1" + - "Sound 2" + - "Sound 3" + - "Sound 4" + update_interval: 2s + web_server: + sorting_group_id: group_sunset + sorting_weight: 3 + lambda: |- + if (!id(sunset_raw_valid)) return {}; + uint8_t s = id(sunset_raw)[2]; + if (s >= 1 && s <= 4) return std::string("Sound ") + std::to_string(s); + return {}; + set_action: + - lambda: |- + if (!id(sunset_raw_valid)) return; + uint8_t value = 1; + if (x == "Sound 2") value = 2; + else if (x == "Sound 3") value = 3; + else if (x == "Sound 4") value = 4; + id(sunset_raw)[2] = value; + - script.execute: send_sunset_settings + - platform: tuya id: clock_display_mode name: "Clock Display" @@ -346,48 +585,184 @@ select: 1: "Day" 2: "Night" 3: "Auto" + web_server: + sorting_group_id: group_clock + sorting_weight: 3 - platform: template - id: alarm_1_sound - name: "Alarm 1 Sound" + id: display_night_level + name: "Display At Night" options: - "Off" - - "Sound 1" - - "Sound 2" - - "Sound 3" - - "Sound 4" - - "Sound 5" - - "Sound 6" - - "Sound 7" - - "FM Radio" - update_interval: 2s - lambda: |- - if (!id(alarm_raw_valid)) return {}; - uint8_t s = id(alarm_raw)[6]; - if (s == 0) return std::string("Off"); - if (s == 8) return std::string("FM Radio"); - if (s >= 1 && s <= 7) return std::string("Sound ") + std::to_string(s); - return {}; - set_action: - - lambda: |- - if (!id(alarm_raw_valid)) return; - uint8_t value = 0; - if (x == "Sound 1") value = 1; - else if (x == "Sound 2") value = 2; - else if (x == "Sound 3") value = 3; - else if (x == "Sound 4") value = 4; - else if (x == "Sound 5") value = 5; - else if (x == "Sound 6") value = 6; - else if (x == "Sound 7") value = 7; - else if (x == "FM Radio") value = 8; - id(alarm_raw)[6] = value; - - script.execute: send_alarm_settings + - "Night" + initial_option: "Night" + optimistic: true + restore_value: true + web_server: + sorting_group_id: group_clock + sorting_weight: 13 + on_value: + - lambda: id(display_schedule_last) = -1; + - script.execute: apply_display_schedule + + - platform: template + id: display_day_level + name: "Display In Daytime" + options: + - "Day" + - "Auto" + initial_option: "Day" + optimistic: true + restore_value: true + web_server: + sorting_group_id: group_clock + sorting_weight: 14 + on_value: + - lambda: id(display_schedule_last) = -1; + - script.execute: apply_display_schedule + + # Labels for these two come from Tuya's data model, not from testing. + # This MCU reports both DPs as enums, so values count from 0. + - platform: tuya + id: snooze_action + name: "Snooze Action" + enum_datapoint: 117 + options: + 0: "Sound + Radio" + 1: "Light" + 2: "Radio" + 3: "Radio + Sound + Light" + web_server: + sorting_group_id: group_snooze + sorting_weight: 3 + + - platform: tuya + id: ambient_color + name: "Ambient Color" + enum_datapoint: 132 + options: + 0: "Cycle" + 1: "Red" + 2: "Orange" + 3: "Yellow" + 4: "Green" + 5: "Cyan" + 6: "Blue" + 7: "Purple" + web_server: + sorting_group_id: group_light + sorting_weight: 3 ############################################################################### # Numbers ############################################################################### number: + # Only meaningful after a Radio Scan has found stations. + - platform: template + id: radio_station + name: "Radio Station" + min_value: 1 + max_value: 60 + step: 1 + update_interval: 2s + web_server: + sorting_group_id: group_radio + sorting_weight: 2.5 + lambda: |- + if (!id(radio_valid) || id(radio_current) == 0) return {}; + return static_cast(id(radio_current)); + set_action: + - lambda: |- + if (!id(radio_valid) || id(radio_total) == 0) { + ESP_LOGW("radio", "No stations found yet; run Radio Scan first"); + return; + } + uint8_t station = static_cast(x); + if (station > id(radio_total)) station = id(radio_total); + std::vector data = {id(radio_total), station}; + id(tuya_mcu).set_raw_datapoint_value(107, data); + + - platform: template + id: sunset_volume + name: "Sunset Volume" + min_value: 1 + max_value: 16 + step: 1 + update_interval: 2s + web_server: + sorting_group_id: group_sunset + sorting_weight: 4 + lambda: |- + if (!id(sunset_raw_valid)) return {}; + return static_cast(id(sunset_raw)[3]); + set_action: + - lambda: |- + if (!id(sunset_raw_valid)) return; + id(sunset_raw)[3] = static_cast(x); + - script.execute: send_sunset_settings + + - platform: template + id: sunset_duration + name: "Sunset Duration" + unit_of_measurement: "min" + min_value: 10 + max_value: 120 + step: 5 + update_interval: 2s + web_server: + sorting_group_id: group_sunset + sorting_weight: 5 + lambda: |- + if (!id(sunset_raw_valid)) return {}; + return static_cast(id(sunset_raw)[8]); + set_action: + - lambda: |- + if (!id(sunset_raw_valid)) return; + uint8_t minutes = static_cast(x); + id(sunset_raw)[4] = minutes; + id(sunset_raw)[6] = minutes; + id(sunset_raw)[8] = minutes; + - script.execute: send_sunset_settings + + - platform: template + id: sunset_brightness + name: "Sunset Start Brightness" + min_value: 1 + max_value: 20 + step: 1 + update_interval: 2s + web_server: + sorting_group_id: group_sunset + sorting_weight: 6 + lambda: |- + if (!id(sunset_raw_valid)) return {}; + return static_cast(id(sunset_raw)[7]); + set_action: + - lambda: |- + if (!id(sunset_raw_valid)) return; + id(sunset_raw)[7] = static_cast(x); + - script.execute: send_sunset_settings + + - platform: template + id: sunset_radio_station + name: "Sunset Radio Station" + min_value: 1 + max_value: 60 + step: 1 + update_interval: 2s + web_server: + sorting_group_id: group_sunset + sorting_weight: 7 + lambda: |- + if (!id(sunset_raw_valid)) return {}; + return static_cast(id(sunset_raw)[5]); + set_action: + - lambda: |- + if (!id(sunset_raw_valid)) return; + id(sunset_raw)[5] = static_cast(x); + - script.execute: send_sunset_settings + - platform: tuya id: radio_volume name: "Radio Volume" @@ -395,63 +770,49 @@ number: min_value: 1 max_value: 16 step: 1 + web_server: + sorting_group_id: group_radio + sorting_weight: 2 - - platform: template - id: alarm_1_volume - name: "Alarm 1 Volume" - min_value: 1 - max_value: 16 - step: 1 - update_interval: 2s - lambda: |- - if (!id(alarm_raw_valid)) return {}; - return static_cast(id(alarm_raw)[7]); - set_action: - - lambda: |- - if (!id(alarm_raw_valid)) return; - id(alarm_raw)[7] = static_cast(x); - - script.execute: send_alarm_settings - - - platform: template - id: alarm_1_wake_brightness - name: "Alarm 1 Wake Light" - min_value: 0 - max_value: 20 - step: 1 - update_interval: 2s - lambda: |- - if (!id(alarm_raw_valid)) return {}; - if (id(alarm_raw)[5] == 0) return 0.0f; - return static_cast(id(alarm_raw)[10]); - set_action: - - lambda: |- - if (!id(alarm_raw_valid)) return; - uint8_t level = static_cast(x); - if (level == 0) { - id(alarm_raw)[5] = 0; - id(alarm_raw)[10] = 0; - } else { - id(alarm_raw)[5] = 1; - id(alarm_raw)[10] = level; - } - - script.execute: send_alarm_settings - - - platform: template - id: alarm_1_sunrise_duration - name: "Alarm 1 Sunrise Duration" + - platform: tuya + id: snooze_duration + name: "Snooze Duration" + number_datapoint: 116 unit_of_measurement: "min" - min_value: 10 - max_value: 60 - step: 10 - update_interval: 2s - lambda: |- - if (!id(alarm_raw_valid)) return {}; - return static_cast(id(alarm_raw)[9]); - set_action: - - lambda: |- - if (!id(alarm_raw_valid)) return; - id(alarm_raw)[9] = static_cast(x); - - script.execute: send_alarm_settings + min_value: 8 + max_value: 15 + step: 1 + web_server: + sorting_group_id: group_snooze + sorting_weight: 2 + + - platform: template + id: bedtime_fade_minutes + name: "Bedtime Fade Length" + unit_of_measurement: "min" + min_value: 1 + max_value: 120 + step: 1 + initial_value: 30 + optimistic: true + restore_value: true + web_server: + sorting_group_id: group_light + sorting_weight: 5 + + - platform: template + id: radio_sleep_minutes + name: "Radio Sleep Length" + unit_of_measurement: "min" + min_value: 5 + max_value: 180 + step: 5 + initial_value: 30 + optimistic: true + restore_value: true + web_server: + sorting_group_id: group_radio + sorting_weight: 5 ############################################################################### # Switches @@ -462,31 +823,63 @@ switch: id: fm_radio name: "FM Radio" switch_datapoint: 105 - - - platform: tuya - id: alarm_1_enable - name: "Alarm 1" - switch_datapoint: 109 - - - platform: tuya - id: alarm_2_enable - name: "Alarm 2" - switch_datapoint: 122 + web_server: + sorting_group_id: group_radio + sorting_weight: 1 - platform: tuya id: sunset_mode name: "Sunset / Sleep Mode" switch_datapoint: 121 + web_server: + sorting_group_id: group_sunset + sorting_weight: 1 - platform: tuya id: ambient_light name: "Ambient Light" switch_datapoint: 129 + web_server: + sorting_group_id: group_light + sorting_weight: 2 - platform: tuya id: clock_24_hour name: "24 Hour Clock" switch_datapoint: 131 + web_server: + sorting_group_id: group_clock + sorting_weight: 4 + + # Tuya's data model calls this online time synchronization. + - platform: tuya + id: network_time_switch + name: "Network Time" + switch_datapoint: 125 + web_server: + sorting_group_id: group_clock + sorting_weight: 5 + + - platform: template + id: display_schedule_enabled + name: "Display Night Schedule" + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + web_server: + sorting_group_id: group_clock + sorting_weight: 10 + on_turn_on: + - lambda: id(display_schedule_last) = -1; + - script.execute: apply_display_schedule + + - platform: template + id: double_knock_light + name: "Double Knock Toggles Light" + optimistic: true + restore_mode: RESTORE_DEFAULT_ON + web_server: + sorting_group_id: group_snooze + sorting_weight: 5 ############################################################################### # Diagnostics / still-undecoded payloads @@ -497,45 +890,188 @@ text_sensor: id: clock_time_text name: "Clock Time" update_interval: never + web_server: + sorting_group_id: group_clock + sorting_weight: 2 - platform: template id: radio_station_raw name: "Radio Station Raw" update_interval: never - - - platform: template - id: alarm_summary - name: "Alarm Settings" - update_interval: never + web_server: + sorting_group_id: group_radio + sorting_weight: 9 - platform: template id: alarm_settings_raw name: "Alarm Settings Raw" update_interval: never + web_server: + sorting_group_id: group_device + sorting_weight: 10 - platform: template id: sunset_settings_raw name: "Sunset Settings Raw" update_interval: never + web_server: + sorting_group_id: group_device + sorting_weight: 11 + - platform: wifi_info + ip_address: + name: "IP Address" + web_server: + sorting_group_id: group_device + sorting_weight: 1 + + - platform: debug + reset_reason: + name: "Last Restart Reason" + web_server: + sorting_group_id: group_device + sorting_weight: 4 + +############################################################################### +# Device health +############################################################################### + +debug: + update_interval: 60s + +sensor: - platform: template - id: ambient_mode_raw - name: "Ambient Mode Raw" + id: radio_stations_found + name: "Radio Stations Found" + accuracy_decimals: 0 update_interval: never + web_server: + sorting_group_id: group_radio + sorting_weight: 8 + + - platform: wifi_signal + name: "WiFi Signal" + update_interval: 60s + web_server: + sorting_group_id: group_device + sorting_weight: 2 + + - platform: uptime + name: "Uptime" + update_interval: 60s + web_server: + sorting_group_id: group_device + sorting_weight: 3 + +############################################################################### +# Knock sensor: switch between 3.3V and PA18, active high +# One knock snoozes straight away. Exactly two knocks also toggle the light. +############################################################################### + +binary_sensor: + - platform: gpio + name: "Knock" + icon: "mdi:gesture-tap" + pin: + number: PA18 + mode: + input: true + pulldown: true + web_server: + sorting_group_id: group_snooze + sorting_weight: 4 + filters: + # Stretch short hits so Home Assistant sees them, and merge bounces into + # one press. + - delayed_off: 200ms + on_press: + - script.execute: knock_snooze + - lambda: id(knock_count) += 1; + - script.execute: knock_pattern ############################################################################### # Buttons ############################################################################### button: + - platform: template + name: "Snooze" + icon: "mdi:alarm-snooze" + web_server: + sorting_group_id: group_snooze + sorting_weight: 1 + on_press: + - script.execute: knock_snooze + + - platform: template + name: "Start Bedtime Fade" + icon: "mdi:weather-night" + web_server: + sorting_group_id: group_light + sorting_weight: 6 + on_press: + - script.execute: bedtime_fade + + - platform: template + name: "Cancel Bedtime Fade" + icon: "mdi:cancel" + web_server: + sorting_group_id: group_light + sorting_weight: 7 + on_press: + - script.stop: bedtime_fade + - platform: template name: "Sync Lamp Clock Now" icon: "mdi:clock-sync" + web_server: + sorting_group_id: group_clock + sorting_weight: 6 on_press: - script.execute: sync_lamp_clock + # DP126: 1 starts a channel search, 0 ends it, per Tuya's data model. + - platform: template + name: "Radio Scan" + icon: "mdi:radio-tower" + web_server: + sorting_group_id: group_radio + sorting_weight: 3 + on_press: + - lambda: id(tuya_mcu).force_set_boolean_datapoint_value(126, true); + + - platform: template + name: "Radio Scan Stop" + icon: "mdi:stop" + web_server: + sorting_group_id: group_radio + sorting_weight: 4 + on_press: + - lambda: id(tuya_mcu).force_set_boolean_datapoint_value(126, false); + + - platform: template + name: "Start Radio Sleep Timer" + icon: "mdi:timer-outline" + web_server: + sorting_group_id: group_radio + sorting_weight: 6 + on_press: + - script.execute: radio_sleep + + - platform: template + name: "Cancel Radio Sleep Timer" + icon: "mdi:timer-off-outline" + web_server: + sorting_group_id: group_radio + sorting_weight: 7 + on_press: + - script.stop: radio_sleep + - platform: restart name: "Restart Wake Up Light" + web_server: + sorting_group_id: group_device + sorting_weight: 5 ############################################################################### # Networking @@ -553,16 +1089,39 @@ button: ############################################################################### wifi: - # ssid: !secret wifi_ssid - # password: !secret wifi_password + ssid: !secret wifi_ssid + password: !secret wifi_password ap: ssid: ${ap_ssid} - password: ${ap_password} + password: !secret ap_password + +api: + encryption: + key: !secret api_encryption_key web_server: port: 80 - version: 2 + version: 3 local: true + sorting_groups: + - id: group_light + name: "Light" + sorting_weight: 10 + - id: group_clock + name: "Clock and Display" + sorting_weight: 20 + - id: group_sunset + name: "Sunset" + sorting_weight: 25 + - id: group_snooze + name: "Snooze and Knock" + sorting_weight: 40 + - id: group_radio + name: "Radio" + sorting_weight: 50 + - id: group_device + name: "Device" + sorting_weight: 90 # Recommended on a shared LAN: # auth: # username: admin @@ -571,16 +1130,3 @@ web_server: ota: - platform: esphome - platform: web_server - -############################################################################### -# Exact-PID tuya-local definition also lists these DPs, but they are not enabled -# above until verified on this physical two-alarm hardware revision: -# DP116 snooze duration (8..15 min) -# DP117 snooze action -# DP125 network-time switch -# DP126 radio seek/stop -# DP130 snooze -# DP132 ambient color/effect (currently shown read-only as Ambient Mode Raw) -# It also lists DP123/124 as alarm 3/4, but this unit has only two physical -# alarms/indications, so they are intentionally not exposed here. -###############################################################################