From 0aa3c9685e4e9454f0d2ffdde6214bb5a5d41dce Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 29 Jul 2025 06:43:22 +1000 Subject: [PATCH 1/9] [lvgl] Bugfix for tileview (#9938) --- esphome/components/lvgl/widgets/tileview.py | 22 ++++++++++++++------- tests/components/lvgl/lvgl-package.yaml | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/widgets/tileview.py b/esphome/components/lvgl/widgets/tileview.py index 3865d404e2..5e3a95f017 100644 --- a/esphome/components/lvgl/widgets/tileview.py +++ b/esphome/components/lvgl/widgets/tileview.py @@ -15,7 +15,7 @@ from ..defines import ( TILE_DIRECTIONS, literal, ) -from ..lv_validation import animated, lv_int +from ..lv_validation import animated, lv_int, lv_pct from ..lvcode import lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr @@ -41,8 +41,8 @@ TILEVIEW_SCHEMA = cv.Schema( container_schema( obj_spec, { - cv.Required(CONF_ROW): lv_int, - cv.Required(CONF_COLUMN): lv_int, + cv.Required(CONF_ROW): cv.positive_int, + cv.Required(CONF_COLUMN): cv.positive_int, cv.GenerateID(): cv.declare_id(lv_tile_t), cv.Optional(CONF_DIR, default="ALL"): TILE_DIRECTIONS.several_of, }, @@ -63,21 +63,29 @@ class TileviewType(WidgetType): ) async def to_code(self, w: Widget, config: dict): - for tile_conf in config.get(CONF_TILES, ()): + tiles = config[CONF_TILES] + for tile_conf in tiles: w_id = tile_conf[CONF_ID] tile_obj = lv_Pvariable(lv_obj_t, w_id) tile = Widget.create(w_id, tile_obj, tile_spec, tile_conf) dirs = tile_conf[CONF_DIR] if isinstance(dirs, list): dirs = "|".join(dirs) + row_pos = tile_conf[CONF_ROW] + col_pos = tile_conf[CONF_COLUMN] lv_assign( tile_obj, - lv_expr.tileview_add_tile( - w.obj, tile_conf[CONF_COLUMN], tile_conf[CONF_ROW], literal(dirs) - ), + lv_expr.tileview_add_tile(w.obj, col_pos, row_pos, literal(dirs)), ) + # Bugfix for LVGL 8.x + lv_obj.set_pos(tile_obj, lv_pct(col_pos * 100), lv_pct(row_pos * 100)) await set_obj_properties(tile, tile_conf) await add_widgets(tile, tile_conf) + if tiles: + # Set the first tile as active + lv_obj.set_tile_id( + w.obj, tiles[0][CONF_COLUMN], tiles[0][CONF_ROW], literal("LV_ANIM_OFF") + ) tileview_spec = TileviewType() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 46341c266d..853466c9cc 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -738,7 +738,7 @@ lvgl: id: bar_id value: !lambda return (int)((float)rand() / RAND_MAX * 100); start_value: !lambda return (int)((float)rand() / RAND_MAX * 100); - mode: symmetrical + mode: range - logger.log: format: "bar value %f" args: [x] From a9b27d1966015b4eba1e1b9525cf839d37d9420f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 14:23:45 -1000 Subject: [PATCH 2/9] [api] Fix OTA progress updates not being sent when main loop is blocked (#10049) --- esphome/components/api/api_connection.h | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 0051a143de..391f4f088f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -657,10 +657,16 @@ class APIConnection : public APIServerConnection { bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, uint8_t estimated_size) { // Try to send immediately if: - // 1. We should try to send immediately (should_try_send_immediately = true) - // 2. Batch delay is 0 (user has opted in to immediate sending) - // 3. Buffer has space available - if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && + // 1. It's an UpdateStateResponse (always send immediately to handle cases where + // the main loop is blocked, e.g., during OTA updates) + // 2. OR: We should try to send immediately (should_try_send_immediately = true) + // AND Batch delay is 0 (user has opted in to immediate sending) + // 3. AND: Buffer has space available + if (( +#ifdef USE_UPDATE + message_type == UpdateStateResponse::MESSAGE_TYPE || +#endif + (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && this->helper_->can_write_without_blocking()) { // Now actually encode and send if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && From da573a217dab381749fff96619bd37c2287aab23 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:55:54 +1000 Subject: [PATCH 3/9] [font] Catch file load exception (#10058) Co-authored-by: clydeps --- esphome/components/font/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7d9a35647e..4ecc76c561 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -15,6 +15,7 @@ from freetype import ( FT_LOAD_RENDER, FT_LOAD_TARGET_MONO, Face, + FT_Exception, ft_pixel_mode_mono, ) import requests @@ -94,7 +95,14 @@ class FontCache(MutableMapping): return self.store[self._keytransform(item)] def __setitem__(self, key, value): - self.store[self._keytransform(key)] = Face(str(value)) + transformed = self._keytransform(key) + try: + self.store[transformed] = Face(str(value)) + except FT_Exception as exc: + file = transformed.split(":", 1) + raise cv.Invalid( + f"{file[0].capitalize()} {file[1]} is not a valid font file" + ) from exc FONT_CACHE = FontCache() From 532e3e370f7d8b39f0065ed5dd875456a1c203a1 Mon Sep 17 00:00:00 2001 From: Chris Beswick Date: Mon, 4 Aug 2025 15:43:44 +0100 Subject: [PATCH 4/9] [i2s_audio] Use high-pass filter for dc offset correction (#10005) --- .../microphone/i2s_audio_microphone.cpp | 64 +++++++++++++------ .../microphone/i2s_audio_microphone.h | 3 +- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 0477e0682d..f442c74c9f 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -24,9 +24,6 @@ static const uint32_t READ_DURATION_MS = 16; static const size_t TASK_STACK_SIZE = 4096; static const ssize_t TASK_PRIORITY = 23; -// Use an exponential moving average to correct a DC offset with weight factor 1/1000 -static const int32_t DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR = 1000; - static const char *const TAG = "i2s_audio.microphone"; enum MicrophoneEventGroupBits : uint32_t { @@ -382,26 +379,57 @@ void I2SAudioMicrophone::mic_task(void *params) { } void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { + /** + * From https://www.musicdsp.org/en/latest/Filters/135-dc-filter.html: + * + * y(n) = x(n) - x(n-1) + R * y(n-1) + * R = 1 - (pi * 2 * frequency / samplerate) + * + * From https://en.wikipedia.org/wiki/Hearing_range: + * The human range is commonly given as 20Hz up. + * + * From https://en.wikipedia.org/wiki/High-resolution_audio: + * A reasonable upper bound for sample rate seems to be 96kHz. + * + * Calculate R value for 20Hz on a 96kHz sample rate: + * R = 1 - (pi * 2 * 20 / 96000) + * R = 0.9986910031 + * + * Transform floating point to bit-shifting approximation: + * output = input - prev_input + R * prev_output + * output = input - prev_input + (prev_output - (prev_output >> S)) + * + * Approximate bit-shift value S from R: + * R = 1 - (1 >> S) + * R = 1 - (1 / 2^S) + * R = 1 - 2^-S + * 0.9986910031 = 1 - 2^-S + * S = 9.57732 ~= 10 + * + * Actual R from S: + * R = 1 - 2^-10 = 0.9990234375 + * + * Confirm this has effect outside human hearing on 96000kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 96000) + * f = 14.9208Hz + * + * Confirm this has effect outside human hearing on PDM 16kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 16000) + * f = 2.4868Hz + * + */ + const uint8_t dc_filter_shift = 10; const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1); const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size()); - - if (total_samples == 0) { - return; - } - - int64_t offset_accumulator = 0; for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) { const uint32_t byte_index = sample_index * bytes_per_sample; - int32_t sample = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); - offset_accumulator += sample; - sample -= this->dc_offset_; - audio::pack_q31_as_audio_sample(sample, &data[byte_index], bytes_per_sample); + int32_t input = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); + int32_t output = input - this->dc_offset_prev_input_ + + (this->dc_offset_prev_output_ - (this->dc_offset_prev_output_ >> dc_filter_shift)); + this->dc_offset_prev_input_ = input; + this->dc_offset_prev_output_ = output; + audio::pack_q31_as_audio_sample(output, &data[byte_index], bytes_per_sample); } - - const int32_t new_offset = offset_accumulator / total_samples; - this->dc_offset_ = new_offset / DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR + - (DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR - 1) * this->dc_offset_ / - DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR; } size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) { diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 5f66f2e962..acfd535188 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -82,7 +82,8 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub bool correct_dc_offset_; bool locked_driver_{false}; - int32_t dc_offset_{0}; + int32_t dc_offset_prev_input_{0}; + int32_t dc_offset_prev_output_{0}; }; } // namespace i2s_audio From d29cae9c3b79a14d23b4fae1a82d2fdb93c79285 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:21:00 +1200 Subject: [PATCH 5/9] Bump version to 2025.7.5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index afd3b582e5..63f48f1238 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.7.4 +PROJECT_NUMBER = 2025.7.5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 6ed0119411..ca6504d0c9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.7.4" +__version__ = "2025.7.5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 06eb1b6014ca3c425086ea969f6ceffc87139089 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 5 Aug 2025 00:09:37 -0400 Subject: [PATCH 6/9] [remote_transmitter] Add digital_write automation (#10069) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/remote_transmitter/__init__.py | 26 +++++++++++++++++++ .../remote_transmitter/automation.h | 18 +++++++++++++ .../remote_transmitter/remote_transmitter.h | 3 ++- .../remote_transmitter_esp8266.cpp | 2 ++ .../remote_transmitter_libretiny.cpp | 2 ++ .../remote_transmitter/common-buttons.yaml | 6 +++++ 6 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_transmitter/automation.h diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 47a46ff56b..e79437013f 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_PIN, CONF_RMT_SYMBOLS, CONF_USE_DMA, + CONF_VALUE, PlatformFramework, ) from esphome.core import CORE @@ -22,11 +23,17 @@ AUTO_LOAD = ["remote_base"] CONF_EOT_LEVEL = "eot_level" CONF_ON_TRANSMIT = "on_transmit" CONF_ON_COMPLETE = "on_complete" +CONF_TRANSMITTER_ID = remote_base.CONF_TRANSMITTER_ID remote_transmitter_ns = cg.esphome_ns.namespace("remote_transmitter") RemoteTransmitterComponent = remote_transmitter_ns.class_( "RemoteTransmitterComponent", remote_base.RemoteTransmitterBase, cg.Component ) +DigitalWriteAction = remote_transmitter_ns.class_( + "DigitalWriteAction", + automation.Action, + cg.Parented.template(RemoteTransmitterComponent), +) MULTI_CONF = True CONFIG_SCHEMA = cv.Schema( @@ -63,6 +70,25 @@ CONFIG_SCHEMA = cv.Schema( } ).extend(cv.COMPONENT_SCHEMA) +DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(CONF_TRANSMITTER_ID): cv.use_id(RemoteTransmitterComponent), + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + }, + key=CONF_VALUE, +) + + +@automation.register_action( + "remote_transmitter.digital_write", DigitalWriteAction, DIGITAL_WRITE_ACTION_SCHEMA +) +async def digital_write_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_TRANSMITTER_ID]) + template_ = await cg.templatable(config[CONF_VALUE], args, bool) + cg.add(var.set_value(template_)) + return var + async def to_code(config): pin = await cg.gpio_pin_expression(config[CONF_PIN]) diff --git a/esphome/components/remote_transmitter/automation.h b/esphome/components/remote_transmitter/automation.h new file mode 100644 index 0000000000..75b017ec61 --- /dev/null +++ b/esphome/components/remote_transmitter/automation.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/remote_transmitter/remote_transmitter.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace remote_transmitter { + +template class DigitalWriteAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, value) + void play(Ts... x) override { this->parent_->digital_write(this->value_.value(x...)); } +}; + +} // namespace remote_transmitter +} // namespace esphome diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index f0dab2aaf8..aa1f54911d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -30,10 +30,11 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, void set_carrier_duty_percent(uint8_t carrier_duty_percent) { this->carrier_duty_percent_ = carrier_duty_percent; } + void digital_write(bool value); + #if defined(USE_ESP32) void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; } void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; } - void digital_write(bool value); #endif Trigger<> *get_transmit_trigger() const { return this->transmit_trigger_; }; diff --git a/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp b/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp index 73a1a7754f..fdd4198773 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp @@ -73,6 +73,8 @@ void RemoteTransmitterComponent::space_(uint32_t usec) { this->target_time_ += usec; } +void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital_write(value); } + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { ESP_LOGD(TAG, "Sending remote code"); uint32_t on_time, off_time; diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp index 42bf5bd95b..9ba850090d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp @@ -75,6 +75,8 @@ void RemoteTransmitterComponent::space_(uint32_t usec) { this->target_time_ += usec; } +void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital_write(value); } + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { ESP_LOGD(TAG, "Sending remote code"); uint32_t on_time, off_time; diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 29f48d995d..3be4bf3cca 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -204,3 +204,9 @@ button: command: 0xEC rc_code_1: 0x0D rc_code_2: 0x0D + - platform: template + name: Digital Write + on_press: + - remote_transmitter.digital_write: true + - remote_transmitter.digital_write: + value: false From 969034b61a36cca73831f2f9a4cb278e37c53cf3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 16:18:42 +1200 Subject: [PATCH 7/9] Bump docker/login-action from 3.4.0 to 3.5.0 in the docker-actions group (#10081) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44919a6270..9d955df85f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@v3.11.1 - name: Log in to docker hub - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} From bc03538e25a954cd78bac6b35de08a149988b026 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 18:40:46 -1000 Subject: [PATCH 8/9] Support multiple --device arguments for address fallback (#10003) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/__main__.py | 224 +++++++++++++++++++++---------- esphome/components/api/client.py | 18 ++- esphome/dashboard/web_server.py | 44 +++--- esphome/util.py | 11 +- 4 files changed, 201 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 47e1c774ac..7cc8296e7e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -9,6 +9,7 @@ import os import re import sys import time +from typing import Protocol import argcomplete @@ -44,6 +45,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError, coroutine from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log +from esphome.types import ConfigType from esphome.util import ( get_serial_ports, list_yaml_files, @@ -55,6 +57,23 @@ from esphome.util import ( _LOGGER = logging.getLogger(__name__) +class ArgsProtocol(Protocol): + device: list[str] | None + reset: bool + username: str | None + password: str | None + client_id: str | None + topic: str | None + file: str | None + no_logs: bool + only_generate: bool + show_secrets: bool + dashboard: bool + configuration: str + name: str + upload_speed: str | None + + def choose_prompt(options, purpose: str = None): if not options: raise EsphomeError( @@ -88,30 +107,54 @@ def choose_prompt(options, purpose: str = None): def choose_upload_log_host( - default, check_default, show_ota, show_mqtt, show_api, purpose: str = None -): + default: list[str] | str | None, + check_default: str | None, + show_ota: bool, + show_mqtt: bool, + show_api: bool, + purpose: str | None = None, +) -> list[str]: + # Convert to list for uniform handling + defaults = [default] if isinstance(default, str) else default or [] + + # If devices specified, resolve them + if defaults: + resolved: list[str] = [] + for device in defaults: + if device == "SERIAL": + serial_ports = get_serial_ports() + if not serial_ports: + _LOGGER.warning("No serial ports found, skipping SERIAL device") + continue + options = [ + (f"{port.path} ({port.description})", port.path) + for port in serial_ports + ] + resolved.append(choose_prompt(options, purpose=purpose)) + elif device == "OTA": + if (show_ota and "ota" in CORE.config) or ( + show_api and "api" in CORE.config + ): + resolved.append(CORE.address) + elif show_mqtt and has_mqtt_logging(): + resolved.append("MQTT") + else: + resolved.append(device) + return resolved + + # No devices specified, show interactive chooser options = [ (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() ] - if default == "SERIAL": - return choose_prompt(options, purpose=purpose) if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config): options.append((f"Over The Air ({CORE.address})", CORE.address)) - if default == "OTA": - return CORE.address - if ( - show_mqtt - and (mqtt_config := CORE.config.get(CONF_MQTT)) - and mqtt_logging_enabled(mqtt_config) - ): + if show_mqtt and has_mqtt_logging(): + mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if default == "OTA": - return "MQTT" - if default is not None: - return default + if check_default is not None and check_default in [opt[1] for opt in options]: - return check_default - return choose_prompt(options, purpose=purpose) + return [check_default] + return [choose_prompt(options, purpose=purpose)] def mqtt_logging_enabled(mqtt_config): @@ -123,7 +166,14 @@ def mqtt_logging_enabled(mqtt_config): return log_topic.get(CONF_LEVEL, None) != "NONE" -def get_port_type(port): +def has_mqtt_logging() -> bool: + """Check if MQTT logging is available.""" + return (mqtt_config := CORE.config.get(CONF_MQTT)) and mqtt_logging_enabled( + mqtt_config + ) + + +def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" if port == "MQTT": @@ -131,7 +181,7 @@ def get_port_type(port): return "NETWORK" -def run_miniterm(config, port, args): +def run_miniterm(config: ConfigType, port: str, args) -> int: from aioesphomeapi import LogParser import serial @@ -208,7 +258,7 @@ def wrap_to_code(name, comp): return wrapped -def write_cpp(config): +def write_cpp(config: ConfigType) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -216,7 +266,7 @@ def write_cpp(config): return write_cpp_file() -def generate_cpp_contents(config): +def generate_cpp_contents(config: ConfigType) -> None: _LOGGER.info("Generating C++ source...") for name, component, conf in iter_component_configs(CORE.config): @@ -227,7 +277,7 @@ def generate_cpp_contents(config): CORE.flush_tasks() -def write_cpp_file(): +def write_cpp_file() -> int: code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) @@ -238,7 +288,7 @@ def write_cpp_file(): return 0 -def compile_program(args, config): +def compile_program(args: ArgsProtocol, config: ConfigType) -> int: from esphome import platformio_api _LOGGER.info("Compiling app...") @@ -249,7 +299,9 @@ def compile_program(args, config): return 0 if idedata is not None else 1 -def upload_using_esptool(config, port, file, speed): +def upload_using_esptool( + config: ConfigType, port: str, file: str, speed: int +) -> str | int: from esphome import platformio_api first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get( @@ -314,7 +366,7 @@ def upload_using_esptool(config, port, file, speed): return run_esptool(115200) -def upload_using_platformio(config, port): +def upload_using_platformio(config: ConfigType, port: str): from esphome import platformio_api upload_args = ["-t", "upload", "-t", "nobuild"] @@ -323,7 +375,7 @@ def upload_using_platformio(config, port): return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) -def check_permissions(port): +def check_permissions(port: str): if os.name == "posix" and get_port_type(port) == "SERIAL": # Check if we can open selected serial port if not os.access(port, os.F_OK): @@ -341,7 +393,7 @@ def check_permissions(port): ) -def upload_program(config, args, host): +def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | str: try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -356,7 +408,7 @@ def upload_program(config, args, host): return upload_using_esptool(config, host, file, args.upload_speed) if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, args.device) + return upload_using_platformio(config, host) if CORE.is_libretiny: return upload_using_platformio(config, host) @@ -379,9 +431,12 @@ def upload_program(config, args, host): remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD, "") + # Check if we should use MQTT for address resolution + # This happens when no device was specified, or the current host is "MQTT"/"OTA" + devices: list[str] = args.device or [] if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not args.device or args.device in ("MQTT", "OTA")) + and (not devices or host in ("MQTT", "OTA")) and ( ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) or get_port_type(host) == "MQTT" @@ -399,23 +454,28 @@ def upload_program(config, args, host): return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config, args, port): +def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: if "logger" not in config: raise EsphomeError("Logger is not configured!") + + port = devices[0] + if get_port_type(port) == "SERIAL": check_permissions(port) return run_miniterm(config, port, args) if get_port_type(port) == "NETWORK" and "api" in config: + addresses_to_use = devices if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config: from esphome import mqtt - port = mqtt.get_esphome_device_ip( + mqtt_address = mqtt.get_esphome_device_ip( config, args.username, args.password, args.client_id )[0] + addresses_to_use = [mqtt_address] from esphome.components.api.client import run_logs - return run_logs(config, port) + return run_logs(config, addresses_to_use) if get_port_type(port) == "MQTT" and "mqtt" in config: from esphome import mqtt @@ -426,7 +486,7 @@ def show_logs(config, args, port): raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") -def clean_mqtt(config, args): +def clean_mqtt(config: ConfigType, args: ArgsProtocol) -> int | None: from esphome import mqtt return mqtt.clear_topic( @@ -434,13 +494,13 @@ def clean_mqtt(config, args): ) -def command_wizard(args): +def command_wizard(args: ArgsProtocol) -> int | None: from esphome import wizard return wizard.wizard(args.configuration) -def command_config(args, config): +def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) @@ -455,7 +515,7 @@ def command_config(args, config): return 0 -def command_vscode(args): +def command_vscode(args: ArgsProtocol) -> int | None: from esphome import vscode logging.disable(logging.INFO) @@ -463,7 +523,7 @@ def command_vscode(args): vscode.read_config(args) -def command_compile(args, config): +def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -477,8 +537,9 @@ def command_compile(args, config): return 0 -def command_upload(args, config): - port = choose_upload_log_host( +def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( default=args.device, check_default=None, show_ota=True, @@ -486,14 +547,22 @@ def command_upload(args, config): show_api=False, purpose="uploading", ) - exit_code = upload_program(config, args, port) - if exit_code != 0: - return exit_code - _LOGGER.info("Successfully uploaded program.") - return 0 + + # Try each device until one succeeds + exit_code = 1 + for device in devices: + _LOGGER.info("Uploading to %s", device) + exit_code = upload_program(config, args, device) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + return 0 + if len(devices) > 1: + _LOGGER.warning("Failed to upload to %s", device) + + return exit_code -def command_discover(args, config): +def command_discover(args: ArgsProtocol, config: ConfigType) -> int | None: if "mqtt" in config: from esphome import mqtt @@ -502,8 +571,9 @@ def command_discover(args, config): raise EsphomeError("No discover method configured (mqtt)") -def command_logs(args, config): - port = choose_upload_log_host( +def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( default=args.device, check_default=None, show_ota=False, @@ -511,10 +581,10 @@ def command_logs(args, config): show_api=True, purpose="logging", ) - return show_logs(config, args, port) + return show_logs(config, args, devices) -def command_run(args, config): +def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -531,7 +601,8 @@ def command_run(args, config): program_path = idedata.raw["prog_path"] return run_external_process(program_path) - port = choose_upload_log_host( + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( default=args.device, check_default=None, show_ota=True, @@ -539,39 +610,53 @@ def command_run(args, config): show_api=True, purpose="uploading", ) - exit_code = upload_program(config, args, port) - if exit_code != 0: + + # Try each device for upload until one succeeds + successful_device: str | None = None + for device in devices: + _LOGGER.info("Uploading to %s", device) + exit_code = upload_program(config, args, device) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + successful_device = device + break + if len(devices) > 1: + _LOGGER.warning("Failed to upload to %s", device) + + if successful_device is None: return exit_code - _LOGGER.info("Successfully uploaded program.") + if args.no_logs: return 0 - port = choose_upload_log_host( - default=args.device, - check_default=port, + + # For logs, prefer the device we successfully uploaded to + devices = choose_upload_log_host( + default=successful_device, + check_default=successful_device, show_ota=False, show_mqtt=True, show_api=True, purpose="logging", ) - return show_logs(config, args, port) + return show_logs(config, args, devices) -def command_clean_mqtt(args, config): +def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: return clean_mqtt(config, args) -def command_mqtt_fingerprint(args, config): +def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import mqtt return mqtt.get_fingerprint(config) -def command_version(args): +def command_version(args: ArgsProtocol) -> int | None: safe_print(f"Version: {const.__version__}") return 0 -def command_clean(args, config): +def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: try: writer.clean_build() except OSError as err: @@ -581,13 +666,13 @@ def command_clean(args, config): return 0 -def command_dashboard(args): +def command_dashboard(args: ArgsProtocol) -> int | None: from esphome.dashboard import dashboard return dashboard.start_dashboard(args) -def command_update_all(args): +def command_update_all(args: ArgsProtocol) -> int | None: import click success = {} @@ -634,7 +719,7 @@ def command_update_all(args): return failed -def command_idedata(args, config): +def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json from esphome import platformio_api @@ -650,7 +735,7 @@ def command_idedata(args, config): return 0 -def command_rename(args, config): +def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: for c in args.name: if c not in ALLOWED_NAME_CHARS: print( @@ -860,7 +945,8 @@ def parse_args(argv): ) parser_upload.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_upload.add_argument( "--upload_speed", @@ -882,7 +968,8 @@ def parse_args(argv): ) parser_logs.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_logs.add_argument( "--reset", @@ -911,7 +998,8 @@ def parse_args(argv): ) parser_run.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_run.add_argument( "--upload_speed", diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 5239e07435..ce018b3b98 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -async def async_run_logs(config: dict[str, Any], address: str) -> None: +async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: """Run the logs command in the event loop.""" conf = config["api"] name = config["esphome"]["name"] @@ -39,13 +39,21 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: noise_psk: str | None = None if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): noise_psk = key - _LOGGER.info("Starting log output from %s using esphome API", address) + + if len(addresses) == 1: + _LOGGER.info("Starting log output from %s using esphome API", addresses[0]) + else: + _LOGGER.info( + "Starting log output from %s using esphome API", " or ".join(addresses) + ) + cli = APIClient( - address, + addresses[0], # Primary address for compatibility port, password, client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, + addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard @@ -66,7 +74,7 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: await stop() -def run_logs(config: dict[str, Any], address: str) -> None: +def run_logs(config: dict[str, Any], addresses: list[str]) -> None: """Run the logs command.""" with contextlib.suppress(KeyboardInterrupt): - asyncio.run(async_run_logs(config, address)) + asyncio.run(async_run_logs(config, addresses)) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 286dc9e1d7..46f09336bb 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -324,39 +324,47 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] + addresses: list[str] = [port] if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - if (mdns := dashboard.mdns_status) and ( - address_list := await mdns.async_resolve_host(entry.name) - ): - # Use the IP address if available but only - # if the API is loaded and the device is online - # since MQTT logging will not work otherwise - port = sort_ip_addresses(address_list)[0] - elif ( - entry.address + addresses = [] + # First priority: entry.address AKA use_address + if ( + (use_address := entry.address) and ( address_list := await dashboard.dns_cache.async_resolve( - entry.address, time.monotonic() + use_address, time.monotonic() ) ) and not isinstance(address_list, Exception) ): - # If mdns is not available, try to use the DNS cache - port = sort_ip_addresses(address_list)[0] + addresses.extend(sort_ip_addresses(address_list)) - return [ - *DASHBOARD_COMMAND, - *args, - config_file, - "--device", - port, + # Second priority: mDNS + if ( + (mdns := dashboard.mdns_status) + and (address_list := await mdns.async_resolve_host(entry.name)) + and ( + new_addresses := [ + addr for addr in address_list if addr not in addresses + ] + ) + ): + # Use the IP address if available but only + # if the API is loaded and the device is online + # since MQTT logging will not work otherwise + addresses.extend(sort_ip_addresses(new_addresses)) + + device_args: list[str] = [ + arg for address in addresses for arg in ("--device", address) ] + return [*DASHBOARD_COMMAND, *args, config_file, *device_args] + class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: diff --git a/esphome/util.py b/esphome/util.py index 9aa0f6b9d8..ed9ab4a446 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -6,6 +6,7 @@ from pathlib import Path import re import subprocess import sys +from typing import Any from esphome import const @@ -110,7 +111,7 @@ class RedirectText: def __getattr__(self, item): return getattr(self._out, item) - def _write_color_replace(self, s): + def _write_color_replace(self, s: str | bytes) -> None: from esphome.core import CORE if CORE.dashboard: @@ -121,7 +122,7 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) - def write(self, s): + def write(self, s: str | bytes) -> int: # s is usually a str already (self._out is of type TextIOWrapper) # However, s is sometimes also a bytes object in python3. Let's make sure it's a # str @@ -223,7 +224,7 @@ def run_external_command( return retval -def run_external_process(*cmd, **kwargs): +def run_external_process(*cmd: str, **kwargs: Any) -> int | str: full_cmd = " ".join(shlex_quote(x) for x in cmd) _LOGGER.debug("Running: %s", full_cmd) filter_lines = kwargs.get("filter_lines") @@ -266,7 +267,7 @@ class OrderedDict(collections.OrderedDict): return dict(self).__repr__() -def list_yaml_files(folders): +def list_yaml_files(folders: list[str]) -> list[str]: files = filter_yaml_files( [os.path.join(folder, p) for folder in folders for p in os.listdir(folder)] ) @@ -274,7 +275,7 @@ def list_yaml_files(folders): return files -def filter_yaml_files(files): +def filter_yaml_files(files: list[str]) -> list[str]: return [ f for f in files From ba9cf1b5f6035260b58dcfceeedbe9e4b008b03a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 19:56:22 -1000 Subject: [PATCH 9/9] Add myself to multiple bluetooth codeowners --- CODEOWNERS | 7 ++++--- esphome/components/bluetooth_proxy/__init__.py | 2 +- esphome/components/esp32_ble/__init__.py | 2 +- esphome/components/esp32_ble_client/__init__.py | 2 +- esphome/components/esp32_ble_tracker/__init__.py | 1 + 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e40be9a737..5ef08d711a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -69,7 +69,7 @@ esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @tobias- esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow -esphome/components/bluetooth_proxy/* @jesserockz +esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bme280_base/* @esphome/core esphome/components/bme280_spi/* @apbodrov esphome/components/bme680_bsec/* @trvrnrth @@ -144,9 +144,10 @@ esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT esphome/components/esp32/* @esphome/core -esphome/components/esp32_ble/* @Rapsssito @jesserockz -esphome/components/esp32_ble_client/* @jesserockz +esphome/components/esp32_ble/* @Rapsssito @bdraco @jesserockz +esphome/components/esp32_ble_client/* @bdraco @jesserockz esphome/components/esp32_ble_server/* @Rapsssito @clydebarrow @jesserockz +esphome/components/esp32_ble_tracker/* @bdraco esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4087255410..fb7f7a37c0 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -11,7 +11,7 @@ from esphome.log import AnsiFore, color AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] DEPENDENCIES = ["api", "esp32"] -CODEOWNERS = ["@jesserockz"] +CODEOWNERS = ["@jesserockz", "@bdraco"] _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 1c7c075cfa..f208fda34c 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -11,7 +11,7 @@ from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX import esphome.final_validate as fv DEPENDENCIES = ["esp32"] -CODEOWNERS = ["@jesserockz", "@Rapsssito"] +CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] class BTLoggers(Enum): diff --git a/esphome/components/esp32_ble_client/__init__.py b/esphome/components/esp32_ble_client/__init__.py index 25957ed0da..55619f1fc0 100644 --- a/esphome/components/esp32_ble_client/__init__.py +++ b/esphome/components/esp32_ble_client/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import esp32_ble_tracker AUTO_LOAD = ["esp32_ble_tracker"] -CODEOWNERS = ["@jesserockz"] +CODEOWNERS = ["@jesserockz", "@bdraco"] DEPENDENCIES = ["esp32"] esp32_ble_client_ns = cg.esphome_ns.namespace("esp32_ble_client") diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 9daa6ee34e..e1abdd8490 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -36,6 +36,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] +CODEOWNERS = ["@bdraco"] KEY_ESP32_BLE_TRACKER = "esp32_ble_tracker" KEY_USED_CONNECTION_SLOTS = "used_connection_slots"