Trim comments

This commit is contained in:
J. Nick Koston
2026-09-08 13:55:58 +02:00
parent 773050d7c9
commit 8e9e6bbdd7
11 changed files with 40 additions and 77 deletions
+1 -3
View File
@@ -308,9 +308,7 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_ALLOW_PARTITION_ACCESS):
cg.add_define("USE_OTA_PARTITIONS")
# ESP8266 and RP2040 inflate a gzip image at reboot from their bootloader
# or OTA stub; every other platform inflates a deflate stream on the fly
# while it receives the image
# ESP8266 and RP2040 inflate gzip at reboot; the rest inflate on the fly
if not (CORE.is_esp8266 or CORE.is_rp2):
cg.add_define("USE_OTA_DEFLATE")
+10 -17
View File
@@ -189,9 +189,8 @@ static constexpr uint8_t CLIENT_NOISE_FEATURES =
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;
// The device inflates a raw deflate stream (window <= OTA_INFLATE_WINDOW_SIZE).
// The offer is binding: a client that asked for it must then send the inflated
// size frame and a deflate stream, the device does not check again.
// 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;
inline bool ESPHomeOTAComponent::extended_proto_() const {
@@ -320,8 +319,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
#endif
#ifdef USE_OTA_DEFLATE
// Offer to inflate on the fly once the session memory (a few KB) is in
// hand; otherwise the upload stays uncompressed
// Offered only once the session memory is in hand; else uncompressed
if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) {
this->inflate_.reset(new (std::nothrow) InflateSession());
if (this->inflate_ != nullptr) {
@@ -478,7 +476,6 @@ void ESPHomeOTAComponent::handle_data_() {
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
image_size = xfer.ota_size;
#ifdef USE_OTA_DEFLATE
// A deflate upload also announces the inflated size
if (this->inflate_ != nullptr && !this->read_size_(buf, image_size, LOG_STR("image size")))
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
#endif
@@ -820,9 +817,8 @@ void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) {
}
#ifdef USE_OTA_DEFLATE
// The window doubles as the output buffer: the decoder fills it, we flush it to
// the backend, and its bytes remain available as the back-reference history for
// the next windowful.
// 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;
@@ -850,9 +846,8 @@ ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t ima
session.written = 0;
session.error = ota::OTA_RESPONSE_OK;
ota_inflate_init(&session, session.window, OTA_INFLATE_WINDOW_SIZE);
// Pulls the next compressed chunk when the decoder runs dry. Where the ack
// must follow the write, everything decoded so far is written and acked
// first, or the client would wait for an ack while the decoder waits for it.
// 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<InflateSession *>(d);
if (ACK_AFTER_WRITE) {
@@ -860,7 +855,7 @@ ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t ima
if (s->error != ota::OTA_RESPONSE_OK)
return -1;
}
// The stream wants more than announced; the size check below reports it
// 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);
@@ -876,14 +871,12 @@ ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t ima
int res;
do {
// The ring index wrapped to 0 exactly when the window filled, so the
// window and the output cursor stay in lockstep
// 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 decoding zeros after the read callback failed, so
// eof is checked as well
// A stored block keeps emitting zeros after a failed read, hence eof
if (res < 0 || session.eof)
break;
session.error = this->inflate_flush_(session);
+7 -11
View File
@@ -132,12 +132,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
uint32_t last_data_ms{0};
uint32_t last_progress{0};
};
// Receives up to OTA_BUFFER_SIZE bytes of upload data into buf, waiting up to
// the data timeout, and updates xfer. Returns bytes read, -1 on failure (logged).
// Up to OTA_BUFFER_SIZE bytes into buf; returns bytes read, -1 on failure (logged)
inline ssize_t receive_data_(uint8_t *buf, DataTransfer &xfer);
// When lwIP runs in this loop the radio is deaf while a sector is written, so
// the client is kept quiet until the block is in flash; with a socket task the
// ack goes out on receipt so the next block arrives while this one is written
// 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
@@ -209,12 +207,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
#endif
#ifdef USE_OTA_DEFLATE
// Deflate back references reach 1 << espota2.DEFLATE_WINDOW_BITS bytes; the
// ring window must be at least that. It also serves as the inflate output
// buffer, so it is flushed to the backend one windowful at a time.
// 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-compressed upload is negotiated; the
// decoder state is the base so its read callback can recover the session
// 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 {
ESPHomeOTAComponent *self;
DataTransfer *xfer;
@@ -229,7 +225,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
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; dest stays put so the ring history is intact
// 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);
std::unique_ptr<InflateSession> inflate_;
@@ -1,8 +1,6 @@
#pragma once
// Raw deflate decoder for compressed OTA uploads, cut down from uzlib
// (https://github.com/pfalcon/uzlib, zlib licence, see the .c file).
// Kept in C so it stays close to upstream; the decoder writes through a
// ring window so the image never has to be held in RAM.
// 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 <stdbool.h>
#include <stdint.h>
@@ -56,9 +54,9 @@ struct OtaInflateState {
uint16_t dtrans[32]; /* the distance alphabet has 30 symbols, so the tree is kept small */
};
/* dict must be at least as large as the window the encoder used (its max back reference distance) */
/* 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);
/* Produce output until dest reaches dest_limit (OK), the stream ends (DONE) or an error occurs */
/* Fills dest up to dest_limit (OK) or to the end of the stream (DONE) */
int ota_inflate(struct OtaInflateState *d);
#ifdef __cplusplus
+2 -3
View File
@@ -103,9 +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: static constexpr, whether a gzip image can be stored
// as is and inflated at reboot; the esphome platform asserts it at compile
// time when it builds its own inflater.
// - supports_compression: constexpr, whether a gzip image is stored as is and
// inflated at reboot.
template<typename T>
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<OTAResponseTypes>;
@@ -15,11 +15,9 @@ class ArduinoRP2OTABackend final {
OTAResponseTypes write(uint8_t *data, size_t len);
OTAResponseTypes end();
void abort();
// A gzip image is staged on LittleFS as is; the core's OTA stub inflates it
// into the app region at reboot (arduino-pico 2.4.0 and later, RP2350 from
// 4.0.3; ESPHome requires 6.0.0), the same way the ESP8266 bootloader does.
// begin() then sees the gzip size, so only the staging space is checked up
// front; the inflated size is not known until the stub reads the trailer.
// The core's OTA stub inflates a staged gzip image at reboot (arduino-pico
// 2.4.0, RP2350 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 true; }
private:
+4 -9
View File
@@ -70,13 +70,10 @@ 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 frame and a
# deflate stream, so there is no opting out per upload
# Binding once offered: the device then expects the image size and a deflate stream
SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08
# Window of the raw deflate stream sent to a device that inflates on the fly.
# Part of the protocol: the server's deflate bit promises a 4 KB ring window
# (OTA_INFLATE_WINDOW_SIZE), so a larger window needs a new feature bit
# Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE)
DEFLATE_WINDOW_BITS = 12
NOISE_FRAME_INDICATOR = 0x01
@@ -729,15 +726,13 @@ def perform_ota(
prepare_start = time.perf_counter()
send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size")
if deflate:
# The device sizes the partition by the inflated image; its own frame,
# as an encrypted session carries one field per frame
# 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)
# The device hashes what it writes to flash: the inflated image for a
# deflate upload, the received bytes otherwise (the gzip file on ESP8266)
# 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)
+2 -3
View File
@@ -3,10 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it emits the component count the application needs
# to_code emits the component count the application needs
manifest.enable_codegen()
# The deflate decoder lives with the ota platform, which is not part of
# this build; only the decoder itself is under test here
# 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"),
+3 -12
View File
@@ -8,11 +8,7 @@
namespace esphome::testing {
// The plaintext below, as built by build_plain(): repeated text, a pseudo random
// run, a zero run and the text again, so literals, short and long back references
// and stored data are all exercised across the 4 KB window. Regenerate with the
// window the CLI uses (espota2.DEFLATE_WINDOW_BITS):
// plain = build_plain() written out by the same recipe in Python
// 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[] = {
@@ -185,7 +181,6 @@ static const uint8_t DEFLATED[] = {
0x90, 0x3b, 0x14, 0x38, 0xe0, 0x80, 0x03, 0x0e, 0x38, 0xe0, 0x80, 0xfb, 0xff, 0xba, 0xff, 0x00,
};
// The first 300 bytes of the same plaintext as a stored (uncompressed) block
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,
@@ -209,7 +204,6 @@ static const uint8_t STORED[] = {
static constexpr size_t WINDOW = 4096;
static constexpr size_t PLAIN_SIZE = 16000;
// Pseudo random bytes reproducible from Python for the vectors above
static uint8_t lcg_next(uint32_t &x) {
x = (x * 1103515245u + 12345u) & 0x7fffffffu;
return (x >> 16) & 0xff;
@@ -229,8 +223,7 @@ static std::vector<uint8_t> build_plain() {
return plain;
}
// Mirrors the OTA session: the state is the base, input arrives through the
// read callback in chunks, the window doubles as the output buffer.
// Mirrors the OTA session: chunked input through the read callback, window as output
struct Session : OtaInflateState {
const uint8_t *in;
size_t in_len;
@@ -310,9 +303,7 @@ TEST(OtaInflate, TruncatedStoredBlockFails) {
}
TEST(OtaInflate, CorruptStreamsNeverEscapeTheWindow) {
// Every third byte of the stream flipped in turn, plus pseudo random garbage:
// the sanitizers check that the decoder never reads or writes out of bounds
// whatever it returns.
// Flipped bytes and garbage; the sanitizers check the decoder stays in bounds
auto s = std::make_unique<Session>();
std::vector<uint8_t> bad(DEFLATED, DEFLATED + sizeof(DEFLATED));
for (size_t i = 0; i < bad.size(); i += 3) {
+1 -1
View File
@@ -185,7 +185,7 @@ async def test_host_ota_self_update(
def on_log(line: str) -> None:
if "OTA staged at" in line:
staged.set()
# The host backend has no gzip support, so the upload negotiates deflate
# The host backend cannot store gzip, so the upload negotiates deflate
if "Inflated " in line and " bytes from " in line:
inflated.set()
dev.on_log(line)
+3 -7
View File
@@ -601,10 +601,7 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
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.
With server_features the device answers with the extended feature flags.
"""
"""Recv responses for a handshake without auth, up to the MD5 check."""
if server_features is None:
features = [bytes([espota2.RESPONSE_HEADER_OK])]
else:
@@ -1532,13 +1529,13 @@ _UPLOAD_TAIL = [
"server_features",
[
espota2.SERVER_FEATURE_SUPPORTS_DEFLATE,
# A deflate offer is binding, so it wins should a device set both bits
# 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:
"""A device that inflates on the fly gets a raw deflate stream, both sizes and the image MD5."""
"""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
@@ -1552,6 +1549,5 @@ def test_perform_ota_with_deflate(mock_socket: Mock, server_features: int) -> No
assert sent[4] == len(original_content).to_bytes(espota2.SIZE_FIELD_BYTES, "big")
payload = sent[6]
assert len(payload) == sent_size < len(original_content)
# The device decodes through a window of 1 << DEFLATE_WINDOW_BITS bytes
assert zlib.decompress(payload, -espota2.DEFLATE_WINDOW_BITS) == original_content
assert sent[5] == hashlib.md5(original_content).hexdigest().encode()