From ac8a2467a5004908ed06b13f1bbc5a3c30341f89 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:51:55 -0400 Subject: [PATCH 1/4] [core] Fix PlatformIO progress bar rendering in subprocess mode (#15681) --- esphome/platformio_api.py | 3 --- esphome/platformio_runner.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index e9719f7dcd..fc21977fdd 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -65,9 +65,6 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("UV_HTTP_RETRIES", "10") cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args) - if not CORE.verbose: - kwargs["filter_lines"] = FILTER_PLATFORMIO_LINES - return run_external_process(*cmd, **kwargs) diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py index 408d49d1a6..92700d5c42 100644 --- a/esphome/platformio_runner.py +++ b/esphome/platformio_runner.py @@ -105,6 +105,36 @@ def main() -> int: patch_structhash() patch_file_downloader() + # Wrap stdout/stderr with RedirectText before PlatformIO runs: + # + # 1. RedirectText.isatty() unconditionally returns True. Click, tqdm, and + # PlatformIO's own progress-bar code check ``stream.isatty()`` to + # decide whether to emit TTY-format output (``\r`` cursor moves, ANSI + # colors, fancy progress bars). With the wrapper in place they always + # emit TTY format, even when our real stdout is a pipe to the parent + # process. Downstream consumers (local terminals and the Home + # Assistant dashboard log viewer) render the TTY control sequences + # correctly, so the user sees real progress bars. + # + # 2. FILTER_PLATFORMIO_LINES is applied inside RedirectText.write() in + # this subprocess, so noisy PlatformIO output is dropped before it + # ever leaves the runner. This replaces the parent-side filtering + # that was lost when we switched from in-process to subprocess — the + # parent's ``subprocess.run`` uses ``.fileno()`` on RedirectText and + # bypasses its ``write()`` path entirely. + # + # Filtering is disabled when the user passed -v / --verbose to + # ``esphome compile``, preserving the previous in-process behavior where + # verbose mode let all PlatformIO output through unfiltered. + from esphome.platformio_api import FILTER_PLATFORMIO_LINES + from esphome.util import RedirectText + + is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:]) + filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES + + sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) + import platformio.__main__ return platformio.__main__.main() or 0 From 70dd7328214d9c495d67a2dfaa662a80454b491d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:12:31 -1000 Subject: [PATCH 2/4] [api] Add speed_optimized proto option for hot encode paths Add a new (speed_optimized) message option that emits __attribute__((optimize("O2"))) on the generated encode() and calculate_size() methods. Under -Os, GCC does not inline the small ProtoEncode helpers (write_raw_byte, encode_varint, etc.) into the generated methods, causing significant overhead on hot paths. Apply to SensorStateResponse and BluetoothLERawAdvertisementsResponse which are the highest-frequency encode paths. --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 10 ++++++---- script/api_protobuf/api_protobuf.py | 11 +++++++++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e4d0c2d16d..4a9a6f9051 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -671,6 +671,7 @@ message SensorStateResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SENSOR"; option (no_delay) = true; + option (speed_optimized) = true; fixed32 key = 1 [(force) = true]; float state = 2; @@ -1638,6 +1639,7 @@ message BluetoothLERawAdvertisementsResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_BLUETOOTH_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"]; } diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index dacc290e31..d5d0b37e8d 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -23,6 +23,7 @@ extend google.protobuf.MessageOptions { optional bool no_delay = 1040 [default=false]; optional string base_class = 1041; optional bool inline_encode = 1042 [default=false]; + optional bool speed_optimized = 1043 [default=false]; } extend google.protobuf.FieldOptions { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c2d513f0d3..ddc5ed18b9 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -745,7 +745,8 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { #endif return size; } -uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) uint8_t *SensorStateResponse::encode( + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -755,7 +756,7 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG #endif return pos; } -uint32_t SensorStateResponse::calculate_size() const { +__attribute__((optimize("O2"))) uint32_t SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -2328,7 +2329,8 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, } return true; } -uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) uint8_t *BluetoothLERawAdvertisementsResponse::encode( + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2350,7 +2352,7 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P } return pos; } -uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +__attribute__((optimize("O2"))) uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 39bfc865d0..d9fdadd375 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2679,6 +2679,13 @@ def build_message_type( and get_opt(desc, inline_opt, False) ) + # Check if this message wants speed-optimized encode/calculate_size. + # When set, __attribute__((optimize("O2"))) is added to the definitions + # so GCC inlines the small ProtoEncode helpers even under -Os. + speed_opt = getattr(pb, "speed_optimized", None) + is_speed_optimized = speed_opt is not None and get_opt(desc, speed_opt, False) + speed_attr = '__attribute__((optimize("O2"))) ' if is_speed_optimized else "" + # Only generate encode method if this message needs encoding and has fields if needs_encode and encode and not is_inline_only: # Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls @@ -2688,7 +2695,7 @@ def build_message_type( ) for line in encode ] - o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" + o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" o += indent("\n".join(encode_debug)) + "\n" o += " return pos;\n" @@ -2702,7 +2709,7 @@ def build_message_type( # Add calculate_size method only if this message needs encoding and has fields if needs_encode and size_calc and not is_inline_only: - o = f"uint32_t {desc.name}::calculate_size() const {{\n" + o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n" o += " uint32_t size = 0;\n" o += indent("\n".join(size_calc)) + "\n" o += " return size;\n" From d217ab3cd4b7b63104f1b5484678fa1bc01dd638 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:23:39 -1000 Subject: [PATCH 3/4] [api] Add speed_optimized proto option for hot encode paths Add a new (speed_optimized) message option that emits __attribute__((optimize("O2"))) on the generated encode() and calculate_size() methods. Under -Os, GCC does not inline the small ProtoEncode helpers (write_raw_byte, encode_varint, etc.) into the generated methods, causing significant overhead on hot paths. Apply to SensorStateResponse and BluetoothLERawAdvertisementsResponse which are the highest-frequency encode paths. --- script/api_protobuf/api_protobuf.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index d9fdadd375..4f52b07809 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2682,8 +2682,7 @@ def build_message_type( # Check if this message wants speed-optimized encode/calculate_size. # When set, __attribute__((optimize("O2"))) is added to the definitions # so GCC inlines the small ProtoEncode helpers even under -Os. - speed_opt = getattr(pb, "speed_optimized", None) - is_speed_optimized = speed_opt is not None and get_opt(desc, speed_opt, False) + is_speed_optimized = get_opt(desc, pb.speed_optimized, False) speed_attr = '__attribute__((optimize("O2"))) ' if is_speed_optimized else "" # Only generate encode method if this message needs encoding and has fields From 0436e5bd863282e459026d385120ae8e29616e1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 19:55:30 -1000 Subject: [PATCH 4/4] [core] Optimize main loop with -O2 Add __attribute__((optimize("O2"))) to the main loop functions (loop_task on ESP32, codegen loop() on other platforms) so GCC inlines scheduler helpers and loop bookkeeping more aggressively. Under -Os, GCC outlines small functions (Scheduler::call helpers, millis conversions, etc.) that are called every loop iteration. With -O2, these get inlined into the loop body, reducing call overhead on the hottest code path in the firmware. ESP32: loop_task grows from 303 to 416 bytes (+113 bytes). ESP8266: no change (already fully inlined via ESPHOME_ALWAYS_INLINE). --- esphome/components/esp32/core.cpp | 2 +- esphome/writer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index add50dcf4d..1fa51f1d9e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -65,7 +65,7 @@ static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non- static StackType_t loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void loop_task(void *pv_params) { +void __attribute__((optimize("O2"))) loop_task(void *pv_params) { setup(); while (true) { App.loop(); diff --git a/esphome/writer.py b/esphome/writer.py index 06a2230118..c2e0d08fa4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -45,7 +45,7 @@ void setup() { App.setup(); } -void loop() { +void __attribute__((optimize("O2"))) loop() { App.loop(); } """,