From d970e9292e941c01bc8b5523c706f85599879463 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Oct 2026 08:00:10 -0500 Subject: [PATCH] [esphome][ota][core] Compress OTA uploads on ESP32, RP2040, LibreTiny and host (#19037) --- esphome/__main__.py | 7 +- esphome/components/esphome/ota/__init__.py | 15 +- .../components/esphome/ota/ota_esphome.cpp | 232 +++++--- esphome/components/esphome/ota/ota_esphome.h | 66 +++ .../esphome/ota/ota_esphome_inflate.c | 498 ++++++++++++++++++ .../esphome/ota/ota_esphome_inflate.h | 65 +++ .../ota/ota_esphome_inflate_session.cpp | 94 ++++ .../esphome/ota/ota_esphome_noise.cpp | 11 +- esphome/components/ota/ota_backend.h | 7 +- .../ota/ota_backend_arduino_libretiny.h | 2 +- .../components/ota/ota_backend_arduino_rp2.h | 5 +- esphome/components/ota/ota_backend_esp8266.h | 2 +- esphome/components/ota/ota_backend_esp_idf.h | 2 +- esphome/components/ota/ota_backend_factory.h | 5 +- esphome/components/ota/ota_backend_host.h | 2 +- esphome/core/defines.h | 1 + esphome/espota2.py | 38 +- script/build_helpers.py | 17 +- script/ci-custom.py | 2 + tests/components/esphome/__init__.py | 12 + tests/components/esphome/test_ota_inflate.cpp | 324 ++++++++++++ tests/components/main.cpp | 5 + tests/components/ota/test.bk72xx-ard.yaml | 1 + .../components/ota/test_backend_contract.cpp | 2 +- .../fixtures/host_ota_deflate.yaml | 9 + tests/integration/test_host_ota.py | 78 +++ tests/unit_tests/test_espota2.py | 51 +- 27 files changed, 1440 insertions(+), 113 deletions(-) create mode 100644 esphome/components/esphome/ota/ota_esphome_inflate.c create mode 100644 esphome/components/esphome/ota/ota_esphome_inflate.h create mode 100644 esphome/components/esphome/ota/ota_esphome_inflate_session.cpp create mode 100644 tests/components/esphome/__init__.py create mode 100644 tests/components/esphome/test_ota_inflate.cpp create mode 100644 tests/components/ota/test.bk72xx-ard.yaml create mode 100644 tests/integration/fixtures/host_ota_deflate.yaml diff --git a/esphome/__main__.py b/esphome/__main__.py index cbeb9ff01c..40457dbcc2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1300,10 +1300,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: The native API uses challenge-response auth with MD5/SHA256 hashing of a server-issued nonce, so the password is never sent over the wire; the ``web_server`` path uses HTTP Basic auth which transmits credentials in - cleartext over the LAN. (The native path also supports gzip compression - on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the - backend reports ``supports_compression() == false`` and the firmware is - sent uncompressed regardless of which platform is used.) Falls back to + cleartext over the LAN. (The native path also compresses the upload: + gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate + stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to ``web_server`` only when that is the only available platform. """ # Use a dict (insertion-ordered) instead of a list so error messages and diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index e14f5e7e86..6ecae24e29 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -315,10 +315,19 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate FILTER_SOURCE_FILES = filter_source_files_from_defines( - {"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"} + { + "ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION", + "ota_esphome_inflate_session.cpp": "USE_OTA_DEFLATE", + "ota_esphome_inflate.c": "USE_OTA_DEFLATE", + } ) +def enable_deflate() -> None: + """Compile the on-the-fly inflater for compressed uploads.""" + cg.add_define("USE_OTA_DEFLATE") + + @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) @@ -340,6 +349,10 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ALLOW_PARTITION_ACCESS): cg.add_define("USE_OTA_PARTITIONS") + # ESP8266 and RP2040 inflate gzip at reboot; the rest inflate on the fly + if not (CORE.is_esp8266 or CORE.is_rp2): + enable_deflate() + # One key per device: an api encryption block supplies it (static or # runtime) and offers; the ota block only adds the requirement api_conf = CORE.config.get(CONF_API) or {} diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 22580343e8..c399c0901c 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -22,8 +22,10 @@ #include "esphome/core/lwip_fast_select.h" #endif +#include #include #include +#include #include namespace esphome { @@ -47,6 +49,8 @@ static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds // practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits // longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; +static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000; +static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first // Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -194,12 +198,23 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10; // Noise needs the extended protocol: the prologue binds the 2-byte feature ack static constexpr uint8_t CLIENT_NOISE_FEATURES = CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04; +// Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the +// client must then send the image size frame and a deflate stream. +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08; + +#ifdef USE_OTA_ENCRYPTION +inline bool ESPHomeOTAComponent::noise_offered_() const { + return (this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 && + (this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES; +} +#endif inline bool ESPHomeOTAComponent::extended_proto_() const { #ifdef USE_OTA_ENCRYPTION_REQUIRED @@ -305,7 +320,7 @@ void ESPHomeOTAComponent::handle_handshake_() { this->transition_ota_state_(OTAState::FEATURE_ACK); const bool supports_compression = - (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); + (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && ota::OTABackend::supports_compression(); // Compose the feature-ack response. When the client negotiates the extended protocol we emit // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte @@ -325,6 +340,28 @@ void ESPHomeOTAComponent::handle_handshake_() { #elif defined(USE_OTA_ENCRYPTION) // A yaml key always exists: validation rejects the all-zeros key this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE; +#endif +#ifdef USE_OTA_ENCRYPTION + // Reserve the noise session before the optional inflate buffer, so the + // required allocation is not starved by the compression window + if (this->noise_offered_()) { + this->noise_reserve_session_(); + } +#endif +#ifdef USE_OTA_DEFLATE + // Offered only once the session memory is in hand; else uncompressed + if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) { + // Value initialized: a corrupt stream that back references the + // window before it is filled then copies zeros, never stale memory. + // Default placement, PSRAM first where present: the session lives for one + // upload and keeps 4.9 KB of internal heap free while it runs + this->inflate_ = RAMAllocator().make_unique(); + if (this->inflate_ != nullptr) { + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_DEFLATE; + } else { + ESP_LOGW(TAG, "No memory to inflate"); + } + } #endif } else { this->handshake_buf_[0] = @@ -343,8 +380,7 @@ void ESPHomeOTAComponent::handle_handshake_() { #ifdef USE_OTA_ENCRYPTION // Latch the offer actually sent: a key activating between the two // states must not start a session the client never expects - if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 && - (this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) { + if (this->noise_offered_()) { // handshake_buf_ still holds the feature ack composed above; a // would-block re-entry lands here without rebuilding it if (!this->noise_start_session_(this->handshake_buf_[1])) { @@ -442,16 +478,11 @@ void ESPHomeOTAComponent::handle_data_() { // Backend calls overwrite this with OK; reset to UNKNOWN before any // goto error that follows a successful begin()/write() ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; - size_t total = 0; - uint32_t last_progress = 0; - uint32_t last_data_ms = 0; + DataTransfer xfer; uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); - size_t ota_size; + size_t image_size; ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; -#if USE_OTA_VERSION == 2 - size_t size_acknowledged = 0; -#endif // Set socket timeouts and blocking mode (see strategy table above) struct timeval tv; @@ -480,16 +511,20 @@ void ESPHomeOTAComponent::handle_data_() { ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); // Read size, 4 bytes MSB first - if (!this->data_readall_(buf, 4)) { + if (!this->read_size_(buf, xfer.ota_size, LOG_STR("size"))) { // The first request byte is the type on the extended protocol; a close after it was a cut-off request if (!this->extended_proto_() && this->client_left_before_start_()) return; this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | - (static_cast(buf[2]) << 8) | buf[3]; - ESP_LOGV(TAG, "Size is %zu bytes", ota_size); + image_size = xfer.ota_size; +#ifdef USE_OTA_DEFLATE + if (this->inflate_ != nullptr && !this->read_size_(buf, image_size, LOG_STR("image size"))) { + this->log_read_error_(LOG_STR("image size")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } +#endif #ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { @@ -509,7 +544,7 @@ void ESPHomeOTAComponent::handle_data_() { #endif // begin() returns quickly; flash sectors are erased incrementally during write(). - error_code = this->backend_->begin(ota_size, ota_type); + error_code = this->backend_->begin(image_size, ota_type); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -529,77 +564,25 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge MD5 OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); - // Track when we last received data so a silently-vanished peer (no FIN/RST - // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) - // can't wedge the device indefinitely. Without this, the loop only exits - // on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP - // TCP keepalive isn't enabled here. - last_data_ms = millis(); - while (total < ota_size) { - if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { - ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); - error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + xfer.last_data_ms = millis(); +#ifdef USE_OTA_DEFLATE + if (this->inflate_ != nullptr) { + error_code = this->inflate_data_(buf, image_size, xfer); + if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - size_t remaining = ota_size - total; - size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; - ssize_t read; -#ifdef USE_OTA_ENCRYPTION - if (this->noise_ != nullptr) { - // One frame per call; noise_read_data_ waits internally (readall_), so - // there is no would-block retry here and failures are already logged. - read = this->noise_read_data_(buf, requested); - if (read <= 0) { - if (this->remote_closed_) - this->log_remote_closed_(LOG_STR("data")); + } else +#endif + { + while (xfer.total < xfer.ota_size) { + ssize_t read = this->receive_data_(buf, xfer); + if (read < 0) { error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - } else -#endif - { - read = this->client_->read(buf, requested); - if (read == -1) { - const int err = errno; - if (this->would_block_(err)) { - // read() already waited up to SO_RCVTIMEO for data, just feed WDT - App.feed_wdt(); - continue; - } - ESP_LOGW(TAG, "Read err %d", err); - error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + error_code = this->write_flash_(buf, read); + if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed"); - error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - } - - last_data_ms = millis(); - error_code = this->backend_->write(buf, read); - if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Flash write err %d", error_code); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - total += read; -#if USE_OTA_VERSION == 2 - while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) { - this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK); - size_acknowledged += OTA_BLOCK_SIZE; - } -#endif - - uint32_t now = millis(); - if (now - last_progress > 1000) { - last_progress = now; - float percentage = (total * 100.0f) / ota_size; - ESP_LOGD(TAG, "Progress: %0.1f%%", percentage); -#ifdef USE_OTA_STATE_LISTENER - this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0); -#endif - // feed watchdog and give other tasks a chance to run - this->yield_and_feed_watchdog_(); + this->ack_written_(xfer); } } @@ -815,6 +798,90 @@ bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) { return this->handshake_buf_pos_ >= to_write; } +bool ESPHomeOTAComponent::read_size_(uint8_t *buf, size_t &size, const LogString *desc) { + if (!this->data_readall_(buf, OTA_SIZE_FIELD_BYTES)) + return false; + size = encode_uint32(buf[0], buf[1], buf[2], buf[3]); + ESP_LOGV(TAG, "%s is %zu bytes", LOG_STR_ARG(desc), size); + return true; +} + +ota::OTAResponseTypes ESPHomeOTAComponent::write_flash_(uint8_t *data, size_t len) { + ota::OTAResponseTypes result = this->backend_->write(data, len); + if (result != ota::OTA_RESPONSE_OK) { + ESP_LOGW(TAG, "Flash write err %d", result); + } + return result; +} + +ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) { + const size_t remaining = xfer.ota_size - xfer.total; + const size_t requested = std::min(remaining, OTA_BUFFER_SIZE); + ssize_t read; + for (;;) { + // A silently-vanished peer (no FIN/RST delivered, e.g. uploader killed + // mid-transfer or NAT/router dropped state) must not wedge the device: + // read() only fails on EOF or a real error, and lwIP TCP keepalive isn't + // enabled here. + if (millis() - xfer.last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); + return -1; + } +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ != nullptr) { + // One frame per call; noise_read_data_ waits internally (readall_), so + // there is no would-block retry here and failures are already logged. + read = this->noise_read_data_(buf, requested); + if (read <= 0) { + if (this->remote_closed_) + this->log_remote_closed_(LOG_STR("data")); + return -1; + } + break; + } +#endif + read = this->client_->read(buf, requested); + if (read > 0) + break; + if (read == 0) { + this->log_remote_closed_(LOG_STR("data")); + return -1; + } + if (!this->would_block_(errno)) { + this->log_socket_error_(LOG_STR("data")); + return -1; + } + // read() already waited up to SO_RCVTIMEO for data, just feed WDT + App.feed_wdt(); + } + + const uint32_t now = millis(); + xfer.last_data_ms = now; + xfer.total += read; + this->ack_received_(xfer); + if (now - xfer.last_progress > OTA_PROGRESS_INTERVAL_MS) { + xfer.last_progress = now; + float percentage = (xfer.total * 100.0f) / xfer.ota_size; + ESP_LOGD(TAG, "Progress: %0.1f%%", percentage); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0); +#endif + // feed watchdog and give other tasks a chance to run + this->yield_and_feed_watchdog_(); + } + return read; +} + +void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) { +#if USE_OTA_VERSION == 2 + while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total || + (xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) { + this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK); + xfer.acknowledged += OTA_BLOCK_SIZE; + } +#endif +} + void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; @@ -829,6 +896,9 @@ void ESPHomeOTAComponent::cleanup_connection_() { #endif #ifdef USE_OTA_ENCRYPTION this->noise_ = nullptr; +#endif +#ifdef USE_OTA_DEFLATE + this->inflate_ = nullptr; #endif // Intentionally no disable_loop() — letting loop() run one more iteration catches // any connection that queued on the listener mid-session (otherwise the wake flag, diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 6f04b78da5..910a5b3d99 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -7,6 +7,9 @@ #ifdef USE_OTA_ENCRYPTION #include "esphome/components/noise/noise_handshake.h" #endif +#ifdef USE_OTA_DEFLATE +#include "ota_esphome_inflate.h" +#endif #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" @@ -89,6 +92,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { // The api server's live context when it exists, otherwise our own (a build // time key, or the saved key loaded in safe mode) const noise::NoiseContext &noise_context_() const; + // True once the feature ack offers noise and the client asked for it + bool noise_offered_() const; + void noise_reserve_session_(); bool noise_start_session_(uint8_t server_feature_flags); bool handle_noise_handshake_(); bool noise_try_read_frame_(); @@ -120,6 +126,38 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { return this->readall_(buf, len); } + // Upload accounting shared by the data loop and the inflate read callback + struct DataTransfer { + size_t ota_size{0}; // bytes the client sends + size_t total{0}; // bytes received so far +#if USE_OTA_VERSION == 2 + size_t acknowledged{0}; +#endif + uint32_t last_data_ms{0}; + uint32_t last_progress{0}; + }; + // Up to OTA_BUFFER_SIZE bytes into buf; returns bytes read, -1 on failure (logged) + ssize_t receive_data_(uint8_t *buf, DataTransfer &xfer); + // Raw lwIP cannot service the radio during a sector write, so the ack waits + // for the write there; a socket task lets the next block arrive meanwhile +#ifdef USE_SOCKET_IMPL_LWIP_TCP + static constexpr bool ACK_AFTER_WRITE = true; +#else + static constexpr bool ACK_AFTER_WRITE = false; +#endif + void send_chunk_acks_(DataTransfer &xfer); + inline void ack_received_(DataTransfer &xfer) { + if (!ACK_AFTER_WRITE) + this->send_chunk_acks_(xfer); + } + inline void ack_written_(DataTransfer &xfer) { + if (ACK_AFTER_WRITE) + this->send_chunk_acks_(xfer); + } + inline bool read_size_(uint8_t *buf, size_t &size, const LogString *desc); + // Writes to the backend and logs a failure + ota::OTAResponseTypes write_flash_(uint8_t *data, size_t len); + bool try_read_(size_t to_read, const LogString *desc); bool try_write_(size_t to_write, const LogString *desc); @@ -175,6 +213,34 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { static_assert(OTA_BUFFER_SIZE >= NOISE_CLIENT_MAX_PLAINTEXT + noise::MAC_SIZE, "OTA_BUFFER_SIZE must fit a full encrypted data frame"); #endif +#ifdef USE_OTA_DEFLATE + // At least 1 << espota2.DEFLATE_WINDOW_BITS; also the inflate output buffer + static constexpr size_t OTA_INFLATE_WINDOW_SIZE = 4096; + // Heap-allocated only while a deflate upload is negotiated; the decoder + // state is the base so the read callback can recover the session + struct InflateSession : OtaInflateState { + // The session outlives the upload it serves, but these three are borrowed + // from inflate_data_'s caller and dangle once that call returns; only that + // call, and the flush and read callback it drives, may read them + ESPHomeOTAComponent *self; + DataTransfer *xfer; + uint8_t *in; // caller's buffer for the compressed input + size_t image_size; + size_t written; // inflated bytes in flash + size_t flushed; // bytes of the current window already in flash + ota::OTAResponseTypes error; // first failure inside the read callback + uint8_t window[OTA_INFLATE_WINDOW_SIZE]; + }; +#ifndef CLANG_TIDY // static analysis sets every define at once + static_assert(!ota::OTABackend::supports_compression(), + "USE_OTA_DEFLATE is for backends that cannot store a gzip image"); +#endif + // Writes the decoded bytes not yet in flash without moving dest + ota::OTAResponseTypes inflate_flush_(InflateSession &session); + ota::OTAResponseTypes inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer); + RAMUniquePtr inflate_; +#endif + static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; // Derived from the feature byte; storing it would pad the trailing bytes bool extended_proto_() const; diff --git a/esphome/components/esphome/ota/ota_esphome_inflate.c b/esphome/components/esphome/ota/ota_esphome_inflate.c new file mode 100644 index 0000000000..1208f82ae8 --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_inflate.c @@ -0,0 +1,498 @@ +/* + * uzlib - tiny deflate/inflate library (deflate, gzip, zlib) + * + * Copyright (c) 2003 by Joergen Ibsen / Jibz + * All Rights Reserved + * http://www.ibsensoftware.com/ + * + * Copyright (c) 2014-2018 by Paul Sokolovsky + * + * This software is provided 'as-is', without any express + * or implied warranty. In no event will the authors be + * held liable for any damages arising from the use of + * this software. + * + * Permission is granted to anyone to use this software + * for any purpose, including commercial applications, + * and to alter it and redistribute it freely, subject to + * the following restrictions: + * + * 1. The origin of this software must not be + * misrepresented; you must not claim that you + * wrote the original software. If you use this + * software in a product, an acknowledgment in + * the product documentation would be appreciated + * but is not required. + * + * 2. Altered source versions must be plainly marked + * as such, and must not be misrepresented as + * being the original software. + * + * 3. This notice may not be removed or altered from + * any source distribution. + */ + +/* + * Altered for ESPHome: this is the raw deflate decoder from uzlib's + * tinflate.c (v2.9.5) with the gzip/zlib header parsers, checksums, + * runtime table builder and in-memory (non ring window) output path + * removed, and the public names prefixed with ota_inflate. + */ + +#include "ota_esphome_inflate.h" + +#include + +#define TINF_OK OTA_INFLATE_OK +#define TINF_DONE OTA_INFLATE_DONE +#define TINF_DATA_ERROR OTA_INFLATE_DATA_ERROR +#define TINF_DICT_ERROR OTA_INFLATE_DICT_ERROR +#define TINF_DATA struct OtaInflateState +#define TINF_TREE struct OtaInflateTree +#define TINF_ARRAY_SIZE(arr) (sizeof(arr) / sizeof(*(arr))) + +/* every output byte also goes into the ring window */ +#define TINF_PUT(d, c) \ + { \ + *d->dest++ = c; \ + d->dict_ring[d->dict_idx++] = c; \ + if (d->dict_idx == d->dict_size) \ + d->dict_idx = 0; \ + } + +/* --------------------------------------------------- * + * -- constant tables (upstream builds them at runtime) -- * + * --------------------------------------------------- */ + +static const unsigned char LENGTH_BITS[30] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, + 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5}; +static const unsigned short LENGTH_BASE[30] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, + 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258}; + +static const unsigned char DIST_BITS[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, + 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13}; +static const unsigned short DIST_BASE[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, + 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, + 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577}; + +/* special ordering of code length codes */ +static const unsigned char CLCIDX[] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15}; + +/* ----------------------- * + * -- utility functions -- * + * ----------------------- */ + +/* given an array of code lengths, build a tree */ +static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned int num) { + unsigned short offs[16]; + unsigned int i, sum; + + /* clear code length count table */ + for (i = 0; i < 16; ++i) + t->table[i] = 0; + + /* scan symbol lengths, and sum code length counts */ + for (i = 0; i < num; ++i) + t->table[lengths[i]]++; + + /* In the lengths array, 0 means unused code. So, t->table[0] now contains + number of unused codes. But table's purpose is to contain # of codes of + particular length, and there're 0 codes of length 0. */ + t->table[0] = 0; + + /* compute offset table for distribution sort */ + for (sum = 0, i = 0; i < 16; ++i) { + offs[i] = sum; + sum += t->table[i]; + } + + /* create code->symbol translation table (symbols sorted by code) */ + for (i = 0; i < num; ++i) { + if (lengths[i]) + t->trans[offs[lengths[i]]++] = i; + } +} + +/* ---------------------- * + * -- decode functions -- * + * ---------------------- */ + +static unsigned char uzlib_get_byte(TINF_DATA *d) { + /* If end of source buffer is not reached, return next byte from source + buffer. */ + if (d->source < d->source_limit) { + return *d->source++; + } + + /* Otherwise if there's callback and we haven't seen EOF yet, try to + read next byte using it. (Note: the callback can also update ->source + and ->source_limit). */ + if (!d->eof) { + int val = d->source_read_cb(d); + if (val >= 0) { + return (unsigned char) val; + } + } + + /* Otherwise, we hit EOF (either from ->source_read_cb() or from exhaustion + of the buffer), and it will be "sticky", i.e. further calls to this + function will end up here too. */ + d->eof = true; + + return 0; +} + +/* get one bit from source stream */ +static int tinf_getbit(TINF_DATA *d) { + unsigned int bit; + + /* check if tag is empty */ + if (!d->bitcount--) { + /* load next tag */ + d->tag = uzlib_get_byte(d); + d->bitcount = 7; + } + + /* shift bit out of tag */ + bit = d->tag & 0x01; + d->tag >>= 1; + + return bit; +} + +/* read a num bit value from a stream and add base */ +static unsigned int tinf_read_bits(TINF_DATA *d, int num, int base) { + unsigned int val = 0; + + /* read num bits */ + if (num) { + unsigned int limit = 1 << (num); + unsigned int mask; + + for (mask = 1; mask < limit; mask *= 2) + if (tinf_getbit(d)) + val += mask; + } + + return val + base; +} + +/* given a data stream and a tree, decode a symbol */ +static int tinf_decode_symbol(TINF_DATA *d, TINF_TREE *t) { + int sum = 0, cur = 0, len = 0; + + /* get more bits while code value is above sum */ + do { + cur = 2 * cur + tinf_getbit(d); + + if (++len == TINF_ARRAY_SIZE(t->table)) { + return TINF_DATA_ERROR; + } + + sum += t->table[len]; + cur -= t->table[len]; + + } while (cur >= 0); + + sum += cur; + if (sum < 0 || sum >= t->size) { + return TINF_DATA_ERROR; + } + + return t->trans[sum]; +} + +/* given a data stream, decode dynamic trees from it */ +static int tinf_decode_trees(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) { + /* code lengths for 288 literal/len symbols and 32 dist symbols */ + unsigned char lengths[288 + 32]; + unsigned int hlit, hdist, hclen, hlimit; + unsigned int i, num, length; + + /* get 5 bits HLIT (257-286) */ + hlit = tinf_read_bits(d, 5, 257); + + /* get 5 bits HDIST (1-32) */ + hdist = tinf_read_bits(d, 5, 1); + + /* get 4 bits HCLEN (4-19) */ + hclen = tinf_read_bits(d, 4, 4); + + for (i = 0; i < 19; ++i) + lengths[i] = 0; + + /* read code lengths for code length alphabet */ + for (i = 0; i < hclen; ++i) { + /* get 3 bits code length (0-7) */ + unsigned int clen = tinf_read_bits(d, 3, 0); + + lengths[CLCIDX[i]] = clen; + } + + /* build code length tree, temporarily use length tree */ + tinf_build_tree(lt, lengths, 19); + + /* decode code lengths for the dynamic trees */ + hlimit = hlit + hdist; + for (num = 0; num < hlimit;) { + int sym = tinf_decode_symbol(d, lt); + unsigned char fill_value = 0; + int lbits, lbase = 3; + + /* error decoding */ + if (sym < 0) + return sym; + + switch (sym) { + case 16: + /* copy previous code length 3-6 times (read 2 bits) */ + if (num == 0) + return TINF_DATA_ERROR; + fill_value = lengths[num - 1]; + lbits = 2; + break; + case 17: + /* repeat code length 0 for 3-10 times (read 3 bits) */ + lbits = 3; + break; + case 18: + /* repeat code length 0 for 11-138 times (read 7 bits) */ + lbits = 7; + lbase = 11; + break; + default: + /* values 0-15 represent the actual code lengths */ + lengths[num++] = sym; + /* continue the for loop */ + continue; + } + + /* special code length 16-18 are handled here */ + length = tinf_read_bits(d, lbits, lbase); + if (num + length > hlimit) + return TINF_DATA_ERROR; + for (; length; --length) { + lengths[num++] = fill_value; + } + } + + /* Check that there's "end of block" symbol */ + if (lengths[256] == 0) { + return TINF_DATA_ERROR; + } + + /* build dynamic trees */ + tinf_build_tree(lt, lengths, hlit); + tinf_build_tree(dt, lengths + hlit, hdist); + + return TINF_OK; +} + +/* build the fixed huffman trees (RFC 1951 3.2.6) through the generic tree + builder; altered from upstream, which unrolls them by hand */ +static void tinf_build_fixed_trees(TINF_TREE *lt, TINF_TREE *dt) { + unsigned char lengths[288]; + unsigned int i; + + for (i = 0; i < 144; ++i) + lengths[i] = 8; + for (; i < 256; ++i) + lengths[i] = 9; + for (; i < 280; ++i) + lengths[i] = 7; + for (; i < 288; ++i) + lengths[i] = 8; + tinf_build_tree(lt, lengths, 288); + + for (i = 0; i < 32; ++i) + lengths[i] = 5; + tinf_build_tree(dt, lengths, 32); +} + +/* ----------------------------- * + * -- block inflate functions -- * + * ----------------------------- */ + +/* given a stream and two trees, inflate next chunk of output (a byte or more) */ +static int tinf_inflate_block_data(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) { + if (d->curlen == 0) { + unsigned int offs; + int dist; + int sym = tinf_decode_symbol(d, lt); + + if (d->eof) { + return TINF_DATA_ERROR; + } + + if (sym < 0) { + return sym; + } + + /* literal byte */ + if (sym < 256) { + TINF_PUT(d, sym); + return TINF_OK; + } + + /* end of block */ + if (sym == 256) { + return TINF_DONE; + } + + /* substring from sliding dictionary */ + sym -= 257; + if (sym >= 29) { + return TINF_DATA_ERROR; + } + + /* possibly get more bits from length code */ + d->curlen = tinf_read_bits(d, LENGTH_BITS[sym], LENGTH_BASE[sym]); + + dist = tinf_decode_symbol(d, dt); + if (dist < 0 || dist >= 30) { + return TINF_DATA_ERROR; + } + + /* possibly get more bits from distance code */ + offs = tinf_read_bits(d, DIST_BITS[dist], DIST_BASE[dist]); + + /* calculate and validate actual LZ offset to use */ + if (offs > d->dict_size) { + return TINF_DICT_ERROR; + } + /* Note: we don't try to catch offset which points to not yet filled + part of the dictionary here. Doing so would require keeping another + variable to track "filled in" size of the dictionary. Appearance of + such an offset cannot lead to accessing memory outside of the + dictionary buffer, and clients which don't want to leak unrelated + information, should explicitly initialize dictionary buffer passed + to uzlib. */ + + d->lz_off = d->dict_idx - offs; + if (d->lz_off < 0) { + d->lz_off += d->dict_size; + } + } + + /* copy next byte from dict substring */ + TINF_PUT(d, d->dict_ring[d->lz_off]); + if ((unsigned) ++d->lz_off == d->dict_size) { + d->lz_off = 0; + } + d->curlen--; + return TINF_OK; +} + +/* inflate next byte from uncompressed block of data */ +static int tinf_inflate_uncompressed_block(TINF_DATA *d) { + if (d->curlen == 0) { + unsigned int length, invlength; + + /* get length */ + length = uzlib_get_byte(d); + length += 256 * uzlib_get_byte(d); + /* get one's complement of length */ + invlength = uzlib_get_byte(d); + invlength += 256 * uzlib_get_byte(d); + /* check length */ + if (length != (~invlength & 0x0000ffff)) + return TINF_DATA_ERROR; + + /* increment length to properly return TINF_DONE below, without + producing data at the same time */ + d->curlen = length + 1; + + /* make sure we start next block on a byte boundary */ + d->bitcount = 0; + } + + if (--d->curlen == 0) { + return TINF_DONE; + } + + unsigned char c = uzlib_get_byte(d); + TINF_PUT(d, c); + return TINF_OK; +} + +/* ---------------------- * + * -- public functions -- * + * ---------------------- */ + +/* initialize decompression structure */ +void ota_inflate_init(TINF_DATA *d, unsigned char *dict, unsigned int dict_len) { + d->source = NULL; + d->source_limit = NULL; + d->tag = 0; + d->eof = 0; + d->bitcount = 0; + d->lz_off = 0; + d->bfinal = 0; + d->btype = -1; + d->dict_size = dict_len; + d->dict_ring = dict; + d->dict_idx = 0; + d->curlen = 0; + d->ltree.trans = d->ltrans; + d->ltree.size = TINF_ARRAY_SIZE(d->ltrans); + d->dtree.trans = d->dtrans; + d->dtree.size = TINF_ARRAY_SIZE(d->dtrans); +} + +/* inflate next output bytes from compressed stream */ +int ota_inflate(TINF_DATA *d) { + do { + int res; + + /* start a new block */ + if (d->btype == -1) { + int old_btype; + next_blk: + old_btype = d->btype; + /* read final block flag */ + d->bfinal = tinf_getbit(d); + /* read block type (2 bits) */ + d->btype = tinf_read_bits(d, 2, 0); + + if (d->btype == 1 && old_btype != 1) { + /* build fixed huffman trees */ + tinf_build_fixed_trees(&d->ltree, &d->dtree); + } else if (d->btype == 2) { + /* decode trees from stream */ + res = tinf_decode_trees(d, &d->ltree, &d->dtree); + if (res != TINF_OK) { + return res; + } + } + } + + /* process current block */ + switch (d->btype) { + case 0: + /* decompress uncompressed block */ + res = tinf_inflate_uncompressed_block(d); + break; + case 1: + case 2: + /* decompress block with fixed/dynamic huffman trees */ + /* trees were decoded previously, so it's the same routine for both */ + res = tinf_inflate_block_data(d, &d->ltree, &d->dtree); + break; + default: + return TINF_DATA_ERROR; + } + + if (res == TINF_DONE && !d->bfinal) { + /* the block has ended (without producing more data), but we + can't return without data, so start procesing next block */ + goto next_blk; + } + + if (res != TINF_OK) { + return res; + } + + } while (d->dest < d->dest_limit); + + return TINF_OK; +} diff --git a/esphome/components/esphome/ota/ota_esphome_inflate.h b/esphome/components/esphome/ota/ota_esphome_inflate.h new file mode 100644 index 0000000000..9726508a30 --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_inflate.h @@ -0,0 +1,65 @@ +#pragma once +// Raw deflate decoder cut down from uzlib (https://github.com/pfalcon/uzlib, +// zlib licence, see the .c file); output goes through a ring window. + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum OtaInflateResult { + OTA_INFLATE_OK = 0, /* more data produced, call again */ + OTA_INFLATE_DONE = 1, /* end of compressed stream reached */ + OTA_INFLATE_DATA_ERROR = -3, + OTA_INFLATE_DICT_ERROR = -5, +}; + +struct OtaInflateTree { + uint16_t table[16]; /* table of code length counts */ + uint16_t *trans; /* code -> symbol translation table, size entries */ + uint16_t size; +}; + +struct OtaInflateState { + /* Next byte in the input buffer and one past its end */ + const unsigned char *source; + const unsigned char *source_limit; + /* Called when source is exhausted; returns the next byte or -1 at EOF. + It may refill source/source_limit for buffered operation. */ + int (*source_read_cb)(struct OtaInflateState *d); + /* Output cursor and one past the end of the output buffer */ + unsigned char *dest; + unsigned char *dest_limit; + /* Ring window holding the last dict_size output bytes for back references */ + unsigned char *dict_ring; + + unsigned int tag; + unsigned int curlen; + int lz_off; + unsigned int dict_size; + unsigned int dict_idx; + + /* One word: btype is -1 between blocks, bitcount never exceeds 7 */ + int8_t btype; + uint8_t bfinal; + uint8_t bitcount; + bool eof; + + struct OtaInflateTree ltree; /* dynamic length/symbol tree */ + struct OtaInflateTree dtree; /* dynamic distance tree */ + uint16_t ltrans[288]; + uint16_t dtrans[32]; /* the distance alphabet has 30 symbols, so the tree is kept small */ +}; + +/* dict must cover the encoder's window (its largest back reference) */ +void ota_inflate_init(struct OtaInflateState *d, unsigned char *dict, unsigned int dict_len); +/* Fills dest up to dest_limit (OK) or to the end of the stream (DONE). dest may + alias dict only if dest_limit - dest == dict_len and dest is reset to dict + exactly when a call returns OK, so the ring index and dest stay in lockstep */ +int ota_inflate(struct OtaInflateState *d); + +#ifdef __cplusplus +} +#endif diff --git a/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp b/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp new file mode 100644 index 0000000000..f3005cac48 --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp @@ -0,0 +1,94 @@ +#include "ota_esphome.h" +#ifdef USE_OTA +#ifdef USE_OTA_DEFLATE +#include "esphome/components/ota/ota_backend.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome { + +static const char *const TAG = "esphome.ota"; + +// The window doubles as the output buffer; flushed bytes stay as back +// reference history for the next windowful. +ota::OTAResponseTypes ESPHomeOTAComponent::inflate_flush_(InflateSession &session) { + const size_t produced = session.dest - session.window; + const size_t pending = produced - session.flushed; + if (pending != 0) { + if (pending > session.image_size - session.written) { + ESP_LOGW(TAG, "Inflate overrun"); + return ota::OTA_RESPONSE_ERROR_UNKNOWN; + } + ota::OTAResponseTypes result = this->write_flash_(session.window + session.flushed, pending); + if (result != ota::OTA_RESPONSE_OK) + return result; + session.flushed = produced; + session.written += pending; + // A compressible region yields many windows per socket read + App.feed_wdt(); + } + // Even with nothing new written: a block boundary can fall inside a header + this->ack_written_(*session.xfer); + return ota::OTA_RESPONSE_OK; +} + +ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer) { + InflateSession &session = *this->inflate_; + session.self = this; + session.xfer = &xfer; + session.in = in; + session.image_size = image_size; + session.written = 0; + session.error = ota::OTA_RESPONSE_OK; + ota_inflate_init(&session, session.window, OTA_INFLATE_WINDOW_SIZE); + // Where the ack must follow the write, flush and ack before waiting for + // input, or the client waits for an ack while the decoder waits for data + session.source_read_cb = [](OtaInflateState *d) -> int { + auto *s = static_cast(d); + if (ACK_AFTER_WRITE) { + s->error = s->self->inflate_flush_(*s); + if (s->error != ota::OTA_RESPONSE_OK) + return -1; + } + // More input than announced; reported by the size check below + if (s->xfer->total >= s->xfer->ota_size) + return -1; + ssize_t read = s->self->receive_data_(s->in, *s->xfer); + if (read <= 0) { + // Already logged by receive_data_ + s->error = ota::OTA_RESPONSE_ERROR_UNKNOWN; + return -1; + } + d->source = s->in + 1; + d->source_limit = s->in + read; + return s->in[0]; + }; + + int res; + do { + // The ring index wrapped to 0 exactly when the window filled + session.dest = session.window; + session.dest_limit = session.window + OTA_INFLATE_WINDOW_SIZE; + session.flushed = 0; + res = ota_inflate(&session); + // A stored block keeps emitting zeros after a failed read, hence eof + if (res < 0 || session.eof) + break; + session.error = this->inflate_flush_(session); + } while (res != OTA_INFLATE_DONE && session.error == ota::OTA_RESPONSE_OK); + + // Transport and flash failures are logged where they happen + if (session.error != ota::OTA_RESPONSE_OK) + return session.error; + if (res != OTA_INFLATE_DONE || session.written != image_size || xfer.total != xfer.ota_size) { + ESP_LOGW(TAG, "Inflate err %d, %zu of %zu B from %zu of %zu", res, session.written, image_size, xfer.total, + xfer.ota_size); + return ota::OTA_RESPONSE_ERROR_UNKNOWN; + } + ESP_LOGD(TAG, "Inflated %zu bytes from %zu", session.written, xfer.total); + return ota::OTA_RESPONSE_OK; +} + +} // namespace esphome +#endif // USE_OTA_DEFLATE +#endif // USE_OTA diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 65476572a1..b3d8501f10 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -32,7 +32,12 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { } } -/** Allocate the session and start the responder handshake. +void ESPHomeOTAComponent::noise_reserve_session_() { + // Default placement, PSRAM first where present: the session lives for one upload + this->noise_ = RAMAllocator().make_unique(); +} + +/** Start the responder handshake, on the session reserved at offer time. * * The prologue binds the whole plaintext preamble, so any tampering with the * negotiation (a stripped feature flag, a changed version) breaks the first @@ -41,9 +46,7 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { */ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { // A provisioned key cleared between the offer and here is not guarded: the - // session runs on the zero key load_psk fills in and fails the client's MAC. - // Default placement, PSRAM first where present: the session only lives for one upload - this->noise_ = RAMAllocator().make_unique(); + // session runs on the zero key load_psk fills in and fails the client's MAC static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1; static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 7348a0ce90..a505d58efe 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -7,6 +7,7 @@ #include #include #include +#include #ifdef USE_OTA_STATE_LISTENER #include @@ -102,6 +103,8 @@ enum OTAType : uint8_t { // - set_update_md5: expected digest of the incoming image, hex string. // - write: consume the next chunk; end: finalize and mark bootable. // - abort: safe to call in any state, including after end(). +// - supports_compression: constexpr, whether a gzip image is stored as is and +// inflated at reboot. template concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *data, size_t len, const char *md5) { { backend.begin(image_size, OTA_TYPE_UPDATE_APP) } -> std::same_as; @@ -110,7 +113,9 @@ concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *dat { backend.write(data, len) } -> std::same_as; { backend.end() } -> std::same_as; backend.abort(); - { backend.supports_compression() } -> std::same_as; + { T::supports_compression() } -> std::same_as; + // The value must be a constant expression + typename std::bool_constant; }; /** Listener interface for OTA state changes. diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c2716a44d1..c322ed21f2 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -13,7 +13,7 @@ class ArduinoLibreTinyOTABackend final { OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } private: bool md5_set_{false}; diff --git a/esphome/components/ota/ota_backend_arduino_rp2.h b/esphome/components/ota/ota_backend_arduino_rp2.h index f7c0037bd2..15142869ac 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -15,7 +15,10 @@ class ArduinoRP2OTABackend final { OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); - bool supports_compression() { return false; } + // The core's OTA stub inflates a staged gzip image at reboot, on every chip + // from 4.0.3 (ESPHome pins 6.0.0). begin() only sees the gzip size; the + // inflated size is known when the stub reads the trailer. + static constexpr bool supports_compression() { return USE_ARDUINO_VERSION_CODE >= VERSION_CODE(4, 0, 3); } private: bool md5_set_{false}; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 21b5c12c2d..1f1ec37eee 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -20,7 +20,7 @@ class ESP8266OTABackend final { OTAResponseTypes end(); void abort(); // Compression supported in all ESP8266 Arduino versions ESPHome supports (>= 2.7.0) - bool supports_compression() { return true; } + static constexpr bool supports_compression() { return true; } protected: /// Erase flash sector if current address is at sector boundary diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index c991f896e8..4f4093a594 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -33,7 +33,7 @@ class IDFOTABackend final { OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } protected: #ifdef USE_OTA_PARTITIONS diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index 82d001ed9e..06c58582b5 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -25,7 +25,7 @@ struct StubOTABackend { OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_UNKNOWN; } OTAResponseTypes end() { return OTA_RESPONSE_ERROR_UNKNOWN; } void abort() {} - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } }; std::unique_ptr make_ota_backend(); } // namespace esphome::ota @@ -33,6 +33,7 @@ std::unique_ptr make_ota_backend(); namespace esphome::ota { using OTABackendPtr = decltype(make_ota_backend()); -static_assert(OTABackendContract, +using OTABackend = OTABackendPtr::element_type; +static_assert(OTABackendContract, "The platform's OTA backend is missing part of the backend surface (ota_backend.h)"); } // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 51ffdaeda3..e53868f102 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -19,7 +19,7 @@ class HostOTABackend final { OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } protected: md5::MD5Digest md5_{}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 88c82b0d7c..558bbd54cd 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -295,6 +295,7 @@ #define USE_RUNTIME_IMAGE_QOI #define USE_RUNTIME_STATS #define USE_OTA +#define USE_OTA_DEFLATE #define USE_OTA_ENCRYPTION #define USE_OTA_ENCRYPTION_PROVISIONED #define USE_OTA_ENCRYPTION_REQUIRED diff --git a/esphome/espota2.py b/esphome/espota2.py index 952f88fc4b..dbdf323366 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -65,9 +65,15 @@ CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01 CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02 CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04 CLIENT_FEATURE_SUPPORTS_NOISE = 0x08 +CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10 SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01 SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02 SERVER_FEATURE_SUPPORTS_NOISE = 0x04 +# Binding once offered: the device then expects the image size and a deflate stream +SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08 + +# Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE) +DEFLATE_WINDOW_BITS = 12 NOISE_FRAME_INDICATOR = 0x01 NOISE_HANDSHAKE_OK = 0x00 @@ -87,6 +93,9 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( ) UPLOAD_BLOCK_SIZE = 8192 +# Sizes on the wire are 4 bytes MSB first +SIZE_FIELD_BYTES = 4 +COMPRESS_LEVEL = 9 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 # Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time @@ -573,6 +582,7 @@ def perform_ota( CLIENT_FEATURE_SUPPORTS_COMPRESSION | CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | CLIENT_FEATURE_SUPPORTS_DEFLATE ) if noise_psk: features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE @@ -675,8 +685,18 @@ def perform_ota( f"retry {flag_name}." ) - if features & SERVER_FEATURE_SUPPORTS_COMPRESSION: - upload_contents = gzip.compress(file_contents, compresslevel=9) + deflate = bool(extended_proto and features & SERVER_FEATURE_SUPPORTS_DEFLATE) + if deflate: + import zlib + + # The device inflates while receiving through a small ring window + upload_contents = zlib.compress( + file_contents, COMPRESS_LEVEL, wbits=-DEFLATE_WINDOW_BITS + ) + _LOGGER.info("Compressed to %s bytes (deflate)", len(upload_contents)) + elif features & SERVER_FEATURE_SUPPORTS_COMPRESSION: + # The device stores the gzip file and inflates it when it reboots + upload_contents = gzip.compress(file_contents, compresslevel=COMPRESS_LEVEL) _LOGGER.info("Compressed to %s bytes", len(upload_contents)) else: upload_contents = file_contents @@ -735,22 +755,20 @@ def perform_ota( send_check(sock, ota_type, "ota type") upload_size = len(upload_contents) - upload_size_encoded = [ - (upload_size >> 24) & 0xFF, - (upload_size >> 16) & 0xFF, - (upload_size >> 8) & 0xFF, - (upload_size >> 0) & 0xFF, - ] # The device erases flash between receiving the size and acking the # prepare, so this window shows the erase cost (near zero when the # device erases lazily during the upload) prepare_start = time.perf_counter() - send_check(sock, upload_size_encoded, "binary size") + send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size") + if deflate: + # Own frame: an encrypted session carries one field per frame + send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size") receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) prepare_duration = time.perf_counter() - prepare_start _LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration) - upload_md5 = hashlib.md5(upload_contents).hexdigest() + # The device hashes what it writes: the inflated image, else the received bytes + upload_md5 = hashlib.md5(file_contents if deflate else upload_contents).hexdigest() _LOGGER.debug("MD5 of upload is %s", upload_md5) send_check(sock, upload_md5, "file checksum") diff --git a/script/build_helpers.py b/script/build_helpers.py index f0b354a8a7..885a5a1475 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -46,6 +46,8 @@ EXIT_SKIPPED = 1 EXIT_COMPILE_ERROR = 2 EXIT_CONFIG_ERROR = 3 EXIT_NO_EXECUTABLE = 4 +# A test folder with this name would be synced into src/esphome and swept away with the core tree +CORE_TREE_DIR = "esphome" # Name of the per-component YAML config file in benchmark directories BENCHMARK_YAML_FILENAME = "benchmark.yaml" @@ -465,8 +467,19 @@ def build_and_run( components = sorted(components) - # Build include list: main entry point + component folders + extra dirs - includes: list[str] = [main_entry] + components + # Build include list: main entry point + component folders + extra dirs. The core tree + # folder is listed file by file, nested files included, since a folder include would + # land in src/esphome (see CORE_TREE_DIR) + includes: list[str] = [main_entry] + for component in components: + if component != CORE_TREE_DIR: + includes.append(component) + continue + includes.extend( + str(path.relative_to(tests_dir)) + for path in sorted((tests_dir / component).rglob("*")) + if path.suffix in (".cpp", ".h") + ) if extra_include_dirs: for d in extra_include_dirs: if d.is_dir() and (any(d.glob("*.cpp")) or any(d.glob("*.h"))): diff --git a/script/ci-custom.py b/script/ci-custom.py index aaf177c941..692393b221 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -904,6 +904,8 @@ def lint_relative_py_import(fname: Path, line, col, content): # neither can live in a C++ namespace. "esphome/components/esp32_hosted/esp_now_hosted.cpp", "esphome/components/esp32_hosted/esp_now_hosted_rpc.h", + # C header shared with the vendored decoder + "esphome/components/esphome/ota/ota_esphome_inflate.h", ], ) def lint_namespace(fname: Path, content: str) -> str | None: diff --git a/tests/components/esphome/__init__.py b/tests/components/esphome/__init__.py new file mode 100644 index 0000000000..713c0d5a63 --- /dev/null +++ b/tests/components/esphome/__init__.py @@ -0,0 +1,12 @@ +from esphome.loader import FileResource +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code emits the component count the application needs + manifest.enable_codegen() + # Only the decoder is under test; its ota platform is not in this build + manifest.resources = manifest.resources + [ + FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.c"), + FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.h"), + ] diff --git a/tests/components/esphome/test_ota_inflate.cpp b/tests/components/esphome/test_ota_inflate.cpp new file mode 100644 index 0000000000..e18c96440f --- /dev/null +++ b/tests/components/esphome/test_ota_inflate.cpp @@ -0,0 +1,324 @@ +#include + +#include +#include +#include + +#include "esphome/components/esphome/ota/ota_esphome_inflate.h" + +namespace esphome::testing { + +// build_plain() compressed with the CLI's window (espota2.DEFLATE_WINDOW_BITS): +// DEFLATED = zlib.compress(plain, 9, wbits=-12) +// STORED = zlib.compress(plain[:300], 0, wbits=-12) +static const uint8_t DEFLATED[] = { + 0xed, 0xc8, 0xf7, 0x3f, 0xd4, 0x0f, 0x03, 0x00, 0x70, 0x67, 0xaf, 0x4b, 0x67, 0x66, 0x9f, 0x90, 0x91, 0x11, 0xc2, + 0x11, 0x91, 0xb8, 0xb3, 0xf7, 0x3a, 0xd9, 0x5f, 0x4e, 0x99, 0x67, 0x1e, 0xce, 0x8a, 0xac, 0xec, 0x59, 0xb8, 0xc2, + 0x95, 0x5d, 0x56, 0x42, 0x67, 0x73, 0x46, 0xf6, 0xca, 0xce, 0xc8, 0xc8, 0xc8, 0x91, 0x8a, 0x7c, 0x2f, 0x7a, 0xfe, + 0x86, 0xe7, 0x87, 0xe7, 0x87, 0xe7, 0xf5, 0xfa, 0xbc, 0x7f, 0x7c, 0xbb, 0x07, 0xa2, 0x1f, 0xfa, 0xf9, 0xb8, 0x43, + 0xfd, 0x82, 0x5c, 0xa0, 0x6e, 0xee, 0x28, 0x6f, 0x97, 0x20, 0x77, 0xa8, 0x3b, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70, + 0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c, + 0x70, 0xc0, 0x01, 0xf7, 0x5f, 0xdd, 0x40, 0xd4, 0x63, 0x2f, 0x03, 0xf2, 0x17, 0xb2, 0xe5, 0x69, 0xc7, 0x5a, 0x04, + 0xdf, 0x46, 0x22, 0xa8, 0x4b, 0x6e, 0xd5, 0x3a, 0x80, 0x05, 0xe1, 0x81, 0x4a, 0x44, 0x56, 0x27, 0xa9, 0x21, 0xf7, + 0xad, 0xd9, 0xb0, 0xd3, 0xf1, 0xa5, 0x0b, 0x6a, 0x96, 0x56, 0xb2, 0xca, 0xb5, 0xee, 0x0f, 0x96, 0x28, 0xc3, 0x9e, + 0x8f, 0x6e, 0xf2, 0x84, 0xa4, 0x3b, 0x2d, 0x16, 0x64, 0x08, 0x6a, 0x28, 0x91, 0xee, 0x87, 0x1a, 0x81, 0xff, 0xed, + 0x2c, 0x5f, 0xda, 0x4a, 0x48, 0x5f, 0x91, 0xf0, 0x31, 0xfe, 0x6b, 0xbd, 0x81, 0x86, 0x92, 0x33, 0x1a, 0x6c, 0x69, + 0x32, 0xfb, 0x19, 0x56, 0x2c, 0xc6, 0xaa, 0xef, 0xe8, 0x16, 0x98, 0xcf, 0x98, 0xbf, 0xa0, 0x2d, 0xde, 0x7f, 0xdf, + 0x4b, 0xcb, 0xf6, 0x5c, 0xaf, 0x11, 0x24, 0xf0, 0x46, 0x3e, 0x49, 0x0e, 0x67, 0x0e, 0xcf, 0xf3, 0x57, 0xca, 0xb0, + 0x39, 0x55, 0xe1, 0xe7, 0xfc, 0x37, 0x29, 0xe8, 0xd7, 0xf3, 0x08, 0x2e, 0xfb, 0x7b, 0x6d, 0x3e, 0xfe, 0xe9, 0x2c, + 0x68, 0xe4, 0x20, 0x88, 0x57, 0x56, 0x41, 0x3d, 0xab, 0x9b, 0xbf, 0x0c, 0x0c, 0xae, 0x51, 0x48, 0x8b, 0x72, 0xcc, + 0xb8, 0xbb, 0xf3, 0x30, 0xa8, 0x5c, 0xb5, 0xa1, 0x2f, 0x07, 0x52, 0xe9, 0x36, 0xb8, 0x3c, 0xbc, 0xee, 0xbc, 0xf1, + 0xa3, 0x8b, 0x81, 0xc2, 0x03, 0xbf, 0x29, 0x5a, 0x22, 0x24, 0x97, 0xf8, 0xc9, 0xb5, 0xbf, 0xd2, 0x24, 0x4a, 0x86, + 0xc1, 0x2b, 0xb7, 0xb8, 0xde, 0xa7, 0xea, 0xa5, 0x1d, 0x13, 0x1c, 0x1d, 0xd3, 0x3c, 0x81, 0x60, 0x2e, 0x2f, 0x93, + 0x5c, 0x78, 0x43, 0x2e, 0x39, 0x68, 0x09, 0x13, 0x09, 0x10, 0xce, 0xd6, 0x8d, 0xe9, 0x2f, 0xaf, 0x88, 0x6f, 0x99, + 0x4f, 0xcd, 0xdc, 0xaa, 0xf9, 0x47, 0xdb, 0x1c, 0xb5, 0x89, 0x53, 0x10, 0x3d, 0x77, 0xac, 0x26, 0x04, 0x3a, 0x3b, + 0x18, 0xf8, 0x47, 0x75, 0x56, 0xa5, 0xda, 0x70, 0xfb, 0xf7, 0x0d, 0x3b, 0x6e, 0x4b, 0x2a, 0xbc, 0x49, 0x32, 0x43, + 0x95, 0x62, 0x83, 0x3d, 0xdc, 0x0a, 0x1f, 0x1d, 0xf9, 0x59, 0x6b, 0x95, 0xf0, 0x9b, 0xf5, 0x53, 0x9e, 0xb5, 0x65, + 0xeb, 0x74, 0xfa, 0x81, 0x9b, 0x61, 0xa3, 0x57, 0x2f, 0xda, 0x2c, 0xcd, 0xf8, 0xb0, 0x74, 0xb9, 0x62, 0x39, 0x91, + 0xd9, 0x7d, 0xb3, 0x03, 0x65, 0x2e, 0x66, 0x20, 0x18, 0xac, 0xa2, 0xeb, 0x3b, 0x35, 0x98, 0xa3, 0x28, 0x46, 0x30, + 0xee, 0xd3, 0xeb, 0x47, 0x49, 0x11, 0xc5, 0xcd, 0xa0, 0xa5, 0x1c, 0x2e, 0x9d, 0x14, 0xd6, 0x46, 0x4a, 0x05, 0x7b, + 0xd3, 0xb9, 0x0d, 0xeb, 0xd7, 0x6f, 0xb5, 0xf4, 0xed, 0x3f, 0x27, 0xb8, 0xec, 0x51, 0xb1, 0x6e, 0xb0, 0x14, 0xed, + 0xdf, 0x90, 0x72, 0x59, 0x71, 0xd5, 0xf5, 0xf2, 0x61, 0x5f, 0xa7, 0x8a, 0xd7, 0x0f, 0x80, 0x2f, 0xe5, 0x0f, 0x45, + 0xf2, 0x4d, 0xd1, 0xdc, 0xdd, 0xce, 0x46, 0xdf, 0x77, 0xf2, 0xa6, 0xa2, 0x79, 0x77, 0xf0, 0x0e, 0xaa, 0x68, 0x14, + 0x85, 0x32, 0x05, 0x29, 0x75, 0x2b, 0x6c, 0xb0, 0x7c, 0x84, 0xc9, 0xec, 0x49, 0x70, 0x9b, 0x38, 0x36, 0x4c, 0xe9, + 0xa5, 0xdc, 0xb1, 0xb8, 0x28, 0xd6, 0x20, 0x89, 0x8a, 0x2a, 0x62, 0xc1, 0x90, 0x3a, 0x43, 0x48, 0xaa, 0xfc, 0xec, + 0x52, 0xf4, 0x02, 0xa7, 0xcd, 0x46, 0xf9, 0x78, 0x64, 0x4f, 0xad, 0x6b, 0x17, 0xdb, 0x56, 0xa2, 0xa1, 0x6a, 0xcf, + 0x3b, 0x66, 0x64, 0x01, 0xc2, 0xd6, 0xae, 0x23, 0x34, 0x82, 0x60, 0x0a, 0x3a, 0xe4, 0xdd, 0x7f, 0xaa, 0x97, 0x44, + 0x9a, 0x63, 0x8f, 0xaf, 0xa9, 0x97, 0x5a, 0x55, 0xcc, 0x81, 0x48, 0x83, 0xf8, 0xe6, 0xc5, 0xef, 0xdf, 0xfa, 0x5a, + 0x4f, 0x7f, 0x18, 0x56, 0xc0, 0x66, 0x36, 0xad, 0x8a, 0x79, 0xab, 0xde, 0xf4, 0x7b, 0x70, 0x98, 0xea, 0xfa, 0xf4, + 0xc7, 0x01, 0x31, 0x16, 0xea, 0xf1, 0x70, 0x8d, 0xee, 0x87, 0x52, 0x13, 0x01, 0x9e, 0x0c, 0xbd, 0x5a, 0x14, 0x01, + 0x8e, 0xf7, 0x6e, 0xff, 0xce, 0x7f, 0xa4, 0xd1, 0xa1, 0x6a, 0x9b, 0x95, 0xb2, 0x31, 0x8a, 0x6f, 0xc8, 0xfe, 0x8c, + 0x29, 0x55, 0xbc, 0xfc, 0x61, 0xd0, 0xde, 0xb0, 0xa9, 0x0c, 0x01, 0x0f, 0xba, 0x77, 0x7e, 0x77, 0xd9, 0x76, 0xa4, + 0xfd, 0xab, 0x38, 0x18, 0x92, 0xec, 0xf0, 0xd3, 0x57, 0x7f, 0xf4, 0xbd, 0x65, 0xd4, 0x77, 0x8e, 0xa1, 0x92, 0x82, + 0x0c, 0x7b, 0x34, 0xd3, 0x11, 0xfb, 0xc0, 0x36, 0x23, 0x8e, 0xbb, 0x89, 0xdf, 0x70, 0xdc, 0x9a, 0x76, 0x45, 0x0b, + 0x22, 0xfa, 0x35, 0xdf, 0x8f, 0x45, 0x48, 0x7d, 0xed, 0xdc, 0x06, 0x01, 0x5e, 0xbb, 0xe9, 0xf9, 0x07, 0x93, 0xf6, + 0x69, 0x6f, 0xf1, 0xfc, 0xfd, 0xfa, 0x41, 0xc0, 0x13, 0x9e, 0x74, 0x2d, 0x76, 0xfe, 0xa7, 0xbe, 0x4e, 0xd9, 0xaa, + 0x64, 0xa6, 0xed, 0xd3, 0xad, 0xee, 0x62, 0x9d, 0x39, 0xc9, 0xb8, 0xae, 0x86, 0x31, 0x4f, 0x62, 0x57, 0xea, 0xea, + 0x5a, 0xe3, 0x59, 0xd8, 0x99, 0xb3, 0xcd, 0x9f, 0x3b, 0xea, 0x58, 0x99, 0x11, 0xdc, 0x3d, 0xac, 0x58, 0xd9, 0xa6, + 0xae, 0x2d, 0x07, 0x1d, 0xd7, 0xfa, 0x87, 0x78, 0xa7, 0x7f, 0x2a, 0x4f, 0x25, 0x58, 0xef, 0x53, 0x78, 0x2e, 0x93, + 0xdc, 0x44, 0xcc, 0x53, 0x88, 0x77, 0x1c, 0xda, 0x14, 0xaf, 0xe1, 0x67, 0x92, 0xff, 0x36, 0x96, 0x20, 0x6f, 0x9d, + 0x2c, 0x7f, 0xea, 0x31, 0xf2, 0x34, 0x50, 0xc2, 0x39, 0x84, 0xee, 0x4c, 0xbe, 0xca, 0x06, 0x6f, 0x67, 0x42, 0xea, + 0x13, 0x58, 0xee, 0xdd, 0x8e, 0x29, 0x4f, 0xee, 0xd8, 0x93, 0x0d, 0x45, 0x80, 0x4d, 0xf3, 0x12, 0x79, 0xbb, 0x36, + 0xa3, 0x73, 0x95, 0x95, 0xb6, 0xfc, 0x54, 0x60, 0xb2, 0xcc, 0x71, 0xaa, 0xf1, 0x6b, 0x66, 0xed, 0xba, 0x8b, 0xd6, + 0x6d, 0x61, 0x79, 0x61, 0x1d, 0xb3, 0xba, 0xa6, 0x2f, 0xaa, 0xdc, 0x1d, 0xb8, 0x22, 0xd8, 0x98, 0x58, 0xed, 0x4d, + 0x3c, 0xea, 0xa9, 0x37, 0x5e, 0x5e, 0x7b, 0x47, 0xa1, 0x7a, 0x39, 0x42, 0xe4, 0x3b, 0xbb, 0x69, 0x0a, 0x8b, 0x32, + 0x6e, 0x63, 0xeb, 0x87, 0xf6, 0x5d, 0xaa, 0xbf, 0xbe, 0xc5, 0xb2, 0x85, 0x60, 0xdc, 0x32, 0x07, 0x85, 0x73, 0x3d, + 0x96, 0x8b, 0x89, 0x71, 0x52, 0xb4, 0x93, 0xe6, 0x18, 0xad, 0xbf, 0xce, 0x21, 0x1d, 0x33, 0xb5, 0xb3, 0x35, 0x6a, + 0x5b, 0xe7, 0x47, 0x13, 0x19, 0x8f, 0x53, 0xf4, 0x0c, 0x2a, 0x39, 0x16, 0x37, 0x39, 0x3b, 0x5f, 0x81, 0x51, 0xbc, + 0x23, 0x92, 0x2c, 0x8d, 0xbf, 0x2f, 0xee, 0xbf, 0xed, 0x9d, 0x3f, 0xe0, 0x16, 0x21, 0x5a, 0x57, 0xa6, 0x8c, 0x58, + 0x7a, 0xd5, 0xa1, 0x6d, 0xed, 0xe8, 0x90, 0x97, 0x14, 0xb4, 0x6b, 0xa5, 0x3b, 0xd7, 0x90, 0x84, 0x9e, 0x07, 0xc2, + 0x7f, 0x1e, 0x08, 0xa0, 0x6f, 0x69, 0xf1, 0xcc, 0x4e, 0xca, 0x07, 0x64, 0xa2, 0xb4, 0xbc, 0x5f, 0xe0, 0xa7, 0x0e, + 0x31, 0x77, 0x6f, 0x35, 0xd7, 0x66, 0x83, 0x5d, 0x64, 0x4e, 0xf1, 0x3c, 0x7a, 0xcb, 0xa5, 0xfc, 0xc9, 0x95, 0xab, + 0x27, 0x30, 0x6b, 0x82, 0x57, 0xcd, 0xb0, 0x85, 0x9d, 0xc5, 0xa9, 0x0f, 0xdb, 0xe3, 0x5a, 0xc1, 0xb2, 0x3f, 0xfa, + 0xea, 0xf3, 0x3a, 0xa0, 0xe4, 0xad, 0xd7, 0x14, 0xcf, 0x4f, 0xc9, 0x46, 0x09, 0x6e, 0x70, 0x27, 0xe4, 0x9b, 0x5f, + 0x61, 0x4a, 0xb2, 0xfb, 0xc3, 0xf0, 0x3a, 0x53, 0x38, 0x2a, 0x59, 0x48, 0x2b, 0xab, 0x21, 0x64, 0x1a, 0x1c, 0x90, + 0xb6, 0x69, 0xbb, 0x89, 0xe3, 0x9a, 0x12, 0xb7, 0xfd, 0x47, 0xcc, 0x57, 0xdb, 0x2b, 0xf6, 0x35, 0xe1, 0x79, 0xe0, + 0xbf, 0x6d, 0x7f, 0x71, 0x3c, 0x21, 0x27, 0x82, 0x12, 0x05, 0xae, 0x43, 0x11, 0x0f, 0xc8, 0xe7, 0x3c, 0xf0, 0x0b, + 0xe4, 0x69, 0xff, 0xb2, 0xda, 0x9e, 0x7f, 0xfe, 0xa4, 0x3f, 0xdf, 0x02, 0x58, 0xaa, 0x60, 0xd2, 0x8e, 0xd5, 0x6c, + 0x92, 0x22, 0x38, 0xb1, 0xd2, 0x84, 0x80, 0xea, 0xce, 0xfe, 0x34, 0x79, 0xd6, 0x94, 0xb0, 0xad, 0x51, 0x67, 0x65, + 0xf5, 0xab, 0xd5, 0x18, 0x05, 0x92, 0x8a, 0x24, 0x36, 0xc2, 0xc4, 0x99, 0xad, 0x0d, 0x26, 0xb8, 0x70, 0xea, 0x5b, + 0x74, 0x35, 0x6a, 0xfa, 0xac, 0x97, 0xd8, 0x4c, 0x08, 0x6d, 0xc1, 0xfe, 0x60, 0xe4, 0xd4, 0x8f, 0x93, 0xd4, 0xaf, + 0xa2, 0xec, 0xb8, 0x71, 0xf6, 0xcb, 0x3f, 0xdc, 0x3e, 0x42, 0xca, 0xe2, 0x8d, 0xb9, 0x0a, 0x2c, 0xef, 0xb6, 0x18, + 0x75, 0x46, 0x8a, 0x2b, 0x21, 0xeb, 0xc5, 0x12, 0x4d, 0xd7, 0xb8, 0x8e, 0x6c, 0xec, 0x9d, 0x12, 0xde, 0xa5, 0x45, + 0xab, 0xc4, 0x26, 0x47, 0xfa, 0x57, 0xad, 0x78, 0x4c, 0x5e, 0x87, 0x0b, 0x42, 0x6a, 0xbe, 0xef, 0xbd, 0xbc, 0xe5, + 0xd7, 0x4c, 0x3a, 0x0f, 0x96, 0x29, 0xcb, 0x45, 0x46, 0x3f, 0x8a, 0x31, 0xda, 0x8d, 0x7f, 0x89, 0xa4, 0xd6, 0xb4, + 0xe5, 0x99, 0x27, 0xa7, 0xab, 0x15, 0x82, 0x75, 0x3e, 0xc0, 0x9d, 0xae, 0x8c, 0x7f, 0x19, 0x80, 0xf5, 0xa6, 0x89, + 0xbc, 0xb7, 0x36, 0x50, 0x1f, 0x63, 0xca, 0x40, 0x3e, 0xe1, 0x4c, 0x2d, 0x1e, 0x15, 0x51, 0x93, 0xf2, 0x1a, 0x97, + 0x3e, 0xad, 0x5e, 0xba, 0xe2, 0xd6, 0xa7, 0x7c, 0x51, 0x45, 0xdf, 0x92, 0xbe, 0x35, 0xf3, 0xb4, 0xe8, 0x5c, 0x2e, + 0x6a, 0x5c, 0xda, 0xe8, 0xf7, 0x51, 0xdc, 0xcb, 0x06, 0xd2, 0xd2, 0xa4, 0x7c, 0x7d, 0x5f, 0x20, 0xb8, 0x50, 0x04, + 0xb5, 0xb1, 0x9a, 0x4e, 0xc9, 0x94, 0xe4, 0x40, 0x2b, 0x85, 0xc8, 0xd3, 0x71, 0xfa, 0x83, 0x81, 0x28, 0x53, 0xef, + 0xa0, 0xb0, 0xf0, 0xc8, 0xb9, 0x52, 0xb2, 0x0f, 0x8c, 0x49, 0x93, 0xeb, 0x2a, 0x95, 0x83, 0x4f, 0x47, 0xe8, 0x0c, + 0x83, 0x7b, 0x88, 0x18, 0x63, 0xa9, 0x87, 0x7f, 0xe3, 0x57, 0xa6, 0x57, 0xdd, 0x6d, 0x2a, 0x85, 0xe9, 0x79, 0x16, + 0x3b, 0x07, 0x59, 0x4f, 0xed, 0x9a, 0x17, 0xe7, 0xcd, 0x35, 0xa6, 0x9b, 0x06, 0x1d, 0x18, 0x5e, 0xb2, 0x9b, 0xac, + 0x18, 0xc6, 0x5b, 0xec, 0xbe, 0x29, 0xa3, 0x57, 0xc2, 0x6b, 0x3f, 0x11, 0x89, 0xd1, 0x57, 0xe4, 0xb8, 0xaf, 0x11, + 0xba, 0x90, 0x93, 0x4f, 0x5e, 0x0c, 0x6b, 0x9f, 0x61, 0x5c, 0xdd, 0xb6, 0xcf, 0x18, 0xcc, 0x9a, 0x95, 0x32, 0x5f, + 0x6f, 0xf1, 0x43, 0x17, 0x2d, 0xbc, 0x70, 0xbe, 0xd4, 0x76, 0x38, 0x42, 0xcc, 0xdd, 0xf0, 0xff, 0x5b, 0xe8, 0x19, + 0xc9, 0xf4, 0x25, 0xda, 0xdb, 0x5f, 0xd2, 0x3c, 0xf3, 0xe0, 0xed, 0xb0, 0xa2, 0x4c, 0xdf, 0x05, 0xf2, 0x0c, 0xc4, + 0x21, 0xb9, 0xc4, 0x02, 0xd2, 0x1e, 0x46, 0x2c, 0x57, 0x78, 0xb0, 0xf1, 0x85, 0x33, 0x51, 0xc5, 0x3e, 0xce, 0x69, + 0x68, 0x77, 0x3e, 0xb4, 0x5a, 0x4e, 0x43, 0x89, 0x85, 0x71, 0x36, 0x84, 0x56, 0x5c, 0x6b, 0xce, 0x78, 0x4e, 0x86, + 0x6a, 0x5f, 0xf1, 0x2d, 0x73, 0x4c, 0xba, 0xc1, 0x5e, 0x2b, 0x6e, 0x0d, 0xdd, 0x45, 0x1d, 0x92, 0xb4, 0x31, 0xd9, + 0xce, 0x0b, 0x58, 0x67, 0xc1, 0xa6, 0x47, 0x2e, 0x5e, 0x6d, 0x6d, 0xa3, 0x78, 0xed, 0xc5, 0xcb, 0xac, 0x20, 0xb8, + 0x6a, 0x14, 0x32, 0x55, 0xe8, 0xfb, 0xe0, 0xee, 0x31, 0xfc, 0x47, 0x33, 0xf6, 0xe7, 0x54, 0xd1, 0x61, 0x91, 0x74, + 0x90, 0xfa, 0x44, 0x0a, 0xc8, 0xaf, 0xa2, 0xdb, 0x2c, 0x83, 0xda, 0xe8, 0xd0, 0xb7, 0x34, 0x8a, 0xc8, 0xbb, 0x86, + 0x58, 0x82, 0x1a, 0x2f, 0xd6, 0xc5, 0x92, 0xe9, 0xd0, 0x21, 0x11, 0xf6, 0xeb, 0x9b, 0xed, 0xbf, 0xa9, 0x71, 0x89, + 0xb2, 0x59, 0x76, 0xb3, 0xeb, 0x9c, 0xe1, 0x73, 0x79, 0x7b, 0x11, 0x3d, 0x4e, 0x33, 0xca, 0xf6, 0x7e, 0xd8, 0xcf, + 0x06, 0x41, 0x39, 0xef, 0x78, 0x5a, 0x89, 0x69, 0x0c, 0xff, 0xda, 0x31, 0x16, 0x32, 0xd4, 0xe5, 0x2d, 0x8b, 0x22, + 0xb9, 0x82, 0x38, 0xd8, 0x22, 0xfc, 0xe8, 0xbd, 0xbf, 0x0d, 0x4b, 0xc9, 0x0d, 0x74, 0x5e, 0x2d, 0xb2, 0x1f, 0x23, + 0xa8, 0xf8, 0xa7, 0x27, 0xe1, 0x02, 0x61, 0x83, 0xc4, 0xd0, 0x63, 0x0e, 0x23, 0xf2, 0x34, 0x06, 0x69, 0xd1, 0xa3, + 0xa2, 0x76, 0x76, 0x94, 0xbc, 0x1f, 0x78, 0x73, 0x5a, 0x48, 0x82, 0xb4, 0x68, 0x3d, 0x24, 0x52, 0xe8, 0x92, 0x4e, + 0x58, 0xdf, 0x55, 0x3f, 0xa8, 0x48, 0x59, 0x35, 0x90, 0x9b, 0x55, 0xc9, 0xdc, 0xff, 0xcc, 0x22, 0x7c, 0xd1, 0x29, + 0xe0, 0xd5, 0xd8, 0x31, 0x3d, 0x9b, 0xae, 0x38, 0x52, 0x2a, 0xa1, 0xc5, 0x86, 0x19, 0x93, 0xe7, 0x2d, 0x9b, 0xa9, + 0x09, 0xbf, 0xdd, 0x89, 0x43, 0x35, 0xb2, 0x6f, 0x91, 0xba, 0x28, 0xfa, 0x2f, 0x2c, 0xab, 0xe8, 0x58, 0xe6, 0x4c, + 0xbf, 0xd5, 0xaf, 0x49, 0xcd, 0x7f, 0x18, 0xa4, 0x69, 0xd3, 0xff, 0x99, 0xad, 0x9d, 0xb0, 0x93, 0x3b, 0x04, 0x01, + 0xd5, 0x7d, 0x33, 0xe0, 0x66, 0xe2, 0xd4, 0xeb, 0xae, 0xcd, 0x7f, 0x66, 0xf2, 0x2c, 0xa7, 0xfe, 0x7d, 0x51, 0x8b, + 0x2a, 0x25, 0xa8, 0x90, 0xb2, 0x71, 0xe5, 0x98, 0x40, 0xb8, 0xe9, 0x1f, 0x64, 0xd9, 0x90, 0xf2, 0x39, 0x25, 0x6f, + 0x58, 0x35, 0x9e, 0x40, 0x25, 0x92, 0xbb, 0xa2, 0x4d, 0x45, 0xf6, 0xc3, 0x1a, 0xc6, 0xc0, 0xf6, 0xc2, 0x9b, 0x2b, + 0xb5, 0xc7, 0xe9, 0xc8, 0x75, 0x03, 0x71, 0x1c, 0x76, 0x0e, 0xde, 0x5c, 0xe7, 0xf4, 0x91, 0xc4, 0x1d, 0x9f, 0xcb, + 0x67, 0x1c, 0x1b, 0xc9, 0x25, 0x6d, 0x95, 0x05, 0xd7, 0x3b, 0xbf, 0x2d, 0x90, 0x96, 0xc3, 0x44, 0xa3, 0x0a, 0xee, + 0x7a, 0x4c, 0x7e, 0x7f, 0xc2, 0x4e, 0x4f, 0x38, 0x88, 0xe9, 0x9f, 0x93, 0xd8, 0xd1, 0x10, 0x8c, 0x85, 0x89, 0xb2, + 0xd0, 0x95, 0xc3, 0xe4, 0xd2, 0x8f, 0x2b, 0x0c, 0x82, 0xbd, 0x25, 0x74, 0x39, 0xe7, 0x3e, 0xef, 0xd2, 0x2f, 0x6f, + 0xdc, 0xe0, 0x75, 0x6b, 0xf2, 0x66, 0x7d, 0x65, 0x18, 0x26, 0xb0, 0x7a, 0x7a, 0x45, 0xf0, 0xf2, 0xc4, 0xbe, 0x70, + 0x6e, 0x58, 0x3a, 0x54, 0x31, 0x64, 0xf6, 0x49, 0xb5, 0xdc, 0x1c, 0x9e, 0x87, 0xdb, 0x6a, 0x92, 0x2a, 0x59, 0x73, + 0x35, 0x4d, 0x59, 0xed, 0xd0, 0x8a, 0xd9, 0xac, 0xea, 0x7c, 0xc0, 0x0a, 0xfe, 0x8f, 0x29, 0xb7, 0xda, 0xdc, 0x8a, + 0x55, 0x0f, 0x61, 0xc0, 0x82, 0x49, 0x4c, 0xc2, 0x51, 0x4b, 0x41, 0x7d, 0x1f, 0xf1, 0x07, 0x13, 0x23, 0x16, 0xe4, + 0x88, 0x76, 0x38, 0x92, 0xbd, 0x3d, 0xe2, 0x7b, 0xab, 0x1c, 0x53, 0x35, 0xfb, 0x85, 0x76, 0x35, 0xf7, 0xfc, 0xdc, + 0x3e, 0xc9, 0xae, 0x6a, 0x7c, 0x10, 0x44, 0xa5, 0xfb, 0xc4, 0x81, 0x9a, 0x6d, 0x9b, 0xf2, 0x87, 0x0a, 0x9e, 0xcd, + 0x75, 0xc2, 0xd5, 0xe7, 0x0b, 0xcd, 0x5c, 0x6e, 0xf2, 0x51, 0x96, 0xa3, 0xe8, 0xd5, 0x5a, 0x17, 0xf5, 0x1a, 0x28, + 0xff, 0xc8, 0xf0, 0xfc, 0x7e, 0x3b, 0xd3, 0xf3, 0x12, 0x61, 0xe9, 0x02, 0xfa, 0x7b, 0x8e, 0x6b, 0x34, 0x4a, 0x5a, + 0xb6, 0xb1, 0x71, 0x7d, 0xad, 0xbc, 0x88, 0x22, 0xf6, 0x14, 0xc5, 0x57, 0x1a, 0x0b, 0xb6, 0x8b, 0xbb, 0x9a, 0x09, + 0xf2, 0xe8, 0x8d, 0x32, 0xd1, 0x54, 0xa9, 0xd2, 0x9f, 0xc5, 0xf4, 0x12, 0xad, 0xb3, 0xfd, 0x52, 0xb7, 0xff, 0x0e, + 0x72, 0x59, 0xcb, 0x1d, 0x8d, 0xa4, 0xf2, 0x0f, 0x39, 0x8f, 0xc9, 0x84, 0xe0, 0x5c, 0xdd, 0x9c, 0xe4, 0x42, 0x4f, + 0xf2, 0x09, 0x1e, 0x05, 0xcc, 0x41, 0xdd, 0xbd, 0xc1, 0xed, 0xdc, 0x8e, 0x64, 0xc3, 0x67, 0x51, 0xe5, 0x8a, 0x9a, + 0x0d, 0x09, 0xb8, 0x4b, 0xa1, 0xb2, 0xdb, 0x01, 0xde, 0xf5, 0xd1, 0x51, 0xf7, 0x9d, 0x88, 0x59, 0xfd, 0xd5, 0x13, + 0x0d, 0x05, 0xd5, 0x5e, 0xfa, 0x63, 0x26, 0x25, 0xad, 0x01, 0x83, 0x6f, 0xf3, 0x7f, 0x56, 0x11, 0x89, 0xaa, 0xdb, + 0x49, 0xaa, 0x8f, 0x7d, 0x10, 0x99, 0xbf, 0x8b, 0xcf, 0xf4, 0xf8, 0xc2, 0x4e, 0x36, 0xa7, 0xb1, 0xfe, 0xc5, 0xf0, + 0x07, 0xc3, 0x57, 0xe3, 0xbd, 0x1e, 0xa1, 0x56, 0x46, 0x32, 0x43, 0x8f, 0x8d, 0xc7, 0x73, 0x0a, 0xa5, 0x35, 0xa9, + 0x93, 0x7c, 0x53, 0x3c, 0xf2, 0x58, 0xf3, 0x6a, 0x37, 0x41, 0xb4, 0xc7, 0x9d, 0xcd, 0xb1, 0x67, 0xb7, 0xaa, 0x5f, + 0x25, 0xb9, 0xc5, 0xb6, 0xa2, 0xdb, 0xce, 0x34, 0x2c, 0xea, 0x93, 0x45, 0x6e, 0x7b, 0x6f, 0xb6, 0xf6, 0x89, 0x83, + 0xd1, 0x9e, 0x08, 0x4b, 0x57, 0x4e, 0xd9, 0xf3, 0xe8, 0x81, 0x03, 0x5c, 0xd6, 0x6c, 0x2c, 0xea, 0x27, 0x2d, 0xfb, + 0x39, 0xbb, 0x25, 0x81, 0xbb, 0x1a, 0xc3, 0xcb, 0xf5, 0x5e, 0x69, 0x1c, 0xa4, 0x8a, 0x0e, 0x4a, 0x69, 0xdf, 0xcd, + 0x6c, 0xc7, 0xce, 0x48, 0xdb, 0x7a, 0x0b, 0xcb, 0x40, 0xc8, 0x59, 0x81, 0xde, 0x71, 0x3e, 0x0d, 0x41, 0xc9, 0xa2, + 0x57, 0xae, 0xb7, 0x3f, 0x8b, 0x4e, 0x7c, 0xcb, 0xf9, 0xa5, 0xcb, 0xf8, 0xee, 0x52, 0xbe, 0x54, 0xa8, 0xef, 0x99, + 0xb7, 0xa1, 0x0b, 0x77, 0xa2, 0xa7, 0x89, 0x47, 0x7f, 0x48, 0xaa, 0x02, 0x36, 0xbf, 0x9d, 0x97, 0xba, 0x43, 0x5e, + 0x39, 0xa6, 0x99, 0x81, 0xde, 0xe4, 0x98, 0x06, 0x6a, 0xc8, 0x2c, 0x96, 0xf5, 0x51, 0x8c, 0x95, 0xd3, 0xe4, 0x2b, + 0x94, 0x9a, 0x15, 0x71, 0x9f, 0xa4, 0x24, 0xfc, 0x92, 0x97, 0x18, 0xb7, 0x56, 0xe5, 0x59, 0xe1, 0x1a, 0x79, 0x3a, + 0x47, 0x77, 0x92, 0xf5, 0x75, 0x42, 0xbd, 0x2d, 0x55, 0x05, 0xcf, 0xe8, 0xea, 0x64, 0x8b, 0xff, 0x30, 0xa1, 0x1e, + 0x28, 0x2c, 0xa9, 0xaa, 0xd4, 0x19, 0x1d, 0x24, 0x7a, 0xcf, 0x5d, 0x39, 0xf2, 0xe3, 0xd2, 0xab, 0x26, 0x97, 0x35, + 0xf6, 0x70, 0x8b, 0x09, 0x8b, 0x00, 0xb9, 0xb8, 0xa8, 0xb7, 0x78, 0xf1, 0x96, 0x03, 0xd2, 0x72, 0x46, 0x26, 0x82, + 0x41, 0xce, 0xe3, 0x73, 0xb2, 0x31, 0x4f, 0x49, 0xc8, 0xa8, 0xcf, 0xcc, 0xb8, 0xcc, 0x5d, 0xab, 0xd1, 0x1e, 0xf5, + 0x5a, 0x9a, 0x54, 0xaf, 0x15, 0xed, 0x5f, 0x96, 0x2b, 0x62, 0x0a, 0x4c, 0x67, 0x21, 0x98, 0xfd, 0x29, 0xf8, 0x12, + 0x9d, 0xc2, 0x34, 0x4d, 0x70, 0x0e, 0x3b, 0x5e, 0xe3, 0x91, 0x58, 0x82, 0xa8, 0x9c, 0x7d, 0x11, 0xfb, 0x5d, 0x81, + 0x0c, 0x0f, 0x9d, 0x84, 0xbb, 0xeb, 0x65, 0x1d, 0xeb, 0x6e, 0x18, 0x8e, 0x93, 0x70, 0xbb, 0x9d, 0xed, 0x58, 0xb2, + 0x64, 0xf2, 0x7b, 0x8f, 0x05, 0xd8, 0xfa, 0x55, 0x50, 0xef, 0x7a, 0x49, 0x0d, 0xdf, 0xca, 0x63, 0xff, 0x41, 0x1d, + 0x89, 0x79, 0xca, 0x8c, 0x1b, 0xbe, 0x75, 0x6f, 0x0e, 0x6b, 0x68, 0x8c, 0x5c, 0x93, 0xc8, 0xf3, 0x62, 0x0b, 0x68, + 0xb1, 0x32, 0x2f, 0x1e, 0xda, 0xe5, 0x6d, 0x45, 0xaf, 0x3d, 0xa7, 0xaf, 0x81, 0x0f, 0xda, 0x82, 0x85, 0x94, 0x06, + 0xc5, 0x3f, 0xf5, 0x73, 0x5f, 0xa4, 0x43, 0x5f, 0xb3, 0xac, 0x3d, 0x16, 0xe8, 0x6f, 0x84, 0xb3, 0xb6, 0x75, 0xf7, + 0x13, 0x03, 0x46, 0x67, 0x87, 0x9a, 0xf1, 0x6c, 0x2e, 0x33, 0x33, 0x14, 0x85, 0x18, 0xe9, 0xd6, 0x1b, 0xee, 0x17, + 0xe9, 0xe7, 0x6b, 0xd4, 0x46, 0xaf, 0x26, 0x55, 0x8d, 0xfc, 0x3a, 0xb4, 0x61, 0x1a, 0x9e, 0x2d, 0x85, 0xa2, 0x86, + 0x8c, 0x92, 0xbd, 0x8d, 0x0e, 0x4b, 0x4e, 0x0d, 0xdf, 0x4a, 0xc6, 0x66, 0xcd, 0x28, 0xff, 0xc4, 0x44, 0xba, 0x39, + 0x4a, 0x04, 0x43, 0xce, 0x95, 0x47, 0x99, 0x8c, 0x9b, 0xb7, 0x72, 0x48, 0xdd, 0x9b, 0x98, 0x4f, 0xd5, 0x18, 0x81, + 0xb4, 0xef, 0xfa, 0x96, 0x54, 0x9b, 0xda, 0x73, 0xe2, 0xe0, 0x27, 0x2f, 0x10, 0x0f, 0xf9, 0x0e, 0x24, 0xb4, 0xd2, + 0x39, 0xf7, 0x49, 0x8c, 0x5c, 0x05, 0x37, 0xdd, 0xa7, 0x73, 0xac, 0x16, 0x85, 0x71, 0x46, 0xd1, 0x6f, 0x7a, 0x25, + 0xd2, 0x5c, 0x4e, 0x70, 0x26, 0x93, 0x36, 0x22, 0x5f, 0xe5, 0x42, 0x4e, 0x89, 0xf2, 0x4c, 0x55, 0x9b, 0xd1, 0xa5, + 0xaf, 0x87, 0xd8, 0x7e, 0xe8, 0xdd, 0xb9, 0x47, 0xb3, 0xda, 0xf7, 0x98, 0x52, 0x73, 0x8b, 0x3c, 0x0b, 0xe7, 0xfa, + 0x14, 0x7f, 0x9a, 0xa6, 0x8e, 0xf4, 0x7c, 0xad, 0x58, 0x11, 0x73, 0xe3, 0x3a, 0x56, 0x47, 0x9b, 0x95, 0x24, 0x68, + 0x35, 0xa9, 0xd9, 0x6e, 0x5c, 0x4d, 0xe8, 0xa4, 0xb8, 0x80, 0x0b, 0x73, 0xc4, 0x0f, 0x87, 0x0c, 0xa1, 0xf4, 0x5a, + 0x3c, 0xce, 0x3f, 0x30, 0x3d, 0xb5, 0xc1, 0x0b, 0x43, 0x2a, 0xb1, 0xbe, 0x6b, 0x12, 0xc4, 0x4b, 0xdb, 0xcb, 0x2f, + 0x77, 0x8a, 0x3a, 0xba, 0xf4, 0xa3, 0x9b, 0x05, 0xee, 0x98, 0x50, 0x77, 0xfe, 0x82, 0x5b, 0x29, 0x6d, 0x7c, 0x0c, + 0x4b, 0x41, 0xa2, 0x6a, 0xb2, 0x27, 0x32, 0xf1, 0x0c, 0x1d, 0xd7, 0x6c, 0x02, 0x4d, 0xbd, 0xf2, 0x5a, 0x66, 0xd3, + 0xb5, 0x30, 0xab, 0x4b, 0x7e, 0x72, 0x77, 0x30, 0x42, 0xd7, 0x70, 0x05, 0x9f, 0xeb, 0x2f, 0x22, 0x7f, 0x49, 0x2d, + 0x5d, 0xcf, 0x4b, 0x84, 0x0c, 0xa5, 0xb9, 0xe5, 0x49, 0xae, 0x54, 0x2c, 0x86, 0x53, 0x4c, 0x9c, 0x42, 0x29, 0x78, + 0xf0, 0x0a, 0x8a, 0x36, 0x53, 0x4d, 0x2b, 0xc9, 0xe2, 0x69, 0x0e, 0x9a, 0x37, 0x0c, 0x91, 0x2d, 0xac, 0x2f, 0x43, + 0x6a, 0xf1, 0xe9, 0xfb, 0xb4, 0x68, 0xec, 0x90, 0x00, 0xff, 0x32, 0xf8, 0x58, 0xab, 0xd1, 0x73, 0x9f, 0x77, 0x72, + 0x22, 0xed, 0x64, 0xac, 0x7d, 0xd0, 0xa8, 0x1f, 0x01, 0x99, 0x9f, 0xfd, 0xa6, 0xcb, 0x62, 0xca, 0x65, 0xd8, 0xf8, + 0x31, 0xf0, 0xc1, 0x5e, 0xef, 0xf1, 0x4d, 0x79, 0xa9, 0xfc, 0x8f, 0x57, 0xcb, 0xc1, 0xb7, 0x03, 0x30, 0xbb, 0xad, + 0xdb, 0x88, 0xf7, 0xcc, 0xa1, 0x25, 0xfd, 0x37, 0x03, 0xc5, 0xdb, 0x7f, 0xb4, 0xe0, 0xe6, 0xa3, 0x7e, 0xa4, 0x52, + 0x68, 0xa7, 0x2e, 0xe6, 0x1c, 0xea, 0x91, 0x48, 0x67, 0xbd, 0x3d, 0x6d, 0xcc, 0x09, 0x49, 0x17, 0x50, 0x16, 0xb5, + 0x65, 0x63, 0xd4, 0x84, 0x2c, 0x3a, 0x5f, 0x6a, 0x3c, 0x66, 0xfa, 0x24, 0xaf, 0xd1, 0xfd, 0x18, 0x66, 0xe4, 0xe8, + 0x3c, 0x6c, 0x74, 0xb6, 0xc5, 0x4c, 0xf8, 0xa8, 0x15, 0x3c, 0xd0, 0xb8, 0x4a, 0x53, 0x15, 0x4b, 0x1e, 0x56, 0x7f, + 0xd8, 0x40, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xfe, 0xe7, 0xdc, 0x03, 0xd1, 0x0f, 0xfd, 0x7c, 0xdc, 0xa1, 0x7e, 0x41, 0x2e, 0x50, 0x37, 0x77, 0x94, 0xb7, 0x4b, + 0x90, 0x3b, 0x14, 0x38, 0xe0, 0x80, 0x03, 0x0e, 0x38, 0xe0, 0x80, 0xfb, 0xff, 0xba, 0xff, 0x00, +}; + +static const uint8_t STORED[] = { + 0x01, 0x2c, 0x01, 0xd3, 0xfe, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, + 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, + 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, + 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, + 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, + 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, + 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, + 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, + 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, + 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, + 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, + 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, + 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, + 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, + 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, + 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, + 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, +}; + +static constexpr size_t WINDOW = 4096; +static constexpr size_t PLAIN_SIZE = 16000; + +static uint8_t lcg_next(uint32_t &x) { + x = (x * 1103515245u + 12345u) & 0x7fffffffu; + return (x >> 16) & 0xff; +} + +static std::vector build_plain() { + std::vector plain; + const char *text = "esphome ota deflate "; + for (int i = 0; i < 300; i++) + plain.insert(plain.end(), text, text + strlen(text)); + uint32_t x = 1; + for (int i = 0; i < 3000; i++) + plain.push_back(lcg_next(x)); + plain.insert(plain.end(), 5000, 0); + for (int i = 0; i < 100; i++) + plain.insert(plain.end(), text, text + strlen(text)); + return plain; +} + +// Mirrors the OTA session: chunked input through the read callback, window as output +struct Session : OtaInflateState { + const uint8_t *in; + size_t in_len; + size_t in_pos; + size_t chunk; + std::vector out; + uint8_t window[WINDOW]; +}; + +static int read_cb(OtaInflateState *d) { + auto *s = static_cast(d); + if (s->in_pos >= s->in_len) + return -1; + size_t n = std::min(s->chunk, s->in_len - s->in_pos); + d->source = s->in + s->in_pos + 1; + d->source_limit = s->in + s->in_pos + n; + s->in_pos += n; + return s->in[s->in_pos - n]; +} + +// Inflates the whole input; returns the decoder result and fills s.out +static int inflate_all(Session &s, const uint8_t *in, size_t in_len, size_t chunk) { + s.in = in; + s.in_len = in_len; + s.in_pos = 0; + s.chunk = chunk; + s.out.clear(); + memset(s.window, 0, sizeof(s.window)); + ota_inflate_init(&s, s.window, WINDOW); + s.source_read_cb = read_cb; + int res; + do { + s.dest = s.window; + s.dest_limit = s.window + WINDOW; + res = ota_inflate(&s); + if (res < 0 || s.eof) + return res < 0 ? res : OTA_INFLATE_DATA_ERROR; + s.out.insert(s.out.end(), s.window, s.dest); + if (s.out.size() > PLAIN_SIZE) + return OTA_INFLATE_DATA_ERROR; + } while (res != OTA_INFLATE_DONE); + return res; +} + +TEST(OtaInflate, RoundTripThroughWindow) { + auto s = std::make_unique(); + ASSERT_EQ(inflate_all(*s, DEFLATED, sizeof(DEFLATED), 1040), OTA_INFLATE_DONE); + EXPECT_EQ(s->out, build_plain()); + EXPECT_EQ(s->in_pos, sizeof(DEFLATED)); +} + +TEST(OtaInflate, SmallReadChunks) { + auto s = std::make_unique(); + ASSERT_EQ(inflate_all(*s, DEFLATED, sizeof(DEFLATED), 7), OTA_INFLATE_DONE); + EXPECT_EQ(s->out, build_plain()); +} + +TEST(OtaInflate, StoredBlock) { + auto s = std::make_unique(); + ASSERT_EQ(inflate_all(*s, STORED, sizeof(STORED), 64), OTA_INFLATE_DONE); + auto plain = build_plain(); + plain.resize(300); + EXPECT_EQ(s->out, plain); +} + +TEST(OtaInflate, TruncatedStreamFails) { + auto s = std::make_unique(); + for (size_t cut : {size_t{1}, size_t{100}, size_t{1000}, sizeof(DEFLATED) - 1}) { + EXPECT_LT(inflate_all(*s, DEFLATED, cut, 1040), 0) << "cut at " << cut; + EXPECT_LE(s->out.size(), PLAIN_SIZE); + } +} + +TEST(OtaInflate, TruncatedStoredBlockFails) { + auto s = std::make_unique(); + EXPECT_LT(inflate_all(*s, STORED, sizeof(STORED) - 50, 64), 0); +} + +TEST(OtaInflate, CorruptStreamsNeverEscapeTheWindow) { + // Flipped bytes and garbage; the sanitizers check the decoder stays in bounds + auto s = std::make_unique(); + std::vector bad(DEFLATED, DEFLATED + sizeof(DEFLATED)); + // A coarse, non-aligned stride: neighbouring offsets hit the same paths + for (size_t i = 0; i < bad.size(); i += 29) { + bad[i] ^= 0x5a; + inflate_all(*s, bad.data(), bad.size(), 1040); + bad[i] ^= 0x5a; + } + uint32_t x = 99; + std::vector garbage(2000); + for (int round = 0; round < 50; round++) { + for (auto &b : garbage) + b = lcg_next(x); + inflate_all(*s, garbage.data(), garbage.size(), 1040); + } +} + +} // namespace esphome::testing diff --git a/tests/components/main.cpp b/tests/components/main.cpp index 373fde7151..aa0ceb5abe 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -28,6 +28,11 @@ void setup() { ::testing::InitGoogleTest(); int exit_code = RUN_ALL_TESTS(); + // A test folder that never reached the build would otherwise pass as an empty run + if (::testing::UnitTest::GetInstance()->total_test_count() == 0) { + fprintf(stderr, "No tests were linked into this binary\n"); + exit_code = 1; + } exit(exit_code); } diff --git a/tests/components/ota/test.bk72xx-ard.yaml b/tests/components/ota/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/ota/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/ota/test_backend_contract.cpp b/tests/components/ota/test_backend_contract.cpp index 1b4fbbc32d..36b7a793c3 100644 --- a/tests/components/ota/test_backend_contract.cpp +++ b/tests/components/ota/test_backend_contract.cpp @@ -14,7 +14,7 @@ struct MinimalBackend { OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_OK; } OTAResponseTypes end() { return OTA_RESPONSE_OK; } void abort() {} - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } }; static_assert(OTABackendContract); diff --git a/tests/integration/fixtures/host_ota_deflate.yaml b/tests/integration/fixtures/host_ota_deflate.yaml new file mode 100644 index 0000000000..ebf7977123 --- /dev/null +++ b/tests/integration/fixtures/host_ota_deflate.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index 56a685eac3..17c4dea25f 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -15,6 +15,7 @@ from dataclasses import dataclass import functools from pathlib import Path import socket +import zlib import pytest @@ -123,6 +124,7 @@ class _Device: binary_path: Path proc: asyncio.subprocess.Process | None = None reboots: int = 0 + inflates: int = 0 def __post_init__(self) -> None: self._rebooted = asyncio.Event() @@ -131,6 +133,8 @@ class _Device: if "Rebooting safely" in line: self.reboots += 1 self._rebooted.set() + if "Inflated " in line and " bytes from " in line: + self.inflates += 1 async def wait_reboot(self, count: int, timeout: float = 10.0) -> None: async with asyncio.timeout(timeout): @@ -241,6 +245,80 @@ async def test_host_ota_self_update( await dev.ota(None, None, "second OTA failed -- listener leaked across execv") +@pytest.mark.asyncio +async def test_host_ota_deflate( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Deflate is negotiated by default, an old client gets an uncompressed + upload, and a corrupt stream is rejected without taking the device down.""" + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + errors: list[str] = [] + + def on_log(line: str) -> None: + # A corrupt stream is caught by the decoder, by the size check or by + # the MD5 at the end, depending on where the damage lands + if any( + text in line + for text in ("Inflate err", "Inflate overrun", "End update err") + ): + errors.append(line) + dev.on_log(line) + + real_compress = zlib.compress + + def corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes: + # Reserved block type in the first header: rejected by the decoder on + # every build, unlike a flipped data bit that may only fail the MD5 + out = bytearray(real_compress(data, *args, **kwargs)) + out[0] |= 0x06 + return bytes(out) + + def overlong_compress(data: bytes, *args: object, **kwargs: object) -> bytes: + """A stream that inflates past the size the client announced.""" + return real_compress(data + bytes(8192), *args, **kwargs) + + async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + + # Default: the host backend cannot store gzip, so the CLI sends deflate + await dev.ota(None, None, "deflate upload failed") + assert dev.inflates == 1, "device did not inflate the upload" + + # A client that does not offer deflate is served uncompressed + with monkeypatch.context() as m: + m.setattr(espota2, "CLIENT_FEATURE_SUPPORTS_DEFLATE", 0) + await dev.ota(None, None, "uncompressed upload failed") + assert dev.inflates == 1, "device inflated without a client offer" + + # A corrupt stream fails the upload and leaves the device running + with monkeypatch.context() as m: + m.setattr(zlib, "compress", corrupt_compress) + await dev.refused_ota(None, None, "corrupt deflate stream was accepted") + assert errors, "device did not report the corrupt stream" + + # So does a stream that inflates past the announced image size + errors.clear() + with monkeypatch.context() as m: + m.setattr(zlib, "compress", overlong_compress) + await dev.refused_ota(None, None, "overlong deflate stream was accepted") + assert any("Inflate overrun" in line for line in errors), ( + "device wrote past the announced size" + ) + + # and it still takes a good upload afterwards + await dev.ota(None, None, "upload after a rejected stream failed") + assert dev.inflates == 2 + + @pytest.mark.asyncio async def test_host_ota_encrypted( yaml_config: str, diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 2d65e8e079..f4c5935059 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -12,6 +12,7 @@ from pathlib import Path import socket import struct from unittest.mock import Mock, call, patch +import zlib import pytest from pytest import CaptureFixture @@ -354,6 +355,7 @@ def test_perform_ota_successful_md5_auth( espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE ] ) ) @@ -601,12 +603,16 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") -def _no_auth_handshake(version: int) -> list[bytes]: +def _no_auth_handshake(version: int, server_features: int | None = None) -> list[bytes]: """Recv responses for a handshake without auth, up to the MD5 check.""" + if server_features is None: + features = [bytes([espota2.RESPONSE_HEADER_OK])] + else: + features = [bytes([espota2.RESPONSE_FEATURE_FLAGS]), bytes([server_features])] return [ bytes([espota2.RESPONSE_OK]), # First byte of version response bytes([version]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response + *features, bytes([espota2.RESPONSE_AUTH_OK]), # No auth required bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK @@ -1054,6 +1060,7 @@ def test_perform_ota_successful_sha256_auth( espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE ] ) ) @@ -1110,6 +1117,7 @@ def test_perform_ota_sha256_fallback_to_md5( espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE ] ) ) @@ -1219,6 +1227,7 @@ def test_perform_ota_extended_protocol_app( espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE ] ) ) @@ -1279,6 +1288,7 @@ def test_perform_ota_successful_partition_table( espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE ] ) ) @@ -1507,3 +1517,40 @@ def test_check_error_passes_non_error_when_expect_is_none() -> None: espota2.check_error([espota2.RESPONSE_OK], None) espota2.check_error([espota2.RESPONSE_HEADER_OK], None) espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None) + + +# Device replies after the MD5 check for a one-chunk upload +_UPLOAD_TAIL = [ + bytes([espota2.RESPONSE_CHUNK_OK]), + bytes([espota2.RESPONSE_RECEIVE_OK]), + bytes([espota2.RESPONSE_UPDATE_END_OK]), +] + + +@pytest.mark.usefixtures("mock_time") +@pytest.mark.parametrize( + "server_features", + [ + espota2.SERVER_FEATURE_SUPPORTS_DEFLATE, + # Binding offer: deflate wins over gzip + espota2.SERVER_FEATURE_SUPPORTS_DEFLATE + | espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION, + ], +) +def test_perform_ota_with_deflate(mock_socket: Mock, server_features: int) -> None: + """The device gets a raw deflate stream, both sizes and the image MD5.""" + original_content = b"firmware" * 100 + mock_socket.recv.side_effect = ( + _no_auth_handshake(espota2.OTA_VERSION_2_0, server_features) + _UPLOAD_TAIL + ) + + espota2.perform_ota(mock_socket, None, io.BytesIO(original_content), "test.bin") + + sent = [c[0][0] for c in mock_socket.sendall.call_args_list] + # magic, features, ota type, size, image size, md5, data, end ack + sent_size = struct.unpack(">I", sent[3])[0] + assert sent[4] == len(original_content).to_bytes(espota2.SIZE_FIELD_BYTES, "big") + payload = sent[6] + assert len(payload) == sent_size < len(original_content) + assert zlib.decompress(payload, -espota2.DEFLATE_WINDOW_BITS) == original_content + assert sent[5] == hashlib.md5(original_content).hexdigest().encode()