From 62cb08c3dce9d2579c70dcad157c6634f471f29e Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 9 Jan 2026 04:05:47 -0600 Subject: [PATCH 01/10] [api] Add methods supporting efficient packed repeated sint32 field encoding for #12985 (#13094) --- esphome/components/api/proto.h | 63 +++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 2d93adbb47..a336a9493d 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -39,6 +39,24 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) { return (value & 1) ? static_cast(~(value >> 1)) : static_cast(value >> 1); } +/// Count number of varints in a packed buffer +inline uint16_t count_packed_varints(const uint8_t *data, size_t len) { + uint16_t count = 0; + while (len > 0) { + // Skip varint bytes until we find one without continuation bit + while (len > 0 && (*data & 0x80)) { + data++; + len--; + } + if (len > 0) { + data++; + len--; + count++; + } + } + return count; +} + /* * StringRef Ownership Model for API Protocol Messages * =================================================== @@ -180,9 +198,10 @@ class ProtoVarInt { uint64_t value_; }; -// Forward declaration for decode_to_message and encode_to_writer -class ProtoMessage; +// Forward declarations for decode_to_message, encode_message and encode_packed_sint32 class ProtoDecodableMessage; +class ProtoMessage; +class ProtoSize; class ProtoLengthDelimited { public: @@ -334,6 +353,8 @@ class ProtoWriteBuffer { void encode_sint64(uint32_t field_id, int64_t value, bool force = false) { this->encode_uint64(field_id, encode_zigzag64(value), force); } + /// Encode a packed repeated sint32 field (zero-copy from vector) + void encode_packed_sint32(uint32_t field_id, const std::vector &values); void encode_message(uint32_t field_id, const ProtoMessage &value); std::vector *get_buffer() const { return buffer_; } @@ -341,9 +362,6 @@ class ProtoWriteBuffer { std::vector *buffer_; }; -// Forward declaration -class ProtoSize; - class ProtoMessage { public: virtual ~ProtoMessage() = default; @@ -792,8 +810,43 @@ class ProtoSize { } } } + + /** + * @brief Calculate size of a packed repeated sint32 field + */ + inline void add_packed_sint32(uint32_t field_id_size, const std::vector &values) { + if (values.empty()) + return; + + size_t packed_size = 0; + for (int value : values) { + packed_size += varint(encode_zigzag32(value)); + } + + // field_id + length varint + packed data + total_size_ += field_id_size + varint(static_cast(packed_size)) + static_cast(packed_size); + } }; +// Implementation of encode_packed_sint32 - must be after ProtoSize is defined +inline void ProtoWriteBuffer::encode_packed_sint32(uint32_t field_id, const std::vector &values) { + if (values.empty()) + return; + + // Calculate packed size + size_t packed_size = 0; + for (int value : values) { + packed_size += ProtoSize::varint(encode_zigzag32(value)); + } + + // Write tag (LENGTH_DELIMITED) + length + all zigzag-encoded values + this->encode_field_raw(field_id, WIRE_TYPE_LENGTH_DELIMITED); + this->encode_varint_raw(packed_size); + for (int value : values) { + this->encode_varint_raw(encode_zigzag32(value)); + } +} + // Implementation of encode_message - must be after ProtoMessage is defined inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessage &value) { this->encode_field_raw(field_id, 2); // type 2: Length-delimited message From c40f44f4bdd4fe559925407d22712b93636dc4b8 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 9 Jan 2026 04:06:03 -0600 Subject: [PATCH 02/10] [remote_base] Add zero-copy packed sint32 decoder for #12985 (#13093) --- .../components/remote_base/remote_base.cpp | 33 +++++++++++++++++++ esphome/components/remote_base/remote_base.h | 5 +++ 2 files changed, 38 insertions(+) diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 34aba236b3..2f1c107bf4 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -128,6 +128,39 @@ void RemoteReceiverBase::call_dumpers_() { void RemoteReceiverBinarySensorBase::dump_config() { LOG_BINARY_SENSOR("", "Remote Receiver Binary Sensor", this); } +/* RemoteTransmitData */ + +void RemoteTransmitData::set_data_from_packed_sint32(const uint8_t *data, size_t len, size_t count) { + this->data_.clear(); + this->data_.reserve(count); + + while (len > 0) { + // Parse varint (inline, no dependency on api component) + uint32_t raw = 0; + uint32_t shift = 0; + uint32_t consumed = 0; + for (; consumed < len && consumed < 5; consumed++) { + uint8_t byte = data[consumed]; + raw |= (byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + consumed++; + break; + } + shift += 7; + } + if (consumed == 0) + break; // Parse error + + // Zigzag decode: (n >> 1) ^ -(n & 1) + int32_t decoded = static_cast((raw >> 1) ^ (~(raw & 1) + 1)); + this->data_.push_back(decoded); + data += consumed; + len -= consumed; + } +} + +/* RemoteTransmitterBase */ + void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 2cb79bf571..a11e0271af 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -31,6 +31,11 @@ class RemoteTransmitData { uint32_t get_carrier_frequency() const { return this->carrier_frequency_; } const RawTimings &get_data() const { return this->data_; } void set_data(const RawTimings &data) { this->data_ = data; } + /// Set data from packed protobuf sint32 buffer (zigzag + varint encoded) + /// @param data Pointer to packed zigzag-varint-encoded sint32 values + /// @param len Length of the buffer in bytes + /// @param count Number of values (for reserve optimization) + void set_data_from_packed_sint32(const uint8_t *data, size_t len, size_t count); void reset() { this->data_.clear(); this->carrier_frequency_ = 0; From 3d54ccac65b7d8d02e2f59e17c8b88d3617faa53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Jan 2026 04:35:19 -1000 Subject: [PATCH 03/10] Revert "[wifi] Disable SoftAP support on Arduino ESP32 when ap: not configured" (#13099) --- esphome/components/wifi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index e8bc2edd8f..26aec29b6d 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -475,7 +475,7 @@ async def to_code(config): ) cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) cg.add_define("USE_WIFI_AP") - elif CORE.is_esp32: + elif CORE.is_esp32 and not CORE.using_arduino: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) From ab32b939287ddeaccf009ebfb5b5282930f3089f Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Fri, 9 Jan 2026 11:34:04 -0800 Subject: [PATCH 04/10] [hub75] Fix gamma_correct to use enum value instead of key string (#13102) Co-authored-by: Claude --- esphome/components/hub75/display.py | 2 +- tests/components/hub75/test.esp32-s3-idf-board.yaml | 1 + tests/components/hub75/test.esp32-s3-idf.yaml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 7a3df333c9..20c731e730 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -555,7 +555,7 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag(f"-DHUB75_BIT_DEPTH={config[CONF_BIT_DEPTH]}") if CONF_GAMMA_CORRECT in config: - cg.add_build_flag(f"-DHUB75_GAMMA_MODE={config[CONF_GAMMA_CORRECT]}") + cg.add_build_flag(f"-DHUB75_GAMMA_MODE={config[CONF_GAMMA_CORRECT].enum_value}") # Await all pin expressions pin_expressions = { diff --git a/tests/components/hub75/test.esp32-s3-idf-board.yaml b/tests/components/hub75/test.esp32-s3-idf-board.yaml index 3723a80006..8c73a0fd44 100644 --- a/tests/components/hub75/test.esp32-s3-idf-board.yaml +++ b/tests/components/hub75/test.esp32-s3-idf-board.yaml @@ -6,6 +6,7 @@ display: panel_height: 32 double_buffer: true brightness: 128 + gamma_correct: gamma_2_2 pages: - id: page1 lambda: |- diff --git a/tests/components/hub75/test.esp32-s3-idf.yaml b/tests/components/hub75/test.esp32-s3-idf.yaml index f8ee26e73d..0adb4f991b 100644 --- a/tests/components/hub75/test.esp32-s3-idf.yaml +++ b/tests/components/hub75/test.esp32-s3-idf.yaml @@ -5,6 +5,7 @@ display: panel_height: 32 double_buffer: true brightness: 128 + gamma_correct: cie1931 r1_pin: GPIO42 g1_pin: GPIO41 b1_pin: GPIO40 From 7935fba4b10b87468d1caacaf9a2326d9b3c80ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:37:53 -0500 Subject: [PATCH 05/10] Bump esphome-dashboard from 20251013.0 to 20260110.0 (#13109) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 56df559cd7..00f81f793f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 -esphome-dashboard==20251013.0 +esphome-dashboard==20260110.0 aioesphomeapi==43.10.1 zeroconf==0.148.0 puremagic==1.30 From 2fb7c0d453c37e6c12030d6269a77c74714afa69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Jan 2026 09:39:53 -1000 Subject: [PATCH 06/10] [mapping] Fix test SPI data rate for RP2040 (#13108) --- tests/components/mapping/test.rp2040-ard.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index acc305a701..fdfed5f6ab 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -7,6 +7,7 @@ display: platform: ili9xxx id: main_lcd model: ili9342 + data_rate: 31.25MHz cs_pin: 20 dc_pin: 21 reset_pin: 22 From 32f90b2855c00f2f723751fd5bc896d57fd66233 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Jan 2026 09:40:24 -1000 Subject: [PATCH 07/10] [mdns] Remove deprecated api password from test configuration (#13107) --- tests/components/mdns/test-comprehensive.esp8266-ard.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/mdns/test-comprehensive.esp8266-ard.yaml b/tests/components/mdns/test-comprehensive.esp8266-ard.yaml index 3129ca3143..7d3f61cf08 100644 --- a/tests/components/mdns/test-comprehensive.esp8266-ard.yaml +++ b/tests/components/mdns/test-comprehensive.esp8266-ard.yaml @@ -42,4 +42,3 @@ status_led: # Include API at priority 40 api: - password: "apipassword" From 3c9b300c462e86316c5b154c485fff53bec1a7be Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 9 Jan 2026 23:13:37 +0100 Subject: [PATCH 08/10] [CI] skip endpoint check due to test grouping (#13111) --- esphome/components/zigbee/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 1a017f2ab2..cf37e890c4 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -69,7 +69,7 @@ def validate_number_of_ep(config: ConfigType) -> None: raise cv.Invalid( "Single endpoint is not supported https://github.com/Koenkk/zigbee2mqtt/issues/29888" ) - if count > CONF_MAX_EP_NUMBER: + if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode: raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") From e0ff7fdaa1b4b5e2c26023f0f6671d621b29c8c8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:36:56 -0500 Subject: [PATCH 09/10] [esp32_hosted] Add HTTP-based coprocessor firmware update support (#13090) Co-authored-by: Claude --- .../esp32_hosted/update/__init__.py | 68 +++- .../update/esp32_hosted_update.cpp | 353 ++++++++++++++++-- .../esp32_hosted/update/esp32_hosted_update.h | 35 +- ...f.yaml => test-embedded.esp32-p4-idf.yaml} | 1 + .../esp32_hosted/test-http.esp32-p4-idf.yaml | 10 + 5 files changed, 410 insertions(+), 57 deletions(-) rename tests/components/esp32_hosted/{test.esp32-p4-idf.yaml => test-embedded.esp32-p4-idf.yaml} (92%) create mode 100644 tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index fff0d3623a..b258a26b08 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -4,18 +4,24 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, update import esphome.config_validation as cv -from esphome.const import CONF_PATH, CONF_RAW_DATA_ID -from esphome.core import CORE, HexInt +from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE +from esphome.core import CORE, ID, HexInt CODEOWNERS = ["@swoboda1337"] -AUTO_LOAD = ["sha256", "watchdog"] +AUTO_LOAD = ["sha256", "watchdog", "json"] DEPENDENCIES = ["esp32_hosted"] CONF_SHA256 = "sha256" +CONF_HTTP_REQUEST_ID = "http_request_id" + +TYPE_EMBEDDED = "embedded" +TYPE_HTTP = "http" esp32_hosted_ns = cg.esphome_ns.namespace("esp32_hosted") +http_request_ns = cg.esphome_ns.namespace("http_request") +HttpRequestComponent = http_request_ns.class_("HttpRequestComponent", cg.Component) Esp32HostedUpdate = esp32_hosted_ns.class_( - "Esp32HostedUpdate", update.UpdateEntity, cg.Component + "Esp32HostedUpdate", update.UpdateEntity, cg.PollingComponent ) @@ -30,12 +36,29 @@ def _validate_sha256(value: Any) -> str: return value +BASE_SCHEMA = update.update_schema(Esp32HostedUpdate, device_class="firmware").extend( + cv.polling_component_schema("6h") +) + +EMBEDDED_SCHEMA = BASE_SCHEMA.extend( + { + cv.Required(CONF_PATH): cv.file_, + cv.Required(CONF_SHA256): _validate_sha256, + } +) + +HTTP_SCHEMA = BASE_SCHEMA.extend( + { + cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), + cv.Required(CONF_SOURCE): cv.url, + } +) + CONFIG_SCHEMA = cv.All( - update.update_schema(Esp32HostedUpdate, device_class="firmware").extend( + cv.typed_schema( { - cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_PATH): cv.file_, - cv.Required(CONF_SHA256): _validate_sha256, + TYPE_EMBEDDED: EMBEDDED_SCHEMA, + TYPE_HTTP: HTTP_SCHEMA, } ), esp32.only_on_variant( @@ -48,6 +71,9 @@ CONFIG_SCHEMA = cv.All( def _validate_firmware(config: dict[str, Any]) -> None: + if config[CONF_TYPE] != TYPE_EMBEDDED: + return + path = CORE.relative_config_path(config[CONF_PATH]) with open(path, "rb") as f: firmware_data = f.read() @@ -65,14 +91,22 @@ FINAL_VALIDATE_SCHEMA = _validate_firmware async def to_code(config: dict[str, Any]) -> None: var = await update.new_update(config) - path = config[CONF_PATH] - with open(CORE.relative_config_path(path), "rb") as f: - firmware_data = f.read() - rhs = [HexInt(x) for x in firmware_data] - prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) + if config[CONF_TYPE] == TYPE_EMBEDDED: + path = config[CONF_PATH] + with open(CORE.relative_config_path(path), "rb") as f: + firmware_data = f.read() + rhs = [HexInt(x) for x in firmware_data] + arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8) + prog_arr = cg.progmem_array(arr_id, rhs) + + sha256_bytes = bytes.fromhex(config[CONF_SHA256]) + cg.add(var.set_firmware_sha256([HexInt(b) for b in sha256_bytes])) + cg.add(var.set_firmware_data(prog_arr)) + cg.add(var.set_firmware_size(len(firmware_data))) + else: + http_request_var = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) + cg.add(var.set_http_request_parent(http_request_var)) + cg.add(var.set_source_url(config[CONF_SOURCE])) + cg.add_define("USE_ESP32_HOSTED_HTTP_UPDATE") - sha256_bytes = bytes.fromhex(config[CONF_SHA256]) - cg.add(var.set_firmware_sha256([HexInt(b) for b in sha256_bytes])) - cg.add(var.set_firmware_data(prog_arr)) - cg.add(var.set_firmware_size(len(firmware_data))) await cg.register_component(var, config) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 3598a2e69c..fcec1a5f20 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -7,6 +7,12 @@ #include #include #include +#include + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +#include "esphome/components/json/json_util.h" +#include "esphome/components/network/util.h" +#endif extern "C" { #include @@ -16,18 +22,50 @@ namespace esphome::esp32_hosted { static const char *const TAG = "esp32_hosted.update"; -// older coprocessor firmware versions have a 1500-byte limit per RPC call +// Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; +// Compile-time version string from esp_hosted_host_fw_ver.h macros +#define STRINGIFY_(x) #x +#define STRINGIFY(x) STRINGIFY_(x) +static const char *const ESP_HOSTED_VERSION_STR = STRINGIFY(ESP_HOSTED_VERSION_MAJOR_1) "." STRINGIFY( + ESP_HOSTED_VERSION_MINOR_1) "." STRINGIFY(ESP_HOSTED_VERSION_PATCH_1); + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +// Parse version string "major.minor.patch" into components +// Returns true if parsing succeeded +static bool parse_version(const std::string &version_str, int &major, int &minor, int &patch) { + major = minor = patch = 0; + if (sscanf(version_str.c_str(), "%d.%d.%d", &major, &minor, &patch) >= 2) { + return true; + } + return false; +} + +// Compare two versions, returns: +// -1 if v1 < v2 +// 0 if v1 == v2 +// 1 if v1 > v2 +static int compare_versions(int major1, int minor1, int patch1, int major2, int minor2, int patch2) { + if (major1 != major2) + return major1 < major2 ? -1 : 1; + if (minor1 != minor2) + return minor1 < minor2 ? -1 : 1; + if (patch1 != patch2) + return patch1 < patch2 ? -1 : 1; + return 0; +} +#endif + void Esp32HostedUpdate::setup() { this->update_info_.title = "ESP32 Hosted Coprocessor"; - // if wifi is not present, connect to the coprocessor #ifndef USE_WIFI + // If WiFi is not present, connect to the coprocessor esp_hosted_connect_to_slave(); // NOLINT #endif - // get coprocessor version + // Get coprocessor version esp_hosted_coprocessor_fwver_t ver_info; if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { this->update_info_.current_version = str_sprintf("%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); @@ -36,7 +74,8 @@ void Esp32HostedUpdate::setup() { } ESP_LOGD(TAG, "Coprocessor version: %s", this->update_info_.current_version.c_str()); - // get image version +#ifndef USE_ESP32_HOSTED_HTTP_UPDATE + // Embedded mode: get image version from embedded firmware const int app_desc_offset = sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t); if (this->firmware_size_ >= app_desc_offset + sizeof(esp_app_desc_t)) { esp_app_desc_t *app_desc = (esp_app_desc_t *) (this->firmware_data_ + app_desc_offset); @@ -64,58 +103,272 @@ void Esp32HostedUpdate::setup() { this->state_ = update::UPDATE_STATE_NO_UPDATE; } - // publish state + // Publish state this->status_clear_error(); this->publish_state(); +#else + // HTTP mode: retry initial check every 10s until network is ready (max 6 attempts) + // Only if update interval is > 1 minute to avoid redundant checks + if (this->get_update_interval() > 60000) { + this->set_retry("initial_check", 10000, 6, [this](uint8_t) { + if (!network::is_connected()) { + return RetryResult::RETRY; + } + this->check(); + return RetryResult::DONE; + }); + } +#endif } void Esp32HostedUpdate::dump_config() { ESP_LOGCONFIG(TAG, "ESP32 Hosted Update:\n" - " Current Version: %s\n" - " Latest Version: %s\n" - " Latest Size: %zu bytes", - this->update_info_.current_version.c_str(), this->update_info_.latest_version.c_str(), + " Host Library Version: %s\n" + " Coprocessor Version: %s\n" + " Latest Version: %s", + ESP_HOSTED_VERSION_STR, this->update_info_.current_version.c_str(), + this->update_info_.latest_version.c_str()); +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + ESP_LOGCONFIG(TAG, + " Mode: HTTP\n" + " Source URL: %s", + this->source_url_.c_str()); +#else + ESP_LOGCONFIG(TAG, + " Mode: Embedded\n" + " Firmware Size: %zu bytes", this->firmware_size_); +#endif } -void Esp32HostedUpdate::perform(bool force) { - if (this->state_ != update::UPDATE_STATE_AVAILABLE && !force) { - ESP_LOGW(TAG, "Update not available"); +void Esp32HostedUpdate::check() { +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + if (!network::is_connected()) { + ESP_LOGD(TAG, "Network not connected, skipping update check"); return; } + if (!this->fetch_manifest_()) { + return; + } + + // Compare versions + if (this->update_info_.latest_version.empty() || + this->update_info_.latest_version == this->update_info_.current_version) { + this->state_ = update::UPDATE_STATE_NO_UPDATE; + } else { + this->state_ = update::UPDATE_STATE_AVAILABLE; + } + + this->update_info_.has_progress = false; + this->update_info_.progress = 0.0f; + this->status_clear_error(); + this->publish_state(); +#endif +} + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +bool Esp32HostedUpdate::fetch_manifest_() { + ESP_LOGD(TAG, "Fetching manifest"); + + auto container = this->http_request_parent_->get(this->source_url_); + if (container == nullptr || container->status_code != 200) { + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str()); + this->status_set_error(LOG_STR("Failed to fetch manifest")); + return false; + } + + // Read manifest JSON into string (manifest is small, ~1KB max) + std::string json_str; + json_str.reserve(container->content_length); + uint8_t buf[256]; + while (container->get_bytes_read() < container->content_length) { + int read = container->read(buf, sizeof(buf)); + if (read > 0) { + json_str.append(reinterpret_cast(buf), read); + } + yield(); + } + container->end(); + + // Parse JSON manifest + // Format: {"versions": [{"version": "2.7.0", "url": "...", "sha256": "..."}]} + // Only consider versions <= host library version to avoid compatibility issues + bool valid = json::parse_json(json_str, [this](JsonObject root) -> bool { + if (!root["versions"].is()) { + ESP_LOGE(TAG, "Manifest does not contain 'versions' array"); + return false; + } + + JsonArray versions = root["versions"].as(); + if (versions.size() == 0) { + ESP_LOGE(TAG, "Manifest 'versions' array is empty"); + return false; + } + + // Find the highest version that is compatible with the host library + // (version <= host version to avoid upgrading coprocessor ahead of host) + int best_major = -1, best_minor = -1, best_patch = -1; + std::string best_version, best_url, best_sha256; + + for (JsonObject entry : versions) { + if (!entry["version"].is() || !entry["url"].is() || + !entry["sha256"].is()) { + continue; // Skip malformed entries + } + + std::string ver_str = entry["version"].as(); + int major, minor, patch; + if (!parse_version(ver_str, major, minor, patch)) { + ESP_LOGW(TAG, "Failed to parse version: %s", ver_str.c_str()); + continue; + } + + // Check if this version is compatible (not newer than host) + if (compare_versions(major, minor, patch, ESP_HOSTED_VERSION_MAJOR_1, ESP_HOSTED_VERSION_MINOR_1, + ESP_HOSTED_VERSION_PATCH_1) > 0) { + continue; + } + + // Check if this is better than our current best + if (best_major < 0 || compare_versions(major, minor, patch, best_major, best_minor, best_patch) > 0) { + best_major = major; + best_minor = minor; + best_patch = patch; + best_version = ver_str; + best_url = entry["url"].as(); + best_sha256 = entry["sha256"].as(); + } + } + + if (best_major < 0) { + ESP_LOGW(TAG, "No compatible firmware version found (host is %s)", ESP_HOSTED_VERSION_STR); + return false; + } + + this->update_info_.latest_version = best_version; + this->firmware_url_ = best_url; + + // Parse SHA256 hex string to bytes + if (!parse_hex(best_sha256, this->firmware_sha256_.data(), 32)) { + ESP_LOGE(TAG, "Invalid SHA256: %s", best_sha256.c_str()); + return false; + } + + ESP_LOGD(TAG, "Best compatible version: %s", this->update_info_.latest_version.c_str()); + + return true; + }); + + if (!valid) { + ESP_LOGE(TAG, "Failed to parse manifest JSON"); + this->status_set_error(LOG_STR("Failed to parse manifest")); + return false; + } + + return true; +} + +bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { + ESP_LOGI(TAG, "Downloading firmware"); + + auto container = this->http_request_parent_->get(this->firmware_url_); + if (container == nullptr || container->status_code != 200) { + ESP_LOGE(TAG, "Failed to fetch firmware"); + this->status_set_error(LOG_STR("Failed to fetch firmware")); + return false; + } + + size_t total_size = container->content_length; + ESP_LOGI(TAG, "Firmware size: %zu bytes", total_size); + + // Begin OTA on coprocessor + esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); + container->end(); + this->status_set_error(LOG_STR("Failed to begin OTA")); + return false; + } + + // Stream firmware to coprocessor while computing SHA256 + // Hardware SHA acceleration requires 32-byte alignment on some chips (ESP32-S3 with IDF 5.5.x+) + alignas(32) sha256::SHA256 hasher; + hasher.init(); + + uint8_t buffer[CHUNK_SIZE]; + while (container->get_bytes_read() < total_size) { + int read = container->read(buffer, sizeof(buffer)); + + // Feed watchdog and give other tasks a chance to run + App.feed_wdt(); + yield(); + + // Exit loop if no data available (stream closed or end of data) + if (read <= 0) { + if (read < 0) { + ESP_LOGE(TAG, "Stream closed with error"); + esp_hosted_slave_ota_end(); // NOLINT + container->end(); + this->status_set_error(LOG_STR("Download failed")); + return false; + } + // read == 0: no more data available, exit loop + break; + } + + hasher.add(buffer, read); + err = esp_hosted_slave_ota_write(buffer, read); // NOLINT + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); + esp_hosted_slave_ota_end(); // NOLINT + container->end(); + this->status_set_error(LOG_STR("Failed to write OTA data")); + return false; + } + } + container->end(); + + // Verify SHA256 + hasher.calculate(); + if (!hasher.equals_bytes(this->firmware_sha256_.data())) { + ESP_LOGE(TAG, "SHA256 mismatch"); + esp_hosted_slave_ota_end(); // NOLINT + this->status_set_error(LOG_STR("SHA256 verification failed")); + return false; + } + + ESP_LOGI(TAG, "SHA256 verified successfully"); + return true; +} +#else +bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() { if (this->firmware_data_ == nullptr || this->firmware_size_ == 0) { ESP_LOGE(TAG, "No firmware data available"); - return; + this->status_set_error(LOG_STR("No firmware data available")); + return false; } - // ESP32-S3 hardware SHA acceleration requires 32-byte DMA alignment (IDF 5.5.x+) + // Verify SHA256 before writing + // Hardware SHA acceleration requires 32-byte alignment on some chips (ESP32-S3 with IDF 5.5.x+) alignas(32) sha256::SHA256 hasher; hasher.init(); hasher.add(this->firmware_data_, this->firmware_size_); hasher.calculate(); if (!hasher.equals_bytes(this->firmware_sha256_.data())) { + ESP_LOGE(TAG, "SHA256 mismatch"); this->status_set_error(LOG_STR("SHA256 verification failed")); - this->publish_state(); - return; + return false; } ESP_LOGI(TAG, "Starting OTA update (%zu bytes)", this->firmware_size_); - watchdog::WatchdogManager watchdog(20000); - update::UpdateState prev_state = this->state_; - this->state_ = update::UPDATE_STATE_INSTALLING; - this->update_info_.has_progress = false; - this->publish_state(); - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); - this->state_ = prev_state; this->status_set_error(LOG_STR("Failed to begin OTA")); - this->publish_state(); - return; + return false; } uint8_t chunk[CHUNK_SIZE]; @@ -128,42 +381,68 @@ void Esp32HostedUpdate::perform(bool force) { if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); esp_hosted_slave_ota_end(); // NOLINT - this->state_ = prev_state; this->status_set_error(LOG_STR("Failed to write OTA data")); - this->publish_state(); - return; + return false; } data_ptr += chunk_size; remaining -= chunk_size; App.feed_wdt(); } - err = esp_hosted_slave_ota_end(); // NOLINT - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(err)); + return true; +} +#endif + +void Esp32HostedUpdate::perform(bool force) { + if (this->state_ != update::UPDATE_STATE_AVAILABLE && !force) { + ESP_LOGW(TAG, "Update not available"); + return; + } + + update::UpdateState prev_state = this->state_; + this->state_ = update::UPDATE_STATE_INSTALLING; + this->update_info_.has_progress = false; + this->publish_state(); + + watchdog::WatchdogManager watchdog(60000); + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + if (!this->stream_firmware_to_coprocessor_()) +#else + if (!this->write_embedded_firmware_to_coprocessor_()) +#endif + { + this->state_ = prev_state; + this->publish_state(); + return; + } + + // End OTA and activate new firmware + esp_err_t end_err = esp_hosted_slave_ota_end(); // NOLINT + if (end_err != ESP_OK) { + ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err)); this->state_ = prev_state; this->status_set_error(LOG_STR("Failed to end OTA")); this->publish_state(); return; } - // activate new firmware - err = esp_hosted_slave_ota_activate(); // NOLINT - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to activate OTA: %s", esp_err_to_name(err)); + esp_err_t activate_err = esp_hosted_slave_ota_activate(); // NOLINT + if (activate_err != ESP_OK) { + ESP_LOGE(TAG, "Failed to activate OTA: %s", esp_err_to_name(activate_err)); this->state_ = prev_state; this->status_set_error(LOG_STR("Failed to activate OTA")); this->publish_state(); return; } - // update state + // Update state ESP_LOGI(TAG, "OTA update successful"); this->state_ = update::UPDATE_STATE_NO_UPDATE; this->status_clear_error(); this->publish_state(); - // schedule a restart to ensure everything is in sync + // Schedule a restart to ensure everything is in sync ESP_LOGI(TAG, "Restarting in 1 second"); this->set_timeout(1000, []() { App.safe_reboot(); }); } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 9c087bf72a..7c9645c12a 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -5,26 +5,55 @@ #include "esphome/core/component.h" #include "esphome/components/update/update_entity.h" #include +#include + +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE +#include "esphome/components/http_request/http_request.h" +#endif namespace esphome::esp32_hosted { -class Esp32HostedUpdate : public update::UpdateEntity, public Component { +class Esp32HostedUpdate : public update::UpdateEntity, public PollingComponent { public: void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void update() override { this->check(); } // PollingComponent - delegates to check() void perform(bool force) override; - void check() override {} + void check() override; +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + // HTTP mode setters + void set_source_url(const std::string &url) { this->source_url_ = url; } + void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; } +#else + // Embedded mode setters void set_firmware_data(const uint8_t *data) { this->firmware_data_ = data; } void set_firmware_size(size_t size) { this->firmware_size_ = size; } void set_firmware_sha256(const std::array &sha256) { this->firmware_sha256_ = sha256; } +#endif protected: +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + // HTTP mode members + http_request::HttpRequestComponent *http_request_parent_{nullptr}; + std::string source_url_; + std::string firmware_url_; + + // HTTP mode helpers + bool fetch_manifest_(); + bool stream_firmware_to_coprocessor_(); +#else + // Embedded mode members const uint8_t *firmware_data_{nullptr}; size_t firmware_size_{0}; - std::array firmware_sha256_; + + // Embedded mode helper + bool write_embedded_firmware_to_coprocessor_(); +#endif + + std::array firmware_sha256_{}; }; } // namespace esphome::esp32_hosted diff --git a/tests/components/esp32_hosted/test.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml similarity index 92% rename from tests/components/esp32_hosted/test.esp32-p4-idf.yaml rename to tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml index 2a76f17e2f..9640032b34 100644 --- a/tests/components/esp32_hosted/test.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml @@ -3,5 +3,6 @@ update: - platform: esp32_hosted name: "Coprocessor Firmware Update" + type: embedded path: $component_dir/test_firmware.bin sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31 diff --git a/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml new file mode 100644 index 0000000000..17cde0f35d --- /dev/null +++ b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml @@ -0,0 +1,10 @@ +<<: !include common.yaml + +http_request: + +update: + - platform: esp32_hosted + name: "Coprocessor Firmware Update" + type: http + source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json + update_interval: 6h From cea2878b55630159b791ab1a477afe6b286dca6f Mon Sep 17 00:00:00 2001 From: Douwe <61123717+dhoeben@users.noreply.github.com> Date: Sat, 10 Jan 2026 02:25:42 +0100 Subject: [PATCH 10/10] [water_heater] (3/4) Implement web_server for new water_heater component (#12511) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/web_server/list_entities.cpp | 2 +- esphome/components/web_server/web_server.cpp | 194 ++++++++++++++---- esphome/components/web_server/web_server.h | 52 ++++- .../components/web_server/web_server_v1.cpp | 7 + 4 files changed, 214 insertions(+), 41 deletions(-) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 55beed812f..1e852f6a96 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -136,7 +136,7 @@ bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmCont #ifdef USE_WATER_HEATER bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) { - // Water heater web_server support not yet implemented - this stub acknowledges the entity + this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::water_heater_all_json_generator); return true; } #endif diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index cab177c182..12115083f6 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -28,6 +28,10 @@ #include "esphome/components/climate/climate.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif + #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 #include "server_index_v2.h" @@ -558,7 +562,7 @@ static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const c // Helper to get request detail parameter static JsonDetail get_request_detail(AsyncWebServerRequest *request) { - auto *param = request->getParam("detail"); + auto *param = request->getParam(ESPHOME_F("detail")); return (param && param->value() == "all") ? DETAIL_ALL : DETAIL_STATE; } @@ -837,10 +841,10 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } auto call = is_on ? obj->turn_on() : obj->turn_off(); - parse_int_param_(request, "speed_level", call, &decltype(call)::set_speed); + parse_int_param_(request, ESPHOME_F("speed_level"), call, &decltype(call)::set_speed); - if (request->hasParam("oscillation")) { - auto speed = request->getParam("oscillation")->value(); + if (request->hasParam(ESPHOME_F("oscillation"))) { + auto speed = request->getParam(ESPHOME_F("oscillation"))->value(); auto val = parse_on_off(speed.c_str()); switch (val) { case PARSE_ON: @@ -920,20 +924,20 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa if (is_on) { // Parse color parameters - parse_light_param_(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); - parse_light_param_(request, "r", call, &decltype(call)::set_red, 255.0f); - parse_light_param_(request, "g", call, &decltype(call)::set_green, 255.0f); - parse_light_param_(request, "b", call, &decltype(call)::set_blue, 255.0f); - parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f); - parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature); + parse_light_param_(request, ESPHOME_F("brightness"), call, &decltype(call)::set_brightness, 255.0f); + parse_light_param_(request, ESPHOME_F("r"), call, &decltype(call)::set_red, 255.0f); + parse_light_param_(request, ESPHOME_F("g"), call, &decltype(call)::set_green, 255.0f); + parse_light_param_(request, ESPHOME_F("b"), call, &decltype(call)::set_blue, 255.0f); + parse_light_param_(request, ESPHOME_F("white_value"), call, &decltype(call)::set_white, 255.0f); + parse_light_param_(request, ESPHOME_F("color_temp"), call, &decltype(call)::set_color_temperature); // Parse timing parameters - parse_light_param_uint_(request, "flash", call, &decltype(call)::set_flash_length, 1000); + parse_light_param_uint_(request, ESPHOME_F("flash"), call, &decltype(call)::set_flash_length, 1000); } - parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); + parse_light_param_uint_(request, ESPHOME_F("transition"), call, &decltype(call)::set_transition_length, 1000); if (is_on) { - parse_string_param_(request, "effect", call, &decltype(call)::set_effect); + parse_string_param_(request, ESPHOME_F("effect"), call, &decltype(call)::set_effect); } this->defer([call]() mutable { call.perform(); }); @@ -1016,14 +1020,14 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa } auto traits = obj->get_traits(); - if ((request->hasParam("position") && !traits.get_supports_position()) || - (request->hasParam("tilt") && !traits.get_supports_tilt())) { + if ((request->hasParam(ESPHOME_F("position")) && !traits.get_supports_position()) || + (request->hasParam(ESPHOME_F("tilt")) && !traits.get_supports_tilt())) { request->send(409); return; } - parse_float_param_(request, "position", call, &decltype(call)::set_position); - parse_float_param_(request, "tilt", call, &decltype(call)::set_tilt); + parse_float_param_(request, ESPHOME_F("position"), call, &decltype(call)::set_position); + parse_float_param_(request, ESPHOME_F("tilt"), call, &decltype(call)::set_tilt); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1082,7 +1086,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM } auto call = obj->make_call(); - parse_float_param_(request, "value", call, &decltype(call)::set_value); + parse_float_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_value); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1150,12 +1154,12 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat auto call = obj->make_call(); - if (!request->hasParam("value")) { + if (!request->hasParam(ESPHOME_F("value"))) { request->send(409); return; } - parse_string_param_(request, "value", call, &decltype(call)::set_date); + parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_date); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1214,12 +1218,12 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat auto call = obj->make_call(); - if (!request->hasParam("value")) { + if (!request->hasParam(ESPHOME_F("value"))) { request->send(409); return; } - parse_string_param_(request, "value", call, &decltype(call)::set_time); + parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_time); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1277,12 +1281,12 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur auto call = obj->make_call(); - if (!request->hasParam("value")) { + if (!request->hasParam(ESPHOME_F("value"))) { request->send(409); return; } - parse_string_param_(request, "value", call, &decltype(call)::set_datetime); + parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_datetime); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1342,7 +1346,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat } auto call = obj->make_call(); - parse_string_param_(request, "value", call, &decltype(call)::set_value); + parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_value); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1400,7 +1404,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM } auto call = obj->make_call(); - parse_string_param_(request, "option", call, &decltype(call)::set_option); + parse_string_param_(request, ESPHOME_F("option"), call, &decltype(call)::set_option); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1460,14 +1464,15 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url auto call = obj->make_call(); // Parse string mode parameters - parse_string_param_(request, "mode", call, &decltype(call)::set_mode); - parse_string_param_(request, "fan_mode", call, &decltype(call)::set_fan_mode); - parse_string_param_(request, "swing_mode", call, &decltype(call)::set_swing_mode); + parse_string_param_(request, ESPHOME_F("mode"), call, &decltype(call)::set_mode); + parse_string_param_(request, ESPHOME_F("fan_mode"), call, &decltype(call)::set_fan_mode); + parse_string_param_(request, ESPHOME_F("swing_mode"), call, &decltype(call)::set_swing_mode); // Parse temperature parameters - parse_float_param_(request, "target_temperature_high", call, &decltype(call)::set_target_temperature_high); - parse_float_param_(request, "target_temperature_low", call, &decltype(call)::set_target_temperature_low); - parse_float_param_(request, "target_temperature", call, &decltype(call)::set_target_temperature); + parse_float_param_(request, ESPHOME_F("target_temperature_high"), call, + &decltype(call)::set_target_temperature_high); + parse_float_param_(request, ESPHOME_F("target_temperature_low"), call, &decltype(call)::set_target_temperature_low); + parse_float_param_(request, ESPHOME_F("target_temperature"), call, &decltype(call)::set_target_temperature); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1706,12 +1711,12 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa } auto traits = obj->get_traits(); - if (request->hasParam("position") && !traits.get_supports_position()) { + if (request->hasParam(ESPHOME_F("position")) && !traits.get_supports_position()) { request->send(409); return; } - parse_float_param_(request, "position", call, &decltype(call)::set_position); + parse_float_param_(request, ESPHOME_F("position"), call, &decltype(call)::set_position); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1764,7 +1769,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques } auto call = obj->make_call(); - parse_string_param_(request, "code", call, &decltype(call)::set_code); + parse_string_param_(request, ESPHOME_F("code"), call, &decltype(call)::set_code); // Lookup table for alarm control panel methods static const struct { @@ -1825,6 +1830,117 @@ std::string WebServer::alarm_control_panel_json_(alarm_control_panel::AlarmContr } #endif +#ifdef USE_WATER_HEATER +void WebServer::on_water_heater_update(water_heater::WaterHeater *obj) { + if (!this->include_internal_ && obj->is_internal()) + return; + this->events_.deferrable_send_state(obj, "state", water_heater_state_json_generator); +} +void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (water_heater::WaterHeater *obj : App.get_water_heaters()) { + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) + continue; + + if (request->method() == HTTP_GET && entity_match.action_is_empty) { + auto detail = get_request_detail(request); + std::string data = this->water_heater_json_(obj, detail); + request->send(200, "application/json", data.c_str()); + return; + } + if (!match.method_equals("set")) { + request->send(404); + return; + } + auto call = obj->make_call(); + // Use base class reference for template deduction (make_call returns WaterHeaterCallInternal) + water_heater::WaterHeaterCall &base_call = call; + + // Parse mode parameter + parse_string_param_(request, ESPHOME_F("mode"), base_call, &water_heater::WaterHeaterCall::set_mode); + + // Parse temperature parameters + parse_float_param_(request, ESPHOME_F("target_temperature"), base_call, + &water_heater::WaterHeaterCall::set_target_temperature); + parse_float_param_(request, ESPHOME_F("target_temperature_low"), base_call, + &water_heater::WaterHeaterCall::set_target_temperature_low); + parse_float_param_(request, ESPHOME_F("target_temperature_high"), base_call, + &water_heater::WaterHeaterCall::set_target_temperature_high); + + // Parse away mode parameter + parse_bool_param_(request, ESPHOME_F("away"), base_call, &water_heater::WaterHeaterCall::set_away); + + // Parse on/off parameter + parse_bool_param_(request, ESPHOME_F("is_on"), base_call, &water_heater::WaterHeaterCall::set_on); + + this->defer([call]() mutable { call.perform(); }); + request->send(200); + return; + } + request->send(404); +} + +std::string WebServer::water_heater_state_json_generator(WebServer *web_server, void *source) { + return web_server->water_heater_json_(static_cast(source), DETAIL_STATE); +} +std::string WebServer::water_heater_all_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + return web_server->water_heater_json_(static_cast(source), DETAIL_ALL); +} +std::string WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { + json::JsonBuilder builder; + JsonObject root = builder.root(); + char buf[PSTR_LOCAL_SIZE]; + + const auto mode = obj->get_mode(); + const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode)); + + set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); + + auto traits = obj->get_traits(); + + if (start_config == DETAIL_ALL) { + JsonArray modes = root[ESPHOME_F("modes")].to(); + for (auto m : traits.get_supported_modes()) + modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m))); + this->add_sorting_info_(root, obj); + } + + if (traits.get_supports_current_temperature()) { + float current = obj->get_current_temperature(); + if (!std::isnan(current)) + root[ESPHOME_F("current_temperature")] = current; + } + + if (traits.get_supports_two_point_target_temperature()) { + float low = obj->get_target_temperature_low(); + float high = obj->get_target_temperature_high(); + if (!std::isnan(low)) + root[ESPHOME_F("target_temperature_low")] = low; + if (!std::isnan(high)) + root[ESPHOME_F("target_temperature_high")] = high; + } else { + float target = obj->get_target_temperature(); + if (!std::isnan(target)) + root[ESPHOME_F("target_temperature")] = target; + } + + root[ESPHOME_F("min_temperature")] = traits.get_min_temperature(); + root[ESPHOME_F("max_temperature")] = traits.get_max_temperature(); + root[ESPHOME_F("step")] = traits.get_target_temperature_step(); + + if (traits.get_supports_away_mode()) { + root[ESPHOME_F("away")] = obj->is_away(); + } + + if (traits.has_feature_flags(water_heater::WATER_HEATER_SUPPORTS_ON_OFF)) { + root[ESPHOME_F("is_on")] = obj->is_on(); + } + + return builder.serialize(); +} +#endif + #ifdef USE_EVENT void WebServer::on_event(event::Event *obj) { if (!this->include_internal_ && obj->is_internal()) @@ -2060,6 +2176,9 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { #endif #ifdef USE_UPDATE "update", +#endif +#ifdef USE_WATER_HEATER + "water_heater", #endif }; @@ -2220,6 +2339,11 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { else if (match.domain_equals("update")) { this->handle_update_request(request, match); } +#endif +#ifdef USE_WATER_HEATER + else if (match.domain_equals("water_heater")) { + this->handle_water_heater_request(request, match); + } #endif else { // No matching handler found - send 404 diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 3e1dd867c6..c52cf981e0 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -35,6 +35,13 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome::web_server { +// Type for parameter names that can be stored in flash on ESP8266 +#ifdef USE_ESP8266 +using ParamNameType = const __FlashStringHelper *; +#else +using ParamNameType = const char *; +#endif + /// Result of matching a URL against an entity struct EntityMatchResult { bool matched; ///< True if entity matched the URL @@ -429,6 +436,16 @@ class WebServer : public Controller, static std::string alarm_control_panel_all_json_generator(WebServer *web_server, void *source); #endif +#ifdef USE_WATER_HEATER + void on_water_heater_update(water_heater::WaterHeater *obj) override; + + /// Handle a water_heater request under '/water_heater//'. + void handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match); + + static std::string water_heater_state_json_generator(WebServer *web_server, void *source); + static std::string water_heater_all_json_generator(WebServer *web_server, void *source); +#endif + #ifdef USE_EVENT void on_event(event::Event *obj) override; @@ -472,7 +489,7 @@ class WebServer : public Controller, #ifdef USE_LIGHT // Helper to parse and apply a float parameter with optional scaling template - void parse_light_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float), + void parse_light_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(float), float scale = 1.0f) { if (request->hasParam(param_name)) { auto value = parse_number(request->getParam(param_name)->value().c_str()); @@ -484,7 +501,7 @@ class WebServer : public Controller, // Helper to parse and apply a uint32_t parameter with optional scaling template - void parse_light_param_uint_(AsyncWebServerRequest *request, const char *param_name, T &call, + void parse_light_param_uint_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(uint32_t), uint32_t scale = 1) { if (request->hasParam(param_name)) { auto value = parse_number(request->getParam(param_name)->value().c_str()); @@ -497,7 +514,7 @@ class WebServer : public Controller, // Generic helper to parse and apply a float parameter template - void parse_float_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float)) { + void parse_float_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(float)) { if (request->hasParam(param_name)) { auto value = parse_number(request->getParam(param_name)->value().c_str()); if (value.has_value()) { @@ -508,7 +525,7 @@ class WebServer : public Controller, // Generic helper to parse and apply an int parameter template - void parse_int_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(int)) { + void parse_int_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(int)) { if (request->hasParam(param_name)) { auto value = parse_number(request->getParam(param_name)->value().c_str()); if (value.has_value()) { @@ -519,7 +536,7 @@ class WebServer : public Controller, // Generic helper to parse and apply a string parameter template - void parse_string_param_(AsyncWebServerRequest *request, const char *param_name, T &call, + void parse_string_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(const std::string &)) { if (request->hasParam(param_name)) { // .c_str() is required for Arduino framework where value() returns Arduino String instead of std::string @@ -528,6 +545,28 @@ class WebServer : public Controller, } } + // Generic helper to parse and apply a bool parameter + // Accepts: "on", "true", "1" (case-insensitive) as true + // Accepts: "off", "false", "0" (case-insensitive) as false + // Invalid values are ignored (setter not called) + template + void parse_bool_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(bool)) { + if (request->hasParam(param_name)) { + auto param_value = request->getParam(param_name)->value(); + // First check on/off (default), then true/false (custom) + auto val = parse_on_off(param_value.c_str()); + if (val == PARSE_NONE) { + val = parse_on_off(param_value.c_str(), "true", "false"); + } + if (val == PARSE_ON || param_value == "1") { + (call.*setter)(true); + } else if (val == PARSE_OFF || param_value == "0") { + (call.*setter)(false); + } + // PARSE_NONE/PARSE_TOGGLE: ignore invalid values + } + } + web_server_base::WebServerBase *base_; #ifdef USE_ESP32 AsyncEventSource events_{"/events", this}; @@ -606,6 +645,9 @@ class WebServer : public Controller, #ifdef USE_EVENT std::string event_json_(event::Event *obj, const std::string &event_type, JsonDetail start_config); #endif +#ifdef USE_WATER_HEATER + std::string water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config); +#endif #ifdef USE_UPDATE std::string update_json_(update::UpdateEntity *obj, JsonDetail start_config); #endif diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index a21e9cb9ff..c3fe6f6780 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -232,6 +232,13 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif +#ifdef USE_WATER_HEATER + for (auto *obj : App.get_water_heaters()) { + if (this->include_internal_ || !obj->is_internal()) + write_row(stream, obj, "water_heater", ""); + } +#endif + stream->print(ESPHOME_F("

See ESPHome Web API for " "REST API documentation.

")); #if defined(USE_WEBSERVER_OTA) && !defined(USE_WEBSERVER_OTA_DISABLED)