diff --git a/CODEOWNERS b/CODEOWNERS index b3c66c775b..68c8684024 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -248,6 +248,7 @@ esphome/components/libretiny_pwm/* @kuba2k2 esphome/components/light/* @esphome/core esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz +esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow diff --git a/esphome/__main__.py b/esphome/__main__.py index 2dbdfeb1ff..d8a79c018a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -34,11 +34,9 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, - PLATFORM_RTL87XX, SECRETS_FILES, ) from esphome.core import CORE, EsphomeError, coroutine @@ -354,7 +352,7 @@ def upload_program(config, args, host): if CORE.target_platform in (PLATFORM_RP2040): return upload_using_platformio(config, args.device) - if CORE.target_platform in (PLATFORM_BK72XX, PLATFORM_RTL87XX): + if CORE.is_libretiny: return upload_using_platformio(config, host) return 1 # Unknown target platform diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 6a40f21f99..b7624221c9 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1537,6 +1537,8 @@ DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) { resp.manufacturer = "Raspberry Pi"; #elif defined(USE_BK72XX) resp.manufacturer = "Beken"; +#elif defined(USE_LN882X) + resp.manufacturer = "Lightning"; #elif defined(USE_RTL87XX) resp.manufacturer = "Realtek"; #elif defined(USE_HOST) diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index eec6a0e327..29097ce1b6 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -5,6 +5,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, ) from esphome.core import CORE, coroutine_with_priority @@ -14,7 +15,15 @@ CODEOWNERS = ["@OttoWinter"] CONFIG_SCHEMA = cv.All( cv.Schema({}), cv.only_with_arduino, - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), ) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index a55887948d..cba3b4921a 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, ) from esphome.core import CORE, coroutine_with_priority @@ -27,7 +28,15 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), ) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 51e5cfc8ff..ba392bb0f2 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -47,7 +47,9 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); +#ifdef USE_WEBSERVER_OTA this->base_->add_ota_handler(); +#endif } #ifdef USE_ARDUINO diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 86006e3e18..901657ec82 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -100,6 +100,7 @@ CONFIG_SCHEMA = ( esp32=3232, rp2040=2040, bk72xx=8892, + ln882x=8820, rtl87xx=8892, ): cv.port, cv.Optional(CONF_PASSWORD): cv.string, diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 362609df44..671992f8bd 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -50,6 +50,7 @@ KEY_FAMILY = "family" # COMPONENTS - auto-generated! Do not modify this block. COMPONENT_BK72XX = "bk72xx" +COMPONENT_LN882X = "ln882x" COMPONENT_RTL87XX = "rtl87xx" # COMPONENTS - end @@ -58,6 +59,7 @@ FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" FAMILY_BK7231T = "BK7231T" FAMILY_BK7251 = "BK7251" +FAMILY_LN882H = "LN882H" FAMILY_RTL8710B = "RTL8710B" FAMILY_RTL8720C = "RTL8720C" FAMILIES = [ @@ -65,6 +67,7 @@ FAMILIES = [ FAMILY_BK7231Q, FAMILY_BK7231T, FAMILY_BK7251, + FAMILY_LN882H, FAMILY_RTL8710B, FAMILY_RTL8720C, ] @@ -73,6 +76,7 @@ FAMILY_FRIENDLY = { FAMILY_BK7231Q: "BK7231Q", FAMILY_BK7231T: "BK7231T", FAMILY_BK7251: "BK7251", + FAMILY_LN882H: "LN882H", FAMILY_RTL8710B: "RTL8710B", FAMILY_RTL8720C: "RTL8720C", } @@ -81,6 +85,7 @@ FAMILY_COMPONENT = { FAMILY_BK7231Q: COMPONENT_BK72XX, FAMILY_BK7231T: COMPONENT_BK72XX, FAMILY_BK7251: COMPONENT_BK72XX, + FAMILY_LN882H: COMPONENT_LN882X, FAMILY_RTL8710B: COMPONENT_RTL87XX, FAMILY_RTL8720C: COMPONENT_RTL87XX, } diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index ae55fd9e40..c750b79317 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -94,6 +94,7 @@ PIN_SCHEMA_EXTRA = f"libretiny.BASE_PIN_SCHEMA.extend({VAR_PIN_SCHEMA})" COMPONENT_MAP = { "rtl87xx": "realtek-amb", "bk72xx": "beken-72xx", + "ln882x": "lightning-ln882x", } diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py new file mode 100644 index 0000000000..6a76218f87 --- /dev/null +++ b/esphome/components/ln882x/__init__.py @@ -0,0 +1,52 @@ +# This file was auto-generated by libretiny/generate_components.py +# Do not modify its contents. +# For custom pin validators, put validate_pin() or validate_usage() +# in gpio.py file in this directory. +# For changing schema/pin schema, put COMPONENT_SCHEMA or COMPONENT_PIN_SCHEMA +# in schema.py file in this directory. + +from esphome import pins +from esphome.components import libretiny +from esphome.components.libretiny.const import ( + COMPONENT_LN882X, + KEY_COMPONENT_DATA, + KEY_LIBRETINY, + LibreTinyComponent, +) +from esphome.core import CORE + +from .boards import LN882X_BOARD_PINS, LN882X_BOARDS + +CODEOWNERS = ["@lamauny"] +AUTO_LOAD = ["libretiny"] +IS_TARGET_PLATFORM = True + +COMPONENT_DATA = LibreTinyComponent( + name=COMPONENT_LN882X, + boards=LN882X_BOARDS, + board_pins=LN882X_BOARD_PINS, + pin_validation=None, + usage_validation=None, +) + + +def _set_core_data(config): + CORE.data[KEY_LIBRETINY] = {} + CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] = COMPONENT_DATA + return config + + +CONFIG_SCHEMA = libretiny.BASE_SCHEMA + +PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA + +CONFIG_SCHEMA.prepend_extra(_set_core_data) + + +async def to_code(config): + return await libretiny.component_to_code(config) + + +@pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA) +async def pin_to_code(config): + return await libretiny.gpio.component_pin_to_code(config) diff --git a/esphome/components/ln882x/boards.py b/esphome/components/ln882x/boards.py new file mode 100644 index 0000000000..43f25994a7 --- /dev/null +++ b/esphome/components/ln882x/boards.py @@ -0,0 +1,285 @@ +# This file was auto-generated by libretiny/generate_components.py +# Do not modify its contents. + +from esphome.components.libretiny.const import FAMILY_LN882H + +LN882X_BOARDS = { + "wl2s": { + "name": "WL2S Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "ln-02": { + "name": "LN-02 Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "generic-ln882hki": { + "name": "Generic - LN882HKI", + "family": FAMILY_LN882H, + }, +} + +LN882X_BOARD_PINS = { + "wl2s": { + "WIRE0_SCL_0": 7, + "WIRE0_SCL_1": 12, + "WIRE0_SCL_2": 3, + "WIRE0_SCL_3": 10, + "WIRE0_SCL_4": 2, + "WIRE0_SCL_5": 0, + "WIRE0_SCL_6": 19, + "WIRE0_SCL_7": 11, + "WIRE0_SCL_8": 9, + "WIRE0_SCL_9": 24, + "WIRE0_SCL_10": 25, + "WIRE0_SCL_11": 5, + "WIRE0_SCL_12": 1, + "WIRE0_SDA_0": 7, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 3, + "WIRE0_SDA_3": 10, + "WIRE0_SDA_4": 2, + "WIRE0_SDA_5": 0, + "WIRE0_SDA_6": 19, + "WIRE0_SDA_7": 11, + "WIRE0_SDA_8": 9, + "WIRE0_SDA_9": 24, + "WIRE0_SDA_10": 25, + "WIRE0_SDA_11": 5, + "WIRE0_SDA_12": 1, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC5": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA05": 5, + "PA5": 5, + "PA07": 7, + "PA7": 7, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "SCL0": 1, + "SDA0": 1, + "TX0": 2, + "TX1": 25, + "D0": 7, + "D1": 12, + "D2": 3, + "D3": 10, + "D4": 2, + "D5": 0, + "D6": 19, + "D7": 11, + "D8": 9, + "D9": 24, + "D10": 25, + "D11": 5, + "D12": 1, + "A0": 0, + "A1": 19, + "A2": 1, + }, + "ln-02": { + "WIRE0_SCL_0": 11, + "WIRE0_SCL_1": 19, + "WIRE0_SCL_2": 3, + "WIRE0_SCL_3": 24, + "WIRE0_SCL_4": 2, + "WIRE0_SCL_5": 25, + "WIRE0_SCL_6": 1, + "WIRE0_SCL_7": 0, + "WIRE0_SCL_8": 9, + "WIRE0_SDA_0": 11, + "WIRE0_SDA_1": 19, + "WIRE0_SDA_2": 3, + "WIRE0_SDA_3": 24, + "WIRE0_SDA_4": 2, + "WIRE0_SDA_5": 25, + "WIRE0_SDA_6": 1, + "WIRE0_SDA_7": 0, + "WIRE0_SDA_8": 9, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC5": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB03": 19, + "PB3": 19, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "SCL0": 9, + "SDA0": 9, + "TX0": 2, + "TX1": 25, + "D0": 11, + "D1": 19, + "D2": 3, + "D3": 24, + "D4": 2, + "D5": 25, + "D6": 1, + "D7": 0, + "D8": 9, + "A0": 19, + "A1": 1, + "A2": 0, + }, + "generic-ln882hki": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 8, + "WIRE0_SCL_9": 9, + "WIRE0_SCL_10": 10, + "WIRE0_SCL_11": 11, + "WIRE0_SCL_12": 12, + "WIRE0_SCL_13": 19, + "WIRE0_SCL_14": 20, + "WIRE0_SCL_15": 21, + "WIRE0_SCL_16": 22, + "WIRE0_SCL_17": 23, + "WIRE0_SCL_18": 24, + "WIRE0_SCL_19": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 8, + "WIRE0_SDA_9": 9, + "WIRE0_SDA_10": 10, + "WIRE0_SDA_11": 11, + "WIRE0_SDA_12": 12, + "WIRE0_SDA_13": 19, + "WIRE0_SDA_14": 20, + "WIRE0_SDA_15": 21, + "WIRE0_SDA_16": 22, + "WIRE0_SDA_17": 23, + "WIRE0_SDA_18": 24, + "WIRE0_SDA_19": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 5, + "D6": 6, + "D7": 7, + "D8": 8, + "D9": 9, + "D10": 10, + "D11": 11, + "D12": 12, + "D13": 19, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "D18": 24, + "D19": 25, + "A2": 0, + "A3": 1, + "A4": 4, + "A5": 19, + "A6": 20, + "A7": 21, + }, +} + +BOARDS = LN882X_BOARDS diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index af62d8a73f..3d4907aa6e 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -16,7 +16,11 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S3, ) from esphome.components.libretiny import get_libretiny_component, get_libretiny_family -from esphome.components.libretiny.const import COMPONENT_BK72XX, COMPONENT_RTL87XX +from esphome.components.libretiny.const import ( + COMPONENT_BK72XX, + COMPONENT_LN882X, + COMPONENT_RTL87XX, +) import esphome.config_validation as cv from esphome.const import ( CONF_ARGS, @@ -35,6 +39,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, ) @@ -100,6 +105,7 @@ UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] UART_SELECTION_LIBRETINY = { COMPONENT_BK72XX: [DEFAULT, UART1, UART2], + COMPONENT_LN882X: [DEFAULT, UART0, UART1, UART2], COMPONENT_RTL87XX: [DEFAULT, UART0, UART1, UART2], } @@ -217,6 +223,7 @@ CONFIG_SCHEMA = cv.All( esp32_p4_idf=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, + ln882x=DEFAULT, rtl87xx=DEFAULT, ): cv.All( cv.only_on( @@ -225,6 +232,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP32, PLATFORM_RP2040, PLATFORM_BK72XX, + PLATFORM_LN882X, PLATFORM_RTL87XX, ] ), diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 6f45fb75e4..2952cc3b12 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -6,6 +6,7 @@ #include #include +#include #if ESP_IDF_VERSION_MAJOR >= 5 #include @@ -17,6 +18,9 @@ namespace ota { std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size) { + // Reset MD5 validation state + this->md5_set_ = false; + this->partition_ = esp_ota_get_next_update_partition(nullptr); if (this->partition_ == nullptr) { return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; @@ -67,7 +71,10 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size) { return OTA_RESPONSE_OK; } -void IDFOTABackend::set_update_md5(const char *expected_md5) { memcpy(this->expected_bin_md5_, expected_md5, 32); } +void IDFOTABackend::set_update_md5(const char *expected_md5) { + memcpy(this->expected_bin_md5_, expected_md5, 32); + this->md5_set_ = true; +} OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { esp_err_t err = esp_ota_write(this->update_handle_, data, len); @@ -85,10 +92,15 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { OTAResponseTypes IDFOTABackend::end() { this->md5_.calculate(); - if (!this->md5_.equals_hex(this->expected_bin_md5_)) { - this->abort(); - return OTA_RESPONSE_ERROR_MD5_MISMATCH; + + // Only validate MD5 if one was provided + if (this->md5_set_) { + if (!this->md5_.equals_hex(this->expected_bin_md5_)) { + this->abort(); + return OTA_RESPONSE_ERROR_MD5_MISMATCH; + } } + esp_err_t err = esp_ota_end(this->update_handle_); this->update_handle_ = 0; if (err == ESP_OK) { diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index ed66d9b970..deed354499 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -12,6 +12,7 @@ namespace ota { class IDFOTABackend : public OTABackend { public: + IDFOTABackend() : md5_set_(false), expected_bin_md5_{} {} OTAResponseTypes begin(size_t image_size) override; void set_update_md5(const char *md5) override; OTAResponseTypes write(uint8_t *data, size_t len) override; @@ -24,6 +25,7 @@ class IDFOTABackend : public OTABackend { const esp_partition_t *partition_; md5::MD5Digest md5_{}; char expected_bin_md5_[32]; + bool md5_set_; }; } // namespace ota diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 321cfc93ff..5de7d8c9c4 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -94,6 +94,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( esp32="10000b", esp8266="1000b", bk72xx="1000b", + ln882x="1000b", rtl87xx="1000b", ): cv.validate_bytes, cv.Optional(CONF_FILTER, default="50us"): cv.All( diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 6f883d5bed..1c8ee402ad 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -7,6 +7,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, ) @@ -33,6 +34,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_BK72XX, + PLATFORM_LN882X, PLATFORM_RTL87XX, ] ), diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 667e30df4b..26031a8da5 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -16,6 +16,7 @@ CONFIG_SCHEMA = cv.Schema( esp32=IMPLEMENTATION_BSD_SOCKETS, rp2040=IMPLEMENTATION_LWIP_TCP, bk72xx=IMPLEMENTATION_LWIP_SOCKETS, + ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, host=IMPLEMENTATION_BSD_SOCKETS, ): cv.one_of( diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 8ff7ce1d16..9f6946b181 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -28,6 +28,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, ) from esphome.core import CORE, coroutine_with_priority @@ -71,12 +72,6 @@ def validate_local(config): return config -def validate_ota(config): - if CORE.using_esp_idf and config[CONF_OTA]: - raise cv.Invalid("Enabling 'ota' is not supported for IDF framework yet") - return config - - def validate_sorting_groups(config): if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: raise cv.Invalid( @@ -174,23 +169,23 @@ CONFIG_SCHEMA = cv.All( web_server_base.WebServerBase ), cv.Optional(CONF_INCLUDE_INTERNAL, default=False): cv.boolean, - cv.SplitDefault( - CONF_OTA, - esp8266=True, - esp32_arduino=True, - esp32_idf=False, - bk72xx=True, - rtl87xx=True, - ): cv.boolean, + cv.Optional(CONF_OTA, default=True): cv.boolean, cv.Optional(CONF_LOG, default=True): cv.boolean, cv.Optional(CONF_LOCAL): cv.boolean, cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), default_url, validate_local, - validate_ota, validate_sorting_groups, ) @@ -276,6 +271,8 @@ async def to_code(config): cg.add(var.set_css_url(config[CONF_CSS_URL])) cg.add(var.set_js_url(config[CONF_JS_URL])) cg.add(var.set_allow_ota(config[CONF_OTA])) + if config[CONF_OTA] and "ota" in CORE.config: + cg.add_define("USE_WEBSERVER_OTA") cg.add(var.set_expose_log(config[CONF_LOG])) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 669bfbf279..e0027d0b27 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -299,8 +299,10 @@ void WebServer::setup() { #endif this->base_->add_handler(this); +#ifdef USE_WEBSERVER_OTA if (this->allow_ota_) this->base_->add_ota_handler(); +#endif // doesn't need defer functionality - if the queue is full, the client JS knows it's alive because it's clearly // getting a lot of events @@ -2030,6 +2032,10 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } #endif + + // No matching handler found - send 404 + ESP_LOGV(TAG, "Request for unknown URL: %s", request->url().c_str()); + request->send(404, "text/plain", "Not Found"); } bool WebServer::isRequestHandlerTrivial() const { return false; } diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 2835585387..39cae36b2d 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -14,6 +14,10 @@ #endif #endif +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) +#include "esphome/components/ota/ota_backend.h" +#endif + namespace esphome { namespace web_server_base { @@ -31,6 +35,33 @@ void WebServerBase::add_handler(AsyncWebHandler *handler) { } } +#ifdef USE_WEBSERVER_OTA +void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { + const uint32_t now = millis(); + if (now - this->last_ota_progress_ > 1000) { + if (request->contentLength() != 0) { + float percentage = (this->ota_read_length_ * 100.0f) / request->contentLength(); + ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage); + } else { + ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_); + } + this->last_ota_progress_ = now; + } +} + +void OTARequestHandler::schedule_ota_reboot_() { + ESP_LOGI(TAG, "OTA update successful!"); + this->parent_->set_timeout(100, [this]() { + ESP_LOGI(TAG, "Performing OTA reboot now"); + App.safe_reboot(); + }); +} + +void OTARequestHandler::ota_init_(const char *filename) { + ESP_LOGI(TAG, "OTA Update Start: %s", filename); + this->ota_read_length_ = 0; +} + void report_ota_error() { #ifdef USE_ARDUINO StreamString ss; @@ -44,8 +75,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin #ifdef USE_ARDUINO bool success; if (index == 0) { - ESP_LOGI(TAG, "OTA Update Start: %s", filename.c_str()); - this->ota_read_length_ = 0; + this->ota_init_(filename.c_str()); #ifdef USE_ESP8266 Update.runAsync(true); // NOLINTNEXTLINE(readability-static-accessed-through-instance) @@ -72,31 +102,67 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin return; } this->ota_read_length_ += len; - - const uint32_t now = millis(); - if (now - this->last_ota_progress_ > 1000) { - if (request->contentLength() != 0) { - float percentage = (this->ota_read_length_ * 100.0f) / request->contentLength(); - ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage); - } else { - ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_); - } - this->last_ota_progress_ = now; - } + this->report_ota_progress_(request); if (final) { if (Update.end(true)) { - ESP_LOGI(TAG, "OTA update successful!"); - this->parent_->set_timeout(100, []() { App.safe_reboot(); }); + this->schedule_ota_reboot_(); } else { report_ota_error(); } } -#endif +#endif // USE_ARDUINO + +#ifdef USE_ESP_IDF + // ESP-IDF implementation + if (index == 0 && !this->ota_backend_) { + // Initialize OTA on first call + this->ota_init_(filename.c_str()); + this->ota_success_ = false; + + auto backend = ota::make_ota_backend(); + if (backend->begin(0) != ota::OTA_RESPONSE_OK) { + ESP_LOGE(TAG, "OTA begin failed"); + return; + } + this->ota_backend_ = backend.release(); + } + + auto *backend = static_cast(this->ota_backend_); + if (!backend) { + return; + } + + // Process data + if (len > 0) { + if (backend->write(data, len) != ota::OTA_RESPONSE_OK) { + ESP_LOGE(TAG, "OTA write failed"); + backend->abort(); + delete backend; + this->ota_backend_ = nullptr; + return; + } + this->ota_read_length_ += len; + this->report_ota_progress_(request); + } + + // Finalize + if (final) { + this->ota_success_ = (backend->end() == ota::OTA_RESPONSE_OK); + if (this->ota_success_) { + this->schedule_ota_reboot_(); + } else { + ESP_LOGE(TAG, "OTA end failed"); + } + delete backend; + this->ota_backend_ = nullptr; + } +#endif // USE_ESP_IDF } + void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { -#ifdef USE_ARDUINO AsyncWebServerResponse *response; +#ifdef USE_ARDUINO if (!Update.hasError()) { response = request->beginResponse(200, "text/plain", "Update Successful!"); } else { @@ -105,16 +171,21 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { Update.printError(ss); response = request->beginResponse(200, "text/plain", ss); } +#endif // USE_ARDUINO +#ifdef USE_ESP_IDF + // Send response based on the OTA result + request->send(200, "text/plain", this->ota_success_ ? "Update Successful!" : "Update Failed!"); + return; +#endif // USE_ESP_IDF response->addHeader("Connection", "close"); request->send(response); -#endif } void WebServerBase::add_ota_handler() { -#ifdef USE_ARDUINO this->add_handler(new OTARequestHandler(this)); // NOLINT -#endif } +#endif + float WebServerBase::get_setup_priority() const { // Before WiFi (captive portal) return setup_priority::WIFI + 2.0f; diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 641006cb99..09a41956c9 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -110,13 +110,17 @@ class WebServerBase : public Component { void add_handler(AsyncWebHandler *handler); +#ifdef USE_WEBSERVER_OTA void add_ota_handler(); +#endif void set_port(uint16_t port) { port_ = port; } uint16_t get_port() const { return port_; } protected: +#ifdef USE_WEBSERVER_OTA friend class OTARequestHandler; +#endif int initialized_{0}; uint16_t port_{80}; @@ -125,6 +129,7 @@ class WebServerBase : public Component { internal::Credentials credentials_; }; +#ifdef USE_WEBSERVER_OTA class OTARequestHandler : public AsyncWebHandler { public: OTARequestHandler(WebServerBase *parent) : parent_(parent) {} @@ -139,10 +144,21 @@ class OTARequestHandler : public AsyncWebHandler { bool isRequestHandlerTrivial() const override { return false; } protected: + void report_ota_progress_(AsyncWebServerRequest *request); + void schedule_ota_reboot_(); + void ota_init_(const char *filename); + uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; WebServerBase *parent_; + + private: +#ifdef USE_ESP_IDF + void *ota_backend_{nullptr}; + bool ota_success_{false}; +#endif }; +#endif // USE_WEBSERVER_OTA } // namespace web_server_base } // namespace esphome diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 506e1c5c13..cc453cb60e 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,5 +1,7 @@ -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option import esphome.config_validation as cv +from esphome.const import CONF_OTA, CONF_WEB_SERVER +from esphome.core import CORE CODEOWNERS = ["@dentra"] @@ -12,3 +14,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) + + # Check if web_server component has OTA enabled + web_server_config = CORE.config.get(CONF_WEB_SERVER, {}) + if web_server_config and web_server_config[CONF_OTA] and "ota" in CORE.config: + # Add multipart parser component for ESP-IDF OTA support + add_idf_component(name="zorxx/multipart-parser", ref="1.0.1") diff --git a/esphome/components/web_server_idf/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp new file mode 100644 index 0000000000..8655226ab9 --- /dev/null +++ b/esphome/components/web_server_idf/multipart.cpp @@ -0,0 +1,254 @@ +#include "esphome/core/defines.h" +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) +#include "multipart.h" +#include "utils.h" +#include "esphome/core/log.h" +#include +#include "multipart_parser.h" + +namespace esphome { +namespace web_server_idf { + +static const char *const TAG = "multipart"; + +// ========== MultipartReader Implementation ========== + +MultipartReader::MultipartReader(const std::string &boundary) { + // Initialize settings with callbacks + memset(&settings_, 0, sizeof(settings_)); + settings_.on_header_field = on_header_field; + settings_.on_header_value = on_header_value; + settings_.on_part_data = on_part_data; + settings_.on_part_data_end = on_part_data_end; + + ESP_LOGV(TAG, "Initializing multipart parser with boundary: '%s' (len: %zu)", boundary.c_str(), boundary.length()); + + // Create parser with boundary + parser_ = multipart_parser_init(boundary.c_str(), &settings_); + if (parser_) { + multipart_parser_set_data(parser_, this); + } else { + ESP_LOGE(TAG, "Failed to initialize multipart parser"); + } +} + +MultipartReader::~MultipartReader() { + if (parser_) { + multipart_parser_free(parser_); + } +} + +size_t MultipartReader::parse(const char *data, size_t len) { + if (!parser_) { + ESP_LOGE(TAG, "Parser not initialized"); + return 0; + } + + size_t parsed = multipart_parser_execute(parser_, data, len); + + if (parsed != len) { + ESP_LOGW(TAG, "Parser consumed %zu of %zu bytes - possible error", parsed, len); + } + + return parsed; +} + +void MultipartReader::process_header_(const char *value, size_t length) { + // Process the completed header (field + value pair) + std::string value_str(value, length); + + if (str_startswith_case_insensitive(current_header_field_, "content-disposition")) { + // Parse name and filename from Content-Disposition + current_part_.name = extract_header_param(value_str, "name"); + current_part_.filename = extract_header_param(value_str, "filename"); + } else if (str_startswith_case_insensitive(current_header_field_, "content-type")) { + current_part_.content_type = str_trim(value_str); + } + + // Clear field for next header + current_header_field_.clear(); +} + +int MultipartReader::on_header_field(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + reader->current_header_field_.assign(at, length); + return 0; +} + +int MultipartReader::on_header_value(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + reader->process_header_(at, length); + return 0; +} + +int MultipartReader::on_part_data(multipart_parser *parser, const char *at, size_t length) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + // Only process file uploads + if (reader->has_file() && reader->data_callback_) { + // IMPORTANT: The 'at' pointer points to data within the parser's input buffer. + // This data is only valid during this callback. The callback handler MUST + // process or copy the data immediately - it cannot store the pointer for + // later use as the buffer will be overwritten. + reader->data_callback_(reinterpret_cast(at), length); + } + return 0; +} + +int MultipartReader::on_part_data_end(multipart_parser *parser) { + MultipartReader *reader = static_cast(multipart_parser_get_data(parser)); + ESP_LOGV(TAG, "Part data end"); + if (reader->part_complete_callback_) { + reader->part_complete_callback_(); + } + // Clear part info for next part + reader->current_part_ = Part{}; + return 0; +} + +// ========== Utility Functions ========== + +// Case-insensitive string prefix check +bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix) { + if (str.length() < prefix.length()) { + return false; + } + return str_ncmp_ci(str.c_str(), prefix.c_str(), prefix.length()); +} + +// Extract a parameter value from a header line +// Handles both quoted and unquoted values +std::string extract_header_param(const std::string &header, const std::string ¶m) { + size_t search_pos = 0; + + while (search_pos < header.length()) { + // Look for param name + const char *found = stristr(header.c_str() + search_pos, param.c_str()); + if (!found) { + return ""; + } + size_t pos = found - header.c_str(); + + // Check if this is a word boundary (not part of another parameter) + if (pos > 0 && header[pos - 1] != ' ' && header[pos - 1] != ';' && header[pos - 1] != '\t') { + search_pos = pos + 1; + continue; + } + + // Move past param name + pos += param.length(); + + // Skip whitespace and find '=' + while (pos < header.length() && (header[pos] == ' ' || header[pos] == '\t')) { + pos++; + } + + if (pos >= header.length() || header[pos] != '=') { + search_pos = pos; + continue; + } + + pos++; // Skip '=' + + // Skip whitespace after '=' + while (pos < header.length() && (header[pos] == ' ' || header[pos] == '\t')) { + pos++; + } + + if (pos >= header.length()) { + return ""; + } + + // Check if value is quoted + if (header[pos] == '"') { + pos++; + size_t end = header.find('"', pos); + if (end != std::string::npos) { + return header.substr(pos, end - pos); + } + // Malformed - no closing quote + return ""; + } + + // Unquoted value - find the end (semicolon, comma, or end of string) + size_t end = pos; + while (end < header.length() && header[end] != ';' && header[end] != ',' && header[end] != ' ' && + header[end] != '\t') { + end++; + } + + return header.substr(pos, end - pos); + } + + return ""; +} + +// Parse boundary from Content-Type header +// Returns true if boundary found, false otherwise +// boundary_start and boundary_len will point to the boundary value +bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len) { + if (!content_type) { + return false; + } + + // Check for multipart/form-data (case-insensitive) + if (!stristr(content_type, "multipart/form-data")) { + return false; + } + + // Look for boundary parameter + const char *b = stristr(content_type, "boundary="); + if (!b) { + return false; + } + + const char *start = b + 9; // Skip "boundary=" + + // Skip whitespace + while (*start == ' ' || *start == '\t') { + start++; + } + + if (!*start) { + return false; + } + + // Find end of boundary + const char *end = start; + if (*end == '"') { + // Quoted boundary + start++; + end++; + while (*end && *end != '"') { + end++; + } + *boundary_len = end - start; + } else { + // Unquoted boundary + while (*end && *end != ' ' && *end != ';' && *end != '\r' && *end != '\n' && *end != '\t') { + end++; + } + *boundary_len = end - start; + } + + if (*boundary_len == 0) { + return false; + } + + *boundary_start = start; + + return true; +} + +// Trim whitespace from both ends of a string +std::string str_trim(const std::string &str) { + size_t start = str.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) { + return ""; + } + size_t end = str.find_last_not_of(" \t\r\n"); + return str.substr(start, end - start + 1); +} + +} // namespace web_server_idf +} // namespace esphome +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/multipart.h b/esphome/components/web_server_idf/multipart.h new file mode 100644 index 0000000000..073e1e7c2b --- /dev/null +++ b/esphome/components/web_server_idf/multipart.h @@ -0,0 +1,85 @@ +#pragma once +#include "esphome/core/defines.h" +#if defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) + +#include +#include +#include +#include +#include +#include + +namespace esphome { +namespace web_server_idf { + +// Wrapper around zorxx/multipart-parser for ESP-IDF OTA uploads +class MultipartReader { + public: + struct Part { + std::string name; + std::string filename; + std::string content_type; + }; + + // IMPORTANT: The data pointer in DataCallback is only valid during the callback! + // The multipart parser passes pointers to its internal buffer which will be + // overwritten after the callback returns. Callbacks MUST process or copy the + // data immediately - storing the pointer for deferred processing will result + // in use-after-free bugs. + using DataCallback = std::function; + using PartCompleteCallback = std::function; + + explicit MultipartReader(const std::string &boundary); + ~MultipartReader(); + + // Set callbacks for handling data + void set_data_callback(DataCallback callback) { data_callback_ = callback; } + void set_part_complete_callback(PartCompleteCallback callback) { part_complete_callback_ = callback; } + + // Parse incoming data + size_t parse(const char *data, size_t len); + + // Get current part info + const Part &get_current_part() const { return current_part_; } + + // Check if we found a file upload + bool has_file() const { return !current_part_.filename.empty(); } + + private: + static int on_header_field(multipart_parser *parser, const char *at, size_t length); + static int on_header_value(multipart_parser *parser, const char *at, size_t length); + static int on_part_data(multipart_parser *parser, const char *at, size_t length); + static int on_part_data_end(multipart_parser *parser); + + multipart_parser *parser_{nullptr}; + multipart_parser_settings settings_{}; + + Part current_part_; + std::string current_header_field_; + + DataCallback data_callback_; + PartCompleteCallback part_complete_callback_; + + void process_header_(const char *value, size_t length); +}; + +// ========== Utility Functions ========== + +// Case-insensitive string prefix check +bool str_startswith_case_insensitive(const std::string &str, const std::string &prefix); + +// Extract a parameter value from a header line +// Handles both quoted and unquoted values +std::string extract_header_param(const std::string &header, const std::string ¶m); + +// Parse boundary from Content-Type header +// Returns true if boundary found, false otherwise +// boundary_start and boundary_len will point to the boundary value +bool parse_multipart_boundary(const char *content_type, const char **boundary_start, size_t *boundary_len); + +// Trim whitespace from both ends of a string +std::string str_trim(const std::string &str); + +} // namespace web_server_idf +} // namespace esphome +#endif // defined(USE_ESP_IDF) && defined(USE_WEBSERVER_OTA) diff --git a/esphome/components/web_server_idf/utils.cpp b/esphome/components/web_server_idf/utils.cpp index 349acce50d..ac5df90bb8 100644 --- a/esphome/components/web_server_idf/utils.cpp +++ b/esphome/components/web_server_idf/utils.cpp @@ -1,5 +1,7 @@ #ifdef USE_ESP_IDF #include +#include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "http_parser.h" @@ -88,6 +90,36 @@ optional query_key_value(const std::string &query_url, const std::s return {val.get()}; } +// Helper function for case-insensitive string region comparison +bool str_ncmp_ci(const char *s1, const char *s2, size_t n) { + for (size_t i = 0; i < n; i++) { + if (!char_equals_ci(s1[i], s2[i])) { + return false; + } + } + return true; +} + +// Case-insensitive string search (like strstr but case-insensitive) +const char *stristr(const char *haystack, const char *needle) { + if (!haystack) { + return nullptr; + } + + size_t needle_len = strlen(needle); + if (needle_len == 0) { + return haystack; + } + + for (const char *p = haystack; *p; p++) { + if (str_ncmp_ci(p, needle, needle_len)) { + return p; + } + } + + return nullptr; +} + } // namespace web_server_idf } // namespace esphome #endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/utils.h b/esphome/components/web_server_idf/utils.h index 9ed17c1d50..988b962d72 100644 --- a/esphome/components/web_server_idf/utils.h +++ b/esphome/components/web_server_idf/utils.h @@ -2,6 +2,7 @@ #ifdef USE_ESP_IDF #include +#include #include "esphome/core/helpers.h" namespace esphome { @@ -12,6 +13,15 @@ optional request_get_header(httpd_req_t *req, const char *name); optional request_get_url_query(httpd_req_t *req); optional query_key_value(const std::string &query_url, const std::string &key); +// Helper function for case-insensitive character comparison +inline bool char_equals_ci(char a, char b) { return ::tolower(a) == ::tolower(b); } + +// Helper function for case-insensitive string region comparison +bool str_ncmp_ci(const char *s1, const char *s2, size_t n); + +// Case-insensitive string search (like strstr but case-insensitive) +const char *stristr(const char *haystack, const char *needle); + } // namespace web_server_idf } // namespace esphome #endif // USE_ESP_IDF diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 409230806c..9478e4748c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -1,16 +1,25 @@ #ifdef USE_ESP_IDF #include +#include +#include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esp_tls_crypto.h" +#include +#include #include "utils.h" - #include "web_server_idf.h" +#ifdef USE_WEBSERVER_OTA +#include +#include "multipart.h" // For parse_multipart_boundary and other utils +#endif + #ifdef USE_WEBSERVER #include "esphome/components/web_server/web_server.h" #include "esphome/components/web_server/list_entities.h" @@ -72,18 +81,32 @@ void AsyncWebServer::begin() { esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { ESP_LOGVV(TAG, "Enter AsyncWebServer::request_post_handler. uri=%s", r->uri); auto content_type = request_get_header(r, "Content-Type"); - if (content_type.has_value() && *content_type != "application/x-www-form-urlencoded") { - ESP_LOGW(TAG, "Only application/x-www-form-urlencoded supported for POST request"); - // fallback to get handler to support backward compatibility - return AsyncWebServer::request_handler(r); - } if (!request_has_header(r, "Content-Length")) { - ESP_LOGW(TAG, "Content length is requred for post: %s", r->uri); + ESP_LOGW(TAG, "Content length is required for post: %s", r->uri); httpd_resp_send_err(r, HTTPD_411_LENGTH_REQUIRED, nullptr); return ESP_OK; } + if (content_type.has_value()) { + const char *content_type_char = content_type.value().c_str(); + + // Check most common case first + if (stristr(content_type_char, "application/x-www-form-urlencoded") != nullptr) { + // Normal form data - proceed with regular handling +#ifdef USE_WEBSERVER_OTA + } else if (stristr(content_type_char, "multipart/form-data") != nullptr) { + auto *server = static_cast(r->user_ctx); + return server->handle_multipart_upload_(r, content_type_char); +#endif + } else { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char); + // fallback to get handler to support backward compatibility + return AsyncWebServer::request_handler(r); + } + } + + // Handle regular form data if (r->content_len > HTTPD_MAX_REQ_HDR_LEN) { ESP_LOGW(TAG, "Request size is to big: %zu", r->content_len); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); @@ -539,6 +562,97 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e } #endif +#ifdef USE_WEBSERVER_OTA +esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) { + static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size + static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog + + // Parse boundary and create reader + const char *boundary_start; + size_t boundary_len; + if (!parse_multipart_boundary(content_type, &boundary_start, &boundary_len)) { + ESP_LOGE(TAG, "Failed to parse multipart boundary"); + httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); + return ESP_FAIL; + } + + AsyncWebServerRequest req(r); + AsyncWebHandler *handler = nullptr; + for (auto *h : this->handlers_) { + if (h->canHandle(&req)) { + handler = h; + break; + } + } + + if (!handler) { + ESP_LOGW(TAG, "No handler found for OTA request"); + httpd_resp_send_err(r, HTTPD_404_NOT_FOUND, nullptr); + return ESP_OK; + } + + // Upload state + std::string filename; + size_t index = 0; + // Create reader on heap to reduce stack usage + auto reader = std::make_unique("--" + std::string(boundary_start, boundary_len)); + + // Configure callbacks + reader->set_data_callback([&](const uint8_t *data, size_t len) { + if (!reader->has_file() || !len) + return; + + if (filename.empty()) { + filename = reader->get_current_part().filename; + ESP_LOGV(TAG, "Processing file: '%s'", filename.c_str()); + handler->handleUpload(&req, filename, 0, nullptr, 0, false); // Start + } + + handler->handleUpload(&req, filename, index, const_cast(data), len, false); + index += len; + }); + + reader->set_part_complete_callback([&]() { + if (index > 0) { + handler->handleUpload(&req, filename, index, nullptr, 0, true); // End + filename.clear(); + index = 0; + } + }); + + // Process data + std::unique_ptr buffer(new char[MULTIPART_CHUNK_SIZE]); + size_t bytes_since_yield = 0; + + for (size_t remaining = r->content_len; remaining > 0;) { + int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, MULTIPART_CHUNK_SIZE)); + + if (recv_len <= 0) { + httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, + nullptr); + return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; + } + + if (reader->parse(buffer.get(), recv_len) != static_cast(recv_len)) { + ESP_LOGW(TAG, "Multipart parser error"); + httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); + return ESP_FAIL; + } + + remaining -= recv_len; + bytes_since_yield += recv_len; + + if (bytes_since_yield > YIELD_INTERVAL_BYTES) { + vTaskDelay(1); + bytes_since_yield = 0; + } + } + + handler->handleRequest(&req); + return ESP_OK; +} +#endif // USE_WEBSERVER_OTA + } // namespace web_server_idf } // namespace esphome diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 7547117224..8de25c8e96 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -204,6 +204,9 @@ class AsyncWebServer { static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; +#ifdef USE_WEBSERVER_OTA + esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type); +#endif std::vector handlers_; std::function on_not_found_{}; }; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 582b826de0..e8ae9b1b4e 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -309,6 +309,7 @@ CONFIG_SCHEMA = cv.All( rp2040="light", bk72xx="none", rtl87xx="none", + ln882x="light", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): cv.boolean, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, diff --git a/esphome/const.py b/esphome/const.py index ed6390d8c3..b167935d12 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -12,6 +12,7 @@ PLATFORM_ESP32 = "esp32" PLATFORM_ESP8266 = "esp8266" PLATFORM_HOST = "host" PLATFORM_LIBRETINY_OLDSTYLE = "libretiny" +PLATFORM_LN882X = "ln882x" PLATFORM_RP2040 = "rp2040" PLATFORM_RTL87XX = "rtl87xx" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 368e2affe9..e33bbcf726 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -20,6 +20,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_HOST, + PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, ) @@ -661,9 +662,13 @@ class EsphomeCore: def is_rtl87xx(self): return self.target_platform == PLATFORM_RTL87XX + @property + def is_ln882x(self): + return self.target_platform == PLATFORM_LN882X + @property def is_libretiny(self): - return self.is_bk72xx or self.is_rtl87xx + return self.is_bk72xx or self.is_rtl87xx or self.is_ln882x @property def is_host(self): diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ea3c8bdc17..cfaed6fdb7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -153,6 +153,7 @@ #define USE_SPI #define USE_VOICE_ASSISTANT #define USE_WEBSERVER +#define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_SORTING #define USE_WIFI_11KV_SUPPORT diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 529a0815b8..480285b6c1 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -639,7 +639,11 @@ class DownloadListRequestHandler(BaseHandler): if platform.upper() in ESP32_VARIANTS: platform = "esp32" - elif platform in (const.PLATFORM_RTL87XX, const.PLATFORM_BK72XX): + elif platform in ( + const.PLATFORM_RTL87XX, + const.PLATFORM_BK72XX, + const.PLATFORM_LN882X, + ): platform = "libretiny" try: @@ -837,6 +841,10 @@ class BoardsRequestHandler(BaseHandler): from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS boards = BK72XX_BOARDS + elif platform == const.PLATFORM_LN882X: + from esphome.components.ln882x.boards import BOARDS as LN882X_BOARDS + + boards = LN882X_BOARDS elif platform == const.PLATFORM_RTL87XX: from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6299909033..c43b622684 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -17,3 +17,5 @@ dependencies: version: 2.0.11 rules: - if: "target in [esp32h2, esp32p4]" + zorxx/multipart-parser: + version: 1.0.1 diff --git a/esphome/wizard.py b/esphome/wizard.py index 7b4d87be63..1826487aa4 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -83,6 +83,11 @@ bk72xx: board: {board} """ +LN882X_CONFIG = """ +ln882x: + board: {board} +""" + RTL87XX_CONFIG = """ rtl87xx: board: {board} @@ -93,6 +98,7 @@ HARDWARE_BASE_CONFIGS = { "ESP32": ESP32_CONFIG, "RP2040": RP2040_CONFIG, "BK72XX": BK72XX_CONFIG, + "LN882X": LN882X_CONFIG, "RTL87XX": RTL87XX_CONFIG, } @@ -157,7 +163,7 @@ def wizard_file(**kwargs): """ # pylint: disable=consider-using-f-string - if kwargs["platform"] in ["ESP8266", "ESP32", "BK72XX", "RTL87XX"]: + if kwargs["platform"] in ["ESP8266", "ESP32", "BK72XX", "LN882X", "RTL87XX"]: config += """ # Enable fallback hotspot (captive portal) in case wifi connection fails ap: @@ -181,6 +187,7 @@ def wizard_write(path, **kwargs): from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards + from esphome.components.ln882x import boards as ln882x_boards from esphome.components.rp2040 import boards as rp2040_boards from esphome.components.rtl87xx import boards as rtl87xx_boards @@ -200,6 +207,8 @@ def wizard_write(path, **kwargs): platform = "RP2040" elif board in bk72xx_boards.BOARDS: platform = "BK72XX" + elif board in ln882x_boards.BOARDS: + platform = "LN882X" elif board in rtl87xx_boards.BOARDS: platform = "RTL87XX" else: @@ -253,6 +262,7 @@ def wizard(path): from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards + from esphome.components.ln882x import boards as ln882x_boards from esphome.components.rp2040 import boards as rp2040_boards from esphome.components.rtl87xx import boards as rtl87xx_boards @@ -325,7 +335,7 @@ def wizard(path): "firmwares for it." ) - wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "RTL87XX", "RP2040"] + wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2040"] safe_print( "Please choose one of the supported microcontrollers " "(Use ESP8266 for Sonoff devices)." @@ -361,7 +371,7 @@ def wizard(path): board_link = ( "https://www.raspberrypi.com/documentation/microcontrollers/rp2040.html" ) - elif platform in ["BK72XX", "RTL87XX"]: + elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" else: raise NotImplementedError("Unknown platform!") @@ -384,6 +394,9 @@ def wizard(path): elif platform == "BK72XX": safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "cb2s")}".') boards_list = bk72xx_boards.BOARDS.items() + elif platform == "LN882X": + safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wl2s")}".') + boards_list = ln882x_boards.BOARDS.items() elif platform == "RTL87XX": safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wr3")}".') boards_list = rtl87xx_boards.BOARDS.items() diff --git a/platformio.ini b/platformio.ini index e4fcab2394..0d67e23222 100644 --- a/platformio.ini +++ b/platformio.ini @@ -4,7 +4,7 @@ ; It's *not* used during runtime. [platformio] -default_envs = esp8266-arduino, esp32-arduino, esp32-idf, bk72xx-arduino +default_envs = esp8266-arduino, esp32-arduino, esp32-idf, bk72xx-arduino, ln882h-arduino ; Ideally, we want src_dir to be the root directory of the repository, to mimic the runtime build ; environment as best as possible. Unfortunately, the ESP-IDF toolchain really doesn't like this ; being the root directory. Instead, set esphome/ as the source directory, all our sources are in @@ -530,6 +530,17 @@ build_flags = build_unflags = ${common.build_unflags} +[env:ln882h-arduino] +extends = common:libretiny-arduino +board = generic-ln882hki +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:runtime.build_flags} + -DUSE_LN882X + -DUSE_LIBRETINY_VARIANT_LN882H +build_unflags = + ${common.build_unflags} + [env:rtl87xxb-arduino] extends = common:libretiny-arduino board = generic-rtl8710bn-2mb-788k diff --git a/tests/components/adc/test.ln882x-ard.yaml b/tests/components/adc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..92c76ca9b3 --- /dev/null +++ b/tests/components/adc/test.ln882x-ard.yaml @@ -0,0 +1,4 @@ +sensor: + - platform: adc + pin: PA0 + name: Basic ADC Test diff --git a/tests/components/binary_sensor/test.ln882x-ard.yaml b/tests/components/binary_sensor/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/binary_sensor/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/debug/test.ln882x-ard.yaml b/tests/components/debug/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/debug/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/homeassistant/test.ln882x-ard.yaml b/tests/components/homeassistant/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/homeassistant/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/script/test.ln882x-ard.yaml b/tests/components/script/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/script/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sntp/test.ln882x-ard.yaml b/tests/components/sntp/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sntp/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/switch/test.ln882x-ard.yaml b/tests/components/switch/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/switch/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/syslog/test.ln882x-ard.yaml b/tests/components/syslog/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/syslog/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/template/test.ln882x-ard.yaml b/tests/components/template/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/template/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/web_server/test_no_ota.esp32-idf.yaml b/tests/components/web_server/test_no_ota.esp32-idf.yaml new file mode 100644 index 0000000000..1f677fb948 --- /dev/null +++ b/tests/components/web_server/test_no_ota.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + device_base: !include common.yaml + +# No OTA component defined for this test + +web_server: + port: 8080 + version: 2 + ota: false diff --git a/tests/components/web_server/test_ota.esp32-idf.yaml b/tests/components/web_server/test_ota.esp32-idf.yaml new file mode 100644 index 0000000000..294e7f862e --- /dev/null +++ b/tests/components/web_server/test_ota.esp32-idf.yaml @@ -0,0 +1,32 @@ +# Test configuration for ESP-IDF web server with OTA enabled +esphome: + name: test-web-server-ota-idf + +# Force ESP-IDF framework +esp32: + board: esp32dev + framework: + type: esp-idf + +packages: + device_base: !include common.yaml + +# Enable OTA for multipart upload testing +ota: + - platform: esphome + password: "test_ota_password" + +# Web server with OTA enabled +web_server: + port: 8080 + version: 2 + ota: true + include_internal: true + +# Enable debug logging for OTA +logger: + level: DEBUG + logs: + web_server: VERBOSE + web_server_idf: VERBOSE + diff --git a/tests/components/web_server/test_ota_disabled.esp32-idf.yaml b/tests/components/web_server/test_ota_disabled.esp32-idf.yaml new file mode 100644 index 0000000000..c7c7574e3b --- /dev/null +++ b/tests/components/web_server/test_ota_disabled.esp32-idf.yaml @@ -0,0 +1,11 @@ +packages: + device_base: !include common.yaml + +# OTA is configured but web_server OTA is disabled +ota: + - platform: esphome + +web_server: + port: 8080 + version: 2 + ota: false diff --git a/tests/test_build_components/build_components_base.ln882x-ard.yaml b/tests/test_build_components/build_components_base.ln882x-ard.yaml new file mode 100644 index 0000000000..80fc6690f9 --- /dev/null +++ b/tests/test_build_components/build_components_base.ln882x-ard.yaml @@ -0,0 +1,15 @@ +esphome: + name: componenttestespln882x + friendly_name: $component_name + +ln882x: + board: generic-ln882hki + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7a1354589c..2928c5c83a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -20,6 +20,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_HOST, + PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, ) @@ -214,7 +215,8 @@ def hex_int__valid(value): ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), - ("host", PLATFORM_HOST, None, "23", "23", "23", "23"), + ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), + ("host", PLATFORM_HOST, None, "24", "24", "24", "24"), ], ) def test_split_default(framework, platform, variant, full, idf, arduino, simple): @@ -244,7 +246,8 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "rp2040": "20", "bk72xx": "21", "rtl87xx": "22", - "host": "23", + "ln882x": "23", + "host": "24", } idf_mappings = { diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 6d360740f4..ab20b2abb5 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -8,6 +8,7 @@ import pytest from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS from esphome.components.esp32.boards import ESP32_BOARD_PINS from esphome.components.esp8266.boards import ESP8266_BOARD_PINS +from esphome.components.ln882x.boards import LN882X_BOARD_PINS from esphome.components.rtl87xx.boards import RTL87XX_BOARD_PINS from esphome.core import CORE import esphome.wizard as wz @@ -187,6 +188,27 @@ def test_wizard_write_defaults_platform_from_board_bk72xx( assert "bk72xx:" in generated_config +def test_wizard_write_defaults_platform_from_board_ln882x( + default_config, tmp_path, monkeypatch +): + """ + If the platform is not explicitly set, use "LN882X" if the board is one of LN882X boards + """ + # Given + del default_config["platform"] + default_config["board"] = [*LN882X_BOARD_PINS][0] + + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert "ln882x:" in generated_config + + def test_wizard_write_defaults_platform_from_board_rtl87xx( default_config, tmp_path, monkeypatch ):