diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 4c4b4e5c9c7..da2e8632817 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -edce6cd78b33b296cef3caa5869d237061345eb346e3f9cb21e3239d2711051f +96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index ea22f75ef04..2d000658a2d 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -6,9 +6,10 @@ on: pull_request_target: types: [labeled, opened, reopened, synchronize, edited] +# All PR/label/review writes are performed with the App token minted below, +# so the workflow's GITHUB_TOKEN only needs read access for checkout. permissions: - pull-requests: write - contents: read + contents: read # actions/checkout reads the workflow source env: SMALL_PR_THRESHOLD: 30 @@ -31,6 +32,10 @@ jobs: with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # Scope the minted App token to the minimum needed by auto-label-pr/*.js. + permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js + permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment + permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview - name: Auto Label PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index e5143911d94..2f7fd271ba2 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -12,8 +12,8 @@ on: - ".github/workflows/ci-api-proto.yml" permissions: - contents: read - pull-requests: write + contents: read # actions/checkout for the PR head + pull-requests: write # pulls.createReview / listReviews / dismissReview when generated proto files are stale jobs: check: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 40cdff0cba7..d9148fb06dc 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -12,8 +12,8 @@ on: - ".github/workflows/ci-clang-tidy-hash.yml" permissions: - contents: read - pull-requests: write + contents: read # actions/checkout for the PR head + pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date jobs: verify-hash: diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 4009ac1e174..3fd17888c7c 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,8 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read - packages: read + contents: read # actions/checkout only; the build does not push images concurrency: # yamllint disable-line rule:line-length diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index fbcf5ea5841..025b9609859 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -7,9 +7,9 @@ on: types: [completed] permissions: - contents: read - pull-requests: write - actions: read + contents: read # actions/checkout of the base repo at the PR's target branch + pull-requests: write # gh api to look up the PR by head SHA and post/update the memory-impact comment + actions: read # gh run download for the memory-analysis artifacts produced by the CI workflow run jobs: memory-impact-comment: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9909d7a5ddf..7cb8e07afa8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: merge_group: permissions: - contents: read + contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write env: DEFAULT_PYTHON: "3.11" @@ -1147,8 +1147,8 @@ jobs: - memory-impact-pr-branch if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true' && needs.memory-impact-target-branch.outputs.skip != 'true' permissions: - contents: read - pull-requests: write + contents: read # actions/checkout to load the comment-posting script + pull-requests: write # ci_memory_impact_comment.py posts/updates the memory-impact comment on the PR env: GH_TOKEN: ${{ github.token }} steps: diff --git a/.github/workflows/close-pr-from-fork-default-branch.yml b/.github/workflows/close-pr-from-fork-default-branch.yml index 1cd70f5efcd..5180a071806 100644 --- a/.github/workflows/close-pr-from-fork-default-branch.yml +++ b/.github/workflows/close-pr-from-fork-default-branch.yml @@ -6,8 +6,8 @@ on: types: [opened, reopened] permissions: - pull-requests: write - issues: write + pull-requests: write # pulls.update to close the PR opened from a fork's default branch + issues: write # issues.createComment to explain to the contributor why the PR was closed jobs: close: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 49653b6fb3b..013517bde6d 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -15,9 +15,9 @@ on: - beta permissions: - issues: write - pull-requests: read - contents: read + issues: write # issues.addLabels / removeLabel to manage the 'code-owner-approved' label on the PR + pull-requests: read # listReviews to determine whether a codeowner has approved + contents: read # actions/checkout to read CODEOWNERS and the shared codeowners.js helper jobs: codeowner-approved: diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 76be6ecd7bf..cd6c1d34c65 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -17,9 +17,10 @@ on: - release - beta +# PR/review writes (requestReviewers, issues.createComment) are performed with the App token minted below, +# so the workflow's GITHUB_TOKEN only needs read access for checkout. permissions: - pull-requests: write - contents: read + contents: read # actions/checkout to read CODEOWNERS and the shared codeowners.js helper jobs: request-codeowner-reviews: @@ -32,9 +33,20 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # Scope the minted App token to the minimum needed by the github-script step below. + permission-pull-requests: write # pulls.listFiles, pulls.get, pulls.listReviews, pulls.requestReviewers + permission-issues: write # issues.listComments and issues.createComment (PR comments use the issues API) + - name: Request reviews from component codeowners uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + github-token: ${{ steps.generate-token.outputs.token }} script: | const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 949e45e45c9..0a4dd9a92dd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -16,6 +16,9 @@ on: schedule: - cron: "30 18 * * 4" +# Deny by default; the analyze job opts in to exactly what it needs. +permissions: {} + jobs: analyze: name: Analyze (${{ matrix.language }}) @@ -26,15 +29,10 @@ jobs: # Consider using larger runners or machines with greater resources for possible analysis time improvements. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} permissions: - # required for all workflows - security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read + security-events: write # upload CodeQL SARIF results to the Code Scanning API + packages: read # fetch internal or private CodeQL query packs + actions: read # required by codeql-action when run from a private repo + contents: read # actions/checkout to scan the repository strategy: fail-fast: false diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 3165b170780..2e96bec1dea 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -4,20 +4,29 @@ on: pull_request_target: types: [opened, synchronize] -permissions: - contents: read # Needed to fetch PR details - issues: write # Needed to create and update comments (PR comments are managed via the issues REST API) - pull-requests: write # also needed? +# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with +# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes. +permissions: {} jobs: external-comment: name: External component comment runs-on: ubuntu-latest steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources + # the issues.*Comment APIs require the pull-requests scope, not issues. + permission-pull-requests: write + - name: Add external component comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | // Generate external component usage instructions function generateExternalComponentInstructions(prNumber, componentNames, owner, repo) { diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml index b211c13985d..bc892b64e0b 100644 --- a/.github/workflows/issue-codeowner-notify.yml +++ b/.github/workflows/issue-codeowner-notify.yml @@ -9,8 +9,8 @@ on: types: [labeled] permissions: - issues: write - contents: read + issues: write # issues.createComment to mention component codeowners on the newly labelled issue + contents: read # repos.getContent to fetch CODEOWNERS from the default branch jobs: notify-codeowners: diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 8d1dfe857de..5e701176524 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -6,6 +6,12 @@ on: - cron: "30 0 * * *" # Run daily at 00:30 UTC workflow_dispatch: +# Deny by default; the lock job opts in to exactly what the reusable workflow needs. +permissions: {} + jobs: lock: + permissions: + issues: write # issues.lock on closed issues + pull-requests: write # issues.lock on closed pull requests uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 87009962719..ed0bff9664f 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -8,8 +8,8 @@ on: - beta permissions: - contents: read - pull-requests: read + contents: read # actions/checkout to load detect-tags.js + pull-requests: read # pulls.listFiles to map changed files to component/core/dashboard/ci tags jobs: check: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a16af92b6fb..d07c8fe6333 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: - cron: "0 2 * * *" permissions: - contents: read + contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write jobs: init: @@ -57,8 +57,8 @@ jobs: if: github.repository == 'esphome/esphome' && github.event_name == 'release' runs-on: ubuntu-latest permissions: - contents: read - id-token: write + contents: read # actions/checkout to build the sdist/wheel + id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python @@ -78,8 +78,8 @@ jobs: name: Build ESPHome ${{ matrix.platform.arch }} if: github.repository == 'esphome/esphome' permissions: - contents: read - packages: write + contents: read # actions/checkout to load Dockerfile and build context + packages: write # docker/login-action + build-push-action push image digests to ghcr.io runs-on: ${{ matrix.platform.os }} needs: [init] strategy: @@ -152,8 +152,8 @@ jobs: - deploy-docker if: github.repository == 'esphome/esphome' permissions: - contents: read - packages: write + contents: read # actions/checkout to load Dockerfile and build context + packages: write # docker/login-action + build-push-action push image digests to ghcr.io strategy: fail-fast: false matrix: @@ -227,6 +227,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: home-assistant-addon + permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - name: Trigger Workflow uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -262,6 +263,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: esphome-schema + permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - name: Trigger Workflow uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -293,6 +295,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: version-notifier + permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - name: Trigger Workflow uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ba5c32e0167..2e57093bbb9 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -7,8 +7,8 @@ on: workflow_dispatch: permissions: - issues: write - pull-requests: write + issues: write # actions/stale labels, comments on, and closes stale issues + pull-requests: write # actions/stale labels, comments on, and closes stale pull requests concurrency: group: lock diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 709342e5ae6..d27cc0cbec2 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, reopened, labeled, unlabeled, synchronize] +permissions: + pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr) + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index be1457387d9..c6c829fbb4e 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -6,12 +6,27 @@ on: schedule: - cron: "45 6 * * *" +# Repo writes (branch push, PR open) happen via the App token minted below, +# so the workflow's GITHUB_TOKEN does not need any write scopes. +permissions: + contents: read # actions/checkout for this repo and home-assistant/core + jobs: sync: name: Sync Device Classes runs-on: ubuntu-latest if: github.repository == 'esphome/esphome' steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # Scope the minted App token to the minimum needed by peter-evans/create-pull-request. + permission-contents: write # git.createCommit + refs.create/update to push the sync/device-classes branch + permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -50,4 +65,4 @@ jobs: delete-branch: true title: "Synchronise Device Classes from Home Assistant" body-path: .github/PULL_REQUEST_TEMPLATE.md - token: ${{ secrets.DEVICE_CLASS_SYNC_TOKEN }} + token: ${{ steps.generate-token.outputs.token }} diff --git a/esphome/__main__.py b/esphome/__main__.py index 06336c842c9..4ce40d48a1a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -64,6 +64,7 @@ from esphome.enum import StrEnum from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log from esphome.types import ConfigType +from esphome.upload_targets import PortType, get_port_type from esphome.util import ( PICOTOOL_PACKAGE, FlashImage, @@ -195,14 +196,6 @@ class Purpose(StrEnum): LOGGING = "logging" -class PortType(StrEnum): - SERIAL = "SERIAL" - NETWORK = "NETWORK" - MQTT = "MQTT" - MQTTIP = "MQTTIP" - BOOTSEL = "BOOTSEL" - - # Magic MQTT port types that require special handling _MQTT_PORT_TYPES = frozenset({PortType.MQTT, PortType.MQTTIP}) @@ -598,27 +591,6 @@ def _resolve_network_devices( return network_devices -def get_port_type(port: str) -> PortType: - """Determine the type of port/device identifier. - - Returns: - PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.) - PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool - PortType.MQTT for MQTT logging - PortType.MQTTIP for MQTT IP lookup - PortType.NETWORK for IP addresses, hostnames, or mDNS names - """ - if port == "BOOTSEL": - return PortType.BOOTSEL - if port.startswith("/") or port.startswith("COM"): - return PortType.SERIAL - if port == "MQTT": - return PortType.MQTT - if port == "MQTTIP": - return PortType.MQTTIP - return PortType.NETWORK - - def run_miniterm(config: ConfigType, port: str, args) -> int: from aioesphomeapi import LogParser import serial @@ -767,24 +739,24 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) if CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - rc = api.run_compile(config, CORE.verbose) + rc = toolchain.run_compile(config, CORE.verbose) if rc != 0: return rc # Create factory.bin, ota.bin, and firmware.elf copy - api.create_factory_bin() - api.create_ota_bin() - api.create_elf_copy() + toolchain.create_factory_bin() + toolchain.create_ota_bin() + toolchain.create_elf_copy() else: - from esphome import platformio_api + from esphome.platformio import toolchain - rc = platformio_api.run_compile(config, CORE.verbose) + rc = toolchain.run_compile(config, CORE.verbose) if rc != 0: return rc - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if idedata is None: return 1 @@ -880,7 +852,7 @@ def upload_using_esptool( if file is not None: flash_images = [FlashImage(path=file, offset="0x0")] elif CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain # For ESP-IDF the upload is a single factory image at 0x0 (it bundles # bootloader + partitions + app). The prebuilt-dir form ships that @@ -888,13 +860,13 @@ def upload_using_esptool( # without re-deriving the ESP-IDF build path. factory_path = ( CORE.prebuilt_artifact_path("firmware.factory.bin") - or api.get_factory_firmware_path() + or toolchain.get_factory_firmware_path() ) flash_images = [FlashImage(path=factory_path, offset="0x0")] else: - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) firmware_offset = "0x10000" if CORE.is_esp32 else "0x0" flash_images = [ @@ -1008,7 +980,7 @@ def upload_using_ltchiptool(_config: ConfigType, port: str) -> int: def upload_using_platformio(config: ConfigType, port: str) -> int: - from esphome import platformio_api + from esphome.platformio import toolchain # `upload_program` routes around this helper when --prebuilt-dir is set # (libretiny→ltchiptool, RP2040→1200bps-touch+picotool), so PlatformIO @@ -1018,7 +990,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040: - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" signed_bin = build_dir / "firmware.bin.signed" @@ -1028,15 +1000,15 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: upload_args = ["-t", "upload", "-t", "nobuild"] if port is not None: upload_args += ["--upload-port", port] - return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) + return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) def _find_picotool() -> Path | None: """Find the picotool binary from PlatformIO packages.""" - from esphome import platformio_api + from esphome.platformio import toolchain try: - idedata = platformio_api.get_idedata(CORE.config) + idedata = toolchain.get_idedata(CORE.config) except Exception: # noqa: BLE001 # pylint: disable=broad-except return None return get_picotool_path(idedata.cc_path) @@ -1049,9 +1021,9 @@ def upload_using_picotool(config: ConfigType) -> int: the mass storage copy approach that causes "disk not ejected properly" warnings on macOS. """ - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) # --prebuilt-dir ships canonical artifacts at the root of the directory, # not the full PlatformIO build tree, so the ELF may not be present. @@ -1273,9 +1245,9 @@ def _ensure_platform_packages_for_prebuilt_upload(config: ConfigType) -> int: if rc != 0: return rc - from esphome import platformio_api + from esphome.platformio import toolchain - result = platformio_api.prepare_platform_for_upload(config, CORE.verbose) + result = toolchain.prepare_platform_for_upload(config, CORE.verbose) # prepare_platform_for_upload returns str on capture_stdout=True or int # on success/failure; in our call we don't capture stdout, so it's int. return result if isinstance(result, int) else 0 @@ -1666,11 +1638,11 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: return exit_code if CORE.is_host: if CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - program_path = str(api.get_elf_path()) + program_path = str(toolchain.get_elf_path()) else: - from esphome.platformio_api import get_idedata + from esphome.platformio.toolchain import get_idedata program_path = str(get_idedata(config).firmware_elf_path) _LOGGER.info("Successfully compiled program to path '%s'", program_path) @@ -1724,11 +1696,11 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: _LOGGER.info("Successfully compiled program.") if CORE.is_host: if CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - program_path = str(api.get_elf_path()) + program_path = str(toolchain.get_elf_path()) else: - from esphome.platformio_api import get_idedata + from esphome.platformio.toolchain import get_idedata program_path = str(get_idedata(config).firmware_elf_path) _LOGGER.info("Running program from path '%s'", program_path) @@ -1928,12 +1900,12 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: ) return 1 - from esphome import platformio_api + from esphome.platformio import toolchain logging.disable(logging.INFO) logging.disable(logging.WARNING) - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if idedata is None: return 1 @@ -1962,16 +1934,16 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None if CORE.using_toolchain_esp_idf: - from esphome.espidf import api + from esphome.espidf import toolchain - objdump_path = str(api.get_objdump_path()) - readelf_path = str(api.get_readelf_path()) + objdump_path = str(toolchain.get_objdump_path()) + readelf_path = str(toolchain.get_readelf_path()) - firmware_elf = api.get_elf_path() + firmware_elf = toolchain.get_elf_path() else: - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if idedata is None: _LOGGER.error("Failed to get IDE data for memory analysis") return 1 diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index 33854ac2896..1198562218d 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -24,7 +24,7 @@ from .helpers import ( from .toolchain import find_tool, resolve_tool_path, run_tool if TYPE_CHECKING: - from esphome.platformio_api import IDEData + from esphome.platformio.toolchain import IDEData _LOGGER = logging.getLogger(__name__) diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index b7561e8ffc0..8f1f39e1d65 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -739,7 +739,7 @@ def main(): import json from pathlib import Path - from esphome.platformio_api import IDEData + from esphome.platformio.toolchain import IDEData build_path = Path(build_dir) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 3bbeae78356..a0c59a517a7 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -50,7 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.6") + add_idf_component(name="espressif/esp32-camera", ref="2.1.5") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ba32d13ab34..bb823937aa0 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2582,9 +2582,9 @@ def copy_files(): def _decode_pc(config, addr): - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if not idedata.addr2line_path or not idedata.firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 5165956806f..9883a0a43e0 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -399,7 +399,7 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.6") + add_idf_component(name="espressif/esp32-camera", ref="2.1.5") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index b6383653f4e..38df282fb98 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -463,9 +463,9 @@ ESP8266_EXCEPTION_CODES = { def _decode_pc(config, addr): - from esphome import platformio_api + from esphome.platformio import toolchain - idedata = platformio_api.get_idedata(config) + idedata = toolchain.get_idedata(config) if not idedata.addr2line_path or not idedata.firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index ee3b7f0c20d..f7793b1493c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -130,6 +130,7 @@ CONFIG_SCHEMA = cv.All( bk72xx=8892, ln882x=8820, rtl87xx=8892, + host=8082, ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.string, diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 843028fc974..f1857ed6642 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -68,7 +68,7 @@ void ESPHomeOTAComponent::setup() { return; } - err = this->server_->bind((struct sockaddr *) &server, sizeof(server)); + err = this->server_->bind((struct sockaddr *) &server, sl); if (err != 0) { this->server_failed_(LOG_STR("bind")); return; @@ -133,12 +133,12 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select - // listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back - // to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more - // iteration so a connection queued mid-session is still caught here. + // Self-disable idle loop where a wake path re-enables on listener readiness + // (fast-select, raw-TCP accept_fn_). Host BSD select doesn't, so stay enabled. if (this->client_ == nullptr && !this->server_->ready()) { +#ifndef USE_HOST this->disable_loop(); +#endif return; } this->handle_handshake_(); diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 91239758848..9292cd77f61 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -1,20 +1,92 @@ #ifdef USE_HOST +#include "core.h" + #include "esphome/core/application.h" #include "preferences.h" +#include #include +#include +#include + +#ifdef __APPLE__ +#include +#endif + +#ifdef __linux__ +#include +#endif namespace { volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void signal_handler(int signal) { s_signal_received = signal; } + +char **s_argv = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::string *s_exe_path = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::string *s_reexec_path = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +std::string resolve_exe_path(const char *argv0) { +#ifdef __linux__ + char buf[PATH_MAX]; + ssize_t len = ::readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (len > 0) { + buf[len] = '\0'; + return std::string(buf); + } +#endif +#ifdef __APPLE__ + char buf[PATH_MAX]; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) == 0) { + char real[PATH_MAX]; + if (::realpath(buf, real) != nullptr) + return std::string(real); + return std::string(buf); + } +#endif + if (argv0 == nullptr) + return {}; + char real[PATH_MAX]; + if (::realpath(argv0, real) != nullptr) + return std::string(real); + return std::string(argv0); +} } // namespace +namespace esphome::host { + +char **get_argv() { return s_argv; } + +const std::string &get_exe_path() { + static const std::string empty; + return s_exe_path != nullptr ? *s_exe_path : empty; +} + +void arm_reexec(const std::string &path) { + if (s_reexec_path != nullptr) + *s_reexec_path = path; +} + +const char *get_reexec_path() { + if (s_reexec_path == nullptr || s_reexec_path->empty()) + return nullptr; + return s_reexec_path->c_str(); +} + +} // namespace esphome::host + // HAL functions live in hal.cpp. void setup(); void loop(); -int main() { +int main(int argc, char **argv) { + s_argv = argv; + static std::string exe_path = resolve_exe_path(argc > 0 ? argv[0] : nullptr); + s_exe_path = &exe_path; + static std::string reexec_path; + s_reexec_path = &reexec_path; + // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); diff --git a/esphome/components/host/core.h b/esphome/components/host/core.h new file mode 100644 index 00000000000..ab64119415b --- /dev/null +++ b/esphome/components/host/core.h @@ -0,0 +1,22 @@ +#pragma once +#ifdef USE_HOST + +#include + +namespace esphome::host { + +/// argv captured by main(); stable for process lifetime. +char **get_argv(); + +/// Absolute path to running exe (resolved at startup); empty on failure. +const std::string &get_exe_path(); + +/// Arm an execv on the next arch_restart(). Pass empty to disarm. +void arm_reexec(const std::string &path); + +/// Armed re-exec path, or nullptr. +const char *get_reexec_path(); + +} // namespace esphome::host + +#endif // USE_HOST diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index c7fef8d2e86..9108c1ea9d3 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -2,10 +2,14 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "core.h" #include +#include #include #include +#include // Empty host namespace block to satisfy ci-custom's lint_namespace check. // HAL functions live in namespace esphome (root) — they are not part of the @@ -50,7 +54,19 @@ void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { res = nanosleep(&ts, &ts); } while (res != 0 && errno == EINTR); } -void arch_restart() { exit(0); } +void arch_restart() { + // Host OTA: if a re-exec is armed, swap binaries instead of exiting. + if (const char *target = host::get_reexec_path()) { + char **argv = host::get_argv(); + if (argv != nullptr) { + execv(target, argv); + // execv only returns on failure. + ESP_LOGE("host", "execv('%s') failed: %s", target, std::strerror(errno)); + exit(1); + } + } + exit(0); +} uint32_t arch_get_cpu_cycle_count() { struct timespec spec; diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 23b69c36c60..a035525101d 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -243,6 +243,9 @@ void Logger::dump_config() { #endif #ifdef USE_ZEPHYR dump_crash_(); + if (!device_is_ready(this->uart_dev_)) { + ESP_LOGE(TAG, " %s is not ready.", LOG_STR_ARG(get_uart_selection_())); + } #endif // Warn users that VERBOSE/VERY_VERBOSE logging impacts performance. // Only the compiled log level matters — all log calls up to this level diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index e9caa8d9d99..240bcc57c79 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -74,9 +74,7 @@ void Logger::pre_setup() { break; #endif } - if (!device_is_ready(uart_dev)) { - ESP_LOGE(TAG, "%s is not ready.", LOG_STR_ARG(get_uart_selection_())); - } else { + if (device_is_ready(uart_dev)) { this->uart_dev_ = uart_dev; #if defined(USE_LOGGER_WAIT_FOR_CDC) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) uint32_t dtr = 0; diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2335e947fdc..242a55b5167 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -9,6 +9,7 @@ import subprocess from esphome import pins import esphome.codegen as cg from esphome.components.zephyr import ( + add_extra_script, copy_files as zephyr_copy_files, zephyr_add_overlay, zephyr_add_pm_static, @@ -21,6 +22,7 @@ from esphome.components.zephyr import ( from esphome.components.zephyr.const import ( BOOTLOADER_MCUBOOT, CONF_CDC_ACM, + KEY_BOARD, KEY_BOOTLOADER, KEY_ZEPHYR, CdcAcm, @@ -36,6 +38,7 @@ from esphome.const import ( CONF_OTA, CONF_RESET_PIN, CONF_SAFE_MODE, + CONF_TOOLCHAIN, CONF_VERSION, CONF_VOLTAGE, KEY_CORE, @@ -44,10 +47,12 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PLATFORM_NRF52, ThreadModel, + Toolchain, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv +from esphome.helpers import write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -67,8 +72,35 @@ AUTO_LOAD = ["zephyr", "preferences"] IS_TARGET_PLATFORM = True _LOGGER = logging.getLogger(__name__) +FAKE_BOARD_MANIFEST = """ +{ + "frameworks": [ + "zephyr" + ], + "name": "esphome nrf52", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200 + }, + "url": "https://esphome.io/", + "vendor": "esphome", + "build": { + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_fwid": "0x00B6" + } + } +} +""" + def set_core_data(config: ConfigType) -> ConfigType: + # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) zephyr_set_core_data(config) CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR @@ -80,10 +112,18 @@ def set_core_data(config: ConfigType) -> ConfigType: def set_framework(config: ConfigType) -> ConfigType: + if CONF_VERSION not in config[CONF_FRAMEWORK]: + default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" + config = { + **config, + CONF_FRAMEWORK: {**config[CONF_FRAMEWORK], CONF_VERSION: default_version}, + } framework_ver = cv.Version.parse( cv.version_number(config[CONF_FRAMEWORK][CONF_VERSION]) ) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver + if not CORE.using_toolchain_platformio: + return config if framework_ver < cv.Version(2, 9, 2): return cv.require_framework_version( nrf52_zephyr=cv.Version(2, 6, 1, "a"), @@ -182,7 +222,7 @@ CONFIG_SCHEMA = cv.All( default={}, ): cv.Schema( { - cv.Optional(CONF_VERSION, default="2.6.1-a"): cv.string_strict, + cv.Optional(CONF_VERSION): cv.string_strict, cv.Optional(CONF_ADVANCED, default={}): cv.Schema( { cv.Optional( @@ -238,40 +278,51 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config: ConfigType) -> None: """Convert the configuration to code.""" - cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_NRF52") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", "NRF52") # nRF52 processors are single-core cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option(CONF_FRAMEWORK, CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK]) - cg.add_platformio_option( - "platform", - "https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-5.zip", - ) - cg.add_platformio_option( - "platform_packages", - [ - f"platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v{CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}.zip", - ], - ) + if CORE.using_toolchain_platformio: + cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_platformio_option( + CONF_FRAMEWORK, CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] + ) + cg.add_platformio_option( + "platform", + "https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-5.zip", + ) + cg.add_platformio_option( + "platform_packages", + [ + f"platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v{CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}.zip", + ], + ) + if config[KEY_BOOTLOADER] != BOOTLOADER_MCUBOOT: + # make sure that firmware.zip is created + # for Adafruit_nRF52_Bootloader + cg.add_platformio_option("board_upload.protocol", "nrfutil") + cg.add_platformio_option("board_upload.use_1200bps_touch", "true") + cg.add_platformio_option("board_upload.require_upload_port", "true") + cg.add_platformio_option("board_upload.wait_for_upload_port", "true") + + add_extra_script( + "pre", + "pre_build.py", + Path(__file__).parent / "pre_build.py.script", + ) + # build is done by west so bypass board checking in platformio + cg.add_platformio_option("boards_dir", CORE.relative_build_path("boards")) if config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: cg.add_define("USE_BOOTLOADER_MCUBOOT") - else: - if "_sd" in config[KEY_BOOTLOADER]: - bootloader = config[KEY_BOOTLOADER].split("_") - sd_id = bootloader[2][2:] - cg.add_define("USE_SOFTDEVICE_ID", int(sd_id)) - if (len(bootloader)) > 3: - sd_version = bootloader[3][1:] - cg.add_define("USE_SOFTDEVICE_VERSION", int(sd_version)) - # make sure that firmware.zip is created - # for Adafruit_nRF52_Bootloader - cg.add_platformio_option("board_upload.protocol", "nrfutil") - cg.add_platformio_option("board_upload.use_1200bps_touch", "true") - cg.add_platformio_option("board_upload.require_upload_port", "true") - cg.add_platformio_option("board_upload.wait_for_upload_port", "true") + elif "_sd" in config[KEY_BOOTLOADER]: + bootloader = config[KEY_BOOTLOADER].split("_") + sd_id = bootloader[2][2:] + cg.add_define("USE_SOFTDEVICE_ID", int(sd_id)) + if (len(bootloader)) > 3: + sd_version = bootloader[3][1:] + cg.add_define("USE_SOFTDEVICE_VERSION", int(sd_version)) zephyr_setup_preferences() zephyr_to_code(config) @@ -341,6 +392,16 @@ async def _dfu_to_code(dfu_config): def copy_files() -> None: """Copy files to the build directory.""" + + if CORE.using_toolchain_platformio and ( + zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT + or zephyr_data()[KEY_BOARD] == "xiao_ble" + ): + write_file_if_changed( + CORE.relative_build_path(f"boards/{zephyr_data()[KEY_BOARD]}.json"), + FAKE_BOARD_MANIFEST, + ) + zephyr_copy_files() @@ -397,23 +458,26 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: def _upload_using_platformio( config: ConfigType, port: str, upload_args: list[str] ) -> int | str: - from esphome import platformio_api + from esphome.platformio import toolchain if port is not None: upload_args += ["--upload-port", port] - return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) + return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) def upload_program(config: ConfigType, args, host: str) -> bool: - from esphome.__main__ import check_permissions, get_port_type + from esphome.__main__ import check_permissions + from esphome.upload_targets import PortType, get_port_type mcumgr_device: str | None = None - if get_port_type(host) == "SERIAL": + if get_port_type(host) == PortType.SERIAL: check_permissions(host) if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: mcumgr_device = host else: + if not CORE.using_toolchain_platformio: + raise EsphomeError("Not implemented yet") result = _upload_using_platformio(config, host, ["-t", "upload"]) if result != 0: raise EsphomeError(f"Upload failed with result: {result}") diff --git a/esphome/components/nrf52/boards.py b/esphome/components/nrf52/boards.py index 4c33cd99399..564bf560d69 100644 --- a/esphome/components/nrf52/boards.py +++ b/esphome/components/nrf52/boards.py @@ -25,6 +25,14 @@ BOARDS_ZEPHYR = { BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, ] }, + "adafruit_itsybitsy": { + KEY_BOOTLOADER: [ + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ] + }, } # https://github.com/ffenix113/zigbee_home/blob/17bb7b9e9d375e756da9e38913f53303937fb66a/types/board/known_boards.go diff --git a/esphome/components/zephyr/pre_build.py.script b/esphome/components/nrf52/pre_build.py.script similarity index 100% rename from esphome/components/zephyr/pre_build.py.script rename to esphome/components/nrf52/pre_build.py.script diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 21dad4f8670..8557427096f 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -2,8 +2,6 @@ #ifdef USE_OPENTHREAD #include "openthread.h" -#include - #include #include #include diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 579491fe1a3..83d8c611d5e 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,5 +1,3 @@ -import logging - from esphome import automation import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform @@ -38,8 +36,6 @@ CONF_ON_PROGRESS = "on_progress" CONF_ON_STATE_CHANGE = "on_state_change" -_LOGGER = logging.getLogger(__name__) - ota_ns = cg.esphome_ns.namespace("ota") OTAComponent = ota_ns.class_("OTAComponent", cg.Component) OTAState = ota_ns.enum("OTAState") @@ -58,10 +54,6 @@ def _ota_final_validate(config): raise cv.Invalid( f"At least one platform must be specified for '{CONF_OTA}'; add '{CONF_PLATFORM}: {CONF_ESPHOME}' for original OTA functionality" ) - if CORE.is_host: - _LOGGER.warning( - "OTA not available for platform 'host'. OTA functionality disabled." - ) FINAL_VALIDATE_SCHEMA = _ota_final_validate @@ -172,5 +164,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "ota_backend_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index a2c9f2cc33a..ee503a49e16 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -1,26 +1,339 @@ #ifdef USE_HOST #include "ota_backend_host.h" -#include "esphome/core/defines.h" +#include "esphome/components/host/core.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include + +#include +#include +#include + +#ifdef __linux__ +#include +#include +#endif + +#ifdef __APPLE__ +#include +#endif namespace esphome::ota { -// Stub implementation - OTA is not supported on host platform. -// All methods return error codes to allow compilation of configs with OTA triggers. +namespace { + +const char *const TAG = "ota.host"; + +constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB +constexpr size_t HEADER_PEEK_SIZE = 64; + +ssize_t read_header_(const char *path, uint8_t *buf, size_t len) { + int fd = ::open(path, O_RDONLY); + if (fd < 0) + return -1; + ssize_t got = ::read(fd, buf, len); + ::close(fd); + return got; +} + +#ifdef __linux__ +struct ElfIdent { + bool valid; + uint8_t ei_class; + uint8_t ei_data; + uint16_t e_machine; + uint16_t e_type; +}; + +ElfIdent parse_elf_(const uint8_t *buf, size_t len) { + ElfIdent out{}; + if (len < EI_NIDENT + 4) + return out; + if (buf[EI_MAG0] != ELFMAG0 || buf[EI_MAG1] != ELFMAG1 || buf[EI_MAG2] != ELFMAG2 || buf[EI_MAG3] != ELFMAG3) + return out; + out.ei_class = buf[EI_CLASS]; + out.ei_data = buf[EI_DATA]; + // e_type @ 16, e_machine @ 18, both in EI_DATA endianness. + uint16_t e_type; + uint16_t e_machine; + std::memcpy(&e_type, buf + 16, sizeof(e_type)); + std::memcpy(&e_machine, buf + 18, sizeof(e_machine)); + if (out.ei_data == ELFDATA2LSB) { + out.e_type = le16toh(e_type); + out.e_machine = le16toh(e_machine); + } else if (out.ei_data == ELFDATA2MSB) { + out.e_type = be16toh(e_type); + out.e_machine = be16toh(e_machine); + } else { + return out; + } + out.valid = true; + return out; +} + +bool validate_elf_(const char *staging_path, const std::string &exe_path) { + uint8_t new_buf[HEADER_PEEK_SIZE]; + uint8_t cur_buf[HEADER_PEEK_SIZE]; + ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + if (new_n < static_cast(EI_NIDENT + 4) || cur_n < static_cast(EI_NIDENT + 4)) { + ESP_LOGE(TAG, "ELF header read failed"); + return false; + } + ElfIdent new_id = parse_elf_(new_buf, new_n); + ElfIdent cur_id = parse_elf_(cur_buf, cur_n); + if (!new_id.valid) { + ESP_LOGE(TAG, "Uploaded payload is not a valid ELF"); + return false; + } + if (!cur_id.valid) { + ESP_LOGE(TAG, "Could not parse running exe ELF header"); + return false; + } + if (new_id.ei_class != cur_id.ei_class) { + ESP_LOGE(TAG, "ELF class mismatch (uploaded=%u, running=%u)", new_id.ei_class, cur_id.ei_class); + return false; + } + if (new_id.ei_data != cur_id.ei_data) { + ESP_LOGE(TAG, "ELF endianness mismatch"); + return false; + } + if (new_id.e_machine != cur_id.e_machine) { + ESP_LOGE(TAG, "ELF e_machine mismatch (uploaded=0x%04x, running=0x%04x)", new_id.e_machine, cur_id.e_machine); + return false; + } + if (new_id.e_type != ET_EXEC && new_id.e_type != ET_DYN) { + ESP_LOGE(TAG, "ELF e_type=%u is not executable", new_id.e_type); + return false; + } + return true; +} +#endif // __linux__ + +#ifdef __APPLE__ +struct MachOIdent { + bool valid; + uint32_t cputype; + uint32_t cpusubtype; +}; + +MachOIdent parse_macho_(const uint8_t *buf, size_t len) { + MachOIdent out{}; + // mach_header is the common prefix of mach_header and mach_header_64; + // cputype/cpusubtype/filetype have identical offsets in both. + if (len < sizeof(struct mach_header)) + return out; + uint32_t magic; + std::memcpy(&magic, buf, sizeof(magic)); + bool swap; + if (magic == MH_MAGIC || magic == MH_MAGIC_64) { + swap = false; + } else if (magic == MH_CIGAM || magic == MH_CIGAM_64) { + swap = true; + } else { + return out; + } + struct mach_header hdr; + std::memcpy(&hdr, buf, sizeof(hdr)); + if (swap) { + hdr.cputype = OSSwapInt32(hdr.cputype); + hdr.cpusubtype = OSSwapInt32(hdr.cpusubtype); + hdr.filetype = OSSwapInt32(hdr.filetype); + } + if (hdr.filetype != MH_EXECUTE) + return out; + out.cputype = hdr.cputype; + out.cpusubtype = hdr.cpusubtype; + out.valid = true; + return out; +} + +bool validate_macho_(const char *staging_path, const std::string &exe_path) { + uint8_t new_buf[HEADER_PEEK_SIZE]; + uint8_t cur_buf[HEADER_PEEK_SIZE]; + ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + if (new_n < static_cast(sizeof(struct mach_header)) || + cur_n < static_cast(sizeof(struct mach_header))) { + ESP_LOGE(TAG, "Mach-O header read failed"); + return false; + } + MachOIdent new_id = parse_macho_(new_buf, new_n); + MachOIdent cur_id = parse_macho_(cur_buf, cur_n); + if (!new_id.valid) { + ESP_LOGE(TAG, "Uploaded payload is not a valid thin Mach-O executable"); + return false; + } + if (!cur_id.valid) { + ESP_LOGE(TAG, "Could not parse running exe Mach-O header"); + return false; + } + if (new_id.cputype != cur_id.cputype || new_id.cpusubtype != cur_id.cpusubtype) { + ESP_LOGE(TAG, "Mach-O arch mismatch (uploaded=0x%x/0x%x, running=0x%x/0x%x)", new_id.cputype, new_id.cpusubtype, + cur_id.cputype, cur_id.cpusubtype); + return false; + } + return true; +} +#endif // __APPLE__ + +bool validate_executable_(const char *staging_path, const std::string &exe_path) { +#ifdef __linux__ + return validate_elf_(staging_path, exe_path); +#elif defined(__APPLE__) + return validate_macho_(staging_path, exe_path); +#else + (void) staging_path; + (void) exe_path; + ESP_LOGE(TAG, "Host OTA validation not implemented for this OS"); + return false; +#endif +} + +} // namespace std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { - return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + if (ota_type != OTA_TYPE_UPDATE_APP) + return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; + // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. + if (image_size > MAX_OTA_SIZE) { + ESP_LOGE(TAG, "Refusing OTA of size %zu (exceeds %zu)", image_size, MAX_OTA_SIZE); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + + const std::string &exe = host::get_exe_path(); + if (exe.empty()) { + ESP_LOGE(TAG, "Could not resolve running executable path; cannot stage OTA"); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + this->final_path_ = exe; + this->staging_path_ = exe + ".ota.new"; + + // Clean up any leftover from a prior aborted OTA. + ::unlink(this->staging_path_.c_str()); + + this->fd_ = ::open(this->staging_path_.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0755); + if (this->fd_ < 0) { + ESP_LOGE(TAG, "Open '%s' failed: %s", this->staging_path_.c_str(), std::strerror(errno)); + return OTA_RESPONSE_ERROR_UPDATE_PREPARE; + } + + this->expected_size_ = image_size; + this->bytes_written_ = 0; + this->md5_set_ = false; + this->md5_.init(); + + ESP_LOGD(TAG, "OTA begin: staging=%s, size=%zu", this->staging_path_.c_str(), image_size); + return OTA_RESPONSE_OK; } -void HostOTABackend::set_update_md5(const char *expected_md5) {} +void HostOTABackend::set_update_md5(const char *md5) { + if (parse_hex(md5, this->expected_md5_, 16)) + this->md5_set_ = true; +} -OTAResponseTypes HostOTABackend::write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } +OTAResponseTypes HostOTABackend::write(uint8_t *data, size_t len) { + if (this->fd_ < 0) + return OTA_RESPONSE_ERROR_WRITING_FLASH; + size_t limit = this->expected_size_ != 0 ? this->expected_size_ : MAX_OTA_SIZE; + if (this->bytes_written_ + len > limit) { + ESP_LOGE(TAG, "Write past size limit (%zu)", limit); + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } -OTAResponseTypes HostOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } + size_t remaining = len; + const uint8_t *p = data; + while (remaining > 0) { + ssize_t n = ::write(this->fd_, p, remaining); + if (n < 0) { + if (errno == EINTR) + continue; + ESP_LOGE(TAG, "Write failed: %s", std::strerror(errno)); + return OTA_RESPONSE_ERROR_WRITING_FLASH; + } + p += n; + remaining -= n; + } + this->md5_.add(data, len); + this->bytes_written_ += len; + return OTA_RESPONSE_OK; +} -void HostOTABackend::abort() {} +OTAResponseTypes HostOTABackend::end() { + if (this->fd_ < 0) + return OTA_RESPONSE_ERROR_UPDATE_END; + + if (this->bytes_written_ == 0) { + ESP_LOGE(TAG, "OTA ended with no data written"); + this->abort(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + if (this->expected_size_ != 0 && this->bytes_written_ != this->expected_size_) { + ESP_LOGE(TAG, "Size mismatch: got %zu, expected %zu", this->bytes_written_, this->expected_size_); + this->abort(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + if (this->md5_set_) { + this->md5_.calculate(); + if (!this->md5_.equals_bytes(this->expected_md5_)) { + ESP_LOGE(TAG, "MD5 mismatch"); + this->abort(); + return OTA_RESPONSE_ERROR_MD5_MISMATCH; + } + } + + if (::fsync(this->fd_) != 0) { + ESP_LOGW(TAG, "fsync failed: %s", std::strerror(errno)); + } + ::close(this->fd_); + this->fd_ = -1; + + if (!validate_executable_(this->staging_path_.c_str(), this->final_path_)) { + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + if (::chmod(this->staging_path_.c_str(), 0755) != 0) { + ESP_LOGW(TAG, "chmod failed: %s", std::strerror(errno)); + } + + if (::rename(this->staging_path_.c_str(), this->final_path_.c_str()) != 0) { + ESP_LOGE(TAG, "rename '%s' -> '%s' failed: %s", this->staging_path_.c_str(), this->final_path_.c_str(), + std::strerror(errno)); + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + return OTA_RESPONSE_ERROR_UPDATE_END; + } + + // arch_restart() (via App::safe_reboot) will execv this path with the original argv. + host::arm_reexec(this->final_path_); + this->staging_path_.clear(); + ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + return OTA_RESPONSE_OK; +} + +void HostOTABackend::abort() { + if (this->fd_ >= 0) { + ::close(this->fd_); + this->fd_ = -1; + } + if (!this->staging_path_.empty()) { + ::unlink(this->staging_path_.c_str()); + this->staging_path_.clear(); + } + this->expected_size_ = 0; + this->bytes_written_ = 0; + this->md5_set_ = false; +} } // namespace esphome::ota #endif diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 4451fdfe182..51ffdaeda3e 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -2,11 +2,16 @@ #ifdef USE_HOST #include "ota_backend.h" +#include "esphome/components/md5/md5.h" + +#include +#include +#include + namespace esphome::ota { -/// Stub OTA backend for host platform - allows compilation but does not implement OTA. -/// All operations return error codes immediately. This enables configurations with -/// OTA triggers to compile for host platform during development. +/// Host OTA backend: stages new binary to `.ota.new`, validates ELF/Mach-O +/// matches the running arch, renames over ``, and arms execv via arch_restart(). class HostOTABackend final { public: OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); @@ -15,6 +20,16 @@ class HostOTABackend final { OTAResponseTypes end(); void abort(); bool supports_compression() { return false; } + + protected: + md5::MD5Digest md5_{}; + std::string staging_path_; + std::string final_path_; + size_t expected_size_{0}; + size_t bytes_written_{0}; + uint8_t expected_md5_[16]{}; + int fd_{-1}; + bool md5_set_{false}; }; std::unique_ptr make_ota_backend(); diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 7e450578cdd..81809c05519 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -510,7 +510,7 @@ def process_stacktrace(config, line: str, backtrace_state: bool) -> bool: if backtrace_state: if match := _CRASH_ADDR_RE.search(line): - from esphome.platformio_api import get_idedata + from esphome.platformio.toolchain import get_idedata idedata = get_idedata(config) if idedata.addr2line_path: diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 8e9968e05c0..ee22e4b97b2 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -14,7 +14,15 @@ namespace esphome::socket { BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { this->fd_ = fd; - if (!monitor_loop || this->fd_ < 0) + if (this->fd_ < 0) + return; +#ifdef USE_HOST + // Release listening ports on OTA re-exec. + int flags = ::fcntl(this->fd_, F_GETFD, 0); + if (flags >= 0) + ::fcntl(this->fd_, F_SETFD, flags | FD_CLOEXEC); +#endif + if (!monitor_loop) return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d8478d2648d..2390a96337c 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -506,8 +506,10 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu case climate::CLIMATE_ACTION_IDLE: if (this->idle_action_ready_()) { this->start_timer_(thermostat::THERMOSTAT_TIMER_IDLE_ON); - if (this->action == climate::CLIMATE_ACTION_COOLING) + if (this->action == climate::CLIMATE_ACTION_COOLING) { this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_OFF); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); + } if (this->action == climate::CLIMATE_ACTION_FAN) { if (this->supports_fan_only_action_uses_fan_mode_timer_) { this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); @@ -515,8 +517,10 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_OFF); } } - if (this->action == climate::CLIMATE_ACTION_HEATING) + if (this->action == climate::CLIMATE_ACTION_HEATING) { this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_OFF); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); + } // trig = this->idle_action_trigger_; ESP_LOGVV(TAG, "Switching to IDLE/OFF action"); this->cooling_max_runtime_exceeded_ = false; @@ -599,16 +603,6 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu } void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction action) { - // Always cancel max-runtime timers and clear exceeded flags when transitioning to idle/off, - // even if supplemental_action_ is already idle (early-return path). This prevents a stale - // heating_max_runtime_exceeded_ flag from triggering supplemental on the next heating cycle - // when HEATING_MAX_RUN_TIME fires while the main action is already IDLE. - if (action == climate::CLIMATE_ACTION_OFF || action == climate::CLIMATE_ACTION_IDLE) { - this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); - this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); - this->cooling_max_runtime_exceeded_ = false; - this->heating_max_runtime_exceeded_ = false; - } // setup_complete_ helps us ensure an action is called immediately after boot if ((action == this->supplemental_action_) && this->setup_complete_) { // already in target mode diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index d682adffe38..b29905f9a0d 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -209,13 +209,12 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff bool is_pin_equals = this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_; // Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send - if (is_pin_equals) { - ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_reported_); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); - } else { - ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. TuyaMcu will work in limited mode.", + if (!is_pin_equals) { + ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.", this->status_pin_reported_); } + ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); + this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 316d4321403..bad57fc4810 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -76,11 +76,17 @@ def variant_has_wifi(variant: str) -> bool: Variants without a native PHY (ESP32-H2, ESP32-P4) need the ``esp32_hosted`` co-processor to use ``wifi:``. + Case-insensitive on *variant* so external callers can pass either + the upstream uppercase form (e.g. ``"ESP32H2"`` from + ``const.VARIANT_ESP32H2``) or a lowercase form their own enum + surfaces (e.g. ``"esp32h2"`` from device-builder's + ``Esp32Variant``). Both classify identically. + Used by device-builder (esphome/device-builder) to decide whether its basic-setup wizard emits a ``wifi:`` block — please keep the signature stable. """ - return variant not in NO_WIFI_VARIANTS + return variant.upper() not in NO_WIFI_VARIANTS _WIFI_FIRST_PLATFORMS: frozenset[str] = frozenset( diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 5dccecc0974..57f5778d547 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -10,9 +10,7 @@ from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType from .const import ( - BOOTLOADER_MCUBOOT, CONF_CDC_ACM, - KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, @@ -50,8 +48,8 @@ class Section: class ZephyrData(TypedDict): board: str bootloader: str - prj_conf: dict[str, tuple[PrjConfValueType, bool]] - overlay: str + prj_conf: dict[str, dict[str, tuple[PrjConfValueType, bool]]] + overlay: dict[str, str] extra_build_files: dict[str, Path] pm_static: list[Section] user: dict[str, list[str]] @@ -63,7 +61,9 @@ def zephyr_set_core_data(config: ConfigType) -> None: board=config[CONF_BOARD], bootloader=config[KEY_BOOTLOADER], prj_conf={}, - overlay="", + overlay={ + "": "", + }, # set empty to make sure that overlay is cleared after config change extra_build_files={}, pm_static=[], user={}, @@ -76,12 +76,14 @@ def zephyr_data() -> ZephyrData: def zephyr_add_prj_conf( - name: str, value: PrjConfValueType, required: bool = True + name: str, value: PrjConfValueType, required: bool = True, image: str = "" ) -> None: """Set an zephyr prj conf value.""" if not name.startswith("CONFIG_"): name = "CONFIG_" + name - prj_conf = zephyr_data()[KEY_PRJ_CONF] + if image not in zephyr_data()[KEY_PRJ_CONF]: + zephyr_data()[KEY_PRJ_CONF][image] = {} + prj_conf = zephyr_data()[KEY_PRJ_CONF][image] if name not in prj_conf: prj_conf[name] = (value, required) return @@ -94,8 +96,11 @@ def zephyr_add_prj_conf( prj_conf[name] = (value, required) -def zephyr_add_overlay(content): - zephyr_data()[KEY_OVERLAY] += textwrap.dedent(content) +def zephyr_add_overlay(content: str, image: str = "") -> None: + data = zephyr_data() + if image not in data[KEY_OVERLAY]: + data[KEY_OVERLAY][image] = "" + data[KEY_OVERLAY][image] += textwrap.dedent(content) def add_extra_build_file(filename: str, path: Path) -> bool: @@ -118,8 +123,6 @@ def zephyr_to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_ZEPHYR") cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") - # build is done by west so bypass board checking in platformio - cg.add_platformio_option("boards_dir", CORE.relative_build_path("boards")) # c++ support zephyr_add_prj_conf("NEWLIB_LIBC", True) zephyr_add_prj_conf("FPU", True) @@ -132,18 +135,12 @@ def zephyr_to_code(config: ConfigType) -> None: # os: Illegal load of EXC_RETURN into PC zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048) - add_extra_script( - "pre", - "pre_build.py", - Path(__file__).parent / "pre_build.py.script", - ) - CORE.add_job(_cdc_acm_to_code, config) @coroutine_with_priority(CoroPriority.FINAL) async def _cdc_acm_to_code(config: ConfigType) -> None: - if "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT" in zephyr_data()[KEY_PRJ_CONF]: + if "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT" in zephyr_data()[KEY_PRJ_CONF][""]: var = cg.new_Pvariable(config[CONF_CDC_ACM]) await cg.register_component(var, {}) @@ -219,55 +216,28 @@ def copy_files(): """ ) - want_opts = zephyr_data()[KEY_PRJ_CONF] - - prj_conf = ( - "\n".join( - f"{name}={_format_prj_conf_val(value[0])}" - for name, value in sorted(want_opts.items()) + for image, want_opts in zephyr_data()[KEY_PRJ_CONF].items(): + prj_conf = ( + "\n".join( + f"{name}={_format_prj_conf_val(value[0])}" + for name, value in sorted(want_opts.items()) + ) + + "\n" ) - + "\n" - ) - write_file_if_changed(CORE.relative_build_path("zephyr/prj.conf"), prj_conf) + if image: + path = CORE.relative_build_path(f"sysbuild/{image}.conf") + else: + path = CORE.relative_build_path("zephyr/prj.conf") - write_file_if_changed( - CORE.relative_build_path("zephyr/app.overlay"), - zephyr_data()[KEY_OVERLAY], - ) + write_file_if_changed(CORE.relative_build_path(path), prj_conf) - if ( - zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT - or zephyr_data()[KEY_BOARD] == "xiao_ble" - ): - fake_board_manifest = """ -{ - "frameworks": [ - "zephyr" - ], - "name": "esphome nrf52", - "upload": { - "maximum_ram_size": 248832, - "maximum_size": 815104, - "speed": 115200 - }, - "url": "https://esphome.io/", - "vendor": "esphome", - "build": { - "bsp": { - "name": "adafruit" - }, - "softdevice": { - "sd_fwid": "0x00B6" - } - } -} -""" - - write_file_if_changed( - CORE.relative_build_path(f"boards/{zephyr_data()[KEY_BOARD]}.json"), - fake_board_manifest, - ) + for image, content in zephyr_data()[KEY_OVERLAY].items(): + if image: + path = CORE.relative_build_path(f"sysbuild/{image}.overlay") + else: + path = CORE.relative_build_path("zephyr/app.overlay") + write_file_if_changed(path, content) for filename, path in zephyr_data()[KEY_EXTRA_BUILD_FILES].items(): copy_file_if_changed( diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 8605b4fa1a3..a7e9d1096f7 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -36,6 +36,7 @@ from .const_zephyr import ( from .zigbee_esp32 import ( final_validate_esp32, validate_binary_sensor_esp32, + validate_sensor_esp32, zigbee_require_vfs_select, ) from .zigbee_zephyr import ( @@ -49,8 +50,7 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] - -BINARY_SENSOR_SCHEMA = cv.Schema( +BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( cv.requires_component("zigbee"), @@ -58,8 +58,9 @@ BINARY_SENSOR_SCHEMA = cv.Schema( cv.enum(REPORT, lower=True), ) } -).extend(zephyr_binary_sensor) -SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) +) +BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor) +SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) @@ -227,7 +228,7 @@ def validate_sensor(config: ConfigType) -> ConfigType: if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: - return config + return validate_sensor_esp32(config) return consume_endpoint(config) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 26ae2cc0ec4..7d0e14c67aa 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -1,4 +1,47 @@ +from enum import IntEnum + import esphome.codegen as cg +from esphome.const import ( + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_CENTIMETER, + UNIT_DECIBEL, + UNIT_HECTOPASCAL, + UNIT_HERTZ, + UNIT_HOUR, + UNIT_KELVIN, + UNIT_KILOMETER, + UNIT_KILOWATT, + UNIT_KILOWATT_HOURS, + UNIT_LITRE_PER_SECOND, + UNIT_LUX, + UNIT_METER, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_MILLIAMP, + UNIT_MILLIGRAMS_PER_CUBIC_METER, + UNIT_MILLIMETER, + UNIT_MILLISECOND, + UNIT_MILLIVOLT, + UNIT_MINUTE, + UNIT_OHM, + UNIT_PARTS_PER_BILLION, + UNIT_PARTS_PER_MILLION, + UNIT_PASCAL, + UNIT_PERCENT, + UNIT_SECOND, + UNIT_VOLT, + UNIT_WATT, + UNIT_WATT_HOURS, +) zigbee_ns = cg.esphome_ns.namespace("zigbee") ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) @@ -30,3 +73,73 @@ POWER_SOURCE = { } KEY_ZIGBEE = "zigbee" + +# BACnet engineering units mapping (ZCL uses BACnet unit codes) +# See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py +BACNET_UNITS = { + UNIT_CELSIUS: 62, + UNIT_KELVIN: 63, + UNIT_VOLT: 5, + UNIT_MILLIVOLT: 124, + UNIT_AMPERE: 3, + UNIT_MILLIAMP: 2, + UNIT_OHM: 4, + UNIT_WATT: 47, + UNIT_KILOWATT: 48, + UNIT_WATT_HOURS: 18, + UNIT_KILOWATT_HOURS: 19, + UNIT_PASCAL: 53, + UNIT_HECTOPASCAL: 133, + UNIT_HERTZ: 27, + UNIT_MILLIMETER: 30, + UNIT_CENTIMETER: 118, + UNIT_METER: 31, + UNIT_KILOMETER: 193, + UNIT_MILLISECOND: 159, + UNIT_SECOND: 73, + UNIT_MINUTE: 72, + UNIT_HOUR: 71, + UNIT_PARTS_PER_MILLION: 96, + UNIT_PARTS_PER_BILLION: 97, + UNIT_MICROGRAMS_PER_CUBIC_METER: 219, + UNIT_MILLIGRAMS_PER_CUBIC_METER: 218, + UNIT_LUX: 37, + UNIT_DECIBEL: 199, + UNIT_PERCENT: 98, +} +BACNET_UNIT_NO_UNITS = 95 + + +class AnalogInputType(IntEnum): + TEMP_DEGREES_C = 0x00 + RELATIVE_HUMIDITY_PERCENT = 0x01 + PRESSURE_PASCAL = 0x02 + FLOW_LITERS_PER_SEC = 0x03 + PERCENTAGE = 0x04 + PARTS_PER_MILLION = 0x05 + ROTATIONAL_SPEED_RPM = 0x06 + CURRENT_AMPS = 0x07 + FREQUENCY_HZ = 0x08 + POWER_WATTS = 0x09 + POWER_KILO_WATTS = 0x0A + ENERGY_KILO_WATT_HOURS = 0x0B + COUNT = 0x0C + ENTHALPY_KJOULES_PER_KG = 0x0D + TIME_SECONDS = 0x0E + + +ANALOG_INPUT_APPTYPE = { + (DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS): AnalogInputType.TEMP_DEGREES_C, + (DEVICE_CLASS_HUMIDITY, UNIT_PERCENT): AnalogInputType.RELATIVE_HUMIDITY_PERCENT, + (DEVICE_CLASS_PRESSURE, UNIT_PASCAL): AnalogInputType.PRESSURE_PASCAL, + ( + DEVICE_CLASS_VOLUME_FLOW_RATE, + UNIT_LITRE_PER_SECOND, + ): AnalogInputType.FLOW_LITERS_PER_SEC, + (DEVICE_CLASS_CURRENT, UNIT_AMPERE): AnalogInputType.CURRENT_AMPS, + (DEVICE_CLASS_FREQUENCY, UNIT_HERTZ): AnalogInputType.FREQUENCY_HZ, + (DEVICE_CLASS_POWER, UNIT_WATT): AnalogInputType.POWER_WATTS, + (DEVICE_CLASS_POWER, UNIT_KILOWATT): AnalogInputType.POWER_KILO_WATTS, + (DEVICE_CLASS_ENERGY, UNIT_KILOWATT_HOURS): AnalogInputType.ENERGY_KILO_WATT_HOURS, + (DEVICE_CLASS_DURATION, UNIT_SECOND): AnalogInputType.TIME_SECONDS, +} diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index 682638439e9..bb507320eb0 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -11,6 +11,7 @@ CONF_CLUSTER = "cluster" SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" KEY_BS_EP = "binary_sensor_ep" +KEY_SENSOR_EP = "sensor_ep" ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") DEVICE_ID = { @@ -22,6 +23,7 @@ cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") CLUSTER_ID = { "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, + "ANALOG_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, } cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") CLUSTER_ROLE = { @@ -32,4 +34,6 @@ ATTR_TYPE = { "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, + "SINGLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_SINGLE, + "DOUBLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_DOUBLE, } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 5a0cfc4fbd7..90a5cf8ff9b 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -12,6 +12,9 @@ #include "esp_zigbee_core.h" #include "zigbee_esp32.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif @@ -42,6 +45,9 @@ class ZigbeeAttribute : public Component { template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(bool force); +#ifdef USE_SENSOR + template void connect(sensor::Sensor *sensor); +#endif #ifdef USE_BINARY_SENSOR template void connect(binary_sensor::BinarySensor *sensor); #endif @@ -78,6 +84,11 @@ template void ZigbeeAttribute::set_attr(const T &value) { this->enable_loop(); } +#ifdef USE_SENSOR +template void ZigbeeAttribute::connect(sensor::Sensor *sensor) { + sensor->add_on_state_callback([this](float value) { this->set_attr((T) (this->scale_ * value)); }); +} +#endif #ifdef USE_BINARY_SENSOR template void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) { sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 791232d4633..5dd76e99038 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -46,6 +46,35 @@ ep_configs: dict[str, dict[str, Any]] = { }, ], }, + "analog_input": { + DEVICE_TYPE: "CUSTOM_ATTR", + CONF_CLUSTERS: [ + { + CONF_ID: "ANALOG_INPUT", + ROLE: CLUSTER_ROLE["SERVER"], + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "SINGLE", + CONF_REPORT: REPORT["enable"], + CONF_DEVICE: None, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "8BITMAP", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "CHAR_STRING", + }, + ], + }, + ], + }, } diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 9081582c7be..99238f758e8 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -16,11 +16,13 @@ import esphome.config_validation as cv from esphome.const import ( CONF_AP, CONF_DEVICE, + CONF_DEVICE_CLASS, CONF_ID, CONF_MAX_LENGTH, CONF_MODEL, CONF_NAME, CONF_TYPE, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WIFI, ) @@ -29,7 +31,16 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType -from .const import CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, REPORT, ZigbeeAttribute +from .const import ( + ANALOG_INPUT_APPTYPE, + BACNET_UNIT_NO_UNITS, + BACNET_UNITS, + CONF_REPORT, + CONF_ROUTER, + KEY_ZIGBEE, + REPORT, + ZigbeeAttribute, +) from .const_esp32 import ( ATTR_TYPE, CLUSTER_ID, @@ -40,6 +51,7 @@ from .const_esp32 import ( DEVICE_ID, DEVICE_TYPE, KEY_BS_EP, + KEY_SENSOR_EP, ROLE, SCALE, ) @@ -55,6 +67,10 @@ def get_c_size(bits: str, options: list[int]) -> str: def get_c_type(attr_type: str) -> Any | None: if attr_type == "BOOL": return cg.bool_ + if attr_type == "SINGLE": + return cg.float_ + if attr_type == "DOUBLE": + return cg.double if "STRING" in attr_type: return cg.std_string test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) @@ -66,19 +82,23 @@ def get_c_type(attr_type: str) -> Any | None: def get_cv_by_type(attr_type: str) -> Any | None: if attr_type == "BOOL": return cv.boolean + if attr_type in ["SINGLE", "DOUBLE"]: + return cv.float_ if "STRING" in attr_type: return cv.string test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) if test and test.group(2): return cv.positive_int - return None + raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented") -def get_default_by_type(attr_type: str) -> str | bool | int: +def get_default_by_type(attr_type: str) -> str | bool | int | float: if attr_type == "CHAR_STRING": return "" if attr_type == "BOOL": return False + if attr_type in ["SINGLE", "DOUBLE"]: + return float("nan") return 0 @@ -134,9 +154,8 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: return config -def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: - ep = copy.deepcopy(ep_configs["binary_input"]) - for cl in ep.get(CONF_CLUSTERS, []): +def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None: + for cl in clusters: for attr in cl[CONF_ATTRIBUTES]: if ( attr[CONF_ATTRIBUTE_ID] == 0x1C @@ -159,6 +178,41 @@ def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: else: attr[CONF_ID] = None validate_attributes(attr) + + +def validate_sensor_esp32(config: ConfigType) -> ConfigType: + ep = copy.deepcopy(ep_configs["analog_input"]) + # get application type from device class and meas unit + # if none get BACNET unit from meas unit + dev_class = config.get(CONF_DEVICE_CLASS) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) + bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + if apptype is not None: + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x100, + CONF_VALUE: (apptype << 16) | 0xFFFF, + CONF_TYPE: "U32", + }, + ) + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x75, + CONF_VALUE: bacunit, + CONF_TYPE: "16BIT_ENUM", + }, + ) + setup_attributes(config, ep[CONF_CLUSTERS]) + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + sensor_ep: list[dict] = zb_data.setdefault(KEY_SENSOR_EP, []) + sensor_ep.append(ep) + return config + + +def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: + ep = copy.deepcopy(ep_configs["binary_input"]) + setup_attributes(config, ep[CONF_CLUSTERS]) zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) binary_sensor_ep.append(ep) @@ -254,8 +308,9 @@ async def esp32_to_code(config: ConfigType) -> None: # create endpoints zb_data = CORE.data.get(KEY_ZIGBEE, {}) + sensor_ep: list[dict] = zb_data.get(KEY_SENSOR_EP, []) binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) - ep_list = create_ep(binary_sensor_ep, config.get(CONF_ROUTER)) + ep_list = create_ep(sensor_ep + binary_sensor_ep, config.get(CONF_ROUTER)) # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 4ba71ec6098..5254818df49 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -33,6 +33,9 @@ esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); break; + case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + ret = esp_zb_cluster_list_add_analog_input_cluster(cluster_list, attr_list, role_mask); + break; case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); break; @@ -49,6 +52,8 @@ esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id return esp_zb_basic_cluster_create(NULL); case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: return esp_zb_identify_cluster_create(NULL); + case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return esp_zb_analog_input_cluster_create(NULL); case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: return esp_zb_binary_input_cluster_create(NULL); default: @@ -63,6 +68,8 @@ esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); + case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return esp_zb_analog_input_cluster_add_attr(attr_list, attr_id, value_p); case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); default: diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index b74074e50f8..033511691cc 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -10,35 +10,6 @@ from esphome.const import ( CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - UNIT_AMPERE, - UNIT_CELSIUS, - UNIT_CENTIMETER, - UNIT_DECIBEL, - UNIT_HECTOPASCAL, - UNIT_HERTZ, - UNIT_HOUR, - UNIT_KELVIN, - UNIT_KILOMETER, - UNIT_KILOWATT, - UNIT_KILOWATT_HOURS, - UNIT_LUX, - UNIT_METER, - UNIT_MICROGRAMS_PER_CUBIC_METER, - UNIT_MILLIAMP, - UNIT_MILLIGRAMS_PER_CUBIC_METER, - UNIT_MILLIMETER, - UNIT_MILLISECOND, - UNIT_MILLIVOLT, - UNIT_MINUTE, - UNIT_OHM, - UNIT_PARTS_PER_BILLION, - UNIT_PARTS_PER_MILLION, - UNIT_PASCAL, - UNIT_PERCENT, - UNIT_SECOND, - UNIT_VOLT, - UNIT_WATT, - UNIT_WATT_HOURS, __version__, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -50,6 +21,8 @@ from esphome.cpp_generator import ( from esphome.types import ConfigType from .const import ( + BACNET_UNIT_NO_UNITS, + BACNET_UNITS, CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_ROUTER, @@ -86,41 +59,6 @@ ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component) ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component) ZigbeeNumber = zigbee_ns.class_("ZigbeeNumber", cg.Component) -# BACnet engineering units mapping (ZCL uses BACnet unit codes) -# See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py -BACNET_UNITS = { - UNIT_CELSIUS: 62, - UNIT_KELVIN: 63, - UNIT_VOLT: 5, - UNIT_MILLIVOLT: 124, - UNIT_AMPERE: 3, - UNIT_MILLIAMP: 2, - UNIT_OHM: 4, - UNIT_WATT: 47, - UNIT_KILOWATT: 48, - UNIT_WATT_HOURS: 18, - UNIT_KILOWATT_HOURS: 19, - UNIT_PASCAL: 53, - UNIT_HECTOPASCAL: 133, - UNIT_HERTZ: 27, - UNIT_MILLIMETER: 30, - UNIT_CENTIMETER: 118, - UNIT_METER: 31, - UNIT_KILOMETER: 193, - UNIT_MILLISECOND: 159, - UNIT_SECOND: 73, - UNIT_MINUTE: 72, - UNIT_HOUR: 71, - UNIT_PARTS_PER_MILLION: 96, - UNIT_PARTS_PER_BILLION: 97, - UNIT_MICROGRAMS_PER_CUBIC_METER: 219, - UNIT_MILLIGRAMS_PER_CUBIC_METER: 218, - UNIT_LUX: 37, - UNIT_DECIBEL: 199, - UNIT_PERCENT: 98, -} -BACNET_UNIT_NO_UNITS = 95 - zephyr_binary_sensor = cv.Schema( { cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent), diff --git a/esphome/const.py b/esphome/const.py index c39225fdec0..a256a10e627 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1240,6 +1240,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kvarh" UNIT_KILOWATT = "kW" UNIT_KILOWATT_HOURS = "kWh" UNIT_LITRE = "L" +UNIT_LITRE_PER_SECOND = "L/s" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" UNIT_METER = "m" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index cdb12c129a7..17269a6fdb3 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -808,6 +808,9 @@ class EsphomeCore: return self.relative_build_path("build", f"{self.name}.bin") if self.is_libretiny: return self.relative_pioenvs_path(self.name, "firmware.uf2") + if self.is_host: + # Host builds produce a native ELF/Mach-O named `program`. + return self.relative_pioenvs_path(self.name, "program") return self.relative_pioenvs_path(self.name, "firmware.bin") @property diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index d67245967c5..916e937a532 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -40,8 +40,9 @@ import voluptuous as vol import yaml from yaml.nodes import Node -from esphome import const, platformio_api, yaml_util +from esphome import const, yaml_util from esphome.helpers import get_bool_env, mkdir_p, sort_ip_addresses +from esphome.platformio import toolchain from esphome.storage_json import ( StorageJSON, archive_storage_path, @@ -1090,7 +1091,7 @@ class DownloadBinaryRequestHandler(BaseHandler): self.send_error(404 if rc == 2 else 500) return - idedata = platformio_api.IDEData(json.loads(stdout)) + idedata = toolchain.IDEData(json.loads(stdout)) found = False for image in idedata.extra_flash_images: diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index e740ab72854..34e3e7694b5 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -190,7 +190,7 @@ def main() -> int: script_path = sys.argv[1] - # Mirror the platformio_runner behaviour: verbose mode disables the + # Mirror the platformio runner behaviour: verbose mode disables the # line filter so all output reaches the user. is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None diff --git a/esphome/espidf/api.py b/esphome/espidf/toolchain.py similarity index 99% rename from esphome/espidf/api.py rename to esphome/espidf/toolchain.py index 847de249a7f..da6d3a8a37f 100644 --- a/esphome/espidf/api.py +++ b/esphome/espidf/toolchain.py @@ -15,7 +15,7 @@ from esphome.espidf.framework import check_esp_idf_install, get_framework_env _LOGGER = logging.getLogger(__name__) -DOMAIN = "espidf_api" +DOMAIN = "espidf_toolchain" @dataclass diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 8ffcffa7051..45aaa827c87 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.6 + version: 2.1.5 espressif/mdns: version: 1.11.0 espressif/esp_wifi_remote: diff --git a/esphome/platformio/__init__.py b/esphome/platformio/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/platformio_runner.py b/esphome/platformio/runner.py similarity index 99% rename from esphome/platformio_runner.py rename to esphome/platformio/runner.py index 5b14a725577..976979dc57b 100644 --- a/esphome/platformio_runner.py +++ b/esphome/platformio/runner.py @@ -1,6 +1,6 @@ """Subprocess entry point that applies ESPHome's PlatformIO patches. -Invoked via ``python -m esphome.platformio_runner`` instead of +Invoked via ``python -m esphome.platformio.runner`` instead of ``python -m platformio`` so that the patches (incremental rebuild preservation, download retries) apply inside the subprocess. Running PlatformIO in a subprocess keeps its ``sys.path`` mutations and other diff --git a/esphome/platformio_api.py b/esphome/platformio/toolchain.py similarity index 99% rename from esphome/platformio_api.py rename to esphome/platformio/toolchain.py index 7f56ee86f7f..83faec84aa9 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio/toolchain.py @@ -64,7 +64,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int: # a user-provided value (or the unmodified path on platforms that # don't need the strip). os.environ["PYTHONEXEPATH"] = python_exe - cmd = [python_exe, "-m", "esphome.platformio_runner"] + list(args) + cmd = [python_exe, "-m", "esphome.platformio.runner"] + list(args) return run_external_process(*cmd, **kwargs) diff --git a/esphome/upload_targets.py b/esphome/upload_targets.py new file mode 100644 index 00000000000..302ecf73011 --- /dev/null +++ b/esphome/upload_targets.py @@ -0,0 +1,66 @@ +"""Stable classification of ``--device`` / port strings. + +External tooling (the device-builder dashboard at +esphome/device-builder, and other consumers) needs to decide whether +a user-supplied port string names a local serial device, an OTA +network target, an MQTT magic string, or an RP2040 BOOTSEL upload. + +This module is the single stable home for that classification. The +upstream CLI (``esphome.__main__``) re-exports ``PortType`` and +``get_port_type`` from here for its own use; external callers should +import directly from ``esphome.upload_targets`` so the surface stays +stable across releases (``esphome/__main__`` is a CLI entrypoint and +not a stable import path). + +Please keep ``PortType`` member names / values and the +``get_port_type`` signature stable — see the docstrings on each for +the contract. +""" + +from __future__ import annotations + +from esphome.enum import StrEnum + + +class PortType(StrEnum): + """Port classification returned by :func:`get_port_type`. + + Used by device-builder (esphome/device-builder) and other + external tooling to route a user-supplied ``--device`` value to + the right upload / log path. Member names and string values are + part of the stable surface — adding new members is fine, but + existing names / values must not be renamed or changed. + """ + + SERIAL = "SERIAL" + NETWORK = "NETWORK" + MQTT = "MQTT" + MQTTIP = "MQTTIP" + BOOTSEL = "BOOTSEL" + + +def get_port_type(port: str) -> PortType: + """Determine the type of port/device identifier. + + Used by device-builder (esphome/device-builder)'s dashboard to + decide whether a user-supplied ``--device`` value names a local + serial port (must build / flash locally), an OTA network target + (eligible for remote builds), an MQTT magic string, or an RP2040 + BOOTSEL upload. Please keep the signature stable. + + Returns: + PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.) + PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool + PortType.MQTT for MQTT logging + PortType.MQTTIP for MQTT IP lookup + PortType.NETWORK for IP addresses, hostnames, or mDNS names + """ + if port == "BOOTSEL": + return PortType.BOOTSEL + if port.startswith("/") or port.startswith("COM"): + return PortType.SERIAL + if port == "MQTT": + return PortType.MQTT + if port == "MQTTIP": + return PortType.MQTTIP + return PortType.NETWORK diff --git a/platformio.ini b/platformio.ini index 3023a157323..42b74007793 100644 --- a/platformio.ini +++ b/platformio.ini @@ -233,7 +233,7 @@ extends = common platform = https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-5.zip framework = zephyr platform_packages = - platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-a.zip + platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-b.zip build_flags = ${common.build_flags} -DUSE_ZEPHYR diff --git a/script/build_helpers.py b/script/build_helpers.py index 0e0e8170a0a..fa722aa0990 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -23,7 +23,7 @@ from esphome.config import validate_config from esphome.const import CONF_PLATFORM from esphome.core import CORE from esphome.loader import get_component, get_platform -from esphome.platformio_api import get_idedata +from esphome.platformio.toolchain import get_idedata from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest # This must coincide with the version in /platformio.ini diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index dd91fa861ce..2aa7394b112 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -26,7 +26,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position from esphome.analyze_memory import MemoryAnalyzer -from esphome.platformio_api import IDEData +from esphome.platformio.toolchain import IDEData from script.ci_helpers import write_github_output # Regex patterns for extracting memory usage from PlatformIO output diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index 300cb7b5d75..3ae48b2a5f8 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -20,4 +20,4 @@ nrf52: voltage: 2.1V uicr_erase: true framework: - version: "2.6.1-a" + version: "2.6.1-b" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 4494b4081d4..94e3f3c8c04 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -1,10 +1,10 @@ +packages: + - !include common.yaml + binary_sensor: - platform: template name: "Garage Door Open 10" report: "enable" - - platform: template - name: "Garage Door Open 11" - report: "coordinator" - platform: template name: "Garage Door Open 12" report: "force" diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 1a62cfda904..626aea02162 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -128,8 +128,8 @@ def mock_storage_json() -> Generator[MagicMock]: @pytest.fixture def mock_idedata() -> Generator[MagicMock]: - """Fixture to mock platformio_api.IDEData.""" - with patch("esphome.dashboard.web_server.platformio_api.IDEData") as mock: + """Fixture to mock platformio toolchain.IDEData.""" + with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock: yield mock diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f36543b7cd9..fb025ce427f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -23,7 +23,7 @@ import pytest_asyncio import esphome.config from esphome.core import CORE -from esphome.platformio_api import get_idedata +from esphome.platformio.toolchain import get_idedata from .const import ( API_CONNECTION_TIMEOUT, diff --git a/tests/integration/fixtures/host_ota_rejects_garbage.yaml b/tests/integration/fixtures/host_ota_rejects_garbage.yaml new file mode 100644 index 00000000000..ebf7977123d --- /dev/null +++ b/tests/integration/fixtures/host_ota_rejects_garbage.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_self_update.yaml b/tests/integration/fixtures/host_ota_self_update.yaml new file mode 100644 index 00000000000..ebf7977123d --- /dev/null +++ b/tests/integration/fixtures/host_ota_self_update.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py new file mode 100644 index 00000000000..e1036fdf1cf --- /dev/null +++ b/tests/integration/test_host_ota.py @@ -0,0 +1,152 @@ +"""End-to-end OTA tests on the host platform. + +Exercises the native OTA protocol against a real host binary, then asserts +pid is preserved across the post-OTA execv. A second OTA on the post-exec +instance covers the FD_CLOEXEC path. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from contextlib import contextmanager +import socket + +import pytest + +from esphome import espota2 + +from .conftest import run_binary, wait_and_connect_api_client +from .const import LOCALHOST, PORT_POLL_INTERVAL, PORT_WAIT_TIMEOUT +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "host-ota-test" + + +@contextmanager +def _reserve_port() -> Generator[tuple[int, socket.socket]]: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", 0)) + try: + yield s.getsockname()[1], s + finally: + s.close() + + +async def _wait_for_port(host: str, port: int, timeout: float) -> None: + """Poll until a TCP port accepts connections, or raise TimeoutError.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + try: + _, writer = await asyncio.open_connection(host, port) + except (ConnectionRefusedError, OSError): + await asyncio.sleep(PORT_POLL_INTERVAL) + continue + writer.close() + await writer.wait_closed() + return + raise TimeoutError(f"Port {port} on {host} did not open within {timeout}s") + + +@pytest.mark.asyncio +async def test_host_ota_self_update( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Self-OTA: upload the running binary back to itself, expect re-exec.""" + api_port, api_socket = reserved_tcp_port + with _reserve_port() as (ota_port, ota_socket): + yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + api_socket.close() + ota_socket.close() + + loop = asyncio.get_running_loop() + ota_staged = loop.create_future() + rebooted = loop.create_future() + + def on_log(line: str) -> None: + if not ota_staged.done() and "OTA staged at" in line: + ota_staged.set_result(True) + if not rebooted.done() and "Rebooting safely" in line: + rebooted.set_result(True) + + async with run_binary(binary_path, line_callback=on_log) as (proc, _lines): + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + pid_before = proc.pid + async with wait_and_connect_api_client(port=api_port) as client: + info_before = await client.device_info() + assert info_before.name == DEVICE_NAME + + # espota2 is blocking; run in executor. + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path + ) + assert rc == 0, "espota2 reported failure" + + await asyncio.wait_for(ota_staged, timeout=10.0) + await asyncio.wait_for(rebooted, timeout=10.0) + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + + # execv preserves pid; mismatch means external respawn. + assert proc.returncode is None, "process exited instead of execing" + assert proc.pid == pid_before + + async with wait_and_connect_api_client(port=api_port) as client: + info_after = await client.device_info() + assert info_after.name == DEVICE_NAME + assert info_after.name == info_before.name + + # Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind). + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path + ) + assert rc == 0, "second OTA failed -- listener leaked across execv" + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + assert proc.pid == pid_before + + +@pytest.mark.asyncio +async def test_host_ota_rejects_garbage( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + integration_test_dir, +) -> None: + """Bogus payload is rejected and the device keeps running.""" + api_port, api_socket = reserved_tcp_port + with _reserve_port() as (ota_port, ota_socket): + yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # 192 bytes that are neither ELF nor Mach-O. + bogus_path = integration_test_dir / "bogus.bin" + bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8) + + api_socket.close() + ota_socket.close() + + async with run_binary(binary_path) as (proc, _lines): + await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) + pid_before = proc.pid + + loop = asyncio.get_running_loop() + rc, _ = await loop.run_in_executor( + None, espota2.run_ota, LOCALHOST, ota_port, None, bogus_path + ) + assert rc == 1 + + await asyncio.sleep(0.5) + assert proc.returncode is None, "process died on rejected OTA" + assert proc.pid == pid_before + + async with wait_and_connect_api_client(port=api_port) as client: + info = await client.device_info() + assert info.name == DEVICE_NAME diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index e93ae4b5036..71a14d78177 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -10,27 +10,44 @@ from esphome.const import Platform @pytest.mark.parametrize( "variant", [ + # Upstream's canonical uppercase form. const.VARIANT_ESP32, const.VARIANT_ESP32S2, const.VARIANT_ESP32S3, const.VARIANT_ESP32C3, const.VARIANT_ESP32C6, + # Lowercase form external callers (e.g. device-builder's + # ``Esp32Variant`` StrEnum) surface. + "esp32", + "esp32s3", + "esp32c3", + # Mixed-case — defence in depth against future callers that + # pull the value off some other serialisation. + "Esp32", ], ) def test_variant_has_wifi_for_native_phy_variants(variant: str) -> None: - """Variants with a native WiFi PHY → True.""" + """Variants with a native WiFi PHY → True, case-insensitive.""" assert variant_has_wifi(variant) is True @pytest.mark.parametrize( "variant", [ + # Upstream's canonical uppercase form. const.VARIANT_ESP32H2, const.VARIANT_ESP32P4, + # Lowercase form external callers (e.g. device-builder's + # ``Esp32Variant`` StrEnum) surface. + "esp32h2", + "esp32p4", + # Mixed-case — defence in depth against future callers that + # pull the value off some other serialisation. + "Esp32H2", ], ) def test_variant_has_wifi_for_no_phy_variants(variant: str) -> None: - """Variants that need ``esp32_hosted`` → False.""" + """Variants that need ``esp32_hosted`` → False, case-insensitive.""" assert variant_has_wifi(variant) is False @@ -44,6 +61,18 @@ def test_has_native_wifi_dispatches_esp32_to_variant_check() -> None: ) +def test_has_native_wifi_esp32_variant_case_insensitive() -> None: + """has_native_wifi accepts lowercase variant input. + + External callers (device-builder's wizard, etc.) may surface + variant strings from their own enums that don't match upstream's + uppercase convention. The dispatcher should classify them + identically. + """ + assert has_native_wifi(platform=Platform.ESP32, variant="esp32h2") is False + assert has_native_wifi(platform=Platform.ESP32, variant="esp32c3") is True + + def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None: """RP2040 platform routes through ``rp2040.board_id_has_wifi``.""" assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 626f4168a60..13450b10f08 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -64,15 +64,15 @@ def mock_copy_file_if_changed() -> Generator[Mock, None, None]: @pytest.fixture def mock_run_platformio_cli() -> Generator[Mock, None, None]: - """Mock run_platformio_cli for platformio_api.""" - with patch("esphome.platformio_api.run_platformio_cli") as mock: + """Mock run_platformio_cli for platformio toolchain.""" + with patch("esphome.platformio.toolchain.run_platformio_cli") as mock: yield mock @pytest.fixture def mock_run_platformio_cli_run() -> Generator[Mock, None, None]: - """Mock run_platformio_cli_run for platformio_api.""" - with patch("esphome.platformio_api.run_platformio_cli_run") as mock: + """Mock run_platformio_cli_run for platformio toolchain.""" + with patch("esphome.platformio.toolchain.run_platformio_cli_run") as mock: yield mock @@ -92,8 +92,8 @@ def mock_esp8266_decode_pc() -> Generator[Mock, None, None]: @pytest.fixture def mock_run_external_process() -> Generator[Mock, None, None]: - """Mock run_external_process for platformio_api.""" - with patch("esphome.platformio_api.run_external_process") as mock: + """Mock run_external_process for platformio toolchain.""" + with patch("esphome.platformio.toolchain.run_external_process") as mock: yield mock @@ -113,8 +113,8 @@ def mock_subprocess_run() -> Generator[Mock, None, None]: @pytest.fixture def mock_get_idedata() -> Generator[Mock, None, None]: - """Mock get_idedata for platformio_api.""" - with patch("esphome.platformio_api.get_idedata") as mock: + """Mock get_idedata for platformio toolchain.""" + with patch("esphome.platformio.toolchain.get_idedata") as mock: yield mock diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 9cbd6cc1209..21a2f44986d 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -591,6 +591,30 @@ class TestEsphomeCore: assert target.is_esp32 is False assert target.is_esp8266 is True + def test_firmware_bin__default(self, target): + """Default platforms produce //firmware.bin.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"} + assert target.firmware_bin == Path( + "foo/build/.pioenvs/test-device/firmware.bin" + ) + + def test_firmware_bin__libretiny(self, target): + """The libretiny platform produces firmware.uf2.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "bk72xx"} + assert target.firmware_bin == Path( + "foo/build/.pioenvs/test-device/firmware.uf2" + ) + + def test_firmware_bin__host(self, target): + """Host platform produces a native ELF/Mach-O named `program`, + not firmware.bin -- needed for `esphome upload` to find the + right artifact for the host OTA backend.""" + target.name = "test-device" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "host"} + assert target.firmware_bin == Path("foo/build/.pioenvs/test-device/program") + @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") def test_data_dir_default_unix(self, target): """Test data_dir returns .esphome in config directory by default on Unix.""" diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 7162b72947d..d285b7b79e9 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -18,7 +18,6 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange -from esphome import platformio_api from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -97,6 +96,7 @@ from esphome.espota2 import ( OTA_TYPE_UPDATE_BOOTLOADER, OTA_TYPE_UPDATE_PARTITION_TABLE, ) +from esphome.platformio import toolchain from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -288,7 +288,7 @@ def mock_run_external_process() -> Generator[Mock]: @pytest.fixture def mock_run_external_command_main() -> Generator[Mock]: - """Mock run_external_command in __main__ module (different from platformio_api).""" + """Mock run_external_command in __main__ module (different from platformio toolchain).""" with patch("esphome.__main__.run_external_command") as mock: mock.return_value = 0 # Default to success yield mock @@ -1201,7 +1201,7 @@ def test_upload_using_esptool_path_conversion( CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} # Create mock IDEData with Path objects - mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata = MagicMock(spec=toolchain.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [ FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), @@ -1279,7 +1279,7 @@ def test_upload_using_esptool_skips_missing_extra_flash_images( missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin" - mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata = MagicMock(spec=toolchain.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [ FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), @@ -1547,8 +1547,8 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040( mock_idedata.firmware_elf_path = str(firmware_elf) with ( - patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), - patch("esphome.platformio_api.run_platformio_cli_run", return_value=0), + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0), ): result = upload_using_platformio({}, "/dev/ttyACM0") @@ -1564,7 +1564,7 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040( """Test that upload_using_platformio doesn't create signed bin for non-RP2040.""" setup_core(platform=PLATFORM_ESP32) - with patch("esphome.platformio_api.run_platformio_cli_run", return_value=0): + with patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0): result = upload_using_platformio({}, "/dev/ttyUSB0") assert result == 0 @@ -1621,7 +1621,7 @@ def test_upload_program_prebuilt_dir_installs_libretiny_platform_if_missing( patch("esphome.__main__.get_ltchiptool_path", return_value=None) as mock_find, patch("esphome.__main__.write_cpp", return_value=0) as mock_write_cpp, patch( - "esphome.platformio_api.prepare_platform_for_upload", return_value=0 + "esphome.platformio.toolchain.prepare_platform_for_upload", return_value=0 ) as mock_prep, patch("esphome.__main__.upload_using_ltchiptool", return_value=0), ): @@ -1655,7 +1655,7 @@ def test_upload_program_prebuilt_dir_skips_install_when_tool_present( return_value=tmp_path / "ltchiptool", ), patch("esphome.__main__.write_cpp") as mock_write_cpp, - patch("esphome.platformio_api.prepare_platform_for_upload") as mock_prep, + patch("esphome.platformio.toolchain.prepare_platform_for_upload") as mock_prep, patch("esphome.__main__.upload_using_ltchiptool", return_value=0), ): exit_code, _ = upload_program({}, args, ["/dev/ttyUSB0"]) @@ -1684,7 +1684,7 @@ def test_upload_program_prebuilt_dir_write_cpp_failure_aborts_upload( with ( patch("esphome.__main__.get_ltchiptool_path", return_value=None), patch("esphome.__main__.write_cpp", return_value=3), - patch("esphome.platformio_api.prepare_platform_for_upload") as mock_prep, + patch("esphome.platformio.toolchain.prepare_platform_for_upload") as mock_prep, patch("esphome.__main__.upload_using_ltchiptool") as mock_upload, ): exit_code, host = upload_program({}, args, ["/dev/ttyUSB0"]) @@ -1713,7 +1713,9 @@ def test_upload_program_prebuilt_dir_pkg_install_failure_aborts_upload( with ( patch("esphome.__main__._find_picotool", return_value=None), patch("esphome.__main__.write_cpp", return_value=0), - patch("esphome.platformio_api.prepare_platform_for_upload", return_value=2), + patch( + "esphome.platformio.toolchain.prepare_platform_for_upload", return_value=2 + ), patch("esphome.__main__.upload_using_picotool") as mock_upload, patch("esphome.__main__._rp2040_serial_reset_to_bootsel") as mock_reset, ): @@ -1773,7 +1775,7 @@ def test_upload_using_picotool_falls_back_to_firmware_bin_when_elf_missing( # Stub the picotool lookup to short-circuit the toolchain probe. with ( - patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), patch( "esphome.__main__.get_picotool_path", return_value=tmp_path / "picotool", @@ -2019,7 +2021,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None: config = {} with ( - patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), patch("subprocess.run", return_value=mock_result), ): exit_code = upload_using_picotool(config) @@ -2039,7 +2041,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: mock_idedata.cc_path = "/fake/path/gcc" config = {} - with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata): + with patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata): exit_code = upload_using_picotool(config) assert exit_code == 1 @@ -2059,7 +2061,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None: mock_idedata.cc_path = "/fake/path/gcc" config = {} - with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata): + with patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata): exit_code = upload_using_picotool(config) assert exit_code == 1 @@ -2093,7 +2095,7 @@ def test_upload_using_picotool_permission_error(tmp_path: Path) -> None: config = {} with ( - patch("esphome.platformio_api.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), patch("subprocess.run", return_value=mock_result), ): exit_code = upload_using_picotool(config) @@ -5211,7 +5213,7 @@ def test_command_analyze_memory_success( firmware_elf.write_text("mock elf file") # Mock idedata - mock_idedata_obj = MagicMock(spec=platformio_api.IDEData) + mock_idedata_obj = MagicMock(spec=toolchain.IDEData) mock_idedata_obj.firmware_elf_path = str(firmware_elf) mock_idedata_obj.objdump_path = "/path/to/objdump" mock_idedata_obj.readelf_path = "/path/to/readelf" @@ -5283,7 +5285,7 @@ def test_command_analyze_memory_with_external_components( firmware_elf.write_text("mock elf file") # Mock idedata - mock_idedata_obj = MagicMock(spec=platformio_api.IDEData) + mock_idedata_obj = MagicMock(spec=toolchain.IDEData) mock_idedata_obj.firmware_elf_path = str(firmware_elf) mock_idedata_obj.objdump_path = "/path/to/objdump" mock_idedata_obj.readelf_path = "/path/to/readelf" @@ -5374,16 +5376,18 @@ def test_command_analyze_memory_no_idedata( @pytest.fixture def mock_compile_build_info_run_compile() -> Generator[Mock]: - """Mock platformio_api.run_compile for build_info tests.""" - with patch("esphome.platformio_api.run_compile", return_value=0) as mock: + """Mock toolchain.run_compile for build_info tests.""" + with patch("esphome.platformio.toolchain.run_compile", return_value=0) as mock: yield mock @pytest.fixture def mock_compile_build_info_get_idedata() -> Generator[Mock]: - """Mock platformio_api.get_idedata for build_info tests.""" + """Mock toolchain.get_idedata for build_info tests.""" mock_idedata = MagicMock() - with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata) as mock: + with patch( + "esphome.platformio.toolchain.get_idedata", return_value=mock_idedata + ) as mock: yield mock @@ -6293,7 +6297,7 @@ def test_upload_using_esptool_passes_crystal_callback( sdkconfig = build_dir / "sdkconfig.test" sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n") - mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata = MagicMock(spec=toolchain.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [] mock_get_idedata.return_value = mock_idedata @@ -6323,7 +6327,7 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback( sdkconfig = build_dir / "sdkconfig.test" sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n") - mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata = MagicMock(spec=toolchain.IDEData) mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" mock_idedata.extra_flash_images = [] mock_get_idedata.return_value = mock_idedata diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_toolchain.py similarity index 92% rename from tests/unit_tests/test_platformio_api.py rename to tests/unit_tests/test_platformio_toolchain.py index fdf0d5da91f..8d4b17ca7e6 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -1,4 +1,4 @@ -"""Tests for platformio_api.py path functions.""" +"""Tests for esphome.platformio.toolchain path functions.""" # pylint: disable=protected-access @@ -11,8 +11,8 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest -from esphome import platformio_api, platformio_runner from esphome.core import CORE, EsphomeError +from esphome.platformio import runner, toolchain from esphome.util import FlashImage @@ -21,7 +21,7 @@ def test_idedata_firmware_elf_path(setup_core: Path) -> None: CORE.build_path = setup_core / "build" / "test" CORE.name = "test" raw_data = {"prog_path": "/path/to/firmware.elf"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) assert idedata.firmware_elf_path == Path("/path/to/firmware.elf") @@ -32,7 +32,7 @@ def test_idedata_firmware_bin_path(setup_core: Path) -> None: CORE.name = "test" prog_path = str(Path("/path/to/firmware.elf")) raw_data = {"prog_path": prog_path} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.firmware_bin_path assert isinstance(result, Path) @@ -47,7 +47,7 @@ def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None CORE.name = "test" prog_path = str(Path("/complex/path/to/build/firmware.elf")) raw_data = {"prog_path": prog_path} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.firmware_bin_path expected = Path("/complex/path/to/build/firmware.bin") @@ -67,7 +67,7 @@ def test_idedata_extra_flash_images(setup_core: Path) -> None: ] }, } - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) images = idedata.extra_flash_images assert len(images) == 2 @@ -83,7 +83,7 @@ def test_idedata_extra_flash_images_empty(setup_core: Path) -> None: CORE.build_path = setup_core / "build" / "test" CORE.name = "test" raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) images = idedata.extra_flash_images assert images == [] @@ -97,7 +97,7 @@ def test_idedata_cc_path(setup_core: Path) -> None: "prog_path": "/path/to/firmware.elf", "cc_path": "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc", } - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) assert ( idedata.cc_path @@ -132,7 +132,7 @@ def test_load_idedata_returns_dict( mock_run_platformio_cli_run.return_value = '{"prog_path": "/test/firmware.elf"}' config = {"name": "test"} - result = platformio_api._load_idedata(config) + result = toolchain._load_idedata(config) assert result is not None assert isinstance(result, dict) @@ -161,7 +161,7 @@ def test_load_idedata_uses_cache_when_valid( os.utime(idedata_path, (platformio_ini_mtime + 1, platformio_ini_mtime + 1)) config = {"name": "test"} - result = platformio_api._load_idedata(config) + result = toolchain._load_idedata(config) # Should not call _run_idedata since cache is valid mock_run_platformio_cli_run.assert_not_called() @@ -194,7 +194,7 @@ def test_load_idedata_regenerates_when_platformio_ini_newer( mock_run_platformio_cli_run.return_value = json.dumps(new_data) config = {"name": "test"} - result = platformio_api._load_idedata(config) + result = toolchain._load_idedata(config) # Should call _run_idedata since platformio.ini is newer mock_run_platformio_cli_run.assert_called_once() @@ -228,7 +228,7 @@ def test_load_idedata_regenerates_on_corrupted_cache( mock_run_platformio_cli_run.return_value = json.dumps(new_data) config = {"name": "test"} - result = platformio_api._load_idedata(config) + result = toolchain._load_idedata(config) # Should call _run_idedata since cache is corrupted mock_run_platformio_cli_run.assert_called_once() @@ -255,7 +255,7 @@ def test_load_idedata_uses_prebuilt_dir_when_set( CORE.prebuilt_dir = prebuilt_dir - result = platformio_api._load_idedata({"name": "test"}) + result = toolchain._load_idedata({"name": "test"}) assert result["prog_path"] == str(prebuilt_dir / "firmware.elf") # Never re-runs PlatformIO when prebuilt idedata is supplied: the dashboard @@ -293,7 +293,7 @@ def test_load_idedata_resolves_basenames_against_prebuilt_dir( ) CORE.prebuilt_dir = prebuilt_dir - result = platformio_api._load_idedata({"name": "test"}) + result = toolchain._load_idedata({"name": "test"}) assert result["prog_path"] == str(prebuilt_dir / "firmware.elf") images = result["extra"]["flash_images"] @@ -330,7 +330,7 @@ def test_load_idedata_absolute_paths_in_prebuilt_pass_through( ) CORE.prebuilt_dir = prebuilt_dir - result = platformio_api._load_idedata({"name": "test"}) + result = toolchain._load_idedata({"name": "test"}) assert result["prog_path"] == str(prebuilt_dir / "firmware.elf") assert result["extra"]["flash_images"][0]["path"] == abs_bootloader @@ -358,7 +358,7 @@ def test_load_idedata_falls_back_when_prebuilt_idedata_missing( {"prog_path": "/local/firmware.elf"} ) - result = platformio_api._load_idedata({"name": "test"}) + result = toolchain._load_idedata({"name": "test"}) assert result["prog_path"] == "/local/firmware.elf" mock_run_platformio_cli_run.assert_called_once() @@ -381,7 +381,7 @@ def test_run_idedata_parses_json_from_output( f"Some preamble\n{json.dumps(expected_data)}\nSome postamble" ) - result = platformio_api._run_idedata(config) + result = toolchain._run_idedata(config) assert result == expected_data @@ -395,7 +395,7 @@ def test_run_idedata_raises_on_no_json( mock_run_platformio_cli_run.return_value = "No JSON in this output" with pytest.raises(EsphomeError): - platformio_api._run_idedata(config) + toolchain._run_idedata(config) def test_run_idedata_raises_on_invalid_json( @@ -407,7 +407,7 @@ def test_run_idedata_raises_on_invalid_json( # The ValueError from json.loads is re-raised with pytest.raises(ValueError): - platformio_api._run_idedata(config) + toolchain._run_idedata(config) def test_run_platformio_cli_sets_environment_variables( @@ -418,7 +418,7 @@ def test_run_platformio_cli_sets_environment_variables( with patch.dict(os.environ, {}, clear=False): mock_run_external_process.return_value = 0 - platformio_api.run_platformio_cli("test", "arg") + toolchain.run_platformio_cli("test", "arg") # Check environment variables were set assert os.environ["PLATFORMIO_FORCE_COLOR"] == "true" @@ -431,11 +431,11 @@ def test_run_platformio_cli_sets_environment_variables( assert "PYTHONWARNINGS" in os.environ # Check command was called correctly — runs PlatformIO as a subprocess - # via the esphome.platformio_runner entry point. + # via the esphome.platformio.runner entry point. mock_run_external_process.assert_called_once() args = mock_run_external_process.call_args[0] assert "-m" in args - assert "esphome.platformio_runner" in args + assert "esphome.platformio.runner" in args assert "test" in args assert "arg" in args @@ -470,8 +470,8 @@ def test_strip_win_long_path_prefix( platform: str, input_path: str, expected: str ) -> None: r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32.""" - with patch("esphome.platformio_api.sys.platform", platform): - assert platformio_api._strip_win_long_path_prefix(input_path) == expected + with patch("esphome.platformio.toolchain.sys.platform", platform): + assert toolchain._strip_win_long_path_prefix(input_path) == expected def test_run_platformio_cli_strips_win_long_path_prefix( @@ -494,15 +494,15 @@ def test_run_platformio_cli_strips_win_long_path_prefix( with ( patch.dict(os.environ, {}, clear=False), - patch("esphome.platformio_api.sys.platform", "win32"), - patch("esphome.platformio_api.sys.executable", prefixed_exe), + patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch("esphome.platformio.toolchain.sys.executable", prefixed_exe), ): # Pop any pre-existing PYTHONEXEPATH so the assertion below reflects # what run_platformio_cli set, not whatever the test runner's # environment happened to contain. os.environ.pop("PYTHONEXEPATH", None) mock_run_external_process.return_value = 0 - platformio_api.run_platformio_cli("test", "arg") + toolchain.run_platformio_cli("test", "arg") # The subprocess is invoked with the stripped executable path. mock_run_external_process.assert_called_once() @@ -526,12 +526,12 @@ def test_run_platformio_cli_does_not_set_pythonexepath_without_strip( with ( patch.dict(os.environ, {}, clear=False), - patch("esphome.platformio_api.sys.platform", "linux"), - patch("esphome.platformio_api.sys.executable", plain_exe), + patch("esphome.platformio.toolchain.sys.platform", "linux"), + patch("esphome.platformio.toolchain.sys.executable", plain_exe), ): os.environ.pop("PYTHONEXEPATH", None) mock_run_external_process.return_value = 0 - platformio_api.run_platformio_cli("test", "arg") + toolchain.run_platformio_cli("test", "arg") mock_run_external_process.assert_called_once() args = mock_run_external_process.call_args[0] @@ -547,7 +547,7 @@ def test_run_platformio_cli_run_builds_command( mock_run_platformio_cli.return_value = 0 config = {"name": "test"} - platformio_api.run_platformio_cli_run(config, True, "extra", "args") + toolchain.run_platformio_cli_run(config, True, "extra", "args") mock_run_platformio_cli.assert_called_once_with( "run", "-d", CORE.build_path, "-v", "extra", "args" @@ -562,7 +562,7 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}} mock_run_platformio_cli_run.return_value = 0 - platformio_api.run_compile(config, verbose=True) + toolchain.run_compile(config, verbose=True) mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4") @@ -589,22 +589,22 @@ def test_get_idedata_caches_result( config = {"name": "test"} # First call should load and cache - result1 = platformio_api.get_idedata(config) + result1 = toolchain.get_idedata(config) mock_run_platformio_cli_run.assert_called_once() # Second call should use cache from CORE.data - result2 = platformio_api.get_idedata(config) + result2 = toolchain.get_idedata(config) mock_run_platformio_cli_run.assert_called_once() # Still only called once assert result1 is result2 - assert isinstance(result1, platformio_api.IDEData) + assert isinstance(result1, toolchain.IDEData) assert result1.firmware_elf_path == Path("/test/firmware.elf") def test_idedata_addr2line_path_windows(setup_core: Path) -> None: """Test IDEData.addr2line_path on Windows.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.addr2line_path assert result == "C:\\tools\\addr2line.exe" @@ -613,7 +613,7 @@ def test_idedata_addr2line_path_windows(setup_core: Path) -> None: def test_idedata_addr2line_path_unix(setup_core: Path) -> None: """Test IDEData.addr2line_path on Unix.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.addr2line_path assert result == "/usr/bin/addr2line" @@ -622,7 +622,7 @@ def test_idedata_addr2line_path_unix(setup_core: Path) -> None: def test_idedata_objdump_path_windows(setup_core: Path) -> None: """Test IDEData.objdump_path on Windows.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.objdump_path assert result == "C:\\tools\\objdump.exe" @@ -631,7 +631,7 @@ def test_idedata_objdump_path_windows(setup_core: Path) -> None: def test_idedata_objdump_path_unix(setup_core: Path) -> None: """Test IDEData.objdump_path on Unix.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.objdump_path assert result == "/usr/bin/objdump" @@ -640,7 +640,7 @@ def test_idedata_objdump_path_unix(setup_core: Path) -> None: def test_idedata_readelf_path_windows(setup_core: Path) -> None: """Test IDEData.readelf_path on Windows.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.readelf_path assert result == "C:\\tools\\readelf.exe" @@ -649,7 +649,7 @@ def test_idedata_readelf_path_windows(setup_core: Path) -> None: def test_idedata_readelf_path_unix(setup_core: Path) -> None: """Test IDEData.readelf_path on Unix.""" raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"} - idedata = platformio_api.IDEData(raw_data) + idedata = toolchain.IDEData(raw_data) result = idedata.readelf_path assert result == "/usr/bin/readelf" @@ -675,7 +675,7 @@ def test_patch_structhash(setup_core: Path) -> None: }, ): # Call patch_structhash - platformio_runner.patch_structhash() + runner.patch_structhash() # Verify both modules had clean_build_dir patched # Check that clean_build_dir was set on both modules @@ -727,7 +727,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_runner.patch_structhash() + runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -777,7 +777,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_runner.patch_structhash() + runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -825,7 +825,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_runner.patch_structhash() + runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -855,7 +855,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None: ), }, ): - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -894,7 +894,7 @@ def test_patch_file_downloader_retries_on_failure() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -935,7 +935,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -983,7 +983,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() -> ), patch("time.sleep"), ): - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -1018,9 +1018,9 @@ def test_patch_file_downloader_idempotent() -> None: }, ): # Patch multiple times - platformio_runner.patch_file_downloader() - platformio_runner.patch_file_downloader() - platformio_runner.patch_file_downloader() + runner.patch_file_downloader() + runner.patch_file_downloader() + runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -1038,9 +1038,7 @@ def _filter_through_redirect(line: str) -> str: from esphome.util import RedirectText captured = io.StringIO() - redirect = RedirectText( - captured, filter_lines=platformio_runner.FILTER_PLATFORMIO_LINES - ) + redirect = RedirectText(captured, filter_lines=runner.FILTER_PLATFORMIO_LINES) redirect.write(line + "\n") return captured.getvalue() diff --git a/tests/unit_tests/test_upload_targets.py b/tests/unit_tests/test_upload_targets.py new file mode 100644 index 00000000000..52587ca4e62 --- /dev/null +++ b/tests/unit_tests/test_upload_targets.py @@ -0,0 +1,81 @@ +"""Tests for the stable upload-targets classification helpers.""" + +import pytest + +from esphome.upload_targets import PortType, get_port_type + + +@pytest.mark.parametrize( + "port", + [ + "/dev/ttyUSB0", + "/dev/ttyACM0", + "/dev/cu.usbserial-1410", + "/dev/tty.usbmodem1101", + "COM1", + "COM23", + ], +) +def test_get_port_type_serial(port: str) -> None: + """Local serial devices classify as SERIAL.""" + assert get_port_type(port) is PortType.SERIAL + + +def test_get_port_type_bootsel() -> None: + """``BOOTSEL`` magic string classifies as BOOTSEL.""" + assert get_port_type("BOOTSEL") is PortType.BOOTSEL + + +def test_get_port_type_mqtt() -> None: + """``MQTT`` magic string classifies as MQTT.""" + assert get_port_type("MQTT") is PortType.MQTT + + +def test_get_port_type_mqttip() -> None: + """``MQTTIP`` magic string classifies as MQTTIP.""" + assert get_port_type("MQTTIP") is PortType.MQTTIP + + +@pytest.mark.parametrize( + "port", + [ + "192.168.1.10", + "fe80::1", + "device.local", + "my-esp.example.com", + ], +) +def test_get_port_type_network(port: str) -> None: + """IP addresses, mDNS, and hostnames classify as NETWORK.""" + assert get_port_type(port) is PortType.NETWORK + + +def test_port_type_values_are_stable() -> None: + """Member values are part of the stable surface. + + External tooling (device-builder, etc.) may compare against the + string values directly. Renaming or changing these breaks + downstream consumers — guard against accidental edits. + """ + assert PortType.SERIAL.value == "SERIAL" + assert PortType.NETWORK.value == "NETWORK" + assert PortType.MQTT.value == "MQTT" + assert PortType.MQTTIP.value == "MQTTIP" + assert PortType.BOOTSEL.value == "BOOTSEL" + + +def test_main_re_exports_for_backwards_compat() -> None: + """``esphome.__main__`` re-exports the stable surface. + + The CLI entry point pre-dated the stable module and existing + internal callers (and any third-party code that snuck in via + ``__main__``) still import from there. The re-export must + resolve to the same objects. + """ + from esphome.__main__ import ( + PortType as MainPortType, + get_port_type as main_get_port_type, + ) + + assert MainPortType is PortType + assert main_get_port_type is get_port_type