From dc45a613f35f247df81a96dc57ee1be7439c48cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:14:18 -0500 Subject: [PATCH 01/19] Bump pytest from 8.4.1 to 8.4.2 (#10579) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 22f58fd3d7..e0009f2427 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==8.4.1 +pytest==8.4.2 pytest-cov==6.2.1 pytest-mock==3.14.1 pytest-asyncio==1.1.0 From 25489b6009c74f4daf101f2e0af5fe0e93507192 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:14:28 -0500 Subject: [PATCH 02/19] Bump codecov/codecov-action from 5.5.0 to 5.5.1 (#10585) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f429f7b40..ea5ca47a19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5.5.0 + uses: codecov/codecov-action@v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From edf7094662923d5fe550ce1c2cbf13c12d4ac09b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:14:50 -0500 Subject: [PATCH 03/19] Bump esphome-dashboard from 20250828.0 to 20250904.0 (#10580) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 32fdfabcda..b86c7765c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 -esphome-dashboard==20250828.0 +esphome-dashboard==20250904.0 aioesphomeapi==39.0.1 zeroconf==0.147.0 puremagic==1.30 From cbac9caa522a81db481db34376e9d5dc5d19afe8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:15:43 -0500 Subject: [PATCH 04/19] Bump actions/setup-python from 5.6.0 to 6.0.0 (#10584) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/sync-device-classes.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c7cc720323..144cc11647 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index c7da7f6672..582be17fbb 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 61ecf8183b..915a4dfb7e 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -45,7 +45,7 @@ jobs: steps: - uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.11" - name: Set up Docker Buildx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea5ca47a19..07fd91b1c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -217,7 +217,7 @@ jobs: uses: actions/checkout@v5.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.13" - name: Restore Python virtual environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9b0cfb6a0..41db736caa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.11" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index cc03ed3e3f..b129e8f4bf 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -22,7 +22,7 @@ jobs: path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: 3.13 From c471bdb44617a0b47153186eb1b98e5deaf6b1f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:15:57 -0500 Subject: [PATCH 05/19] Bump actions/setup-python from 5.6.0 to 6.0.0 in /.github/actions/restore-python (#10586) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 9fb80e6a9d..5d290894a7 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment From ba2433197e232ef6e28e825b3ab450cf4da7efa1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:16:17 -0500 Subject: [PATCH 06/19] Bump actions/github-script from 7.0.1 to 8.0.0 (#10583) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 4 ++-- .github/workflows/ci-clang-tidy-hash.yml | 4 ++-- .github/workflows/codeowner-review-request.yml | 2 +- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/issue-codeowner-notify.yml | 2 +- .github/workflows/release.yml | 4 ++-- .github/workflows/status-check-labels.yml | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index c42b5330d2..66369c706f 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -32,7 +32,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - name: Auto Label PR - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 144cc11647..ec214d1a77 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -47,7 +47,7 @@ jobs: fi - if: failure() name: Review PR - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | await github.rest.pulls.createReview({ @@ -70,7 +70,7 @@ jobs: esphome/components/api/api_pb2_service.* - if: success() name: Dismiss review - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 582be17fbb..2f47386abf 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -41,7 +41,7 @@ jobs: - if: failure() name: Request changes - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | await github.rest.pulls.createReview({ @@ -54,7 +54,7 @@ jobs: - if: success() name: Dismiss review - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index ab3377365d..475e05b970 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Request reviews from component codeowners - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 29103e8eee..736c986f7e 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Add external component comment - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml index 3639d346f5..ab9b96b45a 100644 --- a/.github/workflows/issue-codeowner-notify.yml +++ b/.github/workflows/issue-codeowner-notify.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify codeowners for component issues - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41db736caa..efc8424cd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -220,7 +220,7 @@ jobs: - deploy-manifest steps: - name: Trigger Workflow - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} script: | @@ -246,7 +246,7 @@ jobs: environment: ${{ needs.init.outputs.deploy_env }} steps: - name: Trigger Workflow - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} script: | diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 157f60f3a1..675be49c27 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -16,7 +16,7 @@ jobs: - merge-after-release steps: - name: Check for ${{ matrix.label }} label - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | const { data: labels } = await github.rest.issues.listLabelsOnIssue({ From e55bce83e3a8b77a068c7b9f381dbe9f0a3e0431 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:20:11 -0500 Subject: [PATCH 07/19] Bump actions/stale from 9.1.0 to 10.0.0 (#10582) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b79939fc8e..88e07d3f58 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9.1.0 + - uses: actions/stale@v10.0.0 with: days-before-pr-stale: 90 days-before-pr-close: 7 @@ -37,7 +37,7 @@ jobs: close-issues: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9.1.0 + - uses: actions/stale@v10.0.0 with: days-before-pr-stale: -1 days-before-pr-close: -1 From 4c2f356b357647368ee1da5c21f20c504e5c9682 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:24:23 +0000 Subject: [PATCH 08/19] Bump ruff from 0.12.11 to 0.12.12 (#10578) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e05733ec96..2b161cf05c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.11 + rev: v0.12.12 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index e0009f2427..ba4ebeef2e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.11 # also change in .pre-commit-config.yaml when updating +ruff==0.12.12 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From e327ae8c95a220f6843a056972d98b8c050ae39d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 23:36:11 +0000 Subject: [PATCH 09/19] Bump pytest-mock from 3.14.1 to 3.15.0 (#10593) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index ba4ebeef2e..eba14fc0b1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ pre-commit # Unit tests pytest==8.4.2 pytest-cov==6.2.1 -pytest-mock==3.14.1 +pytest-mock==3.15.0 pytest-asyncio==1.1.0 pytest-xdist==3.8.0 asyncmock==0.4.2 From 365a427b5776ebbe240e5e7786fd9ab242301f76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 23:37:03 +0000 Subject: [PATCH 10/19] Bump aioesphomeapi from 39.0.1 to 40.0.0 (#10594) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b86c7765c7..60ce3e67e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==39.0.1 +aioesphomeapi==40.0.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From ccbe629f8d62a70fc589f1127d81b319319df75c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 19:58:44 -0500 Subject: [PATCH 11/19] Fix DNS resolution inconsistency between logs and OTA operations --- esphome/__main__.py | 51 ++++++++-------- esphome/espota2.py | 10 +++- esphome/helpers.py | 141 ++++++++++++++++++++------------------------ esphome/resolver.py | 61 +++++++++++++++++++ 4 files changed, 155 insertions(+), 108 deletions(-) create mode 100644 esphome/resolver.py diff --git a/esphome/__main__.py b/esphome/__main__.py index aab3035a5e..e3182ea55f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -396,25 +396,27 @@ def check_permissions(port: str): ) -def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | str: +def upload_program( + config: ConfigType, args: ArgsProtocol, devices: list[str] +) -> int | str: try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "upload_program")(config, args, host): + if getattr(module, "upload_program")(config, args, devices[0]): return 0 except AttributeError: pass - if get_port_type(host) == "SERIAL": - check_permissions(host) + if get_port_type(devices[0]) == "SERIAL": + check_permissions(devices[0]) if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): file = getattr(args, "file", None) - return upload_using_esptool(config, host, file, args.upload_speed) + return upload_using_esptool(config, devices[0], file, args.upload_speed) if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, host) + return upload_using_platformio(config, devices[0]) if CORE.is_libretiny: - return upload_using_platformio(config, host) + return upload_using_platformio(config, devices[0]) return 1 # Unknown target platform @@ -433,28 +435,27 @@ def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | s remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD, "") + binary = args.file if getattr(args, "file", None) is not None else CORE.firmware_bin # 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 devices or host in ("MQTT", "OTA")) + and (not devices or devices[0] in ("MQTT", "OTA")) and ( ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) - or get_port_type(host) == "MQTT" + or get_port_type(devices[0]) == "MQTT" ) ): from esphome import mqtt - host = mqtt.get_esphome_device_ip( - config, args.username, args.password, args.client_id - ) + devices = [ + mqtt.get_esphome_device_ip( + config, args.username, args.password, args.client_id + ) + ] - if getattr(args, "file", None) is not None: - return espota2.run_ota(host, remote_port, password, args.file) - - return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) + return espota2.run_ota(devices, remote_port, password, binary) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: @@ -551,17 +552,11 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: purpose="uploading", ) - # 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) - + exit_code = upload_program(config, args, devices) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + else: + _LOGGER.warning("Failed to upload to %s", devices) return exit_code diff --git a/esphome/espota2.py b/esphome/espota2.py index 279bafee8e..d83f25a303 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -308,8 +308,12 @@ def perform_ota( time.sleep(1) -def run_ota_impl_(remote_host, remote_port, password, filename): +def run_ota_impl_( + remote_host: str | list[str], remote_port: int, password: str, filename: str +) -> int: + # Handle both single host and list of hosts try: + # Resolve all hosts at once for parallel DNS resolution res = resolve_ip_address(remote_host, remote_port) except EsphomeError as err: _LOGGER.error( @@ -350,7 +354,9 @@ def run_ota_impl_(remote_host, remote_port, password, filename): return 1 -def run_ota(remote_host, remote_port, password, filename): +def run_ota( + remote_host: str | list[str], remote_port: int, password: str, filename: str +) -> int: try: return run_ota_impl_(remote_host, remote_port, password, filename) except OTAError as err: diff --git a/esphome/helpers.py b/esphome/helpers.py index 377a4e1717..b00c97ff73 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import codecs from contextlib import suppress import ipaddress @@ -11,6 +13,18 @@ from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION +# Type aliases for socket address information +AddrInfo = tuple[ + int, # family (AF_INET, AF_INET6, etc.) + int, # type (SOCK_STREAM, SOCK_DGRAM, etc.) + int, # proto (IPPROTO_TCP, etc.) + str, # canonname + tuple[str, int] | tuple[str, int, int, int], # sockaddr (IPv4 or IPv6) +] +IPv4SockAddr = tuple[str, int] # (host, port) +IPv6SockAddr = tuple[str, int, int, int] # (host, port, flowinfo, scope_id) +SockAddr = IPv4SockAddr | IPv6SockAddr + _LOGGER = logging.getLogger(__name__) IS_MACOS = platform.system() == "Darwin" @@ -147,32 +161,7 @@ def is_ip_address(host): return False -def _resolve_with_zeroconf(host): - from esphome.core import EsphomeError - from esphome.zeroconf import EsphomeZeroconf - - try: - zc = EsphomeZeroconf() - except Exception as err: - raise EsphomeError( - "Cannot start mDNS sockets, is this a docker container without " - "host network mode?" - ) from err - try: - info = zc.resolve_host(f"{host}.") - except Exception as err: - raise EsphomeError(f"Error resolving mDNS hostname: {err}") from err - finally: - zc.close() - if info is None: - raise EsphomeError( - "Error resolving address with mDNS: Did not respond. " - "Maybe the device is offline." - ) - return info - - -def addr_preference_(res): +def addr_preference_(res: AddrInfo) -> int: # Trivial alternative to RFC6724 sorting. Put sane IPv6 first, then # Legacy IP, then IPv6 link-local addresses without an actual link. sa = res[4] @@ -184,66 +173,70 @@ def addr_preference_(res): return 1 -def resolve_ip_address(host, port): +def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: import socket - from esphome.core import EsphomeError - # There are five cases here. The host argument could be one of: # • a *list* of IP addresses discovered by MQTT, # • a single IP address specified by the user, # • a .local hostname to be resolved by mDNS, # • a normal hostname to be resolved in DNS, or # • A URL from which we should extract the hostname. - # - # In each of the first three cases, we end up with IP addresses in - # string form which need to be converted to a 5-tuple to be used - # for the socket connection attempt. The easiest way to construct - # those is to pass the IP address string to getaddrinfo(). Which, - # coincidentally, is how we do hostname lookups in the other cases - # too. So first build a list which contains either IP addresses or - # a single hostname, then call getaddrinfo() on each element of - # that list. - errs = [] + hosts: list[str] if isinstance(host, list): - addr_list = host - elif is_ip_address(host): - addr_list = [host] + hosts = host else: - url = urlparse(host) - if url.scheme != "": - host = url.hostname + if not is_ip_address(host): + url = urlparse(host) + if url.scheme != "": + host = url.hostname + hosts = [host] - addr_list = [] - if host.endswith(".local"): + res: list[AddrInfo] = [] + if all(is_ip_address(h) for h in hosts): + # Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST + for addr in hosts: try: - _LOGGER.info("Resolving IP address of %s in mDNS", host) - addr_list = _resolve_with_zeroconf(host) - except EsphomeError as err: - errs.append(str(err)) + res += socket.getaddrinfo( + addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + except OSError: + _LOGGER.debug("Failed to parse IP address '%s'", addr) + # Sort by preference + res.sort(key=addr_preference_) + return res - # If not mDNS, or if mDNS failed, use normal DNS - if not addr_list: - addr_list = [host] + from esphome.resolver import AsyncResolver - # Now we have a list containing either IP addresses or a hostname - res = [] - for addr in addr_list: - if not is_ip_address(addr): - _LOGGER.info("Resolving IP address of %s", host) - try: - r = socket.getaddrinfo(addr, port, proto=socket.IPPROTO_TCP) - except OSError as err: - errs.append(str(err)) - raise EsphomeError( - f"Error resolving IP address: {', '.join(errs)}" - ) from err + resolver = AsyncResolver() + addr_infos = resolver.run(hosts, port) + # Convert aioesphomeapi AddrInfo to our format + for addr_info in addr_infos: + sockaddr = addr_info.sockaddr + if addr_info.family == socket.AF_INET6: + # IPv6 + sockaddr_tuple = ( + sockaddr.address, + sockaddr.port, + sockaddr.flowinfo, + sockaddr.scope_id, + ) + else: + # IPv4 + sockaddr_tuple = (sockaddr.address, sockaddr.port) - res = res + r + res.append( + ( + addr_info.family, + addr_info.type, + addr_info.proto, + "", # canonname + sockaddr_tuple, + ) + ) - # Zeroconf tends to give us link-local IPv6 addresses without specifying - # the link. Put those last in the list to be attempted. + # Sort by preference res.sort(key=addr_preference_) return res @@ -262,15 +255,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]: # First "resolve" all the IP addresses to getaddrinfo() tuples of the form # (family, type, proto, canonname, sockaddr) - res: list[ - tuple[ - int, - int, - int, - str | None, - tuple[str, int] | tuple[str, int, int, int], - ] - ] = [] + res: list[AddrInfo] = [] for addr in address_list: # This should always work as these are supposed to be IP addresses try: diff --git a/esphome/resolver.py b/esphome/resolver.py new file mode 100644 index 0000000000..a245737962 --- /dev/null +++ b/esphome/resolver.py @@ -0,0 +1,61 @@ +"""DNS resolver for ESPHome using aioesphomeapi.""" + +from __future__ import annotations + +import asyncio +import threading + +from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError +import aioesphomeapi.host_resolver as hr + +from esphome.core import EsphomeError + +RESOLVE_TIMEOUT = 10.0 # seconds + + +class AsyncResolver: + """Resolver using aioesphomeapi that runs in a thread for faster results. + + This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, + including proper .local domain fallback. Running in a thread allows us to get + the result immediately without waiting for asyncio.run() to complete its + cleanup cycle, which can take significant time. + """ + + def __init__(self) -> None: + """Initialize the resolver.""" + self.result: list[hr.AddrInfo] | None = None + self.exception: Exception | None = None + self.event = threading.Event() + + async def _resolve(self, hosts: list[str], port: int) -> None: + """Resolve hostnames to IP addresses.""" + try: + self.result = await hr.async_resolve_host( + hosts, port, timeout=RESOLVE_TIMEOUT + ) + except Exception as e: + self.exception = e + finally: + self.event.set() + + def run(self, hosts: list[str], port: int) -> list[hr.AddrInfo]: + """Run the DNS resolution in a separate thread.""" + thread = threading.Thread( + target=lambda: asyncio.run(self._resolve(hosts, port)), daemon=True + ) + thread.start() + + if not self.event.wait( + timeout=RESOLVE_TIMEOUT + 1.0 + ): # Give it 1 second more than the resolver timeout + raise EsphomeError("Timeout resolving IP address") + + if exc := self.exception: + if isinstance(exc, ResolveAPIError): + raise EsphomeError(f"Error resolving IP address: {exc}") from exc + if isinstance(exc, ResolveTimeoutAPIError): + raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc + raise exc + + return self.result From d7aec744b78b2cd7228cc36698ed177d3fca2739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:00:31 -0500 Subject: [PATCH 12/19] preen --- esphome/__main__.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index e3182ea55f..70d5cacd72 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -399,24 +399,25 @@ def check_permissions(port: str): def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> int | str: + host = devices[0] try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "upload_program")(config, args, devices[0]): + if getattr(module, "upload_program")(config, args, host): return 0 except AttributeError: pass - if get_port_type(devices[0]) == "SERIAL": - check_permissions(devices[0]) + if get_port_type(host) == "SERIAL": + check_permissions(host) if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): file = getattr(args, "file", None) - return upload_using_esptool(config, devices[0], file, args.upload_speed) + return upload_using_esptool(config, host, file, args.upload_speed) if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, devices[0]) + return upload_using_platformio(config, host) if CORE.is_libretiny: - return upload_using_platformio(config, devices[0]) + return upload_using_platformio(config, host) return 1 # Unknown target platform @@ -441,10 +442,10 @@ def upload_program( # This happens when no device was specified, or the current host is "MQTT"/"OTA" if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not devices or devices[0] 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(devices[0]) == "MQTT" + or get_port_type(host) == "MQTT" ) ): from esphome import mqtt From a282920d7c9fd79b287de7eee3662dd7c2841629 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:06:55 -0500 Subject: [PATCH 13/19] fix, cover --- esphome/resolver.py | 4 +- tests/unit_tests/test_helpers.py | 255 ++++++++++++++++++++++++++++++ tests/unit_tests/test_resolver.py | 157 ++++++++++++++++++ 3 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_resolver.py diff --git a/esphome/resolver.py b/esphome/resolver.py index a245737962..f70ecec357 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -52,10 +52,10 @@ class AsyncResolver: raise EsphomeError("Timeout resolving IP address") if exc := self.exception: - if isinstance(exc, ResolveAPIError): - raise EsphomeError(f"Error resolving IP address: {exc}") from exc if isinstance(exc, ResolveTimeoutAPIError): raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc + if isinstance(exc, ResolveAPIError): + raise EsphomeError(f"Error resolving IP address: {exc}") from exc raise exc return self.result diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index b353d1aa99..706acdd359 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1,8 +1,13 @@ +import socket +from unittest.mock import patch + +from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr from hypothesis import given from hypothesis.strategies import ip_addresses import pytest from esphome import helpers +from esphome.core import EsphomeError @pytest.mark.parametrize( @@ -277,3 +282,253 @@ def test_sort_ip_addresses(text: list[str], expected: list[str]) -> None: actual = helpers.sort_ip_addresses(text) assert actual == expected + + +# DNS resolution tests +def test_is_ip_address_ipv4(): + """Test is_ip_address with IPv4 addresses.""" + assert helpers.is_ip_address("192.168.1.1") is True + assert helpers.is_ip_address("127.0.0.1") is True + assert helpers.is_ip_address("255.255.255.255") is True + assert helpers.is_ip_address("0.0.0.0") is True + + +def test_is_ip_address_ipv6(): + """Test is_ip_address with IPv6 addresses.""" + assert helpers.is_ip_address("::1") is True + assert helpers.is_ip_address("2001:db8::1") is True + assert helpers.is_ip_address("fe80::1") is True + assert helpers.is_ip_address("::") is True + + +def test_is_ip_address_invalid(): + """Test is_ip_address with non-IP strings.""" + assert helpers.is_ip_address("hostname") is False + assert helpers.is_ip_address("hostname.local") is False + assert helpers.is_ip_address("256.256.256.256") is False + assert helpers.is_ip_address("192.168.1") is False + assert helpers.is_ip_address("") is False + + +def test_resolve_ip_address_single_ipv4(): + """Test resolving a single IPv4 address (fast path).""" + result = helpers.resolve_ip_address("192.168.1.100", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET # family + assert result[0][1] == socket.SOCK_STREAM # type + assert result[0][2] == socket.IPPROTO_TCP # proto + assert result[0][3] == "" # canonname + assert result[0][4] == ("192.168.1.100", 6053) # sockaddr + + +def test_resolve_ip_address_single_ipv6(): + """Test resolving a single IPv6 address (fast path).""" + result = helpers.resolve_ip_address("::1", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET6 # family + assert result[0][1] == socket.SOCK_STREAM # type + assert result[0][2] == socket.IPPROTO_TCP # proto + assert result[0][3] == "" # canonname + # IPv6 sockaddr has 4 elements + assert len(result[0][4]) == 4 + assert result[0][4][0] == "::1" # address + assert result[0][4][1] == 6053 # port + + +def test_resolve_ip_address_list_of_ips(): + """Test resolving a list of IP addresses (fast path).""" + ips = ["192.168.1.100", "10.0.0.1", "::1"] + result = helpers.resolve_ip_address(ips, 6053) + + # Should return results sorted by preference (IPv6 first, then IPv4) + assert len(result) >= 2 # At least IPv4 addresses should work + + # Check that results are properly formatted + for addr_info in result: + assert addr_info[0] in (socket.AF_INET, socket.AF_INET6) + assert addr_info[1] == socket.SOCK_STREAM + assert addr_info[2] == socket.IPPROTO_TCP + assert addr_info[3] == "" + + +def test_resolve_ip_address_hostname(): + """Test resolving a hostname (async resolver path).""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET + assert result[0][4] == ("192.168.1.100", 6053) + mock_resolver.run.assert_called_once_with(["test.local"], 6053) + + +def test_resolve_ip_address_mixed_list(): + """Test resolving a mix of IPs and hostnames.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.200", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = [mock_addr_info] + + # Mix of IP and hostname - should use async resolver + result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.200" + mock_resolver.run.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + + +def test_resolve_ip_address_url(): + """Test extracting hostname from URL.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("http://test.local", 6053) + + assert len(result) == 1 + mock_resolver.run.assert_called_once_with(["test.local"], 6053) + + +def test_resolve_ip_address_ipv6_conversion(): + """Test proper IPv6 address info conversion.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr(address="2001:db8::1", port=6053, flowinfo=1, scope_id=2), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET6 + assert result[0][4] == ("2001:db8::1", 6053, 1, 2) + + +def test_resolve_ip_address_error_handling(): + """Test error handling from AsyncResolver.""" + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.side_effect = EsphomeError("Resolution failed") + + with pytest.raises(EsphomeError, match="Resolution failed"): + helpers.resolve_ip_address("test.local", 6053) + + +def test_addr_preference_ipv4(): + """Test address preference for IPv4.""" + addr_info = ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("192.168.1.1", 6053), + ) + assert helpers.addr_preference_(addr_info) == 2 + + +def test_addr_preference_ipv6(): + """Test address preference for regular IPv6.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("2001:db8::1", 6053, 0, 0), + ) + assert helpers.addr_preference_(addr_info) == 1 + + +def test_addr_preference_ipv6_link_local_no_scope(): + """Test address preference for link-local IPv6 without scope.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("fe80::1", 6053, 0, 0), # link-local with scope_id=0 + ) + assert helpers.addr_preference_(addr_info) == 3 + + +def test_addr_preference_ipv6_link_local_with_scope(): + """Test address preference for link-local IPv6 with scope.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("fe80::1", 6053, 0, 2), # link-local with scope_id=2 + ) + assert helpers.addr_preference_(addr_info) == 1 # Has scope, so it's usable + + +def test_resolve_ip_address_sorting(): + """Test that results are sorted by preference.""" + # Create multiple address infos with different preferences + mock_addr_infos = [ + AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr( + address="fe80::1", port=6053, flowinfo=0, scope_id=0 + ), # Preference 3 (link-local no scope) + ), + AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr( + address="192.168.1.100", port=6053 + ), # Preference 2 (IPv4) + ), + AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr( + address="2001:db8::1", port=6053, flowinfo=0, scope_id=0 + ), # Preference 1 (IPv6) + ), + ] + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = mock_addr_infos + + result = helpers.resolve_ip_address("test.local", 6053) + + # Should be sorted: IPv6 first, then IPv4, then link-local without scope + assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1) + assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2) + assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py new file mode 100644 index 0000000000..d49a367085 --- /dev/null +++ b/tests/unit_tests/test_resolver.py @@ -0,0 +1,157 @@ +"""Tests for the DNS resolver module.""" + +from __future__ import annotations + +import asyncio +import socket +from unittest.mock import patch + +from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError +from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr +import pytest + +from esphome.core import EsphomeError +from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver + + +@pytest.fixture +def mock_addr_info_ipv4(): + """Create a mock IPv4 AddrInfo.""" + return AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + +@pytest.fixture +def mock_addr_info_ipv6(): + """Create a mock IPv6 AddrInfo.""" + return AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr(address="2001:db8::1", port=6053, flowinfo=0, scope_id=0), + ) + + +def test_async_resolver_successful_resolution(mock_addr_info_ipv4): + """Test successful DNS resolution.""" + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=[mock_addr_info_ipv4], + ) as mock_resolve: + resolver = AsyncResolver() + result = resolver.run(["test.local"], 6053) + + assert result == [mock_addr_info_ipv4] + mock_resolve.assert_called_once_with( + ["test.local"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_multiple_hosts(mock_addr_info_ipv4, mock_addr_info_ipv6): + """Test resolving multiple hosts.""" + mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] + + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=mock_results, + ) as mock_resolve: + resolver = AsyncResolver() + result = resolver.run(["test1.local", "test2.local"], 6053) + + assert result == mock_results + mock_resolve.assert_called_once_with( + ["test1.local", "test2.local"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_resolve_api_error(): + """Test handling of ResolveAPIError.""" + error_msg = "Failed to resolve" + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=ResolveAPIError(error_msg), + ): + resolver = AsyncResolver() + with pytest.raises( + EsphomeError, match=f"Error resolving IP address: {error_msg}" + ): + resolver.run(["test.local"], 6053) + + +def test_async_resolver_timeout_error(): + """Test handling of ResolveTimeoutAPIError.""" + error_msg = "Resolution timed out" + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=ResolveTimeoutAPIError(error_msg), + ): + resolver = AsyncResolver() + with pytest.raises( + EsphomeError, match=f"Timeout resolving IP address: {error_msg}" + ): + resolver.run(["test.local"], 6053) + + +def test_async_resolver_generic_exception(): + """Test handling of generic exceptions.""" + error = RuntimeError("Unexpected error") + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=error, + ): + resolver = AsyncResolver() + with pytest.raises(RuntimeError, match="Unexpected error"): + resolver.run(["test.local"], 6053) + + +def test_async_resolver_thread_timeout(): + """Test timeout when thread doesn't complete in time.""" + + async def slow_resolve(hosts, port, timeout): + await asyncio.sleep(100) # Sleep longer than timeout + return [] + + with patch("esphome.resolver.hr.async_resolve_host", slow_resolve): + resolver = AsyncResolver() + # Override event.wait to simulate timeout + with ( + patch.object(resolver.event, "wait", return_value=False), + pytest.raises(EsphomeError, match="Timeout resolving IP address"), + ): + resolver.run(["test.local"], 6053) + + +def test_async_resolver_ip_addresses(mock_addr_info_ipv4): + """Test resolving IP addresses.""" + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=[mock_addr_info_ipv4], + ) as mock_resolve: + resolver = AsyncResolver() + result = resolver.run(["192.168.1.100"], 6053) + + assert result == [mock_addr_info_ipv4] + mock_resolve.assert_called_once_with( + ["192.168.1.100"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_mixed_addresses(mock_addr_info_ipv4, mock_addr_info_ipv6): + """Test resolving mix of hostnames and IP addresses.""" + mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] + + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=mock_results, + ) as mock_resolve: + resolver = AsyncResolver() + result = resolver.run(["test.local", "192.168.1.100", "::1"], 6053) + + assert result == mock_results + mock_resolve.assert_called_once_with( + ["test.local", "192.168.1.100", "::1"], 6053, timeout=RESOLVE_TIMEOUT + ) From 2d37518c00be97128def9e7881d6b9bf08b42e39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:16:30 -0500 Subject: [PATCH 14/19] fix, cover --- esphome/resolver.py | 2 +- tests/unit_tests/test_helpers.py | 62 ++++++++++++++++++++----------- tests/unit_tests/test_resolver.py | 24 +++++++----- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/esphome/resolver.py b/esphome/resolver.py index f70ecec357..dff0ca32d7 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -34,7 +34,7 @@ class AsyncResolver: self.result = await hr.async_resolve_host( hosts, port, timeout=RESOLVE_TIMEOUT ) - except Exception as e: + except (ResolveAPIError, ResolveTimeoutAPIError, OSError) as e: self.exception = e finally: self.event.set() diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 706acdd359..867e19a604 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -285,7 +285,7 @@ def test_sort_ip_addresses(text: list[str], expected: list[str]) -> None: # DNS resolution tests -def test_is_ip_address_ipv4(): +def test_is_ip_address_ipv4() -> None: """Test is_ip_address with IPv4 addresses.""" assert helpers.is_ip_address("192.168.1.1") is True assert helpers.is_ip_address("127.0.0.1") is True @@ -293,7 +293,7 @@ def test_is_ip_address_ipv4(): assert helpers.is_ip_address("0.0.0.0") is True -def test_is_ip_address_ipv6(): +def test_is_ip_address_ipv6() -> None: """Test is_ip_address with IPv6 addresses.""" assert helpers.is_ip_address("::1") is True assert helpers.is_ip_address("2001:db8::1") is True @@ -301,7 +301,7 @@ def test_is_ip_address_ipv6(): assert helpers.is_ip_address("::") is True -def test_is_ip_address_invalid(): +def test_is_ip_address_invalid() -> None: """Test is_ip_address with non-IP strings.""" assert helpers.is_ip_address("hostname") is False assert helpers.is_ip_address("hostname.local") is False @@ -310,26 +310,38 @@ def test_is_ip_address_invalid(): assert helpers.is_ip_address("") is False -def test_resolve_ip_address_single_ipv4(): +def test_resolve_ip_address_single_ipv4() -> None: """Test resolving a single IPv4 address (fast path).""" result = helpers.resolve_ip_address("192.168.1.100", 6053) assert len(result) == 1 assert result[0][0] == socket.AF_INET # family - assert result[0][1] == socket.SOCK_STREAM # type - assert result[0][2] == socket.IPPROTO_TCP # proto + assert result[0][1] in ( + 0, + socket.SOCK_STREAM, + ) # type (0 on Windows with AI_NUMERICHOST) + assert result[0][2] in ( + 0, + socket.IPPROTO_TCP, + ) # proto (0 on Windows with AI_NUMERICHOST) assert result[0][3] == "" # canonname assert result[0][4] == ("192.168.1.100", 6053) # sockaddr -def test_resolve_ip_address_single_ipv6(): +def test_resolve_ip_address_single_ipv6() -> None: """Test resolving a single IPv6 address (fast path).""" result = helpers.resolve_ip_address("::1", 6053) assert len(result) == 1 assert result[0][0] == socket.AF_INET6 # family - assert result[0][1] == socket.SOCK_STREAM # type - assert result[0][2] == socket.IPPROTO_TCP # proto + assert result[0][1] in ( + 0, + socket.SOCK_STREAM, + ) # type (0 on Windows with AI_NUMERICHOST) + assert result[0][2] in ( + 0, + socket.IPPROTO_TCP, + ) # proto (0 on Windows with AI_NUMERICHOST) assert result[0][3] == "" # canonname # IPv6 sockaddr has 4 elements assert len(result[0][4]) == 4 @@ -337,7 +349,7 @@ def test_resolve_ip_address_single_ipv6(): assert result[0][4][1] == 6053 # port -def test_resolve_ip_address_list_of_ips(): +def test_resolve_ip_address_list_of_ips() -> None: """Test resolving a list of IP addresses (fast path).""" ips = ["192.168.1.100", "10.0.0.1", "::1"] result = helpers.resolve_ip_address(ips, 6053) @@ -348,12 +360,18 @@ def test_resolve_ip_address_list_of_ips(): # Check that results are properly formatted for addr_info in result: assert addr_info[0] in (socket.AF_INET, socket.AF_INET6) - assert addr_info[1] == socket.SOCK_STREAM - assert addr_info[2] == socket.IPPROTO_TCP + assert addr_info[1] in ( + 0, + socket.SOCK_STREAM, + ) # 0 on Windows with AI_NUMERICHOST + assert addr_info[2] in ( + 0, + socket.IPPROTO_TCP, + ) # 0 on Windows with AI_NUMERICHOST assert addr_info[3] == "" -def test_resolve_ip_address_hostname(): +def test_resolve_ip_address_hostname() -> None: """Test resolving a hostname (async resolver path).""" mock_addr_info = AddrInfo( family=socket.AF_INET, @@ -374,7 +392,7 @@ def test_resolve_ip_address_hostname(): mock_resolver.run.assert_called_once_with(["test.local"], 6053) -def test_resolve_ip_address_mixed_list(): +def test_resolve_ip_address_mixed_list() -> None: """Test resolving a mix of IPs and hostnames.""" mock_addr_info = AddrInfo( family=socket.AF_INET, @@ -395,7 +413,7 @@ def test_resolve_ip_address_mixed_list(): mock_resolver.run.assert_called_once_with(["192.168.1.100", "test.local"], 6053) -def test_resolve_ip_address_url(): +def test_resolve_ip_address_url() -> None: """Test extracting hostname from URL.""" mock_addr_info = AddrInfo( family=socket.AF_INET, @@ -414,7 +432,7 @@ def test_resolve_ip_address_url(): mock_resolver.run.assert_called_once_with(["test.local"], 6053) -def test_resolve_ip_address_ipv6_conversion(): +def test_resolve_ip_address_ipv6_conversion() -> None: """Test proper IPv6 address info conversion.""" mock_addr_info = AddrInfo( family=socket.AF_INET6, @@ -434,7 +452,7 @@ def test_resolve_ip_address_ipv6_conversion(): assert result[0][4] == ("2001:db8::1", 6053, 1, 2) -def test_resolve_ip_address_error_handling(): +def test_resolve_ip_address_error_handling() -> None: """Test error handling from AsyncResolver.""" with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value @@ -444,7 +462,7 @@ def test_resolve_ip_address_error_handling(): helpers.resolve_ip_address("test.local", 6053) -def test_addr_preference_ipv4(): +def test_addr_preference_ipv4() -> None: """Test address preference for IPv4.""" addr_info = ( socket.AF_INET, @@ -456,7 +474,7 @@ def test_addr_preference_ipv4(): assert helpers.addr_preference_(addr_info) == 2 -def test_addr_preference_ipv6(): +def test_addr_preference_ipv6() -> None: """Test address preference for regular IPv6.""" addr_info = ( socket.AF_INET6, @@ -468,7 +486,7 @@ def test_addr_preference_ipv6(): assert helpers.addr_preference_(addr_info) == 1 -def test_addr_preference_ipv6_link_local_no_scope(): +def test_addr_preference_ipv6_link_local_no_scope() -> None: """Test address preference for link-local IPv6 without scope.""" addr_info = ( socket.AF_INET6, @@ -480,7 +498,7 @@ def test_addr_preference_ipv6_link_local_no_scope(): assert helpers.addr_preference_(addr_info) == 3 -def test_addr_preference_ipv6_link_local_with_scope(): +def test_addr_preference_ipv6_link_local_with_scope() -> None: """Test address preference for link-local IPv6 with scope.""" addr_info = ( socket.AF_INET6, @@ -492,7 +510,7 @@ def test_addr_preference_ipv6_link_local_with_scope(): assert helpers.addr_preference_(addr_info) == 1 # Has scope, so it's usable -def test_resolve_ip_address_sorting(): +def test_resolve_ip_address_sorting() -> None: """Test that results are sorted by preference.""" # Create multiple address infos with different preferences mock_addr_infos = [ diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index d49a367085..2044443ddd 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -15,7 +15,7 @@ from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver @pytest.fixture -def mock_addr_info_ipv4(): +def mock_addr_info_ipv4() -> AddrInfo: """Create a mock IPv4 AddrInfo.""" return AddrInfo( family=socket.AF_INET, @@ -26,7 +26,7 @@ def mock_addr_info_ipv4(): @pytest.fixture -def mock_addr_info_ipv6(): +def mock_addr_info_ipv6() -> AddrInfo: """Create a mock IPv6 AddrInfo.""" return AddrInfo( family=socket.AF_INET6, @@ -36,7 +36,7 @@ def mock_addr_info_ipv6(): ) -def test_async_resolver_successful_resolution(mock_addr_info_ipv4): +def test_async_resolver_successful_resolution(mock_addr_info_ipv4: AddrInfo) -> None: """Test successful DNS resolution.""" with patch( "esphome.resolver.hr.async_resolve_host", @@ -51,7 +51,9 @@ def test_async_resolver_successful_resolution(mock_addr_info_ipv4): ) -def test_async_resolver_multiple_hosts(mock_addr_info_ipv4, mock_addr_info_ipv6): +def test_async_resolver_multiple_hosts( + mock_addr_info_ipv4: AddrInfo, mock_addr_info_ipv6: AddrInfo +) -> None: """Test resolving multiple hosts.""" mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] @@ -68,7 +70,7 @@ def test_async_resolver_multiple_hosts(mock_addr_info_ipv4, mock_addr_info_ipv6) ) -def test_async_resolver_resolve_api_error(): +def test_async_resolver_resolve_api_error() -> None: """Test handling of ResolveAPIError.""" error_msg = "Failed to resolve" with patch( @@ -82,7 +84,7 @@ def test_async_resolver_resolve_api_error(): resolver.run(["test.local"], 6053) -def test_async_resolver_timeout_error(): +def test_async_resolver_timeout_error() -> None: """Test handling of ResolveTimeoutAPIError.""" error_msg = "Resolution timed out" with patch( @@ -96,7 +98,7 @@ def test_async_resolver_timeout_error(): resolver.run(["test.local"], 6053) -def test_async_resolver_generic_exception(): +def test_async_resolver_generic_exception() -> None: """Test handling of generic exceptions.""" error = RuntimeError("Unexpected error") with patch( @@ -108,7 +110,7 @@ def test_async_resolver_generic_exception(): resolver.run(["test.local"], 6053) -def test_async_resolver_thread_timeout(): +def test_async_resolver_thread_timeout() -> None: """Test timeout when thread doesn't complete in time.""" async def slow_resolve(hosts, port, timeout): @@ -125,7 +127,7 @@ def test_async_resolver_thread_timeout(): resolver.run(["test.local"], 6053) -def test_async_resolver_ip_addresses(mock_addr_info_ipv4): +def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: """Test resolving IP addresses.""" with patch( "esphome.resolver.hr.async_resolve_host", @@ -140,7 +142,9 @@ def test_async_resolver_ip_addresses(mock_addr_info_ipv4): ) -def test_async_resolver_mixed_addresses(mock_addr_info_ipv4, mock_addr_info_ipv6): +def test_async_resolver_mixed_addresses( + mock_addr_info_ipv4: AddrInfo, mock_addr_info_ipv6: AddrInfo +) -> None: """Test resolving mix of hostnames and IP addresses.""" mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] From 3fc928f5d1c1b9769619a1b1d733f7737caf17c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:17:08 -0500 Subject: [PATCH 15/19] fix, cover --- esphome/resolver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/resolver.py b/esphome/resolver.py index dff0ca32d7..24972a456f 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -34,7 +34,9 @@ class AsyncResolver: self.result = await hr.async_resolve_host( hosts, port, timeout=RESOLVE_TIMEOUT ) - except (ResolveAPIError, ResolveTimeoutAPIError, OSError) as e: + except Exception as e: # pylint: disable=broad-except + # We need to catch all exceptions to ensure the event is set + # Otherwise the thread could hang forever self.exception = e finally: self.event.set() From f18303fe2b45b69d8fe51846b822ed9551cddc18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:56:05 -0500 Subject: [PATCH 16/19] fix test --- tests/unit_tests/test_helpers.py | 41 +++++++++++++++++++++++++++++++ tests/unit_tests/test_resolver.py | 24 +++++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 867e19a604..9a052ad9c2 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1,3 +1,4 @@ +import logging import socket from unittest.mock import patch @@ -371,6 +372,46 @@ def test_resolve_ip_address_list_of_ips() -> None: assert addr_info[3] == "" +def test_resolve_ip_address_with_getaddrinfo_failure(caplog) -> None: + """Test that getaddrinfo OSError is handled gracefully in fast path.""" + with ( + caplog.at_level(logging.DEBUG), + patch("socket.getaddrinfo") as mock_getaddrinfo, + ): + # First IP succeeds + mock_getaddrinfo.side_effect = [ + [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("192.168.1.100", 6053), + ) + ], + OSError("Failed to resolve"), # Second IP fails + ] + + # Should continue despite one failure + result = helpers.resolve_ip_address(["192.168.1.100", "192.168.1.101"], 6053) + + # Should have result from first IP only + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.100" + + # Verify both IPs were attempted + assert mock_getaddrinfo.call_count == 2 + mock_getaddrinfo.assert_any_call( + "192.168.1.100", 6053, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + mock_getaddrinfo.assert_any_call( + "192.168.1.101", 6053, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + + # Verify the debug log was called for the failed IP + assert "Failed to parse IP address '192.168.1.101'" in caplog.text + + def test_resolve_ip_address_hostname() -> None: """Test resolving a hostname (async resolver path).""" mock_addr_info = AddrInfo( diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 2044443ddd..0ec3ef71c5 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -3,7 +3,10 @@ from __future__ import annotations import asyncio +import re import socket +import threading +import time from unittest.mock import patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError @@ -79,7 +82,7 @@ def test_async_resolver_resolve_api_error() -> None: ): resolver = AsyncResolver() with pytest.raises( - EsphomeError, match=f"Error resolving IP address: {error_msg}" + EsphomeError, match=re.escape(f"Error resolving IP address: {error_msg}") ): resolver.run(["test.local"], 6053) @@ -87,13 +90,17 @@ def test_async_resolver_resolve_api_error() -> None: def test_async_resolver_timeout_error() -> None: """Test handling of ResolveTimeoutAPIError.""" error_msg = "Resolution timed out" + with patch( "esphome.resolver.hr.async_resolve_host", side_effect=ResolveTimeoutAPIError(error_msg), ): resolver = AsyncResolver() + # Match either "Timeout" or "Error" since ResolveTimeoutAPIError is a subclass of ResolveAPIError + # and depending on import order/test execution context, it might be caught as either with pytest.raises( - EsphomeError, match=f"Timeout resolving IP address: {error_msg}" + EsphomeError, + match=f"(Timeout|Error) resolving IP address: {re.escape(error_msg)}", ): resolver.run(["test.local"], 6053) @@ -112,9 +119,12 @@ def test_async_resolver_generic_exception() -> None: def test_async_resolver_thread_timeout() -> None: """Test timeout when thread doesn't complete in time.""" + # Use an event to control when the async function completes + test_event = threading.Event() async def slow_resolve(hosts, port, timeout): - await asyncio.sleep(100) # Sleep longer than timeout + # Wait for the test to signal completion + await asyncio.get_event_loop().run_in_executor(None, test_event.wait, 0.5) return [] with patch("esphome.resolver.hr.async_resolve_host", slow_resolve): @@ -122,10 +132,16 @@ def test_async_resolver_thread_timeout() -> None: # Override event.wait to simulate timeout with ( patch.object(resolver.event, "wait", return_value=False), - pytest.raises(EsphomeError, match="Timeout resolving IP address"), + pytest.raises( + EsphomeError, match=re.escape("Timeout resolving IP address") + ), ): resolver.run(["test.local"], 6053) + # Signal the async function to complete and give it time to clean up + test_event.set() + time.sleep(0.1) + def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: """Test resolving IP addresses.""" From f836b71e1c911f7d60c7dc8c60e9043feb9f286d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 21:11:22 -0500 Subject: [PATCH 17/19] Update test_resolver.py --- tests/unit_tests/test_resolver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 0ec3ef71c5..8a00c51635 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -140,7 +140,6 @@ def test_async_resolver_thread_timeout() -> None: # Signal the async function to complete and give it time to clean up test_event.set() - time.sleep(0.1) def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: From 6ab0581c9396a1e83730e13131ff2eea762c6dc4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 02:12:35 +0000 Subject: [PATCH 18/19] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_resolver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 8a00c51635..0dbe89b206 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -6,7 +6,6 @@ import asyncio import re import socket import threading -import time from unittest.mock import patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError From 830b9a881a389e8a4c6b8bcb70e68eae8c5d42ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 21:39:50 -0500 Subject: [PATCH 19/19] redesign --- esphome/helpers.py | 4 +-- esphome/resolver.py | 24 ++++++++------ tests/unit_tests/test_helpers.py | 21 ++++++------ tests/unit_tests/test_resolver.py | 54 ++++++++++++++----------------- 4 files changed, 52 insertions(+), 51 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index b00c97ff73..6beaa24a96 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -209,8 +209,8 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: from esphome.resolver import AsyncResolver - resolver = AsyncResolver() - addr_infos = resolver.run(hosts, port) + resolver = AsyncResolver(hosts, port) + addr_infos = resolver.resolve() # Convert aioesphomeapi AddrInfo to our format for addr_info in addr_infos: sockaddr = addr_info.sockaddr diff --git a/esphome/resolver.py b/esphome/resolver.py index 24972a456f..99482aa20e 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -13,7 +13,7 @@ from esphome.core import EsphomeError RESOLVE_TIMEOUT = 10.0 # seconds -class AsyncResolver: +class AsyncResolver(threading.Thread): """Resolver using aioesphomeapi that runs in a thread for faster results. This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, @@ -22,17 +22,20 @@ class AsyncResolver: cleanup cycle, which can take significant time. """ - def __init__(self) -> None: + def __init__(self, hosts: list[str], port: int) -> None: """Initialize the resolver.""" + super().__init__(daemon=True) + self.hosts = hosts + self.port = port self.result: list[hr.AddrInfo] | None = None self.exception: Exception | None = None self.event = threading.Event() - async def _resolve(self, hosts: list[str], port: int) -> None: + async def _resolve(self) -> None: """Resolve hostnames to IP addresses.""" try: self.result = await hr.async_resolve_host( - hosts, port, timeout=RESOLVE_TIMEOUT + self.hosts, self.port, timeout=RESOLVE_TIMEOUT ) except Exception as e: # pylint: disable=broad-except # We need to catch all exceptions to ensure the event is set @@ -41,12 +44,13 @@ class AsyncResolver: finally: self.event.set() - def run(self, hosts: list[str], port: int) -> list[hr.AddrInfo]: - """Run the DNS resolution in a separate thread.""" - thread = threading.Thread( - target=lambda: asyncio.run(self._resolve(hosts, port)), daemon=True - ) - thread.start() + def run(self) -> None: + """Run the DNS resolution.""" + asyncio.run(self._resolve()) + + def resolve(self) -> list[hr.AddrInfo]: + """Start the thread and wait for the result.""" + self.start() if not self.event.wait( timeout=RESOLVE_TIMEOUT + 1.0 diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 9a052ad9c2..9f51206ff9 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -423,14 +423,15 @@ def test_resolve_ip_address_hostname() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = [mock_addr_info] + mock_resolver.resolve.return_value = [mock_addr_info] result = helpers.resolve_ip_address("test.local", 6053) assert len(result) == 1 assert result[0][0] == socket.AF_INET assert result[0][4] == ("192.168.1.100", 6053) - mock_resolver.run.assert_called_once_with(["test.local"], 6053) + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() def test_resolve_ip_address_mixed_list() -> None: @@ -444,14 +445,15 @@ def test_resolve_ip_address_mixed_list() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = [mock_addr_info] + mock_resolver.resolve.return_value = [mock_addr_info] # Mix of IP and hostname - should use async resolver result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) assert len(result) == 1 assert result[0][4][0] == "192.168.1.200" - mock_resolver.run.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + MockResolver.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + mock_resolver.resolve.assert_called_once() def test_resolve_ip_address_url() -> None: @@ -465,12 +467,13 @@ def test_resolve_ip_address_url() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = [mock_addr_info] + mock_resolver.resolve.return_value = [mock_addr_info] result = helpers.resolve_ip_address("http://test.local", 6053) assert len(result) == 1 - mock_resolver.run.assert_called_once_with(["test.local"], 6053) + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() def test_resolve_ip_address_ipv6_conversion() -> None: @@ -484,7 +487,7 @@ def test_resolve_ip_address_ipv6_conversion() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = [mock_addr_info] + mock_resolver.resolve.return_value = [mock_addr_info] result = helpers.resolve_ip_address("test.local", 6053) @@ -497,7 +500,7 @@ def test_resolve_ip_address_error_handling() -> None: """Test error handling from AsyncResolver.""" with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.side_effect = EsphomeError("Resolution failed") + mock_resolver.resolve.side_effect = EsphomeError("Resolution failed") with pytest.raises(EsphomeError, match="Resolution failed"): helpers.resolve_ip_address("test.local", 6053) @@ -583,7 +586,7 @@ def test_resolve_ip_address_sorting() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = mock_addr_infos + mock_resolver.resolve.return_value = mock_addr_infos result = helpers.resolve_ip_address("test.local", 6053) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 0dbe89b206..b4cca05d9f 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -2,10 +2,8 @@ from __future__ import annotations -import asyncio import re import socket -import threading from unittest.mock import patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError @@ -44,8 +42,8 @@ def test_async_resolver_successful_resolution(mock_addr_info_ipv4: AddrInfo) -> "esphome.resolver.hr.async_resolve_host", return_value=[mock_addr_info_ipv4], ) as mock_resolve: - resolver = AsyncResolver() - result = resolver.run(["test.local"], 6053) + resolver = AsyncResolver(["test.local"], 6053) + result = resolver.resolve() assert result == [mock_addr_info_ipv4] mock_resolve.assert_called_once_with( @@ -63,8 +61,8 @@ def test_async_resolver_multiple_hosts( "esphome.resolver.hr.async_resolve_host", return_value=mock_results, ) as mock_resolve: - resolver = AsyncResolver() - result = resolver.run(["test1.local", "test2.local"], 6053) + resolver = AsyncResolver(["test1.local", "test2.local"], 6053) + result = resolver.resolve() assert result == mock_results mock_resolve.assert_called_once_with( @@ -79,11 +77,11 @@ def test_async_resolver_resolve_api_error() -> None: "esphome.resolver.hr.async_resolve_host", side_effect=ResolveAPIError(error_msg), ): - resolver = AsyncResolver() + resolver = AsyncResolver(["test.local"], 6053) with pytest.raises( EsphomeError, match=re.escape(f"Error resolving IP address: {error_msg}") ): - resolver.run(["test.local"], 6053) + resolver.resolve() def test_async_resolver_timeout_error() -> None: @@ -94,14 +92,14 @@ def test_async_resolver_timeout_error() -> None: "esphome.resolver.hr.async_resolve_host", side_effect=ResolveTimeoutAPIError(error_msg), ): - resolver = AsyncResolver() + resolver = AsyncResolver(["test.local"], 6053) # Match either "Timeout" or "Error" since ResolveTimeoutAPIError is a subclass of ResolveAPIError # and depending on import order/test execution context, it might be caught as either with pytest.raises( EsphomeError, match=f"(Timeout|Error) resolving IP address: {re.escape(error_msg)}", ): - resolver.run(["test.local"], 6053) + resolver.resolve() def test_async_resolver_generic_exception() -> None: @@ -111,34 +109,30 @@ def test_async_resolver_generic_exception() -> None: "esphome.resolver.hr.async_resolve_host", side_effect=error, ): - resolver = AsyncResolver() + resolver = AsyncResolver(["test.local"], 6053) with pytest.raises(RuntimeError, match="Unexpected error"): - resolver.run(["test.local"], 6053) + resolver.resolve() def test_async_resolver_thread_timeout() -> None: """Test timeout when thread doesn't complete in time.""" - # Use an event to control when the async function completes - test_event = threading.Event() - - async def slow_resolve(hosts, port, timeout): - # Wait for the test to signal completion - await asyncio.get_event_loop().run_in_executor(None, test_event.wait, 0.5) - return [] - - with patch("esphome.resolver.hr.async_resolve_host", slow_resolve): - resolver = AsyncResolver() - # Override event.wait to simulate timeout + # Mock the start method to prevent actual thread execution + with ( + patch.object(AsyncResolver, "start"), + patch("esphome.resolver.hr.async_resolve_host"), + ): + resolver = AsyncResolver(["test.local"], 6053) + # Override event.wait to simulate timeout (return False = timeout occurred) with ( patch.object(resolver.event, "wait", return_value=False), pytest.raises( EsphomeError, match=re.escape("Timeout resolving IP address") ), ): - resolver.run(["test.local"], 6053) + resolver.resolve() - # Signal the async function to complete and give it time to clean up - test_event.set() + # Verify thread start was called + resolver.start.assert_called_once() def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: @@ -147,8 +141,8 @@ def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: "esphome.resolver.hr.async_resolve_host", return_value=[mock_addr_info_ipv4], ) as mock_resolve: - resolver = AsyncResolver() - result = resolver.run(["192.168.1.100"], 6053) + resolver = AsyncResolver(["192.168.1.100"], 6053) + result = resolver.resolve() assert result == [mock_addr_info_ipv4] mock_resolve.assert_called_once_with( @@ -166,8 +160,8 @@ def test_async_resolver_mixed_addresses( "esphome.resolver.hr.async_resolve_host", return_value=mock_results, ) as mock_resolve: - resolver = AsyncResolver() - result = resolver.run(["test.local", "192.168.1.100", "::1"], 6053) + resolver = AsyncResolver(["test.local", "192.168.1.100", "::1"], 6053) + result = resolver.resolve() assert result == mock_results mock_resolve.assert_called_once_with(