mirror of
https://github.com/esphome/esphome.git
synced 2026-09-21 20:18:43 +00:00
Merge remote-tracking branch 'upstream/dev' into core-prebuilt-dir-upload
# Conflicts: # esphome/__main__.py
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
edce6cd78b33b296cef3caa5869d237061345eb346e3f9cb21e3239d2711051f
|
||||
96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+36
-64
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -1,20 +1,92 @@
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include "core.h"
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "preferences.h"
|
||||
|
||||
#include <climits>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <mach-o/dyld.h>
|
||||
#endif
|
||||
|
||||
#ifdef __linux__
|
||||
#include <unistd.h>
|
||||
#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);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "core.h"
|
||||
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
#ifdef USE_OPENTHREAD
|
||||
#include "openthread.h"
|
||||
|
||||
#include <freertos/portmacro.h>
|
||||
|
||||
#include <openthread/cli.h>
|
||||
#include <openthread/instance.h>
|
||||
#include <openthread/logging.h>
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <elf.h>
|
||||
#include <endian.h>
|
||||
#endif
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <mach-o/loader.h>
|
||||
#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<ssize_t>(EI_NIDENT + 4) || cur_n < static_cast<ssize_t>(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<ssize_t>(sizeof(struct mach_header)) ||
|
||||
cur_n < static_cast<ssize_t>(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<HostOTABackend> make_ota_backend() { return make_unique<HostOTABackend>(); }
|
||||
|
||||
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
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
#ifdef USE_HOST
|
||||
#include "ota_backend.h"
|
||||
|
||||
#include "esphome/components/md5/md5.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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 `<exe>.ota.new`, validates ELF/Mach-O
|
||||
/// matches the running arch, renames over `<exe>`, 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<HostOTABackend> make_ota_backend();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
# <err> 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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<typename T> void set_attr(const T &value);
|
||||
uint8_t attr_type() { return attr_type_; }
|
||||
void set_report(bool force);
|
||||
#ifdef USE_SENSOR
|
||||
template<typename T> void connect(sensor::Sensor *sensor);
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
template<typename T> void connect(binary_sensor::BinarySensor *sensor);
|
||||
#endif
|
||||
@@ -78,6 +84,11 @@ template<typename T> void ZigbeeAttribute::set_attr(const T &value) {
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
template<typename T> 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<typename T> void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) {
|
||||
sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); });
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,4 +20,4 @@ nrf52:
|
||||
voltage: 2.1V
|
||||
uicr_erase: true
|
||||
framework:
|
||||
version: "2.6.1-a"
|
||||
version: "2.6.1-b"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 <pioenvs>/<name>/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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
+53
-55
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user