Merge remote-tracking branch 'upstream/dev' into core-prebuilt-dir-upload

# Conflicts:
#	esphome/__main__.py
This commit is contained in:
J. Nick Koston
2026-05-11 10:55:31 -05:00
80 changed files with 1486 additions and 449 deletions
+1 -1
View File
@@ -1 +1 @@
edce6cd78b33b296cef3caa5869d237061345eb346e3f9cb21e3239d2711051f
96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c
+7 -2
View File
@@ -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
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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:
+1 -2
View File
@@ -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:
+3 -3
View File
@@ -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:
+14 -2
View File
@@ -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');
+7 -9
View File
@@ -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
+14 -5
View File
@@ -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) {
+2 -2
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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:
+10 -7
View File
@@ -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
+2 -2
View File
@@ -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
+16 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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__)
+1 -1
View File
@@ -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],
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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_();
+73 -1
View File
@@ -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);
+22
View File
@@ -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
+17 -1
View File
@@ -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;
+3
View File
@@ -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
+1 -3
View File
@@ -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;
+95 -31
View File
@@ -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}")
+8
View File
@@ -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 -8
View File
@@ -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},
}
)
+321 -8
View File
@@ -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
+18 -3
View File
@@ -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();
+1 -1
View File
@@ -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
+4 -5
View File
@@ -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");
+7 -1
View File
@@ -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(
+33 -63
View File
@@ -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(
+6 -5
View File
@@ -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)
+113
View File
@@ -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,
}
+4
View File
@@ -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",
},
],
},
],
},
}
+62 -7
View File
@@ -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:
+2 -64
View File
@@ -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),
+1
View File
@@ -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"
+3
View File
@@ -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
+3 -2
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
View File
@@ -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)
+66
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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"
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+152
View File
@@ -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
+31 -2
View File
@@ -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
+8 -8
View File
@@ -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
+24
View File
@@ -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."""
+28 -24
View File
@@ -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
@@ -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()
+81
View File
@@ -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