mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 07:03:55 +00:00 
			
		
		
		
	Merge branch 'align_resolver' into integration
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/actions/restore-python/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/restore-python/action.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,7 +17,7 @@ runs: | |||||||
|   steps: |   steps: | ||||||
|     - name: Set up Python ${{ inputs.python-version }} |     - name: Set up Python ${{ inputs.python-version }} | ||||||
|       id: python |       id: python | ||||||
|       uses: actions/setup-python@v5.6.0 |       uses: actions/setup-python@v6.0.0 | ||||||
|       with: |       with: | ||||||
|         python-version: ${{ inputs.python-version }} |         python-version: ${{ inputs.python-version }} | ||||||
|     - name: Restore Python virtual environment |     - name: Restore Python virtual environment | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/auto-label-pr.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/auto-label-pr.yml
									
									
									
									
										vendored
									
									
								
							| @@ -32,7 +32,7 @@ jobs: | |||||||
|           private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} |           private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} | ||||||
|  |  | ||||||
|       - name: Auto Label PR |       - name: Auto Label PR | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           github-token: ${{ steps.generate-token.outputs.token }} |           github-token: ${{ steps.generate-token.outputs.token }} | ||||||
|           script: | |           script: | | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								.github/workflows/ci-api-proto.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/ci-api-proto.yml
									
									
									
									
										vendored
									
									
								
							| @@ -23,7 +23,7 @@ jobs: | |||||||
|       - name: Checkout |       - name: Checkout | ||||||
|         uses: actions/checkout@v5.0.0 |         uses: actions/checkout@v5.0.0 | ||||||
|       - name: Set up Python |       - name: Set up Python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v6.0.0 | ||||||
|         with: |         with: | ||||||
|           python-version: "3.11" |           python-version: "3.11" | ||||||
|  |  | ||||||
| @@ -47,7 +47,7 @@ jobs: | |||||||
|           fi |           fi | ||||||
|       - if: failure() |       - if: failure() | ||||||
|         name: Review PR |         name: Review PR | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           script: | |           script: | | ||||||
|             await github.rest.pulls.createReview({ |             await github.rest.pulls.createReview({ | ||||||
| @@ -70,7 +70,7 @@ jobs: | |||||||
|             esphome/components/api/api_pb2_service.* |             esphome/components/api/api_pb2_service.* | ||||||
|       - if: success() |       - if: success() | ||||||
|         name: Dismiss review |         name: Dismiss review | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           script: | |           script: | | ||||||
|             let reviews = await github.rest.pulls.listReviews({ |             let reviews = await github.rest.pulls.listReviews({ | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								.github/workflows/ci-clang-tidy-hash.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/ci-clang-tidy-hash.yml
									
									
									
									
										vendored
									
									
								
							| @@ -23,7 +23,7 @@ jobs: | |||||||
|         uses: actions/checkout@v5.0.0 |         uses: actions/checkout@v5.0.0 | ||||||
|  |  | ||||||
|       - name: Set up Python |       - name: Set up Python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v6.0.0 | ||||||
|         with: |         with: | ||||||
|           python-version: "3.11" |           python-version: "3.11" | ||||||
|  |  | ||||||
| @@ -41,7 +41,7 @@ jobs: | |||||||
|  |  | ||||||
|       - if: failure() |       - if: failure() | ||||||
|         name: Request changes |         name: Request changes | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           script: | |           script: | | ||||||
|             await github.rest.pulls.createReview({ |             await github.rest.pulls.createReview({ | ||||||
| @@ -54,7 +54,7 @@ jobs: | |||||||
|  |  | ||||||
|       - if: success() |       - if: success() | ||||||
|         name: Dismiss review |         name: Dismiss review | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           script: | |           script: | | ||||||
|             let reviews = await github.rest.pulls.listReviews({ |             let reviews = await github.rest.pulls.listReviews({ | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -45,7 +45,7 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v5.0.0 |       - uses: actions/checkout@v5.0.0 | ||||||
|       - name: Set up Python |       - name: Set up Python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v6.0.0 | ||||||
|         with: |         with: | ||||||
|           python-version: "3.11" |           python-version: "3.11" | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -42,7 +42,7 @@ jobs: | |||||||
|         run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT |         run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT | ||||||
|       - name: Set up Python ${{ env.DEFAULT_PYTHON }} |       - name: Set up Python ${{ env.DEFAULT_PYTHON }} | ||||||
|         id: python |         id: python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v6.0.0 | ||||||
|         with: |         with: | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON }} |           python-version: ${{ env.DEFAULT_PYTHON }} | ||||||
|       - name: Restore Python virtual environment |       - name: Restore Python virtual environment | ||||||
| @@ -156,7 +156,7 @@ jobs: | |||||||
|           . venv/bin/activate |           . venv/bin/activate | ||||||
|           pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ |           pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ | ||||||
|       - name: Upload coverage to Codecov |       - name: Upload coverage to Codecov | ||||||
|         uses: codecov/codecov-action@v5.5.0 |         uses: codecov/codecov-action@v5.5.1 | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           token: ${{ secrets.CODECOV_TOKEN }} | ||||||
|       - name: Save Python virtual environment cache |       - name: Save Python virtual environment cache | ||||||
| @@ -217,7 +217,7 @@ jobs: | |||||||
|         uses: actions/checkout@v5.0.0 |         uses: actions/checkout@v5.0.0 | ||||||
|       - name: Set up Python 3.13 |       - name: Set up Python 3.13 | ||||||
|         id: python |         id: python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v6.0.0 | ||||||
|         with: |         with: | ||||||
|           python-version: "3.13" |           python-version: "3.13" | ||||||
|       - name: Restore Python virtual environment |       - name: Restore Python virtual environment | ||||||
|   | |||||||
| @@ -25,7 +25,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - name: Request reviews from component codeowners |       - name: Request reviews from component codeowners | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           script: | |           script: | | ||||||
|             const owner = context.repo.owner; |             const owner = context.repo.owner; | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/external-component-bot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/external-component-bot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -15,7 +15,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - name: Add external component comment |       - name: Add external component comment | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} |           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|           script: | |           script: | | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/issue-codeowner-notify.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/issue-codeowner-notify.yml
									
									
									
									
										vendored
									
									
								
							| @@ -19,7 +19,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - name: Notify codeowners for component issues |       - name: Notify codeowners for component issues | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           script: | |           script: | | ||||||
|             const owner = context.repo.owner; |             const owner = context.repo.owner; | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -62,7 +62,7 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v5.0.0 |       - uses: actions/checkout@v5.0.0 | ||||||
|       - name: Set up Python |       - name: Set up Python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v6.0.0 | ||||||
|         with: |         with: | ||||||
|           python-version: "3.x" |           python-version: "3.x" | ||||||
|       - name: Build |       - name: Build | ||||||
| @@ -94,7 +94,7 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v5.0.0 |       - uses: actions/checkout@v5.0.0 | ||||||
|       - name: Set up Python |       - name: Set up Python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v6.0.0 | ||||||
|         with: |         with: | ||||||
|           python-version: "3.11" |           python-version: "3.11" | ||||||
|  |  | ||||||
| @@ -220,7 +220,7 @@ jobs: | |||||||
|       - deploy-manifest |       - deploy-manifest | ||||||
|     steps: |     steps: | ||||||
|       - name: Trigger Workflow |       - name: Trigger Workflow | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} |           github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} | ||||||
|           script: | |           script: | | ||||||
| @@ -246,7 +246,7 @@ jobs: | |||||||
|     environment: ${{ needs.init.outputs.deploy_env }} |     environment: ${{ needs.init.outputs.deploy_env }} | ||||||
|     steps: |     steps: | ||||||
|       - name: Trigger Workflow |       - name: Trigger Workflow | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} |           github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} | ||||||
|           script: | |           script: | | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,7 +17,7 @@ jobs: | |||||||
|   stale: |   stale: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/stale@v9.1.0 |       - uses: actions/stale@v10.0.0 | ||||||
|         with: |         with: | ||||||
|           days-before-pr-stale: 90 |           days-before-pr-stale: 90 | ||||||
|           days-before-pr-close: 7 |           days-before-pr-close: 7 | ||||||
| @@ -37,7 +37,7 @@ jobs: | |||||||
|   close-issues: |   close-issues: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/stale@v9.1.0 |       - uses: actions/stale@v10.0.0 | ||||||
|         with: |         with: | ||||||
|           days-before-pr-stale: -1 |           days-before-pr-stale: -1 | ||||||
|           days-before-pr-close: -1 |           days-before-pr-close: -1 | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/status-check-labels.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/status-check-labels.yml
									
									
									
									
										vendored
									
									
								
							| @@ -16,7 +16,7 @@ jobs: | |||||||
|           - merge-after-release |           - merge-after-release | ||||||
|     steps: |     steps: | ||||||
|       - name: Check for ${{ matrix.label }} label |       - name: Check for ${{ matrix.label }} label | ||||||
|         uses: actions/github-script@v7.0.1 |         uses: actions/github-script@v8.0.0 | ||||||
|         with: |         with: | ||||||
|           script: | |           script: | | ||||||
|             const { data: labels } = await github.rest.issues.listLabelsOnIssue({ |             const { data: labels } = await github.rest.issues.listLabelsOnIssue({ | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/sync-device-classes.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/sync-device-classes.yml
									
									
									
									
										vendored
									
									
								
							| @@ -22,7 +22,7 @@ jobs: | |||||||
|           path: lib/home-assistant |           path: lib/home-assistant | ||||||
|  |  | ||||||
|       - name: Setup Python |       - name: Setup Python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v6.0.0 | ||||||
|         with: |         with: | ||||||
|           python-version: 3.13 |           python-version: 3.13 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -11,7 +11,7 @@ ci: | |||||||
| repos: | repos: | ||||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit |   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||||
|     # Ruff version. |     # Ruff version. | ||||||
|     rev: v0.12.11 |     rev: v0.12.12 | ||||||
|     hooks: |     hooks: | ||||||
|       # Run the linter. |       # Run the linter. | ||||||
|       - id: ruff |       - id: ruff | ||||||
|   | |||||||
| @@ -396,7 +396,10 @@ 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: | ||||||
|  |     host = devices[0] | ||||||
|     try: |     try: | ||||||
|         module = importlib.import_module("esphome.components." + CORE.target_platform) |         module = importlib.import_module("esphome.components." + CORE.target_platform) | ||||||
|         if getattr(module, "upload_program")(config, args, host): |         if getattr(module, "upload_program")(config, args, host): | ||||||
| @@ -433,10 +436,10 @@ def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | s | |||||||
|  |  | ||||||
|     remote_port = int(ota_conf[CONF_PORT]) |     remote_port = int(ota_conf[CONF_PORT]) | ||||||
|     password = ota_conf.get(CONF_PASSWORD, "") |     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 |     # Check if we should use MQTT for address resolution | ||||||
|     # This happens when no device was specified, or the current host is "MQTT"/"OTA" |     # This happens when no device was specified, or the current host is "MQTT"/"OTA" | ||||||
|     devices: list[str] = args.device or [] |  | ||||||
|     if ( |     if ( | ||||||
|         CONF_MQTT in config  # pylint: disable=too-many-boolean-expressions |         CONF_MQTT in config  # pylint: disable=too-many-boolean-expressions | ||||||
|         and (not devices or host in ("MQTT", "OTA")) |         and (not devices or host in ("MQTT", "OTA")) | ||||||
| @@ -447,14 +450,13 @@ def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | s | |||||||
|     ): |     ): | ||||||
|         from esphome import mqtt |         from esphome import mqtt | ||||||
|  |  | ||||||
|         host = mqtt.get_esphome_device_ip( |         devices = [ | ||||||
|             config, args.username, args.password, args.client_id |             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(devices, remote_port, password, binary) | ||||||
|         return espota2.run_ota(host, remote_port, password, args.file) |  | ||||||
|  |  | ||||||
|     return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: | def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: | ||||||
| @@ -558,17 +560,11 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: | |||||||
|         purpose="uploading", |         purpose="uploading", | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Try each device until one succeeds |     exit_code = upload_program(config, args, devices) | ||||||
|     exit_code = 1 |     if exit_code == 0: | ||||||
|     for device in devices: |         _LOGGER.info("Successfully uploaded program.") | ||||||
|         _LOGGER.info("Uploading to %s", device) |     else: | ||||||
|         exit_code = upload_program(config, args, device) |         _LOGGER.warning("Failed to upload to %s", devices) | ||||||
|         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 |     return exit_code | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -308,8 +308,12 @@ def perform_ota( | |||||||
|     time.sleep(1) |     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: |     try: | ||||||
|  |         # Resolve all hosts at once for parallel DNS resolution | ||||||
|         res = resolve_ip_address(remote_host, remote_port) |         res = resolve_ip_address(remote_host, remote_port) | ||||||
|     except EsphomeError as err: |     except EsphomeError as err: | ||||||
|         _LOGGER.error( |         _LOGGER.error( | ||||||
| @@ -350,7 +354,9 @@ def run_ota_impl_(remote_host, remote_port, password, filename): | |||||||
|     return 1 |     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: |     try: | ||||||
|         return run_ota_impl_(remote_host, remote_port, password, filename) |         return run_ota_impl_(remote_host, remote_port, password, filename) | ||||||
|     except OTAError as err: |     except OTAError as err: | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import codecs | import codecs | ||||||
| from contextlib import suppress | from contextlib import suppress | ||||||
| import ipaddress | import ipaddress | ||||||
| @@ -11,6 +13,18 @@ from urllib.parse import urlparse | |||||||
|  |  | ||||||
| from esphome.const import __version__ as ESPHOME_VERSION | 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__) | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
| IS_MACOS = platform.system() == "Darwin" | IS_MACOS = platform.system() == "Darwin" | ||||||
| @@ -147,32 +161,7 @@ def is_ip_address(host): | |||||||
|         return False |         return False | ||||||
|  |  | ||||||
|  |  | ||||||
| def _resolve_with_zeroconf(host): | def addr_preference_(res: AddrInfo) -> int: | ||||||
|     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): |  | ||||||
|     # Trivial alternative to RFC6724 sorting. Put sane IPv6 first, then |     # Trivial alternative to RFC6724 sorting. Put sane IPv6 first, then | ||||||
|     # Legacy IP, then IPv6 link-local addresses without an actual link. |     # Legacy IP, then IPv6 link-local addresses without an actual link. | ||||||
|     sa = res[4] |     sa = res[4] | ||||||
| @@ -184,66 +173,70 @@ def addr_preference_(res): | |||||||
|     return 1 |     return 1 | ||||||
|  |  | ||||||
|  |  | ||||||
| def resolve_ip_address(host, port): | def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: | ||||||
|     import socket |     import socket | ||||||
|  |  | ||||||
|     from esphome.core import EsphomeError |  | ||||||
|  |  | ||||||
|     # There are five cases here. The host argument could be one of: |     # There are five cases here. The host argument could be one of: | ||||||
|     #  • a *list* of IP addresses discovered by MQTT, |     #  • a *list* of IP addresses discovered by MQTT, | ||||||
|     #  • a single IP address specified by the user, |     #  • a single IP address specified by the user, | ||||||
|     #  • a .local hostname to be resolved by mDNS, |     #  • a .local hostname to be resolved by mDNS, | ||||||
|     #  • a normal hostname to be resolved in DNS, or |     #  • a normal hostname to be resolved in DNS, or | ||||||
|     #  • A URL from which we should extract the hostname. |     #  • 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): |     if isinstance(host, list): | ||||||
|         addr_list = host |         hosts = host | ||||||
|     elif is_ip_address(host): |  | ||||||
|         addr_list = [host] |  | ||||||
|     else: |     else: | ||||||
|         url = urlparse(host) |         if not is_ip_address(host): | ||||||
|         if url.scheme != "": |             url = urlparse(host) | ||||||
|             host = url.hostname |             if url.scheme != "": | ||||||
|  |                 host = url.hostname | ||||||
|  |         hosts = [host] | ||||||
|  |  | ||||||
|         addr_list = [] |     res: list[AddrInfo] = [] | ||||||
|         if host.endswith(".local"): |     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: |             try: | ||||||
|                 _LOGGER.info("Resolving IP address of %s in mDNS", host) |                 res += socket.getaddrinfo( | ||||||
|                 addr_list = _resolve_with_zeroconf(host) |                     addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST | ||||||
|             except EsphomeError as err: |                 ) | ||||||
|                 errs.append(str(err)) |             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 |     from esphome.resolver import AsyncResolver | ||||||
|         if not addr_list: |  | ||||||
|             addr_list = [host] |  | ||||||
|  |  | ||||||
|     # Now we have a list containing either IP addresses or a hostname |     resolver = AsyncResolver(hosts, port) | ||||||
|     res = [] |     addr_infos = resolver.resolve() | ||||||
|     for addr in addr_list: |     # Convert aioesphomeapi AddrInfo to our format | ||||||
|         if not is_ip_address(addr): |     for addr_info in addr_infos: | ||||||
|             _LOGGER.info("Resolving IP address of %s", host) |         sockaddr = addr_info.sockaddr | ||||||
|         try: |         if addr_info.family == socket.AF_INET6: | ||||||
|             r = socket.getaddrinfo(addr, port, proto=socket.IPPROTO_TCP) |             # IPv6 | ||||||
|         except OSError as err: |             sockaddr_tuple = ( | ||||||
|             errs.append(str(err)) |                 sockaddr.address, | ||||||
|             raise EsphomeError( |                 sockaddr.port, | ||||||
|                 f"Error resolving IP address: {', '.join(errs)}" |                 sockaddr.flowinfo, | ||||||
|             ) from err |                 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 |     # Sort by preference | ||||||
|     # the link. Put those last in the list to be attempted. |  | ||||||
|     res.sort(key=addr_preference_) |     res.sort(key=addr_preference_) | ||||||
|     return res |     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 |     # First "resolve" all the IP addresses to getaddrinfo() tuples of the form | ||||||
|     # (family, type, proto, canonname, sockaddr) |     # (family, type, proto, canonname, sockaddr) | ||||||
|     res: list[ |     res: list[AddrInfo] = [] | ||||||
|         tuple[ |  | ||||||
|             int, |  | ||||||
|             int, |  | ||||||
|             int, |  | ||||||
|             str | None, |  | ||||||
|             tuple[str, int] | tuple[str, int, int, int], |  | ||||||
|         ] |  | ||||||
|     ] = [] |  | ||||||
|     for addr in address_list: |     for addr in address_list: | ||||||
|         # This should always work as these are supposed to be IP addresses |         # This should always work as these are supposed to be IP addresses | ||||||
|         try: |         try: | ||||||
|   | |||||||
							
								
								
									
										67
									
								
								esphome/resolver.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								esphome/resolver.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | |||||||
|  | """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(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, | ||||||
|  |     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, 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) -> None: | ||||||
|  |         """Resolve hostnames to IP addresses.""" | ||||||
|  |         try: | ||||||
|  |             self.result = await hr.async_resolve_host( | ||||||
|  |                 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 | ||||||
|  |             # Otherwise the thread could hang forever | ||||||
|  |             self.exception = e | ||||||
|  |         finally: | ||||||
|  |             self.event.set() | ||||||
|  |  | ||||||
|  |     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 | ||||||
|  |         ):  # Give it 1 second more than the resolver timeout | ||||||
|  |             raise EsphomeError("Timeout resolving IP address") | ||||||
|  |  | ||||||
|  |         if exc := self.exception: | ||||||
|  |             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 | ||||||
| @@ -11,8 +11,8 @@ pyserial==3.5 | |||||||
| platformio==6.1.18  # When updating platformio, also update /docker/Dockerfile | platformio==6.1.18  # When updating platformio, also update /docker/Dockerfile | ||||||
| esptool==5.0.2 | esptool==5.0.2 | ||||||
| click==8.1.7 | click==8.1.7 | ||||||
| esphome-dashboard==20250828.0 | esphome-dashboard==20250904.0 | ||||||
| aioesphomeapi==39.0.1 | aioesphomeapi==40.0.0 | ||||||
| zeroconf==0.147.0 | zeroconf==0.147.0 | ||||||
| puremagic==1.30 | puremagic==1.30 | ||||||
| ruamel.yaml==0.18.15 # dashboard_import | ruamel.yaml==0.18.15 # dashboard_import | ||||||
|   | |||||||
| @@ -1,13 +1,13 @@ | |||||||
| pylint==3.3.8 | pylint==3.3.8 | ||||||
| flake8==7.3.0  # also change in .pre-commit-config.yaml when updating | 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 | pyupgrade==3.20.0  # also change in .pre-commit-config.yaml when updating | ||||||
| pre-commit | pre-commit | ||||||
|  |  | ||||||
| # Unit tests | # Unit tests | ||||||
| pytest==8.4.1 | pytest==8.4.2 | ||||||
| pytest-cov==6.2.1 | pytest-cov==6.2.1 | ||||||
| pytest-mock==3.14.1 | pytest-mock==3.15.0 | ||||||
| pytest-asyncio==1.1.0 | pytest-asyncio==1.1.0 | ||||||
| pytest-xdist==3.8.0 | pytest-xdist==3.8.0 | ||||||
| asyncmock==0.4.2 | asyncmock==0.4.2 | ||||||
|   | |||||||
| @@ -1,8 +1,14 @@ | |||||||
|  | import logging | ||||||
|  | import socket | ||||||
|  | from unittest.mock import patch | ||||||
|  |  | ||||||
|  | from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr | ||||||
| from hypothesis import given | from hypothesis import given | ||||||
| from hypothesis.strategies import ip_addresses | from hypothesis.strategies import ip_addresses | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from esphome import helpers | from esphome import helpers | ||||||
|  | from esphome.core import EsphomeError | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
| @@ -277,3 +283,314 @@ def test_sort_ip_addresses(text: list[str], expected: list[str]) -> None: | |||||||
|     actual = helpers.sort_ip_addresses(text) |     actual = helpers.sort_ip_addresses(text) | ||||||
|  |  | ||||||
|     assert actual == expected |     assert actual == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # DNS resolution tests | ||||||
|  | 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 | ||||||
|  |     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() -> 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 | ||||||
|  |     assert helpers.is_ip_address("fe80::1") is True | ||||||
|  |     assert helpers.is_ip_address("::") is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  |     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() -> 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] 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() -> 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] 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 | ||||||
|  |     assert result[0][4][0] == "::1"  # address | ||||||
|  |     assert result[0][4][1] == 6053  # port | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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) | ||||||
|  |  | ||||||
|  |     # 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] 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_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( | ||||||
|  |         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.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) | ||||||
|  |         MockResolver.assert_called_once_with(["test.local"], 6053) | ||||||
|  |         mock_resolver.resolve.assert_called_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_resolve_ip_address_mixed_list() -> None: | ||||||
|  |     """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.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" | ||||||
|  |         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: | ||||||
|  |     """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.resolve.return_value = [mock_addr_info] | ||||||
|  |  | ||||||
|  |         result = helpers.resolve_ip_address("http://test.local", 6053) | ||||||
|  |  | ||||||
|  |         assert len(result) == 1 | ||||||
|  |         MockResolver.assert_called_once_with(["test.local"], 6053) | ||||||
|  |         mock_resolver.resolve.assert_called_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_resolve_ip_address_ipv6_conversion() -> None: | ||||||
|  |     """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.resolve.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() -> None: | ||||||
|  |     """Test error handling from AsyncResolver.""" | ||||||
|  |     with patch("esphome.resolver.AsyncResolver") as MockResolver: | ||||||
|  |         mock_resolver = MockResolver.return_value | ||||||
|  |         mock_resolver.resolve.side_effect = EsphomeError("Resolution failed") | ||||||
|  |  | ||||||
|  |         with pytest.raises(EsphomeError, match="Resolution failed"): | ||||||
|  |             helpers.resolve_ip_address("test.local", 6053) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_addr_preference_ipv4() -> None: | ||||||
|  |     """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() -> None: | ||||||
|  |     """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() -> None: | ||||||
|  |     """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() -> None: | ||||||
|  |     """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() -> None: | ||||||
|  |     """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.resolve.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) | ||||||
|   | |||||||
							
								
								
									
										169
									
								
								tests/unit_tests/test_resolver.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								tests/unit_tests/test_resolver.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,169 @@ | |||||||
|  | """Tests for the DNS resolver module.""" | ||||||
|  |  | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import re | ||||||
|  | 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() -> AddrInfo: | ||||||
|  |     """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() -> AddrInfo: | ||||||
|  |     """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: AddrInfo) -> None: | ||||||
|  |     """Test successful DNS resolution.""" | ||||||
|  |     with patch( | ||||||
|  |         "esphome.resolver.hr.async_resolve_host", | ||||||
|  |         return_value=[mock_addr_info_ipv4], | ||||||
|  |     ) as mock_resolve: | ||||||
|  |         resolver = AsyncResolver(["test.local"], 6053) | ||||||
|  |         result = resolver.resolve() | ||||||
|  |  | ||||||
|  |         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: AddrInfo, mock_addr_info_ipv6: AddrInfo | ||||||
|  | ) -> None: | ||||||
|  |     """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(["test1.local", "test2.local"], 6053) | ||||||
|  |         result = resolver.resolve() | ||||||
|  |  | ||||||
|  |         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() -> None: | ||||||
|  |     """Test handling of ResolveAPIError.""" | ||||||
|  |     error_msg = "Failed to resolve" | ||||||
|  |     with patch( | ||||||
|  |         "esphome.resolver.hr.async_resolve_host", | ||||||
|  |         side_effect=ResolveAPIError(error_msg), | ||||||
|  |     ): | ||||||
|  |         resolver = AsyncResolver(["test.local"], 6053) | ||||||
|  |         with pytest.raises( | ||||||
|  |             EsphomeError, match=re.escape(f"Error resolving IP address: {error_msg}") | ||||||
|  |         ): | ||||||
|  |             resolver.resolve() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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(["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.resolve() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_async_resolver_generic_exception() -> None: | ||||||
|  |     """Test handling of generic exceptions.""" | ||||||
|  |     error = RuntimeError("Unexpected error") | ||||||
|  |     with patch( | ||||||
|  |         "esphome.resolver.hr.async_resolve_host", | ||||||
|  |         side_effect=error, | ||||||
|  |     ): | ||||||
|  |         resolver = AsyncResolver(["test.local"], 6053) | ||||||
|  |         with pytest.raises(RuntimeError, match="Unexpected error"): | ||||||
|  |             resolver.resolve() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_async_resolver_thread_timeout() -> None: | ||||||
|  |     """Test timeout when thread doesn't complete in time.""" | ||||||
|  |     # 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.resolve() | ||||||
|  |  | ||||||
|  |         # Verify thread start was called | ||||||
|  |         resolver.start.assert_called_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: | ||||||
|  |     """Test resolving IP addresses.""" | ||||||
|  |     with patch( | ||||||
|  |         "esphome.resolver.hr.async_resolve_host", | ||||||
|  |         return_value=[mock_addr_info_ipv4], | ||||||
|  |     ) as mock_resolve: | ||||||
|  |         resolver = AsyncResolver(["192.168.1.100"], 6053) | ||||||
|  |         result = resolver.resolve() | ||||||
|  |  | ||||||
|  |         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: 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] | ||||||
|  |  | ||||||
|  |     with patch( | ||||||
|  |         "esphome.resolver.hr.async_resolve_host", | ||||||
|  |         return_value=mock_results, | ||||||
|  |     ) as mock_resolve: | ||||||
|  |         resolver = AsyncResolver(["test.local", "192.168.1.100", "::1"], 6053) | ||||||
|  |         result = resolver.resolve() | ||||||
|  |  | ||||||
|  |         assert result == mock_results | ||||||
|  |         mock_resolve.assert_called_once_with( | ||||||
|  |             ["test.local", "192.168.1.100", "::1"], 6053, timeout=RESOLVE_TIMEOUT | ||||||
|  |         ) | ||||||
		Reference in New Issue
	
	Block a user