Compare commits

..
Author SHA1 Message Date
kbx81 590a4a6268 [serial_proxy] Add USB identity query for USB-bridged ports
Add SERIAL_PROXY_PORT_TYPE_USB_SERIAL, derived automatically when a
port's uart_id resolves to a usb_uart channel (never set by the user),
and a SerialProxyGetUsbInfoRequest/Response pair (IDs 153/154) that
reads VID/PID/bcdDevice and the manufacturer/product/serial strings
live from the descriptors the USB host stack caches, so a client can
identify the attached device before subscribing. Ports that are not
USB_SERIAL answer NOT_SUPPORTED; an unplugged device answers with
connected=false. ZigbeeProxyRequest moves to ID 155 (expected merge
order: set_mode, USB info, zigbee).
2026-09-03 22:07:52 -05:00
kbx81 6a9791609f Merge branch '20260902-serial-proxy-tap' into 20260218-zigbee-proxy
# Conflicts:
#	esphome/components/api/api.proto
#	esphome/components/api/api_connection.cpp
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_dump.cpp
#	esphome/components/api/api_pb2_service.cpp
#	esphome/components/serial_proxy/serial_proxy.cpp
#	esphome/components/serial_proxy/serial_proxy.h
2026-09-03 19:52:29 -05:00
kbx81 bd94a6858f Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-09-03 19:49:55 -05:00
kbx81 c432ab146f [serial_proxy] Compile out mode state in builds without a tap
PROTOCOL is refused when no tap exists, so mode_ could never leave RAW
there; gate the member and reset_mode_() behind USE_SERIAL_PROXY_TAP
(no-op inline otherwise), saving the member and the four reset calls in
every tapless build.
2026-09-03 01:16:55 -05:00
kbx81 82675a2c78 [serial_proxy] Guard leaving_protocol_mode with the tap define
Its only reader is tap-gated, so non-tap builds warned about an unused
variable.
2026-09-03 00:52:17 -05:00
kbx81 29b5935a22 [serial_proxy] Split refused-write logging by cause
Writes are the only high-rate, unacknowledged operation, so a legacy
client streaming without a subscription would flood WARN one line per
request. Contention (another client holds the port) stays WARN; the
never-subscribed case logs at VERBOSE. One-shot operations keep WARN
in both cases since their request/ack pattern bounds the rate.
2026-09-03 00:35:44 -05:00
kbx81 0224929624 [serial_proxy] Require an active subscription for every port operation
Writes, configure, modem pins and flush previously passed for any
authenticated client while nobody held the port. With a tap attached
that allowed an unsubscribed writer to share the wire with the tap,
with no way to select RAW to stop it (set_mode already refuses
non-subscribers). All port operations now require being the live
subscriber, and the proto comments state the precondition.

Also fold the ifdef-inside-if in set_mode_from_client into a has_tap
local for readability.
2026-09-03 00:10:19 -05:00
kbx81 4437a0bd7f [serial_proxy] Reset the mode when a subscription is taken over
A client taking over from a crashed subscriber inherited that session's
mode; end the dead session with reset_mode_() before handing over the
port, matching every other subscriber-change path.
2026-09-02 23:48:17 -05:00
kbx81 fa5e784cbe [serial_proxy] Drop the YAML mode option
The boot mode had no coherent job left: before any subscriber the tap
is served via tap_needs_port() regardless of mode, 1.17+ clients select
the mode explicitly after subscribing, and the only remaining effect
was arming the tap for a first-session client that never asked for it
and could not turn it off. The mode is now purely a session property
of the API: ports always boot RAW.

Also polish the tap contract per review: expose tap_is_observed(),
return false from write_from_tap() when the bytes are dropped, and
document that tap_pump() must not be called from tap callbacks.
2026-09-02 23:31:05 -05:00
kbx81 14f6d44ac2 [serial_proxy] Make set_mode acknowledgements report the real outcome
- Refuse PROTOCOL with NOT_SUPPORTED when the port has no tap, so a
  client cannot mistake a plain pipe for an active tap
- Skip tap_pump() when neither the tap nor a subscriber would receive
  the bytes, instead of draining the FIFO into the void
- Rename the client-facing overload to set_mode_from_client, matching
  write_from_client
- Document that PORT_IN_USE also covers callers that never subscribed,
  and that the YAML mode applies only until the first session ends
2026-09-02 23:04:29 -05:00
kbx81 88402743d5 [serial_proxy] Enforce session scoping and RAW inertness for the port mode
Address review findings:
- Only the live subscriber may set the mode, so a mode set by a client
  that never subscribes cannot persist past its session
- With a subscriber attached, the mode alone decides whether the tap is
  served; tap_needs_port() bypasses it only while the port is unheld,
  and write_from_tap() is gated the same way, so RAW is inert by code
- The explicit UNSUBSCRIBE path keeps the loop alive for a tap that
  still needs the port, mirroring the disconnect path in loop()
- Mode values from the wire are validated; unknown values are refused
  with INVALID_ARGUMENT instead of stored and acknowledged OK
- Add a test variant that defines USE_SERIAL_PROXY_TAP so the tap code
  paths compile in a real build
2026-09-02 22:27:52 -05:00
Keith Burzinski 6ae5f070c8 Merge branch 'dev' into 20260902-serial-proxy-tap 2026-09-02 22:10:12 -05:00
kbx81 89d00c6d93 [serial_proxy] Acknowledge set_mode requests
Follow the acknowledgement pattern from #18312: set_mode now returns a
SerialProxyResult and the handler answers with SerialProxyRequestResponse
(type SET_MODE). This matters most for a client switching to RAW before
flashing firmware through the port: without an ack, a refused request
(another client holds the port) is silently dropped and the client cannot
tell that protocol bytes may still be injected.
2026-09-02 21:31:05 -05:00
kbx81 e723d404e2 [serial_proxy] Add set_mode to the benchmark stub
The benchmark harness compiles api_connection.cpp against stub component
headers, so the stub needs the new client-request method.
2026-09-02 21:25:34 -05:00
Keith Burzinski dde216d8c8 Merge branch 'dev' into 20260902-serial-proxy-tap 2026-09-02 19:57:04 -05:00
kbx81 ab8e180ff5 [serial_proxy] Move SerialProxySetModeRequest to ID 152
IDs 150 and 151 were claimed on dev (DeviceCapabilitiesResponse,
ZWaveProxyRequestResponse) after this branch was cut.
2026-09-02 19:16:38 -05:00
kbx81 99a82222ab Merge remote-tracking branch 'upstream/dev' into 20260902-serial-proxy-tap 2026-09-02 18:55:29 -05:00
kbx81andpuddly 3162a16b05 [serial_proxy] Add tap interface and port mode
Add SerialProxyTap, a protocol-agnostic observer interface that lets a
companion component watch the bytes flowing through a proxied port and
inject bytes of its own (protocol acknowledgements, for example) without
owning the port. The tap machinery is compiled in only when a tap
component defines USE_SERIAL_PROXY_TAP, so ports without one pay nothing.

Add a per-port mode (RAW or PROTOCOL) with a matching API message so
clients control whether the tap is active. The mode belongs to the client
session: it resets to RAW whenever the subscriber disconnects, and RAW is
guaranteed inert so a client can flash firmware through the port without
protocol bytes being injected. Bumps the API minor version to 17.

Co-Authored-By: puddly <32534428+puddly@users.noreply.github.com>
2026-09-02 16:07:50 -05:00
kbx81 feeacda4ba Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-09-02 16:04:55 -05:00
kbx81 f14d69ff36 [usb_uart] Drop unused is_usb_uart_channel helper
Its only caller was removed when zigbee_proxy switched to observing a
serial_proxy port instead of owning the UART.
2026-09-02 14:25:13 -05:00
kbx81 5e79c617e8 [serial_proxy] Make port mode protocol-neutral
Rename SERIAL_PROXY_MODE_EZSP_ASH to SERIAL_PROXY_MODE_PROTOCOL so the
serial_proxy API surface carries no protocol-specific names. The mode now
means "the port's tap is active"; which protocol the tap speaks is a
property of the device configuration, discoverable from the tap
component's own API surface. Future protocol taps need no serial_proxy
or API changes.
2026-09-02 14:19:52 -05:00
kbx81 1cc48bfee2 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy
# Conflicts:
#	esphome/components/api/api_connection.cpp
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_defines.h
#	esphome/components/api/api_pb2_service.cpp
#	esphome/components/api/api_pb2_service.h
#	esphome/components/serial_proxy/serial_proxy.cpp
2026-09-02 02:30:09 -05:00
kbx81 890f0408a5 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-08-06 23:27:44 -05:00
puddly 3e0f81bf1e Improve Zigbee/WiFi collision warning 2026-08-05 14:40:38 -04:00
puddly 6111791706 Have zigbee proxying piggyback off of serial proxying? 2026-08-05 14:40:38 -04:00
puddly 7a05749231 Classify UART traffic for arbitrary protocol passthrough 2026-08-05 14:40:38 -04:00
puddly 997e218376 Reset state more reliably 2026-08-05 14:40:38 -04:00
puddly 973da47da6 Simplify startup state machine by using direct NVRAM access 2026-08-05 14:40:38 -04:00
puddly e80aa9579b Handle more of the EZSP protocol and try to detect the bootloader 2026-08-05 14:40:38 -04:00
puddly a060db1251 Fix EZSP and ASH protocol parsing/forwarding 2026-08-05 14:39:01 -04:00
kbx81 9c4016a871 [zigbee_proxy] Drop usb_uart_id removal error, component is unreleased 2026-08-03 16:58:53 -05:00
kbx81 5846977cf6 [zigbee_proxy] Auto-detect USB UART channel from uart_id, drop usb_uart_id
The usb_uart_id key was redundant: uart_id already points at the channel.
A new usb_uart.is_usb_uart_channel() helper checks the config tree (use_id
resolution does not narrow the ID type), and zigbee_proxy uses it to enable
the RX callback fast path and USB timeout defaults automatically.
2026-08-03 16:55:28 -05:00
kbx81 ca42862742 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-08-03 16:32:34 -05:00
kbx81 e0a054dbcb [zigbee_proxy] Harden ASH sessions, fix UAF/boot-stall/overflows, reduce latency
- Unsubscribe on API disconnect (use-after-free) + loop() subscriber guard
- Bounds-checked frame building; cap forwarded RSTACK/ERROR payloads
- Explicit client ACKs, duplicate re-ACK, NAK on reject (both ASH sides)
- Client->NCP TX queue with NAK overflow; retry client frames on API backpressure
- Harvest EUI64 during boot; implement NETWORK_INFO request/response and push
- Proceed after boot timeout instead of stalling setup; periodic NCP recovery
- zwave-style inline UART fast path; process piggybacked ACKs before sequence check
- Wire up bootloader detection; heap-free hex logging
2026-07-22 23:20:51 -05:00
kbx81 5e822b828e Fix zigbee proxy handlers for new non-virtual dispatch, drop deprecated rp2040 platform key in test 2026-07-22 22:31:12 -05:00
kbx81 ef646a9303 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-07-22 22:20:25 -05:00
kbx81 f8bec0813d fix 2026-03-13 16:48:56 -05:00
kbx81 84762e6ae0 oops 2026-03-13 16:46:13 -05:00
kbx81 2edf313ee3 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-03-13 16:45:23 -05:00
kbx81 ae9c999052 fix 2026-02-28 23:21:30 -06:00
kbx81 7d2f6fbf55 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-28 23:12:31 -06:00
kbx81 608bef86cc Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-26 23:42:43 -06:00
kbx81 6514dc2fe1 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-26 20:55:50 -06:00
kbx81 240afd23b3 ... 2026-02-26 14:31:17 -06:00
kbx81 156c2a8cb0 optimize 2026-02-26 14:30:31 -06:00
kbx81 908c47bb5e preen, tune 2026-02-25 23:28:44 -06:00
kbx81 6df3a30740 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-25 17:33:27 -06:00
kbx81 0aaf59dbed Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-24 16:51:04 -06:00
kbx81 249c5bb724 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-23 18:01:56 -06:00
kbx81 54ea8dd207 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-19 18:31:15 -06:00
puddly 4cfb794b62 WIP 2026-02-19 18:22:03 -05:00
kbx81 917af8ff31 [zigbee_proxy] New component 2026-02-19 14:34:29 -06:00
226 changed files with 4570 additions and 15395 deletions
@@ -1,39 +0,0 @@
name: Cache Arduino ESP8266
description: >
Resolve the pinned Arduino core and xtensa toolchain versions and cache the
native ESP8266 install (~110 MB framework + toolchain; no ccache store, the
seed job saves before any compile runs). Exports
ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into
the cached path; the Python venv must already be restored. Mirrors
cache-esp-idf: only dev-branch pushes write the shared cache, everything
else restores.
runs:
using: composite
steps:
- name: Resolve the native toolchain cache key
# Versions are pinned in code, not a hashable file; resolve them so a
# bump changes the cache key. Assignment form so errexit catches a
# resolver failure.
id: version
shell: bash
run: |
# One owner for the install prefix: exported here and referenced by
# the cache steps below via env, so the caller's install and the
# cached path cannot diverge.
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
. venv/bin/activate
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
[ -n "$key" ] || exit 1
echo "key=$key" >> "$GITHUB_OUTPUT"
- name: Cache the native toolchain (write on dev)
if: github.ref == 'refs/heads/dev'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
- name: Restore the native toolchain (off dev)
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
-13
View File
@@ -197,7 +197,6 @@ jobs:
# the default.
id:
- esp8266-arduino
- esp8266-arduino-native
- esp32-arduino-platformio
- esp32-arduino-esp-idf
- esp32-idf-platformio
@@ -208,17 +207,6 @@ jobs:
- ln882x-arduino
- nrf52
- host
# Strict by default so a new matrix id cannot silently join in the
# degrade-quietly mode the knob exists to catch; the knob is inert
# where no pch code runs (esp32-*-platformio, nrf52, host).
# Opt-outs: libretiny GCC rejects its own pch until a toolchain bump.
include:
- id: bk72xx-arduino
pch_strict: "0"
- id: rtl87xx-arduino
pch_strict: "0"
- id: ln882x-arduino
pch_strict: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download image artifact
@@ -230,7 +218,6 @@ jobs:
- name: Compile ${{ matrix.id }}
run: |
docker run --rm \
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '1' }}" \
-v "${{ github.workspace }}/docker/test_configs:/config" \
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
compile "${{ matrix.id }}.yaml"
+3 -80
View File
@@ -102,8 +102,6 @@ jobs:
device-builder: ${{ steps.determine.outputs.device-builder }}
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
esp8266-native: ${{ steps.determine.outputs.esp8266-native }}
esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
@@ -167,8 +165,6 @@ jobs:
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT
echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
@@ -187,32 +183,6 @@ jobs:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
seed-esp8266-native-cache:
name: Seed the esp8266 native toolchain cache
runs-on: ubuntu-24.04
needs:
- common
# PR-branch cache saves are invisible to other PRs, so dev pushes seed
# the shared entry test-esp8266-native restores. Only dev: the composite
# action saves nowhere else, so a beta/release push would download the
# toolchain and discard it.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
timeout-minutes: 15
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache the native toolchain
uses: ./.github/actions/cache-arduino8266
- name: Install the native toolchain
run: |
. venv/bin/activate
python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)"
ci-custom:
name: Run script/ci-custom
runs-on: ubuntu-24.04
@@ -274,20 +244,11 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Read prek version from requirements_test.txt
id: prek
# requirements_test.txt is the only place the version is pinned, so a
# Dependabot bump there is picked up here without a second edit.
run: |
if ! version=$(sed -nE 's/^prek==([^[:space:]#]+).*/\1/p' requirements_test.txt) || [ -z "$version" ]; then
echo "::error::No prek== pin found in requirements_test.txt."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Run prek
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
with:
prek-version: ${{ steps.prek.outputs.version }}
# Keep in sync with requirements_test.txt.
prek-version: "0.4.11"
# This job only runs on pull requests, so nothing ever populates
# the cache on dev. Every run would miss and then write a per-pull
# request copy, which is what the old seed-cache job existed to
@@ -1267,7 +1228,7 @@ jobs:
# compile validates config first, so a separate config pass is
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio
echo ""
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
@@ -1276,42 +1237,6 @@ jobs:
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
test-esp8266-native:
name: Test esp8266 components with the native toolchain
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
env:
# Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS)
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache the native toolchain
uses: ./.github/actions/cache-arduino8266
- name: Run native toolchain compile test
run: |
. venv/bin/activate
echo "Testing components: $TEST_COMPONENTS"
echo ""
# ESP8266 Arduino built directly (no PlatformIO); compile validates
# config first, so a separate config pass is redundant. Strict pch:
# exercises the native ninja pch (and its probe edge) against real
# component configs; the docker matrix smoke-tests both toolchains.
ESPHOME_PCH_STRICT=1 python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
@@ -1674,7 +1599,6 @@ jobs:
needs:
- common
- seed-apt-cache
- seed-esp8266-native-cache
- determine-jobs
- ci-custom
- pylint
@@ -1690,7 +1614,6 @@ jobs:
- clang-tidy-esp32-variants
- test-build-components-split
- test-esp32-platformio
- test-esp8266-native
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
@@ -1,94 +0,0 @@
# Keeps pre-commit hook revs in sync with the requirements files.
#
# Dependabot only bumps the pins in requirements*.txt. Some of those tools
# are pinned again as hook revs in .pre-commit-config.yaml. This workflow
# runs script/sync_dependency_versions.py against the pull request branch
# and pushes a commit with the revs updated.
name: Sync dependency versions
on:
# pull_request_target rather than pull_request so the App secret is
# available on Dependabot pull requests (pull_request runs opened by
# Dependabot only see Dependabot secrets). The job below only touches
# branches in this repository and only ever executes the script from the
# base branch checkout, so fork code never runs with the token.
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- requirements_dev.txt
- requirements_test.txt
- .pre-commit-config.yaml
- script/sync_dependency_versions.py
# The push to the pull request branch uses the App token minted below, so
# the workflow's GITHUB_TOKEN does not need any scopes.
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
sync:
name: Sync pinned versions
runs-on: ubuntu-latest
# Same-repository branches only: a push to a fork is not possible with
# this token, and it keeps untrusted heads out of a privileged job.
if: >-
github.repository == 'esphome/esphome'
&& github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
# A push made with the workflow's own GITHUB_TOKEN would not start
# CI on the new commit; a push with the App token does.
permission-contents: write # git push of the sync commit to the pull request branch
- name: Check out base branch
# Provides the script that runs below. Deliberately the base branch
# so the pull request cannot change what executes here.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Check out pull request branch
# No allow-unsafe-pr-checkout here on purpose: checkout v7 only
# refuses heads that live in a different repository, and the job
# condition above already limits runs to same-repository branches.
# Leaving it off keeps that refusal as a backstop for fork heads.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.ref }}
path: pull-request
token: ${{ steps.generate-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install yamlrocks
# The script edits YAML through yamlrocks. Take the pin from the
# base branch requirements so this workflow has no copy of its own.
run: pip install "$(grep -E '^yamlrocks==' requirements_test.txt | cut -d'#' -f1)"
- name: Sync pinned versions
run: python script/sync_dependency_versions.py --root pull-request
- name: Push changes
working-directory: pull-request
run: |
if git diff --quiet; then
echo "All pinned versions already match the requirements files."
exit 0
fi
git config user.name "esphome[bot]"
git config user.email "115708604+esphome[bot]@users.noreply.github.com"
git commit -am "Sync pinned tool versions with requirements files"
git push
+3 -2
View File
@@ -1,6 +1,7 @@
---
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
ci:
autoupdate_commit_msg: 'pre-commit: autoupdate'
autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit
@@ -10,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.6
rev: v0.16.3
hooks:
# Run the linter.
- id: ruff
@@ -41,7 +42,7 @@ repos:
- id: pyupgrade
args: [--py312-plus]
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.38.0
rev: v1.37.1
hooks:
- id: yamllint
exclude: ^(\.clang-format|\.clang-tidy)$
+1 -2
View File
@@ -553,7 +553,6 @@ file does, and it is the authority when they disagree. The most useful starting
4. **Lint:** Run `prek` to ensure code is compliant.
5. **Commit:** Commit your changes. There is no strict format for commit messages.
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
7. **Comments:** When commenting on GitHub PRs or issues, don't tag contributors, especially bots. Avoid referring to list items (e.g. from reviews) with the form #nn - this will be interpreted by GitHub as a reference to issue or PR nn. Keep comments short and exclude irrelevant details, backstories, restatement of previous comments and anything that is already obvious to the reader.
* **Documentation Contributions:**
* Documentation is hosted in the separate `esphome/esphome.io` repository.
@@ -840,7 +839,7 @@ file does, and it is the authority when they disagree. The most useful starting
cv.rename_key(
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
),
cv.Schema({...}),
cv.Schema({ ... }),
)
```
For other deprecations, warn manually during validation:
+19 -36
View File
@@ -125,47 +125,30 @@ design is optimal or that it will not change.
## OTA update encryption
The `esphome` OTA platform optionally encrypts updates with the same Noise
`NNpsk0` pattern the native API uses; one key protects the device. A device
whose `api:` block has an encryption key, static in the YAML or provisioned at
runtime, compiles in the transport and offers it on every OTA connection once
it holds a key, so an uploader presenting that key gets the guarantees below
even without an `ota: encryption:` block; only that block makes the device
require encryption. The guarantees are: the firmware image is confidential in
transit, the uploader is authenticated by the pre-shared key, and the plaintext
negotiation preceding the handshake is bound into the handshake prologue, so
stripping or tampering with it fails the first MAC. With `ota: encryption:`
configured both ends fail closed with no override: the device refuses
`NNpsk0` pattern the native API uses; one key protects the device. With an
`encryption:` block configured the guarantees are: the firmware image is
confidential in transit, the uploader is authenticated by the pre-shared key,
and the plaintext negotiation preceding the handshake is bound into the
handshake prologue, so stripping or tampering with it fails the first MAC.
Both ends fail closed with no override: a device built with a key refuses
plaintext uploads, and the CLI refuses to send plaintext when a key is
configured. Without that block the CLI tries a static api key when the device
offers and, until 2027.3.0, falls back to plaintext with a warning when the
offer is missing or the handshake fails; a runtime provisioned key never
reaches the CLI, so those uploads stay plaintext.
configured.
Defeating any of that without the key is in scope: a device that requires
encryption accepting a plaintext or downgraded upload, getting past the MAC,
or recovering image contents from captured traffic.
Defeating any of that without the key is in scope: a keyed device accepting a
plaintext or downgraded upload, getting past the MAC, or recovering image
contents from captured traffic.
The following are **not** vulnerabilities, by design:
- Plaintext OTA on a device with no `ota: encryption:` block, including one
that offers encryption because it has an api key. That is the documented
default, authenticated (if at all) by the OTA password. An uploader that
takes the offer skips the password; the key authenticates it. With a
runtime provisioned key and no `provisioning:` window, whoever provisions
the key gains that upload path too; validation warns about the pair.
- The CLI plaintext fallback until 2027.3.0: without `ota: encryption:` an
active attacker who strips the offer or breaks the handshake can make a
keyed CLI upload plaintext, with the pre-existing plaintext exposure. A
device that requires encryption still refuses that upload.
- The enablement window: firmware built with a static api key already offers
encryption, so turning on `ota: encryption:` is itself an encrypted upload.
Older firmware needs one last plaintext upload of an offering build, with
the pre-existing plaintext exposure.
- The web OTA `/update` endpoint alongside encryption. With the `web_server`
or `prometheus` component the shared listener is always up, so the endpoint
stays reachable and validation warns about that combination;
`captive_portal:` alone brings the listener up only for the fallback AP
window, which is the intended recovery path, so that is not warned about.
- Plaintext OTA on a device with no `encryption:` block. That is the
documented default, authenticated (if at all) by the OTA password.
- The enablement window: turning encryption on takes one last upload of the
encryption-enabled firmware over the existing plaintext channel, with the
pre-existing plaintext exposure.
- The web OTA `/update` endpoint alongside encryption. The `web_server`
component keeps it always reachable, and `captive_portal:` auto-loads it
for the fallback AP window; validation warns about both combinations, and
the operator keeps the recovery path.
- CLI retry behavior on transport or MAC failures; every attempt renegotiates
a fresh handshake with fresh ephemerals, so retrying does not weaken
authentication.
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.0
RUN \
platformio settings set enable_telemetry No \
@@ -1,8 +0,0 @@
esphome:
name: docker-test-esp8266-native
esp8266:
board: d1_mini
toolchain: arduino
logger:
+32 -96
View File
@@ -817,9 +817,7 @@ def write_cpp_file() -> int:
from esphome.build_gen import espidf
espidf.write_project()
elif not CORE.using_native_toolchain:
# Other native builds generate their project at compile time;
# never write a platformio.ini for them
else:
from esphome.build_gen import platformio
platformio.write_project()
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
toolchain.create_factory_bin()
toolchain.create_ota_bin()
toolchain.create_elf_copy()
from esphome.build_helpers.idedata import warn_if_idedata_missing
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
warn_if_idedata_missing(toolchain.get_idedata)
elif CORE.using_native_toolchain:
raise EsphomeError(
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
"backend claimed the build"
)
try:
if toolchain.get_idedata() is None:
_LOGGER.warning("No idedata was generated for this build")
except IDEDATA_BEST_EFFORT_ERRORS as err:
# The firmware already built; an idedata failure must not fail
# a successful build.
_LOGGER.warning(
"Could not generate idedata: %s (IDE, clang-tidy, and "
"memory-analysis data will be unavailable for this build)",
err,
)
_LOGGER.debug("Idedata failure detail", exc_info=True)
else:
from esphome.platformio import toolchain
@@ -971,15 +975,12 @@ def upload_using_esptool(
if file is not None:
flash_images = [FlashImage(path=file, offset="0x0")]
elif (native := _native_toolchain_module()) is not None:
# Every native backend supplies its own 0x0 flash image (bootloader
# and partitions included where the target needs them)
image = native.get_factory_firmware_path()
if not image.is_file():
raise EsphomeError(
f"{image} does not exist; compile the configuration first"
)
flash_images = [FlashImage(path=image, offset="0x0")]
elif CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
flash_images = [
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
]
else:
from esphome.platformio import toolchain
@@ -1334,14 +1335,12 @@ def _upload_via_native_api(
break
from esphome import espota2
from esphome.components.noise import static_encryption_key
remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD)
# Fail closed: an encryption block whose key did not resolve must never
# fall back to a plaintext upload
noise_psk = None
plaintext_fallback = False
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
noise_psk = encryption_conf.get(CONF_KEY)
if not noise_psk:
@@ -1352,10 +1351,6 @@ def _upload_via_native_api(
# Ensure the key is a string, as required by the underlying OTA implementation.
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
noise_psk = str(noise_psk)
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
noise_psk = str(api_key)
plaintext_fallback = True
def check_partition_access(option_string: str) -> None:
if not ota_conf.get("allow_partition_access"):
@@ -1387,13 +1382,7 @@ def _upload_via_native_api(
_validate_bootloader_binary(binary)
return espota2.run_ota(
network_devices,
remote_port,
password,
binary,
ota_type,
noise_psk,
plaintext_fallback=plaintext_fallback,
network_devices, remote_port, password, binary, ota_type, noise_psk
)
@@ -1961,39 +1950,15 @@ def command_update_all(args: ArgsProtocol) -> int | None:
return run_multiple_configs(files, build_command)
# Native build backend per (target platform, toolchain). Keyed here rather
# than through a platform hook so the serial upload/logs fast path never
# imports the platform component package (see the esp32 variant comment in
# upload_using_esptool); the platform half comes from CORE.data the same way.
_NATIVE_TOOLCHAIN_MODULES = {
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
}
def _native_toolchain_module():
"""The native build backend module for the resolved toolchain."""
if not CORE.using_native_toolchain:
return None
key = (CORE.target_platform, CORE.toolchain)
if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(key)) is None:
# Degrading to the PlatformIO path would build with the wrong backend
raise EsphomeError(
f"Toolchain '{CORE.toolchain.value}' has no native build backend "
f"module for platform {CORE.target_platform}"
)
return importlib.import_module(module_path)
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
native_toolchain = _native_toolchain_module()
if CORE.using_toolchain_esp_idf:
# Native ESP-IDF derives idedata from the build's compile_commands.json,
# so the configuration must already be compiled.
from esphome.espidf import toolchain as espidf_toolchain
if native_toolchain is not None:
# Native toolchains derive idedata from the build's
# compile_commands.json, so the configuration must already be compiled.
idedata = native_toolchain.get_idedata()
idedata = espidf_toolchain.get_idedata()
if idedata is None:
_LOGGER.error(
"No idedata available; compile the configuration first",
@@ -2032,17 +1997,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
# Refuse an unsupported toolchain before paying for a full compile
native_toolchain = _native_toolchain_module()
if native_toolchain is None and not CORE.using_toolchain_platformio:
_LOGGER.error(
"analyze-memory is not supported with the '%s' toolchain on %s; "
"re-run with --toolchain platformio",
CORE.toolchain.value if CORE.toolchain else "unresolved",
CORE.target_platform,
)
return 1
# Always compile to ensure fresh data (fast if no changes - just relinks)
exit_code = write_cpp(config)
if exit_code != 0:
@@ -2054,31 +2008,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
# Get idedata for analysis
idedata = None
if native_toolchain is not None:
objdump = native_toolchain.get_objdump_path()
readelf = native_toolchain.get_readelf_path()
for tool in (objdump, readelf):
if not tool.is_file():
# The analyzer would silently fall back to host binutils,
# which cannot read the target ELF. clean-all is heavy for
# ESP-IDF, so suggest a recompile first.
_LOGGER.error(
"%s is missing; the toolchain install may be incomplete "
"(recompile, or run 'esphome clean-all' if it persists)",
tool,
)
return 1
objdump_path = str(objdump)
readelf_path = str(readelf)
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
firmware_elf = native_toolchain.get_elf_path()
if not firmware_elf.is_file():
# The analyzer swallows tool failures, so a missing ELF would
# produce an exit-0 zeroed report
_LOGGER.error(
"%s is missing; compile the configuration first", firmware_elf
)
return 1
objdump_path = str(toolchain.get_objdump_path())
readelf_path = str(toolchain.get_readelf_path())
firmware_elf = toolchain.get_elf_path()
else:
from esphome.platformio import toolchain
+3 -1
View File
@@ -23,7 +23,9 @@ from esphome.util import safe_print
if TYPE_CHECKING:
from collections.abc import Callable
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import (
SubscribeLogsResponse, # pylint: disable=no-name-in-module
)
_LOGGER = logging.getLogger(__name__)
+1 -4
View File
@@ -19,7 +19,6 @@ from typing import NamedTuple
from esphome.build_helpers.ccache import ccache_defaults_env
from esphome.build_helpers.ninja import find_ninja
from esphome.build_helpers.pch import ccache_pch_env
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
from esphome.core import EsphomeError, Version
from esphome.framework_helpers import str_to_lst_of_str
@@ -157,6 +156,4 @@ def ccache_env(ccache: str | None) -> dict[str, str]:
"""
if ccache is None:
return {}
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
env.update(ccache_pch_env())
return env
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
-329
View File
@@ -1,329 +0,0 @@
"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent)."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import subprocess
from typing import Any
from esphome.arduino8266 import framework
from esphome.build_helpers.ccache import resolve_ccache_path
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
)
from esphome.core import CORE, EsphomeError
from esphome.helpers import write_file_if_changed
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest)
_MAX_RAM_SIZE = 81920
def _warn_ignored_platformio_options() -> None:
"""Warn for component-added platformio options the native build drops.
The consumed set is exported by core/config.py next to the routing that
stores these options, so the two cannot drift; YAML upload_speed never
reaches CORE.platformio_options here.
"""
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
for key in sorted(CORE.platformio_options or {}):
if key not in consumed:
_LOGGER.warning(
"platformio_options->%s is ignored when building with the "
"native 'arduino' toolchain",
key,
)
_RAM_SECTIONS = (".data", ".rodata", ".bss")
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
def get_build_dir() -> Path:
return CORE.relative_pioenvs_path(CORE.name)
def get_elf_path() -> Path:
return get_build_dir() / "firmware.elf"
def _toolchain_tool(name: str) -> Path:
return framework.toolchain_tool(framework.get_toolchain_path(), name)
def get_factory_firmware_path() -> Path:
"""The image to serial-flash at 0x0 (same bytes as firmware.bin: the
8266 factory copy exists for artifact-contract parity, not content)."""
return get_build_dir() / "firmware.factory.bin"
def get_addr2line_path() -> Path:
return _toolchain_tool("addr2line")
def get_objdump_path() -> Path:
return _toolchain_tool("objdump")
def get_readelf_path() -> Path:
return _toolchain_tool("readelf")
def run_compile(config: ConfigType, verbose: bool) -> int:
from esphome.build_gen import arduino8266 as build_gen
_warn_ignored_platformio_options()
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
# Resolved once per build: the resolution probes PATH and spawns the
# runnability check, and three consumers need the same answer
ccache = resolve_ccache_path()
ninja_changed = build_gen.write_project(paths, ccache)
build_dir = get_build_dir()
env = framework.get_build_env(paths.toolchain, ccache)
# Regenerate the compile DB before the build (a pure function of
# build.ninja); skip only when it is at least as fresh as build.ninja
# (an interrupted previous run may have rewritten the manifest without
# regenerating the DB).
compdb = build_dir / "compile_commands.json"
compdb_stamp = build_dir / ".compile_commands.stamp"
ninja_file = build_dir / "build.ninja"
# Freshness rides a stamp: the DB itself is written through
# write_file_if_changed (its mtime feeds get_idedata's cache), so a
# regeneration with identical content would stay "stale" forever
if (
ninja_changed
or not compdb.is_file()
or not compdb_stamp.is_file()
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
):
_write_compile_commands(paths.ninja, build_dir, env)
compdb_stamp.touch()
cmd = [str(paths.ninja)]
if verbose:
cmd.append("-v")
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
cmd += ["-j", str(jobs)]
# Explicit targets, not the default statement: a generator defect that
# drops them fails loudly with "unknown target" instead of a green
# no-op run that leaves stale artifacts in place
targets = ["firmware.factory.bin", "firmware.ota.bin"]
cmd += targets
# A dry-run probe keeps a no-op rebuild quiet: ninja would only print
# "no work to do". A freshly rewritten manifest all but guarantees work,
# so skip the probe (and its full stat pass) on that path. cwd instead
# of -C also drops the "Entering directory" banner on real builds.
skip_build = False
if not ninja_changed:
probe = subprocess.run(
[str(paths.ninja), "-n", *targets],
cwd=build_dir,
env=env,
capture_output=True,
text=True,
check=False,
close_fds=False,
)
if probe.stderr.strip():
# A load-time diagnostic (e.g. "multiple rules generate X")
# flags a generator bug; the skip branch would otherwise
# swallow it forever
_LOGGER.warning("ninja: %s", probe.stderr.strip())
if probe.returncode != 0:
# An unknown target here is the defective-manifest case; fall
# through to the real build so the error prints attributably
_LOGGER.debug("ninja probe failed; running the full build")
skip_build = probe.returncode == 0 and "no work to do" in probe.stdout
if skip_build:
_LOGGER.debug("ninja: nothing to rebuild")
else:
_LOGGER.debug("Running: %s", " ".join(cmd))
rc = subprocess.run(
cmd, cwd=build_dir, env=env, check=False, close_fds=False
).returncode
if rc != 0:
return rc
# ninja already refused a manifest missing the explicit targets above;
# existence covers the remaining hole (a rule that ran but wrote
# elsewhere). The factory/ota copies are what upload and OTA consume.
build_dir_artifacts = (
get_elf_path(),
build_dir / "firmware.bin",
get_factory_firmware_path(),
build_dir / "firmware.ota.bin",
)
for artifact in build_dir_artifacts:
if not artifact.is_file():
_LOGGER.error("Build produced no %s", artifact)
return 1
if not _print_size_summary(build_dir, paths):
# The cause was already warned; name the consequence so a build
# contributing no RAM/Flash metric is visible to CI harnesses
_LOGGER.warning("Firmware size summary unavailable for this build")
from esphome.build_helpers.idedata import warn_if_idedata_missing
warn_if_idedata_missing(lambda: get_idedata(ccache))
return 0
def _write_compile_commands(
ninja_path: Path, build_dir: Path, env: dict[str, str]
) -> None:
compdb = build_dir / "compile_commands.json"
result = subprocess.run(
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
env=env,
capture_output=True,
text=True,
check=False,
close_fds=False,
)
if result.returncode != 0:
# Drop any stale database so consumers (IDE integration, clang-tidy,
# the memory analyzer) can't silently read outdated data.
compdb.unlink(missing_ok=True)
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
try:
entries = json.loads(result.stdout)
except ValueError as err:
compdb.unlink(missing_ok=True)
raise EsphomeError(
f"ninja produced an unparsable compile database: {err} "
f"(output starts {result.stdout[:120]!r})"
) from err
if not entries:
# compdb exits 0 with [] for unknown rule names; a renamed compile
# rule must fail the build, not silently strand every consumer
compdb.unlink(missing_ok=True)
raise EsphomeError(
"ninja produced an empty compile database; the generator's rule "
"names no longer match"
)
# write_file_if_changed keeps the mtime stable on no-op builds so the
# idedata cache in get_idedata() stays valid.
write_file_if_changed(compdb, result.stdout)
def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None:
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
from esphome.build_gen.arduino8266 import get_flash_ld_path
from esphome.components.esp8266.build_surgery import segment_length
# Warnings, not debug: without the app size the Flash summary line is
# dropped and CI's memory-impact extraction loses its flash metric.
ld_path = get_flash_ld_path(build_dir, paths)
try:
ld_text = ld_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as err:
# UnicodeDecodeError: a truncated/corrupt script must degrade to
# the same warning, never abort an already-linked build
_LOGGER.warning("Cannot read linker script for the Flash summary: %s", err)
return None
app_size = segment_length(ld_text, "irom0_0_seg")
if app_size is None:
_LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path)
return None
if app_size == 0:
_LOGGER.warning(
"irom0_0_seg has zero length in %s; skipping Flash summary", ld_path
)
return None
return app_size
def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool:
"""Print the PlatformIO-shaped RAM/Flash lines; False when skipped.
The exact shape (including the bar) is parsed by
``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it.
"""
from esphome.build_helpers.size_summary import print_size_line
size_tool = _toolchain_tool("size")
try:
result = subprocess.run(
[str(size_tool), "-A", "-d", str(get_elf_path())],
capture_output=True,
text=True,
check=False,
close_fds=False,
)
except OSError as err:
# The summary is a bonus artifact like idedata; a truncated
# toolchain extraction must not discard an already-linked build
_LOGGER.warning("Could not summarize firmware size: %s", err)
return False
if result.returncode != 0:
_LOGGER.warning("Could not summarize firmware size: %s", result.stderr)
return False
sections: dict[str, int] = {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0].startswith("."):
try:
sections[parts[0]] = int(parts[1])
except ValueError:
# An unparsed RAM/Flash section trips the missing-sections
# guard below, so no total is built on a dropped value
_LOGGER.warning("Unparsable size output for section %s", parts[0])
if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections):
# A defaulted 0 would print a confidently wrong total for CI's metric
_LOGGER.warning(
"Size output is missing section(s) %s; skipping the size summary",
", ".join(sorted(missing)),
)
return False
# Resolve the flash budget before printing anything: a RAM line without
# its Flash line would let CI's memory-impact extraction sum the two
# metrics over different build counts (_parse_app_size already warned).
app_size = _parse_app_size(build_dir, paths)
if not app_size:
return False
ram = sum(sections[s] for s in _RAM_SECTIONS)
flash = sum(sections[s] for s in _FLASH_SECTIONS)
print_size_line("RAM", ram, _MAX_RAM_SIZE)
print_size_line("Flash", flash, app_size)
return True
# Sentinel: "resolve for me"; None is a real value meaning disabled.
_CCACHE_UNRESOLVED: Any = object()
def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None:
"""Derive idedata from the build's compile_commands.json.
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
integrations, clang-tidy, and the memory analyzer expect.
"""
from esphome.build_helpers.idedata import load_or_build_idedata
if ccache is _CCACHE_UNRESOLVED:
# Deliberately uncached: env/PATH can change between builds in a
# long-lived host process
ccache = resolve_ccache_path()
return load_or_build_idedata(
get_build_dir() / "compile_commands.json",
get_elf_path(),
# Suffixed so a platformio->arduino->platformio round trip on one
# config never serves the other toolchain's cache shape
CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"),
# The compile DB's commands carry the same ccache prefix the ninja
# rules were generated with
launcher=str(ccache) if ccache else None,
)
File diff suppressed because it is too large Load Diff
+1 -11
View File
@@ -6,7 +6,6 @@ started esphome and must not depend on the package being importable.
Subcommands:
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
copy <src> <dst> copy a file
touch <path> create/update a stamp file
The ar rspfile carries one object path per line (the generating rule must
use ``$in_newline``, never ``$in``).
@@ -84,18 +83,9 @@ def _run_copy(src: str, dst: str) -> int:
return 0
def _run_touch(path: str) -> int:
try:
Path(path).touch()
except OSError as err:
print(f"touch: {path} failed: {err}", file=sys.stderr)
return 1
return 0
# mode -> (handler, expected operand count); surplus argv means a
# mis-specified ninja rule and must error, not silently drop operands
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2), "touch": (_run_touch, 1)}
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
def main() -> int:
-81
View File
@@ -4,14 +4,6 @@ import json
import logging
from pathlib import Path
from esphome.build_helpers import pch
from esphome.build_helpers.pch import (
PCH_DEFAULT_HEADERS,
PCH_HEADER_NAME,
mark_pch_emitted,
pch_enabled,
pch_header_text,
)
from esphome.components.esp32 import (
get_esp32_variant,
get_excluded_builtin_components,
@@ -287,74 +279,9 @@ idf_component_register(
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
)
{_pch_cmake()}"""
def _pch_cmake() -> str:
"""The src component's precompiled-header block (C++ TUs only).
The -include stays relative (resolved from the compiler cwd, the build
dir); an absolute path would poison ccache keys.
"""
if not pch_enabled():
return ""
# Strict inverts: a per-process consumer rejection reds the build.
# Baked at generation: a knob flip takes effect when the CMakeLists is
# rewritten (every esphome compile); a hand-run idf.py keeps the old one
escalation = pch.pch_consumer_escalation()
return f"""
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
# OBJECT_DEPENDS is on the header, not the .gch: pch-baked headers drop
# out of TU depfiles, and prepare_pch() touches the header on rebuild.
target_compile_options(${{COMPONENT_LIB}} PRIVATE
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
)
set_source_files_properties(${{app_sources}} PROPERTIES
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
"""
def discard_pch() -> None:
"""Drop the pch sidecars in the IDF build dir."""
pch.discard_pch(CORE.relative_build_path("build"))
def prepare_pch() -> None:
"""Build the .gch right before ninja, after every reconfigure, so the
compile_commands.json flags and the sdkconfig are the settled ones."""
if not pch_enabled():
# Self-cleaning escape hatch: drop any previously built .gch
pch.discard_pch(CORE.relative_build_path("build"))
pch.pch_disabled_degraded()
return
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
try:
sdkconfig = sdkconfig_path.read_text(encoding="utf-8")
except OSError as err:
# Fail closed: the sdkconfig is the .sum's only config identity for
# sdkconfig.h-only options; a stand-in marker would collide
_LOGGER.warning(
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
)
pch.discard_pch(CORE.relative_build_path("build"))
pch.pch_degraded(f"sdkconfig unreadable: {err}")
return
pch.prepare_pch(
CORE.relative_build_path("build"),
PCH_DEFAULT_HEADERS,
(
str(idf_version()),
CORE.cpp_standard or "",
sdkconfig,
*get_project_compile_flags(),
*get_project_cxx_compile_flags(),
),
)
def write_project(
minimal: bool = False, builtin_components: list[str] | None = None
) -> None:
@@ -374,14 +301,6 @@ def write_project(
get_component_cmakelists(),
)
if pch_enabled():
write_file_if_changed(
CORE.relative_build_path("build", PCH_HEADER_NAME),
pch_header_text(PCH_DEFAULT_HEADERS),
)
# Consumers carry the -include; gate the ccache relaxation on it
mark_pch_emitted()
# Snapshot the exclusion set so has_outdated_files() can trigger a
# discovery reconfigure when it changes. Excluded components never
# register in project_description.json, so re-including one (e.g. a
+3 -21
View File
@@ -21,13 +21,12 @@ def _ccache_runs(ccache: str) -> bool:
)
def parse_enable_env(name: str, strict: bool = False) -> bool | None:
def parse_enable_env(name: str) -> bool | None:
"""Strictly parse an on/off environment knob; None when unset or invalid.
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
1/true/yes/on and 0/false/no/off count; anything else warns and reads
as unset so the caller's default policy applies — or raises when
``strict`` (a typo must not silently disable a CI gate).
as unset so the caller's default policy applies.
"""
raw = os.environ.get(name)
if raw is None:
@@ -40,10 +39,6 @@ def parse_enable_env(name: str, strict: bool = False) -> bool | None:
return True
if lowered in FALSY_ENV_STRINGS:
return False
if strict:
from esphome.core import EsphomeError
raise EsphomeError(f"Unrecognized {name}={raw!r}; use 1 or 0")
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
return None
@@ -92,19 +87,6 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
"CCACHE_DIR": str(cache_dir),
"CCACHE_NOHASHDIR": "true",
"CCACHE_DEPEND": "1",
# A user value wins via the filter below
"CCACHE_BASEDIR": effective_ccache_basedir(),
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
}
return {k: v for k, v in defaults.items() if k not in os.environ}
def effective_ccache_basedir() -> str:
"""The prefix ccache rewrites out of hashed paths: a user CCACHE_BASEDIR
wins, else the resolved build path (matching ccache_defaults_env)."""
from esphome.core import CORE
raw = os.environ.get("CCACHE_BASEDIR")
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
return raw
# Unset or degenerate ("", "/", relative): fall back to the build path
return str(Path(CORE.build_path).resolve())
+15 -70
View File
@@ -11,7 +11,6 @@ consumers (IDE integration, clang-tidy) expect:
from __future__ import annotations
from collections.abc import Callable
import json
import logging
import os
@@ -22,8 +21,6 @@ import subprocess
from esphome.core import EsphomeError
from esphome.helpers import write_file
_LOGGER = logging.getLogger(__name__)
# Everything idedata generation may raise after a successful link; idedata
# is a bonus artifact, so consumers warn instead of failing the build
IDEDATA_BEST_EFFORT_ERRORS = (
@@ -34,36 +31,13 @@ IDEDATA_BEST_EFFORT_ERRORS = (
ValueError,
)
_LOGGER = logging.getLogger(__name__)
def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
"""Run an idedata generator, downgrading any failure to a warning.
Shared by the native backends: the firmware already built, so a missing
or broken idedata must not fail a successful build.
"""
try:
if get_idedata() is None:
_LOGGER.warning("No idedata was generated for this build")
except IDEDATA_BEST_EFFORT_ERRORS as err:
_LOGGER.warning(
"Could not generate idedata: %s (IDE, clang-tidy, and "
"memory-analysis data will be unavailable for this build)",
err,
)
if isinstance(err, (EsphomeError, OSError)):
# Routine environmental failures keep the detail at debug
_LOGGER.debug("Idedata failure detail", exc_info=True)
else:
# LookupError/ValueError/RuntimeError smell like a parsing bug;
# a permanently masked traceback would hide it on every build
_LOGGER.warning("Idedata failure detail", exc_info=True)
# C++ translation-unit suffixes.
CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
# C++ translation-unit suffixes used to identify ESPHome source files.
_CXX_SUFFIXES = (".cpp", ".cc")
# Suffixes of input/output files that appear bare on the command line (and so
# must not be mistaken for compiler flags).
_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s")
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
# Path marker identifying an ESPHome source translation unit.
_ESPHOME_SRC_MARKER = "/src/esphome/"
@@ -72,11 +46,11 @@ def _is_esphome_src(file: str) -> bool:
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
``/`` first since Windows compile DBs use backslashes."""
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
CXX_SOURCE_SUFFIXES
_CXX_SUFFIXES
)
def split_command(command: str) -> list[str]:
def _split_command(command: str) -> list[str]:
r"""Tokenize a compile_commands.json / response-file command string.
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
@@ -112,7 +86,7 @@ def split_command(command: str) -> list[str]:
ctypes.windll.kernel32.LocalFree(argv)
def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
GCC response files embed flags that must be expanded so GCC-only flags
@@ -127,8 +101,8 @@ def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
rf = directory / rf
try:
out.extend(
expand_response_files(
split_command(rf.read_text(encoding="utf-8")), directory
_expand_response_files(
_split_command(rf.read_text(encoding="utf-8")), directory
)
)
continue
@@ -147,7 +121,7 @@ def _pick_entry(entries: list[dict]) -> dict:
if _is_esphome_src(entry["file"]):
return entry
for entry in entries:
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
if entry["file"].endswith(_CXX_SUFFIXES):
return entry
raise ValueError("no C++ translation unit found in compile_commands.json")
@@ -157,25 +131,16 @@ def _pick_entry(entries: list[dict]) -> dict:
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
def is_launcher(token: str) -> bool:
def _is_launcher(token: str) -> bool:
return Path(token).stem.lower() in _LAUNCHER_STEMS
def is_joined_include(tok: str) -> bool:
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
return (
tok.startswith("-include")
and tok != "-include"
and not tok.startswith("-include-")
)
def parse_entry(
entry: dict, launcher: str | None = None
) -> tuple[str, list[str], list[str], list[str]]:
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
directory = Path(entry["directory"])
tokens = expand_response_files(split_command(entry["command"]), directory)
tokens = _expand_response_files(_split_command(entry["command"]), directory)
def _include(raw: str) -> str:
# Resolve against the entry's ``directory`` so cached idedata works
@@ -191,7 +156,7 @@ def parse_entry(
if not tokens:
# An empty command, or one that was only the launcher; fail by name
raise ValueError(f"empty compile command for {entry.get('file')}")
if is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
# Stale DB built with a launcher this run no longer configures; the
# real compiler is the next token
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
@@ -204,23 +169,11 @@ def parse_entry(
defines: list[str] = []
includes: list[str] = []
cxx_flags: list[str] = []
unresolved_force_includes: list[str] = []
it = iter(tokens[1:])
for tok in it:
if tok in ("-c", "-o"):
next(it, None) # drop the flag and its argument (input/output)
elif tok == "-include" or is_joined_include(tok):
# Re-anchor only names next to the compile (the pch); a name
# meant for the -I chain must stay untouched
raw = next(it, "") if tok == "-include" else tok[len("-include") :]
if not raw:
_LOGGER.warning("Dropping -include with no argument")
elif Path(resolved := _include(raw)).is_file():
cxx_flags.extend(("-include", resolved))
else:
unresolved_force_includes.append(raw)
cxx_flags.extend(("-include", raw))
elif tok.startswith("-D"):
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
# quoted arg with a space after -D) that some flags arrive as.
@@ -239,14 +192,6 @@ def parse_entry(
pass # input/output files
else:
cxx_flags.append(tok)
for raw in unresolved_force_includes:
# A deleted build artifact would otherwise surface only downstream
if not any((Path(inc) / raw).is_file() for inc in includes):
_LOGGER.warning(
"-include %s found neither next to the compile nor on the "
"include path; cached idedata may not resolve it",
raw,
)
return cxx_path, defines, includes, cxx_flags
@@ -311,7 +256,7 @@ def _cache_usable(cached: object) -> bool:
if not isinstance(cached, dict) or "cc_path" not in cached:
return False
cxx_path = cached.get("cxx_path")
if not isinstance(cxx_path, str) or is_launcher(cxx_path):
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
return False
includes = cached.get("includes")
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
@@ -359,7 +304,7 @@ def load_or_build_idedata(
def reject_launcher_compiler(cxx_path: str) -> None:
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
must never be probed, cached, or consumed."""
if is_launcher(cxx_path):
if _is_launcher(cxx_path):
raise EsphomeError(
f"compile_commands.json names the launcher {cxx_path} as the "
"compiler; the compile database is unusable"
-549
View File
@@ -1,549 +0,0 @@
"""Shared precompiled-header policy for the build backends.
The prefix either mirrors the TUs' own force-includes (ESP8266) or is a
curated core-header set (ESP-IDF). ``esphome: includes:`` sources receive
it too; Arduino.h visibility there is intended (esphome#8693).
"""
from __future__ import annotations
from collections.abc import Iterable
from contextlib import suppress
from dataclasses import dataclass
import hashlib
import json
import logging
import os
from pathlib import Path
import posixpath
import re
import stat
import subprocess
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
from esphome.build_helpers.idedata import (
CXX_SOURCE_SUFFIXES,
expand_response_files,
is_launcher,
split_command,
)
_DOMAIN = "pch"
@dataclass
class _PCHData:
emitted: bool = False
def _pch_data() -> _PCHData:
from esphome.core import CORE
if _DOMAIN not in CORE.data:
CORE.data[_DOMAIN] = _PCHData()
return CORE.data[_DOMAIN]
def mark_pch_emitted() -> None:
"""Record that this build's consumers reference the pch."""
_pch_data().emitted = True
_LOGGER = logging.getLogger(__name__)
# The header and its .gch/.sum sidecars live in the build directory.
PCH_HEADER_NAME = "esphome_pch.h"
# Every artifact the pch machinery can leave behind, for cleanup.
PCH_ARTIFACT_NAMES = (
PCH_HEADER_NAME,
f"{PCH_HEADER_NAME}.gch",
f"{PCH_HEADER_NAME}.gch.sum",
f"{PCH_HEADER_NAME}.gch.failed",
)
# The core defines header every backend anchors its prefix on.
PCH_CORE_HEADER = "esphome/core/defines.h"
# Prefix-header contents for backends that inject a curated set (rather
# than mirroring the TUs' own force-includes), defines.h first so USE_*
# macros exist for the rest. Deliberately hard-coded: frequency-derived
# sets measured no better and kept selecting headers that cannot compile
# standalone (X-macro, platform-variant). Every entry must be safe to
# include first in an empty TU. Caveat: application.h/automation.h become
# ambiently visible, so a TU missing those #includes still builds on such
# backends; ESPHOME_PCH_ENABLE=0 restores the strict view.
PCH_DEFAULT_HEADERS = (
PCH_CORE_HEADER,
"esphome/core/component.h",
"esphome/core/helpers.h",
"esphome/core/log.h",
"esphome/core/application.h",
"esphome/core/automation.h",
)
# ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the
# .sum sidecar instead of the .gch bytes, which are not reproducible.
# Keep in sync with the literals in platformio/pch.py.script.
_CCACHE_PCH_ENV = {
"CCACHE_SLOPPINESS": "pch_defines,time_macros",
"CCACHE_PCH_EXTSUM": "true",
}
# Both include forms: an angle include resolving under src/ must enter the
# digest too; ones that do not resolve simply end the walk
# Compiler failures that clear on their own must not latch the .failed marker
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
def pch_enabled() -> bool:
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
def pch_strict() -> bool:
"""CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal.
A set-but-unrecognized value raises: a typo must not silently turn
the gate into a no-op that proves nothing.
"""
return parse_enable_env("ESPHOME_PCH_STRICT", strict=True) is True
def pch_degraded(reason: str) -> None:
"""Every degrade path funnels through here; strict mode raises."""
if pch_strict():
from esphome.core import EsphomeError
raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}")
def pch_disabled_degraded() -> None:
"""Strict CI must not read "no pch at all" as success."""
pch_degraded("pch disabled by ESPHOME_PCH_ENABLE")
def pch_probe_tail(source: str = "-") -> list[str]:
"""The syntax-only compile shared by the probe and its baseline."""
return ["-fsyntax-only", "-x", "c++", source]
def pch_probe_args(header: str, source: str = "-") -> list[str]:
"""Flags that load-check a built .gch via a syntax-only compile.
Rejection must be a nonzero exit (never just a wording match), so the
invalid-pch class is always escalated. ``source`` defaults to stdin
(host independent); the ninja probe edge passes a real file.
"""
return [
"-Winvalid-pch",
"-Werror=invalid-pch",
"-include",
header,
*pch_probe_tail(source),
]
def pch_consumer_escalation() -> str:
"""Consumer-side invalid-pch flag: strict reds the build on rejection
(per-process, so the probe alone cannot prove the consumers)."""
return "-Werror=invalid-pch" if pch_strict() else "-Wno-error=invalid-pch"
def ccache_pch_env() -> dict[str, str]:
"""Settings ccache needs to cache compiles that consume the .gch;
empty unless this build actually emitted one. User-set values win.
Native backends export these process-wide; only time_macros affects
non-pch TUs."""
if not (pch_enabled() and _pch_data().emitted):
return {}
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
# ccache then hashes the non-reproducible .gch bytes: permanent misses
_LOGGER.warning("CCACHE_PCH_EXTSUM=%s disables pch caching", extsum)
env = {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ}
user_sloppiness = os.environ.get("CCACHE_SLOPPINESS")
if user_sloppiness is not None and (
missing := [
t
for t in ("pch_defines", "time_macros")
if t not in {tok.strip() for tok in user_sloppiness.split(",")}
]
):
# Without these ccache declines every pch-consuming compile
env["CCACHE_SLOPPINESS"] = ",".join((user_sloppiness, *missing))
_LOGGER.warning(
"Adding %s to CCACHE_SLOPPINESS so ccache can cache compiles "
"that use the precompiled header",
",".join(missing),
)
return env
def pch_extra_scripts() -> list[str]:
"""The extra_scripts entries a PlatformIO platform registers for the
pch; empty when disabled (the script itself has no enable check)."""
if not pch_enabled():
pch_disabled_degraded()
return []
return ["post:pch.py"]
def pch_header_text(include_headers: Iterable[str]) -> str:
"""The prefix-header source: exactly these includes, in order."""
return "".join(f'#include "{name}"\n' for name in include_headers)
def _resolves(path: Path) -> bool:
"""False when missing; other stat failures propagate (identity unknown,
unlike is_file(), which would silently drop the header)."""
try:
return stat.S_ISREG(path.stat().st_mode)
except (FileNotFoundError, NotADirectoryError):
return False
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
"""Include closure of ``roots``: src-relative name -> contents.
Resolution mirrors the compiler (includer's dir, then src root); names
outside ``src_dir`` end the walk and are versioned by the caller. No
#ifdef evaluation: over-approximating is the safe direction.
"""
seen: dict[str, bytes] = {}
stack: list[tuple[str, str]] = [(name, "") for name in roots]
while stack:
name, from_dir = stack.pop()
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
rel = posixpath.normpath(candidate)
if not rel.startswith("..") and _resolves(src_dir / rel):
break
else:
continue
if rel in seen:
continue
try:
data = (src_dir / rel).read_bytes()
except OSError as err:
# A marker would truncate the transitive walk; fail closed
_LOGGER.warning("Could not read %s for the pch checksum: %s", rel, err)
raise
seen[rel] = data
parent = posixpath.dirname(rel)
stack.extend(
# surrogateescape: a non-UTF-8 name just fails to resolve
(inc.decode(errors="surrogateescape"), parent)
for inc in _INCLUDE_RE.findall(data)
)
return seen
def pch_checksum(
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
) -> str:
"""Digest standing in for the .gch in ccache's hash: the include closure
of the prefix header plus caller-supplied identity strings (versioned
install paths, flags). Raises OSError when a header's identity cannot
be established at all; callers must then compile without a pch."""
digest = hashlib.sha256()
closure = _include_closure(src_dir, include_headers)
for name in sorted(closure):
digest.update(name.encode(errors="surrogateescape"))
digest.update(closure[name])
digest.update(b"\0")
for item in extra:
digest.update(item.encode(errors="surrogateescape"))
digest.update(b"\0")
return digest.hexdigest()
# Tokens dropped when retargeting a TU's flags at the prefix header
# (the pch compile must not touch depfiles)
_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"})
_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"})
def pch_compile_command(
build_dir: Path, header: Path, gch: Path
) -> tuple[list[str], Path] | None:
"""The exact src C++ flags from compile_commands.json retargeted at the
header, with the directory they resolve against (relative -I paths must
be expanded and executed from the same root); None (logged) when no
configured C++ TU is available yet."""
from esphome.core import CORE
try:
entries = json.loads(
(build_dir / "compile_commands.json").read_text(encoding="utf-8")
)
except (OSError, json.JSONDecodeError) as err:
# Configure already succeeded, so an unusable DB is a real anomaly
_LOGGER.warning("No usable compile database, skipping pch: %s", err)
return None
if not isinstance(entries, list):
_LOGGER.warning("Malformed compile database, skipping pch")
return None
# CMake may spell paths through a symlink differently than CORE does
# (macOS /tmp vs /private/tmp), so compare resolved paths
src_root = Path(CORE.relative_src_path()).resolve()
entry = next(
(
e
for e in entries
if isinstance(e, dict)
and isinstance(e.get("file"), str)
and e["file"].endswith(CXX_SOURCE_SUFFIXES)
and Path(e["file"]).resolve().is_relative_to(src_root)
),
None,
)
if entry is None:
_LOGGER.warning("No src C++ entry in the compile database, skipping pch")
return None
directory = entry.get("directory")
cmd_dir = Path(directory) if isinstance(directory, str) and directory else build_dir
command = entry.get("command")
tokens = expand_response_files(
split_command(command if isinstance(command, str) else ""), cmd_dir
)
# A DB recorded with ccache enabled prefixes the compiler with the
# launcher; the .gch must be compiled directly
if tokens and is_launcher(tokens[0]):
tokens = tokens[1:]
if not tokens:
# "arguments"-style or empty entries must skip, not spawn "-x ..."
_LOGGER.warning("Compile database entry has no usable command, skipping pch")
return None
args: list[str] = []
arg_it = iter(tokens)
for tok in arg_it:
if tok in _PCH_STRIP_FLAGS_WITH_ARG:
next(arg_it, None)
continue
if tok in _PCH_STRIP_FLAGS:
continue
if tok == "-include":
# Drop only the injected prefix; user force-includes must reach
# the .gch compile or GCC rejects it over the macro mismatch
inc = next(arg_it, "")
if not inc.endswith(PCH_HEADER_NAME):
args.extend(("-include", inc))
continue
args.append(tok)
return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)], cmd_dir
def _log_pch_in_use() -> None:
# The only place a user can discover the knob; emitted only once a
# .gch is actually fresh or being built
_LOGGER.info(
"Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)"
)
def _read_stamp(path: Path) -> str:
"""A corrupt sidecar must read as stale, not kill the pch forever."""
try:
return path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def discard_pch(build_dir: Path) -> None:
"""Remove the pch sidecars so a stale .gch is never consumed.
Bumps the header only when a .gch was actually removed: TUs compiled
against it have incomplete depfiles, while a repeat failure with no
.gch must not force a full rebuild every build. A .gch that survives
an unlink failure would be consumed silently (wrong output, not a
slow build), so that raises.
"""
header = build_dir / PCH_HEADER_NAME
gch = Path(f"{header}.gch")
had_gch = gch.is_file()
errors = []
for sidecar in (gch, Path(f"{gch}.sum")):
try:
sidecar.unlink(missing_ok=True)
except OSError as err:
if sidecar.is_file():
from esphome.core import EsphomeError
raise EsphomeError(
f"Could not discard the stale precompiled header: {err}"
) from err
errors.append(err)
for err in errors:
_LOGGER.warning("Could not discard the pch sidecars: %s", err)
if had_gch and header.is_file():
with suppress(OSError):
os.utime(header)
def prepare_pch(
build_dir: Path, include_headers: tuple[str, ...], extra: Iterable[str]
) -> None:
"""Compile ``build_dir``'s .gch from compile_commands.json flags and
write its ccache .sum.
The .sum doubles as the freshness stamp and folds in the compile
command, so a flag-only change rebuilds the .gch; ``extra`` carries
backend identity (framework version, sdkconfig, ...). A failed
compile falls back to the plain header include.
"""
from esphome.core import CORE
header = build_dir / PCH_HEADER_NAME
gch = Path(f"{header}.gch")
sum_path = Path(f"{gch}.sum")
cmd_and_dir = pch_compile_command(build_dir, header, gch)
if cmd_and_dir is None:
# Freshness cannot be validated; a leftover .gch must not be consumed
discard_pch(build_dir)
pch_degraded("no usable compile command")
return
cmd, cmd_dir = cmd_and_dir
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
# build path covers unresolved (symlinked) spellings
cmd_id = (
" ".join(cmd)
.replace(effective_ccache_basedir(), "")
.replace(str(CORE.build_path), "")
)
try:
checksum = pch_checksum(
CORE.relative_src_path(),
include_headers,
(
# The closure is sorted, so root order only enters via the text
pch_header_text(include_headers),
*extra,
cmd_id,
),
)
except (OSError, UnicodeError) as err:
# Identity unknown: a stale cache entry must never be served
_LOGGER.warning(
"Could not establish the pch identity; compiling without it: %s", err
)
discard_pch(build_dir)
pch_degraded(f"identity unknown: {err}")
return
failed_marker = Path(f"{gch}.failed")
def _run(
run_cmd: list[str], what: str, stdin: str | None = None
) -> subprocess.CompletedProcess | None:
"""Spawn one pch tool step; environmental failures discard and
degrade (None): spawn/IO/timeout errors and signal kills never
latch the marker."""
try:
proc = subprocess.run(
run_cmd,
cwd=cmd_dir,
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
env={**os.environ, "LC_ALL": "C"},
input=stdin,
capture_output=True,
text=True,
check=False,
timeout=300,
)
except (OSError, subprocess.SubprocessError) as err:
_LOGGER.warning("Precompiled header %s did not run: %s", what, err)
discard_pch(build_dir)
pch_degraded(f"{what} did not run: {err}")
return None
if proc.returncode < 0:
# Killed by a signal (OOM, ^C): environmental, do not latch
_LOGGER.warning(
"Precompiled header %s was killed (signal %d); retrying next build",
what,
-proc.returncode,
)
discard_pch(build_dir)
pch_degraded(f"{what} killed by signal {-proc.returncode}")
return None
return proc
def _fail(error: str, reason: str, latch: bool) -> None:
"""Discard and degrade; deterministic failures latch when asked."""
_LOGGER.warning(
"Precompiled header failed; compiling without it: %s", error[:400]
)
# Latching paths keep the full compiler output recoverable
_LOGGER.debug("Full pch output: %s", error)
discard_pch(build_dir)
if latch and not any(m in error for m in _TRANSIENT_ERRORS):
# Skip retries until a header/flag/backend-identity/command change
failed_marker.write_text(checksum + "\n", encoding="utf-8")
os.utime(header)
pch_degraded(f"{reason}: {error[:200]}")
def _probe(latch: bool = True) -> None:
"""Load-check the built .gch: some toolchains build one they then
refuse to load (per-process ASLR). Dep flags are already stripped
from cmd, so no -MF is needed; cmd ends with the fixed
"-x c++-header -c -o" tail. A cached-header rejection may not
reproduce (per-process), so that caller passes latch=False."""
if cmd[-6:-4] != ["-x", "c++-header"]:
# The slice below depends on pch_compile_command's fixed tail
_LOGGER.warning("Unexpected pch command shape: %s", cmd[-6:])
discard_pch(build_dir)
pch_degraded("unexpected pch command shape")
return
base = cmd[:-6]
probe = _run([*base, *pch_probe_args(str(header))], "probe", stdin="")
if probe is None:
return
if probe.returncode != 0:
# Disambiguate: only blame the pch when the same compile passes
# without it; a failing baseline is its own (latchable) problem
baseline = _run([*base, *pch_probe_tail()], "probe baseline", stdin="")
if baseline is None:
return
if baseline.returncode == 0:
error = probe.stderr.strip() or f"exit code {probe.returncode}"
_fail(error, "toolchain cannot load the pch", latch=latch)
else:
error = baseline.stderr.strip() or f"exit code {baseline.returncode}"
_fail(error, "probe cannot run at all", latch=latch)
if gch.is_file() and _read_stamp(sum_path) == checksum:
_log_pch_in_use()
if pch_strict():
# Rejection is per-process, so a cached .gch must re-prove
# loadability for the strict gate (CI-only cost); no latch,
# since the rejection may not reproduce either
_probe(latch=False)
return
if _read_stamp(failed_marker) == checksum:
_LOGGER.info(
"Precompiled header disabled after an earlier failure; delete %s to retry",
failed_marker,
)
pch_degraded("earlier failure latched")
return
_log_pch_in_use()
result = _run(cmd, "compile")
if result is None:
return
error = None
if result.returncode != 0:
error = result.stderr.strip() or f"exit code {result.returncode}"
elif not gch.is_file():
error = "compiler produced no .gch"
if error is not None:
_fail(error, "compile failed", latch=True)
return
_probe()
if not gch.is_file():
# The probe discarded a rejected or unrunnable .gch
return
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
# Consumers depend on the header (depfiles cannot see through a .gch);
# bump it so users of the previous .gch recompile
os.utime(header)
+56 -51
View File
@@ -13,7 +13,7 @@ void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); }
void Anova::setup() {
this->codec_ = make_unique<AnovaCodec>();
this->poll_step_ = PollStep::IDLE;
this->current_request_ = 0;
}
void Anova::loop() {
@@ -22,15 +22,6 @@ void Anova::loop() {
this->disable_loop();
}
void Anova::write_request_(AnovaPacket *pkt) {
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status) {
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
}
}
void Anova::control(const ClimateCall &call) {
auto mode_val = call.get_mode();
if (mode_val.has_value()) {
@@ -47,11 +38,22 @@ void Anova::control(const ClimateCall &call) {
ESP_LOGW(TAG, "Unsupported mode: %d", mode);
return;
}
this->write_request_(pkt);
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status) {
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
}
}
auto target_temp = call.get_target_temperature();
if (target_temp.has_value()) {
this->write_request_(this->codec_->get_set_target_temp_request(*target_temp));
auto *pkt = this->codec_->get_set_target_temp_request(*target_temp);
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status) {
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
}
}
}
@@ -60,7 +62,6 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
case ESP_GATTC_DISCONNECT_EVT: {
this->current_temperature = NAN;
this->target_temperature = NAN;
this->poll_step_ = PollStep::IDLE;
this->publish_state();
break;
}
@@ -82,8 +83,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
this->node_state = espbt::ClientState::ESTABLISHED;
this->poll_step_ = PollStep::IDLE;
this->update(); // begin the first poll cycle immediately
this->current_request_ = 0;
this->update();
break;
}
case ESP_GATTC_NOTIFY_EVT: {
@@ -100,30 +101,33 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF;
}
if (this->codec_->has_unit()) {
ESP_LOGD(TAG, "Anova units is %s", (this->codec_->unit_ == 'f') ? "fahrenheit" : "celsius");
this->fahrenheit_ = (this->codec_->unit_ == 'f');
ESP_LOGD(TAG, "Anova units is %s", this->fahrenheit_ ? "fahrenheit" : "celsius");
this->current_request_++;
}
this->publish_state();
// Advance the poll cycle to its next request based on the reply we got.
switch (this->poll_step_) {
case PollStep::SET_UNIT:
this->poll_step_ = PollStep::STATUS;
this->write_request_(this->codec_->get_read_device_status_request());
break;
case PollStep::STATUS:
this->poll_step_ = PollStep::TARGET;
this->write_request_(this->codec_->get_read_target_temp_request());
break;
case PollStep::TARGET:
this->poll_step_ = PollStep::CURRENT;
this->write_request_(this->codec_->get_read_current_temp_request());
break;
case PollStep::CURRENT:
this->poll_step_ = PollStep::IDLE; // full cycle complete
break;
default:
// A reply to an ad-hoc control() write, outside a managed cycle.
break;
if (this->current_request_ > 1) {
AnovaPacket *pkt = nullptr;
switch (this->current_request_++) {
case 2:
pkt = this->codec_->get_read_target_temp_request();
break;
case 3:
pkt = this->codec_->get_read_current_temp_request();
break;
default:
this->current_request_ = 1;
break;
}
if (pkt != nullptr) {
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status) {
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
}
}
}
break;
}
@@ -132,26 +136,27 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
}
}
void Anova::set_unit_of_measurement(const char *unit) { this->want_fahrenheit_ = !strncmp(unit, "f", 1); }
void Anova::set_unit_of_measurement(const char *unit) { this->fahrenheit_ = !strncmp(unit, "f", 1); }
void Anova::update() {
if (this->node_state != espbt::ClientState::ESTABLISHED)
return;
if (this->poll_step_ != PollStep::IDLE) {
// The previous cycle never finished within a full polling interval -- a
// reply was missed or a write failed. Restart the cycle rather than stall;
// the polling interval itself acts as the timeout. A late reply from the
// abandoned cycle is harmless: state decoding happens on every notify
// regardless of step, and each notify sends at most one follow-up request.
ESP_LOGW(TAG, "[%s] Poll cycle incomplete (step %u); restarting cycle", this->parent_->address_str(),
static_cast<uint8_t>(this->poll_step_));
if (this->current_request_ < 2) {
AnovaPacket *pkt;
if (this->current_request_ == 0) {
pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
} else {
pkt = this->codec_->get_read_device_status_request();
}
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status) {
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
}
this->current_request_++;
}
// Re-assert the configured unit at the start of every poll cycle, then fall
// through the status/temperature reads via the notification handler. Always
// command the configured unit (want_fahrenheit_) -- never the last value the
// device reported, or a drift to 'c' would lock itself in.
this->poll_step_ = PollStep::SET_UNIT;
this->write_request_(this->codec_->get_set_unit_request(this->want_fahrenheit_ ? 'f' : 'c'));
}
} // namespace esphome::anova
+2 -11
View File
@@ -37,20 +37,11 @@ class Anova final : public climate::Climate, public esphome::ble_client::BLEClie
void set_unit_of_measurement(const char *unit);
protected:
// A poll cycle re-asserts the configured unit, then reads device state.
// Re-asserting every cycle prevents the cooker from silently reverting to
// its default (Celsius); previously the unit was only set once on
// connection, so a drift persisted (and corrupted the F/C interpretation of
// subsequent readings) until the BLE link was re-established.
enum class PollStep : uint8_t { SET_UNIT, STATUS, TARGET, CURRENT, IDLE };
void write_request_(AnovaPacket *pkt);
std::unique_ptr<AnovaCodec> codec_;
void control(const climate::ClimateCall &call) override;
uint16_t char_handle_;
bool want_fahrenheit_{true}; // configured target unit; never overwritten by device replies
PollStep poll_step_{PollStep::IDLE};
uint8_t current_request_;
bool fahrenheit_;
};
} // namespace esphome::anova
+2 -2
View File
@@ -14,7 +14,6 @@ from esphome.components.noise import ( # noqa: F401
ENCRYPTION_SCHEMA,
decode_encryption_key,
encryption_schema,
new_psk_progmem,
validate_encryption_key,
)
from esphome.config_helpers import filter_source_files_from_defines, get_logger_level
@@ -590,7 +589,8 @@ async def to_code(config: ConfigType) -> None:
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
decoded = decode_encryption_key(key)
cg.add(var.set_noise_psk(list(decoded)))
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
# No key provided, but encryption desired
+90 -4
View File
@@ -70,13 +70,17 @@ service APIConnection {
rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {}
rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {}
rpc zigbee_proxy_request(ZigbeeProxyRequest) returns (void) {}
rpc infrared_rf_transmit_raw_timings(InfraredRFTransmitRawTimingsRequest) returns (void) {}
rpc serial_proxy_configure(SerialProxyConfigureRequest) returns (void) {}
rpc serial_proxy_write(SerialProxyWriteRequest) returns (void) {}
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
rpc serial_proxy_get_usb_info(SerialProxyGetUsbInfoRequest) returns (void) {}
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
}
@@ -227,6 +231,11 @@ enum SerialProxyPortType {
SERIAL_PROXY_PORT_TYPE_TTL = 0;
SERIAL_PROXY_PORT_TYPE_RS232 = 1;
SERIAL_PROXY_PORT_TYPE_RS485 = 2;
// A serial device attached through a USB bridge. Set by the device configuration, never
// by the user; identifies ports whose USB identity can be read with
// SerialProxyGetUsbInfoRequest. Deliberately not a USB endpoint type: serial_proxy
// carries serial devices only, whatever bridge chip connects them.
SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3;
}
message SerialProxyInfo {
@@ -331,6 +340,10 @@ message DeviceInfoResponse {
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
// Indicates if Zigbee proxy support is available and features supported
uint32 zigbee_proxy_feature_flags = 27 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
uint64 zigbee_ieee_address = 28 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
}
// ==================== DEVICE CAPABILITIES ====================
@@ -2726,7 +2739,8 @@ enum SerialProxyParity {
SERIAL_PROXY_PARITY_ODD = 2;
}
// Configure UART parameters for a serial proxy instance
// Configure UART parameters for a serial proxy instance. Only the subscribed client may
// configure the port; others are refused with PORT_IN_USE (since API 1.17).
message SerialProxyConfigureRequest {
option (id) = 138;
option (source) = SOURCE_CLIENT;
@@ -2752,7 +2766,8 @@ message SerialProxyDataReceived {
bytes data = 2; // Raw data received from the serial device
}
// Write data to a serial device
// Write data to a serial device. Only the subscribed client may write; writes from
// others are ignored (since API 1.17).
message SerialProxyWriteRequest {
option (id) = 140;
option (source) = SOURCE_CLIENT;
@@ -2763,7 +2778,8 @@ message SerialProxyWriteRequest {
bytes data = 2; // Raw data to write to the serial device
}
// Set modem control pin states (RTS and DTR)
// Set modem control pin states (RTS and DTR). Only the subscribed client may set them;
// others are refused with PORT_IN_USE (since API 1.17).
message SerialProxySetModemPinsRequest {
option (id) = 141;
option (source) = SOURCE_CLIENT;
@@ -2802,6 +2818,7 @@ enum SerialProxyRequestType {
// error the device answers with INVALID_ARGUMENT.
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17)
}
enum SerialProxyStatus {
@@ -2814,7 +2831,8 @@ enum SerialProxyStatus {
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
}
// Generic request message for simple serial proxy operations
// Generic request message for simple serial proxy operations. FLUSH requires an active
// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17).
message SerialProxyRequest {
option (id) = 144;
option (source) = SOURCE_CLIENT;
@@ -2838,6 +2856,59 @@ message SerialProxyRequestResponse {
string error_message = 4; // Additional detail on failure (optional)
}
// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL
// activates the port's protocol-aware tap (if one is configured), letting it observe
// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap
// speaks is a property of the device configuration, discoverable from the tap
// component's own API surface. A client that is about to flash firmware selects RAW
// first, which definitively disables that injection.
enum SerialProxyMode {
SERIAL_PROXY_MODE_RAW = 0;
SERIAL_PROXY_MODE_PROTOCOL = 1;
}
// Only the subscribed client may change the mode; any other caller -- including one that
// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED
// when the port has no protocol-aware tap configured.
message SerialProxySetModeRequest {
option (id) = 152;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SERIAL_PROXY";
uint32 instance = 1;
SerialProxyMode mode = 2;
}
// Ask for the USB identity of the device behind a USB_SERIAL port. Read-only, so no
// subscription is required -- a client typically uses this to decide which port to
// subscribe to. Answered with NOT_SUPPORTED on ports that are not USB_SERIAL.
message SerialProxyGetUsbInfoRequest {
option (id) = 153;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SERIAL_PROXY";
uint32 instance = 1;
}
// The USB identity of the device currently behind a port, read live from the cached
// USB descriptors. Fields are zero/empty while no device is connected.
message SerialProxyGetUsbInfoResponse {
option (id) = 154;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SERIAL_PROXY";
uint32 instance = 1;
SerialProxyStatus status = 2; // NOT_SUPPORTED when the port is not USB_SERIAL
bool connected = 3; // True when a USB device is currently attached
uint32 vendor_id = 4;
uint32 product_id = 5;
uint32 bcd_device = 6;
uint32 interface_number = 7; // Channel index on multi-port bridges
string manufacturer = 8;
string product = 9;
string serial_number = 10;
}
// ==================== BLUETOOTH CONNECTION PARAMS ====================
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
@@ -2859,3 +2930,18 @@ message BluetoothSetConnectionParamsResponse {
uint64 address = 1;
int32 error = 2;
}
// ==================== ZIGBEE ====================
enum ZigbeeProxyRequestType {
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0;
}
message ZigbeeProxyRequest {
option (id) = 155;
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZIGBEE_PROXY";
ZigbeeProxyRequestType type = 1;
bytes data = 2;
}
+58 -6
View File
@@ -48,6 +48,12 @@
#ifdef USE_ZWAVE_PROXY
#include "esphome/components/zwave_proxy/zwave_proxy.h"
#endif
#ifdef USE_ZIGBEE_PROXY
#include "esphome/components/zigbee_proxy/zigbee_proxy.h"
#endif
#ifdef USE_SERIAL_PROXY_USB_INFO
#include "esphome/components/usb_host/usb_host.h"
#endif
#ifdef USE_WATER_HEATER
#include "esphome/components/water_heater/water_heater.h"
#endif
@@ -1389,6 +1395,12 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#ifdef USE_ZIGBEE_PROXY
void APIConnection::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) {
zigbee_proxy::global_zigbee_proxy->zigbee_proxy_request(this, msg);
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) {
return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE,
@@ -1642,6 +1654,27 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
}
}
void APIConnection::on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &msg) {
auto &proxies = App.get_serial_proxies();
SerialProxyGetUsbInfoResponse resp{};
resp.instance = msg.instance;
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
} else {
#ifdef USE_SERIAL_PROXY_USB_INFO
// The response's strings are views into this buffer, which outlives the send below
usb_host::UsbDeviceInfo info;
proxies[msg.instance]->get_usb_info(info, resp);
#else
resp.status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
#endif
}
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
}
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
@@ -1661,6 +1694,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
break;
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
// Response-only discriminators; never valid in a request
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
@@ -1673,6 +1707,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
send_serial_proxy_ack(this, msg.instance, msg.type, status);
}
void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
serial_proxy_result_to_status(result));
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
if (!this->send_message(msg)) {
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
@@ -1784,6 +1831,11 @@ void APIConnection::complete_authentication_() {
zwave_proxy::global_zwave_proxy->api_connection_authenticated(this);
}
#endif
#ifdef USE_ZIGBEE_PROXY
if (zigbee_proxy::global_zigbee_proxy != nullptr) {
zigbee_proxy::global_zigbee_proxy->api_connection_authenticated(this);
}
#endif
}
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
@@ -1799,7 +1851,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 16;
resp.api_version_minor = 17;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1936,6 +1988,10 @@ bool APIConnection::send_device_info_response_() {
info.configured_line_states = proxy->get_configured_modem_pins();
}
#endif
#ifdef USE_ZIGBEE_PROXY
resp.zigbee_proxy_feature_flags = zigbee_proxy::global_zigbee_proxy->get_feature_flags();
resp.zigbee_ieee_address = zigbee_proxy::global_zigbee_proxy->get_ieee_address();
#endif
#ifdef USE_API_NOISE
resp.api_encryption_supported = true;
#ifndef USE_API_NOISE_PSK_FROM_YAML
@@ -2161,10 +2217,7 @@ void APIConnection::on_homeassistant_action_response(const HomeassistantActionRe
bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg) {
NoiseEncryptionSetKeyResponse resp;
resp.success = false;
#ifdef USE_API_NOISE_PSK_FROM_YAML
// A yaml key cannot be changed at runtime, so no decode or save path is built
ESP_LOGW(TAG, "Key set in YAML");
#else
#ifdef USE_PROVISIONING
// Refuse to set a key once the provisioning window has closed (defense in depth;
// such connections are already rejected at hello).
@@ -2199,7 +2252,6 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
#endif
}
#endif // USE_API_NOISE_PSK_FROM_YAML
return this->send_message(resp);
}
+6
View File
@@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg);
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
@@ -243,7 +247,9 @@ class APIConnection final : public APIServerConnectionBase {
void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg);
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
void on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &msg);
void on_serial_proxy_request(const SerialProxyRequest &msg);
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
#endif
@@ -548,7 +548,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
* @return 0 on success, -1 on error (check errno)
*/
APIError APINoiseFrameHelper::init_handshake_() {
int err = this->handshake_.init(this->ctx_, prologue_.data(), prologue_.size());
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
+99
View File
@@ -175,6 +175,12 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
#endif
#ifdef USE_API_NOISE
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 28, this->zigbee_ieee_address);
#endif
return pos;
}
@@ -240,6 +246,12 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
size += ProtoSize::calc_uint32(2, this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
size += ProtoSize::calc_uint64(2, this->zigbee_ieee_address);
#endif
return size;
}
@@ -4253,6 +4265,57 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
size += ProtoSize::calc_length(1, this->error_message.size());
return size;
}
bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->instance = value;
break;
case 2:
this->mode = static_cast<enums::SerialProxyMode>(value);
break;
default:
return false;
}
return true;
}
bool SerialProxyGetUsbInfoRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->instance = value;
break;
default:
return false;
}
return true;
}
uint8_t *SerialProxyGetUsbInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->status));
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->connected);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->vendor_id);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->product_id);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->bcd_device);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->interface_number);
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->manufacturer);
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->product);
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->serial_number);
return pos;
}
uint32_t SerialProxyGetUsbInfoResponse::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->instance);
size += this->status ? 2 : 0;
size += ProtoSize::calc_bool(1, this->connected);
size += ProtoSize::calc_uint32(1, this->vendor_id);
size += ProtoSize::calc_uint32(1, this->product_id);
size += ProtoSize::calc_uint32(1, this->bcd_device);
size += ProtoSize::calc_uint32(1, this->interface_number);
size += ProtoSize::calc_length(1, this->manufacturer.size());
size += ProtoSize::calc_length(1, this->product.size());
size += ProtoSize::calc_length(1, this->serial_number.size());
return size;
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
@@ -4290,5 +4353,41 @@ uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const {
return size;
}
#endif
#ifdef USE_ZIGBEE_PROXY
bool ZigbeeProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->type = static_cast<enums::ZigbeeProxyRequestType>(value);
break;
default:
return false;
}
return true;
}
bool ZigbeeProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
case 2: {
this->data = value.data();
this->data_len = value.size();
break;
}
default:
return false;
}
return true;
}
uint8_t *ZigbeeProxyRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->type));
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len);
return pos;
}
uint32_t ZigbeeProxyRequest::calculate_size() const {
uint32_t size = 0;
size += this->type ? 2 : 0;
size += ProtoSize::calc_length(1, this->data_len);
return size;
}
#endif
} // namespace esphome::api
+96 -1
View File
@@ -23,6 +23,7 @@ enum SerialProxyPortType : uint32_t {
SERIAL_PROXY_PORT_TYPE_TTL = 0,
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
SERIAL_PROXY_PORT_TYPE_RS485 = 2,
SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3,
};
enum EntityCategory : uint32_t {
ENTITY_CATEGORY_NONE = 0,
@@ -356,6 +357,7 @@ enum SerialProxyRequestType : uint32_t {
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2,
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3,
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4,
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5,
};
enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_OK = 0,
@@ -366,6 +368,15 @@ enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_PORT_IN_USE = 5,
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6,
};
enum SerialProxyMode : uint32_t {
SERIAL_PROXY_MODE_RAW = 0,
SERIAL_PROXY_MODE_PROTOCOL = 1,
};
#endif
#ifdef USE_ZIGBEE_PROXY
enum ZigbeeProxyRequestType : uint32_t {
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0,
};
#endif
} // namespace enums
@@ -549,7 +560,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 312;
static constexpr uint16_t ESTIMATED_SIZE = 322;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -607,6 +618,12 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif
#ifdef USE_ZIGBEE_PROXY
uint32_t zigbee_proxy_feature_flags{0};
#endif
#ifdef USE_ZIGBEE_PROXY
uint64_t zigbee_ieee_address{0};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
@@ -3403,6 +3420,62 @@ class SerialProxyRequestResponse final : public ProtoMessage {
protected:
};
class SerialProxySetModeRequest final : public ProtoDecodableMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 152;
static constexpr uint8_t ESTIMATED_SIZE = 6;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); }
#endif
uint32_t instance{0};
enums::SerialProxyMode mode{};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class SerialProxyGetUsbInfoRequest final : public ProtoDecodableMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 153;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_get_usb_info_request"); }
#endif
uint32_t instance{0};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class SerialProxyGetUsbInfoResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 154;
static constexpr uint8_t ESTIMATED_SIZE = 51;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_get_usb_info_response"); }
#endif
uint32_t instance{0};
enums::SerialProxyStatus status{};
bool connected{false};
uint32_t vendor_id{0};
uint32_t product_id{0};
uint32_t bcd_device{0};
uint32_t interface_number{0};
StringRef manufacturer{};
StringRef product{};
StringRef serial_number{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
@@ -3442,5 +3515,27 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_ZIGBEE_PROXY
class ZigbeeProxyRequest final : public ProtoDecodableMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 155;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("zigbee_proxy_request"); }
#endif
enums::ZigbeeProxyRequestType type{};
const uint8_t *data{nullptr};
uint16_t data_len{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
#endif
} // namespace esphome::api
-2
View File
@@ -3,10 +3,8 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
#ifndef USE_API_VARINT64
#define USE_API_VARINT64
#endif
#endif
namespace esphome::api {} // namespace esphome::api
+63
View File
@@ -143,6 +143,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::S
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232");
case enums::SERIAL_PROXY_PORT_TYPE_RS485:
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485");
case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL:
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -854,6 +856,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyRequestType>(enums
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -878,6 +882,26 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::SerialProxyMode value) {
switch (value) {
case enums::SERIAL_PROXY_MODE_RAW:
return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW");
case enums::SERIAL_PROXY_MODE_PROTOCOL:
return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
#ifdef USE_ZIGBEE_PROXY
template<> const char *proto_enum_to_string<enums::ZigbeeProxyRequestType>(enums::ZigbeeProxyRequestType value) {
switch (value) {
case enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO:
return ESPHOME_PSTR("ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
const char *HelloRequest::dump_to(DumpBuffer &out) const {
@@ -1008,6 +1032,12 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
dump_field(out, ESPHOME_PSTR("zigbee_proxy_feature_flags"), this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
dump_field(out, ESPHOME_PSTR("zigbee_ieee_address"), this->zigbee_ieee_address);
#endif
return out.c_str();
}
@@ -2805,6 +2835,31 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("error_message"), this->error_message);
return out.c_str();
}
const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
return out.c_str();
}
const char *SerialProxyGetUsbInfoRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetUsbInfoRequest"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
return out.c_str();
}
const char *SerialProxyGetUsbInfoResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetUsbInfoResponse"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SerialProxyStatus>(this->status));
dump_field(out, ESPHOME_PSTR("connected"), this->connected);
dump_field(out, ESPHOME_PSTR("vendor_id"), this->vendor_id);
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer);
dump_field(out, ESPHOME_PSTR("product"), this->product);
dump_field(out, ESPHOME_PSTR("serial_number"), this->serial_number);
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
@@ -2823,6 +2878,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
return out.c_str();
}
#endif
#ifdef USE_ZIGBEE_PROXY
const char *ZigbeeProxyRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZigbeeProxyRequest"));
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ZigbeeProxyRequestType>(this->type));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
#endif
} // namespace esphome::api
@@ -712,6 +712,39 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_device_capabilities_request();
break;
}
#ifdef USE_SERIAL_PROXY
case SerialProxySetModeRequest::MESSAGE_TYPE: {
SerialProxySetModeRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg);
#endif
this->on_serial_proxy_set_mode_request(msg);
break;
}
#endif
#ifdef USE_SERIAL_PROXY
case SerialProxyGetUsbInfoRequest::MESSAGE_TYPE: {
SerialProxyGetUsbInfoRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_serial_proxy_get_usb_info_request"), msg);
#endif
this->on_serial_proxy_get_usb_info_request(msg);
break;
}
#endif
#ifdef USE_ZIGBEE_PROXY
case ZigbeeProxyRequest::MESSAGE_TYPE: {
ZigbeeProxyRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_zigbee_proxy_request"), msg);
#endif
this->on_zigbee_proxy_request(msg);
break;
}
#endif
default:
break;
}
+11
View File
@@ -235,9 +235,20 @@ class APIServerConnectionBase {
void on_serial_proxy_request(const SerialProxyRequest &value){};
#endif
#ifdef USE_SERIAL_PROXY
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
#endif
#ifdef USE_SERIAL_PROXY
void on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &value){};
#endif
};
} // namespace esphome::api
+31 -14
View File
@@ -41,13 +41,13 @@ void APIServer::setup() {
ControllerRegistry::register_controller(this);
#ifdef USE_API_NOISE
// Always reserve the slot: flash preferences are positional on esp8266, so
// a yaml key build must keep the layout of a runtime key build
uint32_t hash = 88491486UL;
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true);
#ifndef USE_API_NOISE_PSK_FROM_YAML
// A cleared record loads fine but holds no key
if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) {
// Only load saved PSK if not set from YAML
if (this->load_and_apply_noise_psk_()) {
ESP_LOGD(TAG, "Loaded saved Noise PSK");
}
#endif
@@ -404,6 +404,14 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#ifdef USE_ZIGBEE_PROXY
void APIServer::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) {
// Very infrequent and small - send to all clients rather than tracking a subscription
for (auto &c : this->active_clients())
c->send_message(msg);
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
const std::vector<int32_t> *timings) {
@@ -550,7 +558,6 @@ const std::vector<APIServer::HomeAssistantStateSubscription> &APIServer::get_sta
#endif
#ifdef USE_API_NOISE
#ifndef USE_API_NOISE_PSK_FROM_YAML
bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg,
const LogString *fail_log_msg, bool make_active) {
if (!this->noise_pref_.save(&new_psk)) {
@@ -584,19 +591,22 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
}
bool APIServer::load_and_apply_noise_psk_() {
// Load into a temp so a failed read cannot disturb the key in use
SavedNoisePsk loaded{};
if (!this->noise_pref_.load(&loaded))
SavedNoisePsk saved{};
if (!this->noise_pref_.load(&saved))
return false;
this->saved_psk_ = loaded;
// An unprovisioned device stores the reserved all-zeros key, which is no key
const bool has_key = !noise::NoiseContext::is_all_zeros(this->saved_psk_.psk);
this->noise_ctx_.set_psk(has_key ? this->saved_psk_.psk.data() : nullptr);
this->set_noise_psk(saved.psk);
return true;
}
bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
if (this->saved_psk_.psk == psk) {
#ifdef USE_API_NOISE_PSK_FROM_YAML
// When PSK is set from YAML, this function should never be called
// but if it is, reject the change
ESP_LOGW(TAG, "Key set in YAML");
return false;
#else
auto &old_psk = this->noise_ctx_.get_psk();
if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) {
ESP_LOGW(TAG, "New PSK matches old");
return true;
}
@@ -612,8 +622,15 @@ bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
}
#endif
return result;
#endif
}
bool APIServer::clear_noise_psk(bool make_active) {
#ifdef USE_API_NOISE_PSK_FROM_YAML
// When PSK is set from YAML, this function should never be called
// but if it is, reject the change
ESP_LOGW(TAG, "Key set in YAML");
return false;
#else
SavedNoisePsk empty_psk{};
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
@@ -625,8 +642,8 @@ bool APIServer::clear_noise_psk(bool make_active) {
}
#endif
return result;
#endif
}
#endif // USE_API_NOISE_PSK_FROM_YAML
#endif
#ifdef USE_HOMEASSISTANT_TIME
+4 -11
View File
@@ -76,14 +76,9 @@ class APIServer final : public Component,
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
#ifdef USE_API_NOISE
#ifndef USE_API_NOISE_PSK_FROM_YAML
// Runtime key changes exist for the provisioning path only (not lambdas);
// with a yaml key they compile out
bool save_noise_psk(noise::psk_t psk, bool make_active = true);
bool clear_noise_psk(bool make_active = true);
#endif
/// psk points at 32 bytes that live in flash for the life of the program
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
#endif // USE_API_NOISE
@@ -194,6 +189,9 @@ class APIServer final : public Component,
#ifdef USE_ZWAVE_PROXY
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg);
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
#endif
@@ -280,12 +278,10 @@ class APIServer final : public Component,
#endif
#ifdef USE_API_NOISE
#ifndef USE_API_NOISE_PSK_FROM_YAML
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
bool make_active);
// Load saved PSK from preferences and apply it. Returns true on success.
bool load_and_apply_noise_psk_();
#endif // USE_API_NOISE_PSK_FROM_YAML
#endif // USE_API_NOISE
#ifdef USE_API_HOMEASSISTANT_STATES
// Helper methods to reduce code duplication
@@ -365,9 +361,6 @@ class APIServer final : public Component,
#ifdef USE_API_NOISE
noise::NoiseContext noise_ctx_;
#ifndef USE_API_NOISE_PSK_FROM_YAML
SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key
#endif
ESPPreferenceObject noise_pref_;
#endif // USE_API_NOISE
};
+156 -208
View File
@@ -9,10 +9,6 @@ namespace esphome::atm90e32 {
static const char *const TAG = "atm90e32";
static const LogString *offset_calibration_name(bool power_offsets) {
return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset");
}
static uint32_t pref_hash(const char *prefix, const char *name_space) {
auto hash = fnv1_hash(prefix);
return fnv1_hash_extend(hash, name_space);
@@ -207,12 +203,13 @@ void ATM90E32Component::setup() {
// Initialize flash storage for power offset calibrations
uint32_t po_hash = pref_hash("_power_offset_calibration_", cs);
this->power_offset_pref_ = global_preferences->make_preference<OffsetCalibration[3]>(po_hash, true);
this->power_offset_pref_ = global_preferences->make_preference<PowerOffsetCalibration[3]>(po_hash, true);
bool migrated_power_offset = false;
if (has_distinct_legacy_namespace) {
uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs);
auto legacy_power_offset_pref = global_preferences->make_preference<OffsetCalibration[3]>(legacy_po_hash, true);
OffsetCalibration power_offset_data[3]{};
auto legacy_power_offset_pref =
global_preferences->make_preference<PowerOffsetCalibration[3]>(legacy_po_hash, true);
PowerOffsetCalibration power_offset_data[3]{};
int migration_status =
migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data);
migrated_power_offset = migration_status > 0;
@@ -227,20 +224,20 @@ void ATM90E32Component::setup() {
global_preferences->sync();
}
this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
this->restore_offset_calibrations_();
this->restore_power_offset_calibrations_();
} else {
ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.",
cs);
for (uint8_t phase = 0; phase < 3; ++phase) {
this->write16_(this->voltage_offset_registers[phase],
static_cast<uint16_t>(this->offset_phase_[phase].first_offset));
static_cast<uint16_t>(this->offset_phase_[phase].voltage_offset_));
this->write16_(this->current_offset_registers[phase],
static_cast<uint16_t>(this->offset_phase_[phase].second_offset));
static_cast<uint16_t>(this->offset_phase_[phase].current_offset_));
this->write16_(this->power_offset_registers[phase],
static_cast<uint16_t>(this->power_offset_phase_[phase].first_offset));
static_cast<uint16_t>(this->power_offset_phase_[phase].active_power_offset));
this->write16_(this->reactive_power_offset_registers[phase],
static_cast<uint16_t>(this->power_offset_phase_[phase].second_offset));
static_cast<uint16_t>(this->power_offset_phase_[phase].reactive_power_offset));
}
}
@@ -320,8 +317,8 @@ void ATM90E32Component::log_calibration_status_() {
cs);
for (uint8_t phase = 0; phase < 3; ++phase) {
ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase,
this->config_offset_phase_[phase].first_offset, this->offset_phase_[phase].first_offset,
this->config_offset_phase_[phase].second_offset, this->offset_phase_[phase].second_offset);
this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_,
this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_);
}
ESP_LOGW(TAG,
"[CALIBRATION][%s] ===============================================================================", cs);
@@ -338,8 +335,10 @@ void ATM90E32Component::log_calibration_status_() {
cs);
for (uint8_t phase = 0; phase < 3; ++phase) {
ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase,
this->config_power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].first_offset,
this->config_power_offset_phase_[phase].second_offset, this->power_offset_phase_[phase].second_offset);
this->config_power_offset_phase_[phase].active_power_offset,
this->power_offset_phase_[phase].active_power_offset,
this->config_power_offset_phase_[phase].reactive_power_offset,
this->power_offset_phase_[phase].reactive_power_offset);
}
ESP_LOGW(TAG,
"[CALIBRATION][%s] ===============================================================================", cs);
@@ -373,7 +372,7 @@ void ATM90E32Component::log_calibration_status_() {
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
for (uint8_t phase = 0; phase < 3; phase++) {
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset);
this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_);
}
ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs);
}
@@ -386,7 +385,8 @@ void ATM90E32Component::log_calibration_status_() {
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
for (uint8_t phase = 0; phase < 3; phase++) {
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset);
this->power_offset_phase_[phase].active_power_offset,
this->power_offset_phase_[phase].reactive_power_offset);
}
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
}
@@ -756,68 +756,36 @@ void ATM90E32Component::save_gain_calibration_to_memory_() {
}
}
void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored,
bool previous_using_saved, OffsetCalibrationType type) {
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
void ATM90E32Component::save_offset_calibration_to_memory_() {
const char *cs = this->get_calibration_id_();
const LogString *name = offset_calibration_name(power_offsets);
OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_;
ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_;
bool *has_stored =
power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_;
bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_;
bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_;
const bool writes_verified = this->verify_offset_writes_(type);
bool saved = false;
bool synced = false;
if (writes_verified) {
saved = preference->save(offsets);
synced = global_preferences->sync();
}
if (writes_verified && saved && synced) {
bool success = this->offset_pref_.save(&this->offset_phase_);
global_preferences->sync();
if (success) {
this->using_saved_calibrations_ = true;
*has_stored = true;
*restored = true;
for (uint8_t phase = 0; phase < 3; phase++)
mismatches[phase] = false;
ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs,
LOG_STR_ARG(name), LOG_STR_ARG(name));
return;
this->restored_offset_calibration_ = true;
for (bool &phase : this->offset_calibration_mismatch_)
phase = false;
ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs);
} else {
this->using_saved_calibrations_ = false;
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs);
}
}
if (writes_verified) {
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name));
void ATM90E32Component::save_power_offset_calibration_to_memory_() {
const char *cs = this->get_calibration_id_();
bool success = this->power_offset_pref_.save(&this->power_offset_phase_);
global_preferences->sync();
if (success) {
this->using_saved_calibrations_ = true;
this->restored_power_offset_calibration_ = true;
for (bool &phase : this->power_offset_calibration_mismatch_)
phase = false;
ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs);
} else {
this->using_saved_calibrations_ = false;
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs);
}
for (uint8_t phase = 0; phase < 3; phase++) {
this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type);
}
const bool rollback_verified = this->verify_offset_writes_(type);
bool rollback_persisted = false;
if (writes_verified) {
OffsetCalibration rollback[3]{};
prepare_offset_rollback(previous, previous_restored, rollback);
const bool rollback_saved = preference->save(&rollback);
const bool rollback_synced = global_preferences->sync();
rollback_persisted = rollback_saved && rollback_synced;
if (!rollback_saved || !rollback_synced) {
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name));
}
}
*restored = previous_restored;
if (rollback_persisted)
*has_stored = previous_restored;
this->using_saved_calibrations_ = previous_using_saved;
if (!rollback_verified) {
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs,
LOG_STR_ARG(name));
return;
}
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name));
}
void ATM90E32Component::run_offset_calibrations() {
@@ -835,16 +803,11 @@ void ATM90E32Component::run_offset_calibrations() {
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs);
OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]};
const bool previous_restored = this->restored_offset_calibration_;
const bool previous_using_saved = this->using_saved_calibrations_;
for (uint8_t phase = 0; phase < 3; phase++) {
int16_t voltage_offset = calibrate_offset(phase, true);
int16_t current_offset = calibrate_offset(phase, false);
this->write_offsets_to_registers_(phase, voltage_offset, current_offset,
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
this->write_offsets_to_registers_(phase, voltage_offset, current_offset);
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset,
current_offset);
@@ -852,8 +815,7 @@ void ATM90E32Component::run_offset_calibrations() {
ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs);
this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved,
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
this->save_offset_calibration_to_memory_();
}
void ATM90E32Component::run_power_offset_calibrations() {
@@ -872,25 +834,18 @@ void ATM90E32Component::run_power_offset_calibrations() {
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1],
this->power_offset_phase_[2]};
const bool previous_restored = this->restored_power_offset_calibration_;
const bool previous_using_saved = this->using_saved_calibrations_;
for (uint8_t phase = 0; phase < 3; ++phase) {
int16_t active_offset = calibrate_power_offset(phase, false);
int16_t reactive_offset = calibrate_power_offset(phase, true);
this->write_offsets_to_registers_(phase, active_offset, reactive_offset,
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset);
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset,
reactive_offset);
}
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved,
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
this->save_power_offset_calibration_to_memory_();
}
void ATM90E32Component::write_gains_to_registers_() {
@@ -904,26 +859,35 @@ void ATM90E32Component::write_gains_to_registers_() {
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
}
void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset,
OffsetCalibrationType type) {
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
OffsetCalibration &offsets = power_offsets ? this->power_offset_phase_[phase] : this->offset_phase_[phase];
offsets.first_offset = first_offset;
offsets.second_offset = second_offset;
if (power_offsets) {
this->phase_[phase].active_power_offset_ = first_offset;
this->phase_[phase].reactive_power_offset_ = second_offset;
} else {
this->phase_[phase].voltage_offset_ = first_offset;
this->phase_[phase].current_offset_ = second_offset;
}
void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset) {
// Save to runtime
this->offset_phase_[phase].voltage_offset_ = voltage_offset;
this->phase_[phase].voltage_offset_ = voltage_offset;
const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers;
const uint16_t *second_registers =
power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers;
// Save to flash-storable struct
this->offset_phase_[phase].current_offset_ = current_offset;
this->phase_[phase].current_offset_ = current_offset;
// Write to registers
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA);
this->write16_(first_registers[phase], static_cast<uint16_t>(first_offset));
this->write16_(second_registers[phase], static_cast<uint16_t>(second_offset));
this->write16_(voltage_offset_registers[phase], static_cast<uint16_t>(voltage_offset));
this->write16_(current_offset_registers[phase], static_cast<uint16_t>(current_offset));
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
}
void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset) {
// Save to runtime
this->phase_[phase].active_power_offset_ = p_offset;
this->phase_[phase].reactive_power_offset_ = q_offset;
// Save to flash-storable struct
this->power_offset_phase_[phase].active_power_offset = p_offset;
this->power_offset_phase_[phase].reactive_power_offset = q_offset;
// Write to registers
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA);
this->write16_(this->power_offset_registers[phase], static_cast<uint16_t>(p_offset));
this->write16_(this->reactive_power_offset_registers[phase], static_cast<uint16_t>(q_offset));
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
}
@@ -983,78 +947,89 @@ void ATM90E32Component::restore_gain_calibrations_() {
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs);
}
void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) {
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
void ATM90E32Component::restore_offset_calibrations_() {
const char *cs = this->get_calibration_id_();
const LogString *name = power_offsets ? LOG_STR("power offset") : LOG_STR("offset");
OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_;
OffsetCalibration(*config_offsets)[3] =
power_offsets ? &this->config_power_offset_phase_ : &this->config_offset_phase_;
ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_;
bool *has_stored =
power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_;
bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_;
bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_;
const bool *has_first = power_offsets ? this->has_config_active_power_offset_ : this->has_config_voltage_offset_;
const bool *has_second = power_offsets ? this->has_config_reactive_power_offset_ : this->has_config_current_offset_;
for (uint8_t i = 0; i < 3; ++i)
(*config_offsets)[i] = (*offsets)[i];
this->config_offset_phase_[i] = this->offset_phase_[i];
bool have_data = this->offset_pref_.load(&this->offset_phase_);
const bool have_data = preference->load(offsets);
bool all_zero = true;
if (have_data) {
for (const auto &phase : *offsets) {
if (phase.first_offset != 0 || phase.second_offset != 0) {
for (auto &phase : this->offset_phase_) {
if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) {
all_zero = false;
break;
}
}
}
*has_stored = have_data && !all_zero;
*restored = false;
if (have_data && !all_zero) {
this->restored_offset_calibration_ = true;
for (uint8_t phase = 0; phase < 3; phase++) {
auto &offset = this->offset_phase_[phase];
bool mismatch = false;
if (this->has_config_voltage_offset_[phase] &&
offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_)
mismatch = true;
if (this->has_config_current_offset_[phase] &&
offset.current_offset_ != this->config_offset_phase_[phase].current_offset_)
mismatch = true;
if (mismatch)
this->offset_calibration_mismatch_[phase] = true;
}
} else {
for (uint8_t phase = 0; phase < 3; phase++)
this->offset_phase_[phase] = this->config_offset_phase_[phase];
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs);
}
for (uint8_t phase = 0; phase < 3; phase++) {
mismatches[phase] = false;
if (*has_stored) {
mismatches[phase] =
(has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) ||
(has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset);
write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_,
this->offset_phase_[phase].current_offset_);
}
}
void ATM90E32Component::restore_power_offset_calibrations_() {
const char *cs = this->get_calibration_id_();
for (uint8_t i = 0; i < 3; ++i)
this->config_power_offset_phase_[i] = this->power_offset_phase_[i];
bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_);
bool all_zero = true;
if (have_data) {
for (auto &phase : this->power_offset_phase_) {
if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) {
all_zero = false;
break;
}
}
}
if (!*has_stored) {
for (uint8_t phase = 0; phase < 3; phase++)
(*offsets)[phase] = (*config_offsets)[phase];
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored %s calibrations found. Using default values.", cs, LOG_STR_ARG(name));
}
for (uint8_t phase = 0; phase < 3; phase++) {
this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type);
}
const bool initial_values_verified = this->verify_offset_writes_(type);
if (initial_values_verified) {
const auto state = resolve_offset_restore_state(*has_stored, true, false);
*restored = state.restored;
ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name));
return;
}
this->using_saved_calibrations_ = false;
for (uint8_t phase = 0; phase < 3; phase++)
mismatches[phase] = false;
for (uint8_t phase = 0; phase < 3; phase++) {
(*offsets)[phase] = (*config_offsets)[phase];
this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type);
}
const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type));
*restored = state.restored;
if (state.values_verified) {
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs,
LOG_STR_ARG(name));
if (have_data && !all_zero) {
this->restored_power_offset_calibration_ = true;
for (uint8_t phase = 0; phase < 3; ++phase) {
auto &offset = this->power_offset_phase_[phase];
bool mismatch = false;
if (this->has_config_active_power_offset_[phase] &&
offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset)
mismatch = true;
if (this->has_config_reactive_power_offset_[phase] &&
offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset)
mismatch = true;
if (mismatch)
this->power_offset_calibration_mismatch_[phase] = true;
}
} else {
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs,
LOG_STR_ARG(name));
for (uint8_t phase = 0; phase < 3; ++phase)
this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase];
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs);
}
for (uint8_t phase = 0; phase < 3; ++phase) {
write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset,
this->power_offset_phase_[phase].reactive_power_offset);
}
}
@@ -1109,14 +1084,14 @@ void ATM90E32Component::clear_gain_calibrations() {
void ATM90E32Component::clear_offset_calibrations() {
const char *cs = this->get_calibration_id_();
if (!this->has_stored_offset_calibration_) {
if (!this->restored_offset_calibration_) {
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
for (uint8_t phase = 0; phase < 3; phase++) {
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset);
this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_);
}
ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs);
return;
@@ -1129,11 +1104,10 @@ void ATM90E32Component::clear_offset_calibrations() {
for (uint8_t phase = 0; phase < 3; phase++) {
int16_t voltage_offset =
this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].first_offset : 0;
this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0;
int16_t current_offset =
this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].second_offset : 0;
this->write_offsets_to_registers_(phase, voltage_offset, current_offset,
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0;
this->write_offsets_to_registers_(phase, voltage_offset, current_offset);
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset,
current_offset);
}
@@ -1143,7 +1117,6 @@ void ATM90E32Component::clear_offset_calibrations() {
this->offset_pref_.save(&zero_offsets); // Clear stored values in flash
global_preferences->sync();
this->has_stored_offset_calibration_ = false;
this->restored_offset_calibration_ = false;
for (bool &phase : this->offset_calibration_mismatch_)
phase = false;
@@ -1153,14 +1126,15 @@ void ATM90E32Component::clear_offset_calibrations() {
void ATM90E32Component::clear_power_offset_calibrations() {
const char *cs = this->get_calibration_id_();
if (!this->has_stored_power_offset_calibration_) {
if (!this->restored_power_offset_calibration_) {
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
for (uint8_t phase = 0; phase < 3; phase++) {
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset);
this->power_offset_phase_[phase].active_power_offset,
this->power_offset_phase_[phase].reactive_power_offset);
}
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
return;
@@ -1173,21 +1147,20 @@ void ATM90E32Component::clear_power_offset_calibrations() {
for (uint8_t phase = 0; phase < 3; phase++) {
int16_t active_offset =
this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].first_offset : 0;
int16_t reactive_offset =
this->has_config_reactive_power_offset_[phase] ? this->config_power_offset_phase_[phase].second_offset : 0;
this->write_offsets_to_registers_(phase, active_offset, reactive_offset,
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0;
int16_t reactive_offset = this->has_config_reactive_power_offset_[phase]
? this->config_power_offset_phase_[phase].reactive_power_offset
: 0;
this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset);
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset,
reactive_offset);
}
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}};
PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}};
this->power_offset_pref_.save(&zero_power_offsets);
global_preferences->sync();
this->has_stored_power_offset_calibration_ = false;
this->restored_power_offset_calibration_ = false;
for (bool &phase : this->power_offset_calibration_mismatch_)
phase = false;
@@ -1242,31 +1215,6 @@ bool ATM90E32Component::verify_gain_writes_() {
return success; // Return true if all writes were successful, false otherwise
}
bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) {
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
const char *cs = this->get_calibration_id_();
const LogString *name = offset_calibration_name(power_offsets);
const LogString *first_name = power_offsets ? LOG_STR("active") : LOG_STR("voltage");
const LogString *second_name = power_offsets ? LOG_STR("reactive") : LOG_STR("current");
const OffsetCalibration *offsets = power_offsets ? this->power_offset_phase_ : this->offset_phase_;
const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers;
const uint16_t *second_registers =
power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers;
bool success = true;
for (uint8_t phase = 0; phase < 3; phase++) {
const uint16_t first = this->read16_(first_registers[phase]);
const uint16_t second = this->read16_(second_registers[phase]);
if (!offset_register_value_matches(first, offsets[phase].first_offset) ||
!offset_register_value_matches(second, offsets[phase].second_offset)) {
ESP_LOGE(TAG, "[CALIBRATION][%s] %s readback failed for Phase %s: %s %d/%d, %s %d/%d.", cs, LOG_STR_ARG(name),
phase_labels[phase], LOG_STR_ARG(first_name), static_cast<int16_t>(first), offsets[phase].first_offset,
LOG_STR_ARG(second_name), static_cast<int16_t>(second), offsets[phase].second_offset);
success = false;
}
}
return success;
}
#ifdef USE_TEXT_SENSOR
void ATM90E32Component::check_phase_status() {
uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0);
+22 -49
View File
@@ -13,40 +13,6 @@
namespace esphome::atm90e32 {
inline bool offset_register_value_matches(uint16_t actual, int16_t expected) {
return actual == static_cast<uint16_t>(expected);
}
struct OffsetCalibration {
int16_t first_offset{0};
int16_t second_offset{0};
};
static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible");
enum class OffsetCalibrationType : uint8_t {
OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT,
OFFSET_CALIBRATION_TYPE_POWER,
};
struct OffsetRestoreState {
bool restored;
bool values_verified;
};
inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified,
bool fallback_values_verified) {
if (initial_values_verified)
return {has_stored_values, true};
return {false, fallback_values_verified};
}
inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values,
OffsetCalibration (&rollback)[3]) {
for (uint8_t phase = 0; phase < 3; phase++)
rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{};
}
class ATM90E32Component final : public PollingComponent,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH,
spi::CLOCK_PHASE_TRAILING, spi::DATA_RATE_1MHZ> {
@@ -105,19 +71,19 @@ class ATM90E32Component final : public PollingComponent,
this->has_config_current_gain_[phase] = true;
}
void set_voltage_offset(uint8_t phase, int16_t offset) {
this->offset_phase_[phase].first_offset = offset;
this->offset_phase_[phase].voltage_offset_ = offset;
this->has_config_voltage_offset_[phase] = true;
}
void set_current_offset(uint8_t phase, int16_t offset) {
this->offset_phase_[phase].second_offset = offset;
this->offset_phase_[phase].current_offset_ = offset;
this->has_config_current_offset_[phase] = true;
}
void set_active_power_offset(uint8_t phase, int16_t offset) {
this->power_offset_phase_[phase].first_offset = offset;
this->power_offset_phase_[phase].active_power_offset = offset;
this->has_config_active_power_offset_[phase] = true;
}
void set_reactive_power_offset(uint8_t phase, int16_t offset) {
this->power_offset_phase_[phase].second_offset = offset;
this->power_offset_phase_[phase].reactive_power_offset = offset;
this->has_config_reactive_power_offset_[phase] = true;
}
void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; }
@@ -205,16 +171,16 @@ class ATM90E32Component final : public PollingComponent,
float get_chip_temperature_();
bool get_publish_interval_flag_() { return publish_interval_flag_; };
void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; };
void restore_offset_calibrations_(OffsetCalibrationType type);
void restore_offset_calibrations_();
void restore_power_offset_calibrations_();
void restore_gain_calibrations_();
void save_offset_calibration_to_memory_();
void save_gain_calibration_to_memory_();
void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored,
bool previous_using_saved, OffsetCalibrationType type);
void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset,
OffsetCalibrationType type);
void save_power_offset_calibration_to_memory_();
void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset);
void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset);
void write_gains_to_registers_();
bool verify_gain_writes_();
bool verify_offset_writes_(OffsetCalibrationType type);
bool validate_spi_read_(uint16_t expected, const char *context = nullptr);
void log_calibration_status_();
const char *get_calibration_id_();
@@ -253,10 +219,19 @@ class ATM90E32Component final : public PollingComponent,
uint32_t cumulative_reverse_active_energy_{0};
} phase_[3];
OffsetCalibration offset_phase_[3];
struct OffsetCalibration {
int16_t voltage_offset_{0};
int16_t current_offset_{0};
} offset_phase_[3];
OffsetCalibration config_offset_phase_[3];
OffsetCalibration power_offset_phase_[3];
OffsetCalibration config_power_offset_phase_[3];
struct PowerOffsetCalibration {
int16_t active_power_offset{0};
int16_t reactive_power_offset{0};
} power_offset_phase_[3];
PowerOffsetCalibration config_power_offset_phase_[3];
struct GainCalibration {
uint16_t voltage_gain{1};
@@ -290,8 +265,6 @@ class ATM90E32Component final : public PollingComponent,
bool enable_offset_calibration_{false};
bool enable_gain_calibration_{false};
const char *instance_id_{nullptr};
bool has_stored_offset_calibration_{false};
bool has_stored_power_offset_calibration_{false};
bool restored_offset_calibration_{false};
bool restored_power_offset_calibration_{false};
bool restored_gain_calibration_{false};
+3 -4
View File
@@ -313,10 +313,9 @@ FileDecoderState AudioDecoder::decode_mp3_() {
this->output_transfer_buffer_->increase_buffer_length(
this->audio_stream_info_.value().frames_to_bytes(samples_decoded));
}
} else if (result == micro_mp3::MP3_STREAM_INFO_READY || result == micro_mp3::MP3_STREAM_INFO_CHANGED) {
// Header parsed: capture stream info and resize the output buffer to fit one full frame.
// microMP3 always outputs 16-bit PCM. MP3_STREAM_INFO_CHANGED is handled identically: despite its
// negative value it is documented as recoverable, so it must not reach the catch-all below.
} else if (result == micro_mp3::MP3_STREAM_INFO_READY) {
// First successful header parse: capture stream info and resize the output buffer to fit one full frame.
// microMP3 always outputs 16-bit PCM.
this->audio_stream_info_ =
audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate());
this->free_buffer_required_ =
+10 -24
View File
@@ -22,23 +22,6 @@ class Automation {
static const char *const TAG;
};
// Base for nodes that never read the parent's services.
// The parent releases its services only once every node reports Established, so a node that never
// reports it keeps that memory allocated for the life of the connection.
class BLEClientServicelessNode : public BLEClientNode {
public:
// Final so that Established is always reported on SEARCH_CMPL, before the derived node sees the event.
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) final {
if (event == ESP_GATTC_SEARCH_CMPL_EVT)
this->node_state = espbt::ClientState::ESTABLISHED;
this->on_gattc_event(event, gattc_if, param);
}
protected:
// Derived nodes handle GATT events here rather than by overriding the handler above.
virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {}
};
// implement on_connect automation.
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
public:
@@ -78,7 +61,7 @@ class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode
}
};
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode {
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode {
public:
explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
void loop() override {}
@@ -88,7 +71,7 @@ class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientS
}
};
class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, public BLEClientServicelessNode {
class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, public BLEClientNode {
public:
explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); }
void loop() override {}
@@ -99,7 +82,7 @@ class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, publ
}
};
class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>, public BLEClientServicelessNode {
class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>, public BLEClientNode {
public:
explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
void loop() override {}
@@ -332,17 +315,19 @@ template<typename... Ts> class BLEClientRemoveBondAction final : public Action<T
BLEClient *parent_{nullptr};
};
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...>, public BLEClientServicelessNode {
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientConnectAction(BLEClient *ble_client) {
ble_client->register_ble_node(this);
ble_client_ = ble_client;
}
void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override {
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
if (this->num_running_ == 0)
return;
switch (event) {
case ESP_GATTC_SEARCH_CMPL_EVT:
this->node_state = espbt::ClientState::ESTABLISHED;
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
break;
// if the connection is closed, terminate the automation chain.
@@ -379,13 +364,14 @@ template<typename... Ts> class BLEClientConnectAction final : public Action<Ts..
std::tuple<Ts...> var_{};
};
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...>, public BLEClientServicelessNode {
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientDisconnectAction(BLEClient *ble_client) {
ble_client->register_ble_node(this);
ble_client_ = ble_client;
}
void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override {
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
if (this->num_running_ == 0)
return;
switch (event) {
-13
View File
@@ -125,19 +125,6 @@ CLIMATE_SWING_MODES = {
validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True)
ClimateAction = climate_ns.enum("ClimateAction")
CLIMATE_ACTIONS = {
"OFF": ClimateAction.CLIMATE_ACTION_OFF,
"COOLING": ClimateAction.CLIMATE_ACTION_COOLING,
"HEATING": ClimateAction.CLIMATE_ACTION_HEATING,
"IDLE": ClimateAction.CLIMATE_ACTION_IDLE,
"DRYING": ClimateAction.CLIMATE_ACTION_DRYING,
"FAN": ClimateAction.CLIMATE_ACTION_FAN,
"DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING,
}
validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True)
CONF_MIN_HUMIDITY = "min_humidity"
CONF_MAX_HUMIDITY = "max_humidity"
CONF_TARGET_HUMIDITY = "target_humidity"
@@ -6,7 +6,6 @@ namespace esphome::dallas_temp {
static const char *const TAG = "dallas.temp.sensor";
static const uint8_t DALLAS_MODEL_DS18S20 = 0x10;
static const uint8_t DALLAS_MODEL_DS18B20 = 0x28;
static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44;
static const uint8_t DALLAS_COMMAND_READ_SCRATCH_PAD = 0xBE;
static const uint8_t DALLAS_COMMAND_WRITE_SCRATCH_PAD = 0x4E;
@@ -155,14 +154,7 @@ float DallasTemperatureSensor::get_temp_c_() {
default:
break;
}
// undocumented test for powerup measurement of 85
// https://github.com/cpetrich/counterfeit_DS18B20#solution-to-the-85-c-problem
if ((this->address_ & 0xff) == DALLAS_MODEL_DS18B20) {
if ((temp == 85 * 16) && (this->scratch_pad_[6] == 0xc)) {
ESP_LOGD(TAG, "dropping reading caused by sensor reset");
return NAN;
}
}
return temp / 16.0f;
}
+2 -6
View File
@@ -66,15 +66,11 @@ const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFE
unsigned reason = esp_reset_reason();
if (reason < sizeof(RESET_REASONS) / sizeof(RESET_REASONS[0])) {
if (reason == ESP_RST_SW || reason == ESP_RST_WDT) {
// On some ESP32-S3 configurations (e.g. SPIRAM with fetch-instructions/rodata),
// esp_restart() intermittently produces RTCWDT_RTC_RST (ESP_RST_WDT) instead of
// ESP_RST_SW. Check the stored reboot source for both reset reasons so a software
// reboot that ends up as WDT still reports the correct source.
if (reason == ESP_RST_SW) {
auto pref = global_preferences->make_preference(REBOOT_MAX_LEN,
fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str()));
char reboot_source[REBOOT_MAX_LEN]{};
if (pref.load(&reboot_source) && reboot_source[0] != '\0') {
if (pref.load(&reboot_source)) {
reboot_source[REBOOT_MAX_LEN - 1] = '\0';
snprintf(buf, size, "Reboot request from %s", reboot_source);
} else {
+5 -1
View File
@@ -23,7 +23,11 @@ from esphome.const import (
)
from esphome.types import ConfigType
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import
from . import ( # noqa: F401 pylint: disable=unused-import
CONF_DEBUG_ID,
FILTER_SOURCE_FILES,
DebugComponent,
)
DEPENDENCIES = ["debug"]
+5 -1
View File
@@ -9,7 +9,11 @@ from esphome.const import (
)
from esphome.types import ConfigType
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import
from . import ( # noqa: F401 pylint: disable=unused-import
CONF_DEBUG_ID,
FILTER_SOURCE_FILES,
DebugComponent,
)
DEPENDENCIES = ["debug"]
+9 -2
View File
@@ -3,11 +3,18 @@ import esphome.codegen as cg
# Re-exported for the many esp32-side users; defined in esphome.const
# and esphome.espidf so the upload/logs fast path can use them without
# importing this package.
from esphome.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
KEY_ESP32,
KEY_FLASH_SIZE,
KEY_IDF_VERSION,
KEY_VARIANT,
)
# Back compat for external components only; in-tree callers import it
# from esphome.espidf directly.
from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import
from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import
variant_to_idf_target,
)
KEY_BOARD = "board"
KEY_SDKCONFIG_OPTIONS = "sdkconfig_options"
+7 -28
View File
@@ -100,38 +100,21 @@ void ESP32BLE::disable() {
#ifdef USE_ESP32_BLE_ADVERTISING
void ESP32BLE::advertising_start() {
this->advertising_init_();
this->advertising_ref_count_++;
this->advertising_refresh();
}
void ESP32BLE::advertising_stop() {
if (this->advertising_ref_count_ == 0)
if (!this->is_active())
return;
this->advertising_ref_count_--;
this->advertising_refresh();
}
void ESP32BLE::advertising_refresh() {
if (this->advertising_ == nullptr || !this->is_active())
return;
// Advertise while any component still needs it, otherwise stop
if (this->advertising_ref_count_ == 0) {
this->advertising_->stop();
} else {
this->advertising_->start();
}
this->advertising_->start();
}
void ESP32BLE::advertising_set_service_data(const std::vector<uint8_t> &data) {
this->advertising_init_();
this->advertising_->set_service_data(data);
this->advertising_refresh();
this->advertising_start();
}
void ESP32BLE::advertising_set_manufacturer_data(const std::vector<uint8_t> &data) {
this->advertising_init_();
this->advertising_->set_manufacturer_data(data);
this->advertising_refresh();
this->advertising_start();
}
void ESP32BLE::advertising_set_service_data_and_name(std::span<const uint8_t> data, bool include_name) {
@@ -153,7 +136,7 @@ void ESP32BLE::advertising_set_service_data_and_name(std::span<const uint8_t> da
this->advertising_->set_service_data(data);
}
this->advertising_refresh();
this->advertising_start();
}
void ESP32BLE::advertising_register_raw_advertisement_callback(std::function<void(bool)> &&callback) {
@@ -164,13 +147,13 @@ void ESP32BLE::advertising_register_raw_advertisement_callback(std::function<voi
void ESP32BLE::advertising_add_service_uuid(ESPBTUUID uuid) {
this->advertising_init_();
this->advertising_->add_service_uuid(uuid);
this->advertising_refresh();
this->advertising_start();
}
void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) {
this->advertising_init_();
this->advertising_->remove_service_uuid(uuid);
this->advertising_refresh();
this->advertising_start();
}
#endif
@@ -592,10 +575,6 @@ void ESP32BLE::loop_handle_state_transition_not_active_() {
}
this->state_ = BLE_COMPONENT_STATE_ACTIVE;
#ifdef USE_ESP32_BLE_ADVERTISING
// Requests made before the stack was up (or before it was re-enabled) take effect now
this->advertising_refresh();
#endif
}
}
-13
View File
@@ -114,17 +114,7 @@ class ESP32BLE final : public Component {
void set_name(const char *name) { this->name_ = name; }
#ifdef USE_ESP32_BLE_ADVERTISING
/** Request advertising on behalf of a component.
*
* Requests are reference counted: advertising runs until every component that called
* advertising_start() has released it again with advertising_stop(). Each component must
* pair its calls, so nothing advertises until something actually asks for it.
*/
void advertising_start();
/// Release a request made with advertising_start(); advertising stops at the last release.
void advertising_stop();
/// Apply the current payload and request count: advertise while requested, otherwise stop.
void advertising_refresh();
void advertising_set_service_data(const std::vector<uint8_t> &data);
void advertising_set_manufacturer_data(const std::vector<uint8_t> &data);
void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; }
@@ -236,9 +226,6 @@ class ESP32BLE final : public Component {
// 1-byte aligned members (grouped together to minimize padding)
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
bool enable_on_boot_{}; // 1 byte
#ifdef USE_ESP32_BLE_ADVERTISING
uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising
#endif
#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS
optional<esp_ble_auth_req_t> auth_req_mode_;
@@ -67,8 +67,6 @@ void ESP32BLEBeacon::setup() {
this->on_advertise_();
}
});
// A beacon always needs the device to advertise, and never releases the request
global_ble->advertising_start();
}
void ESP32BLEBeacon::on_advertise_() {
@@ -596,18 +596,6 @@ async def to_code(config):
cg.add(var.set_parent(parent))
cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE]))
cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS]))
# Only advertise for the server itself when the configuration gives clients something to
# find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays
# silent until that service asks for advertising.
cg.add(
var.set_advertising_required(
CONF_MANUFACTURER_DATA in config
or any(
not uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID)
for service_config in config[CONF_SERVICES]
)
)
)
if CONF_MANUFACTURER_DATA in config:
cg.add(var.set_manufacturer_data(config[CONF_MANUFACTURER_DATA]))
for service_config in config[CONF_SERVICES]:
@@ -81,7 +81,6 @@ void BLEServer::loop() {
if (this->device_information_service_->is_running()) {
this->state_ = RUNNING;
this->restart_advertising_();
this->request_advertising_();
ESP_LOGD(TAG, "BLE server setup successfully");
} else if (this->device_information_service_->is_created()) {
this->device_information_service_->start();
@@ -99,20 +98,6 @@ void BLEServer::restart_advertising_() {
}
}
void BLEServer::request_advertising_() {
if (!this->advertising_required_ || this->advertising_requested_)
return;
this->advertising_requested_ = true;
this->parent_->advertising_start();
}
void BLEServer::release_advertising_() {
if (!this->advertising_requested_)
return;
this->advertising_requested_ = false;
this->parent_->advertising_stop();
}
BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t num_handles) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char uuid_buf[esp32_ble::UUID_STR_LEN];
@@ -185,7 +170,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga
this->add_client_(param->connect.conn_id);
// Resume advertising so additional clients can discover and connect
if (this->client_count_ < this->max_clients_) {
this->parent_->advertising_refresh();
this->parent_->advertising_start();
}
this->dispatch_callbacks_(CallbackType::ON_CONNECT, param->connect.conn_id);
break;
@@ -193,7 +178,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga
case ESP_GATTS_DISCONNECT_EVT: {
ESP_LOGD(TAG, "BLE Client disconnected");
this->remove_client_(param->disconnect.conn_id);
this->parent_->advertising_refresh();
this->parent_->advertising_start();
this->dispatch_callbacks_(CallbackType::ON_DISCONNECT, param->disconnect.conn_id);
break;
}
@@ -241,8 +226,6 @@ void BLEServer::remove_client_(uint16_t conn_id) {
}
void BLEServer::ble_before_disabled_event_handler() {
// Advertising is re-requested once the server is running again after BLE is re-enabled
this->release_advertising_();
// Delete all clients
this->client_count_ = 0;
// Delete all services
@@ -38,13 +38,6 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
this->restart_advertising_();
}
/** Whether this server needs the device to advertise so clients can find and connect to it.
*
* False for a server that only hosts services created at runtime (e.g. esp32_improv), which
* request advertising themselves for as long as they need it.
*/
void set_advertising_required(bool required) { this->advertising_required_ = required; }
void set_max_clients(uint8_t max_clients) { this->max_clients_ = max_clients; }
uint8_t get_max_clients() const { return this->max_clients_; }
@@ -89,8 +82,6 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
};
void restart_advertising_();
void request_advertising_();
void release_advertising_();
int8_t find_client_index_(uint16_t conn_id) const;
void add_client_(uint16_t conn_id);
@@ -102,8 +93,6 @@ class BLEServer final : public Component, public Parented<ESP32BLE> {
std::vector<uint8_t> manufacturer_data_{};
esp_gatt_if_t gatts_if_{0};
bool registered_{false};
bool advertising_required_{true};
bool advertising_requested_{false};
uint16_t clients_[USE_ESP32_BLE_MAX_CONNECTIONS]{};
uint8_t client_count_{0};
@@ -37,25 +37,6 @@ CONF_HANDSHAKE_PIN = "handshake_pin"
CONF_SDIO_FREQUENCY = "sdio_frequency"
CONF_SPI_MODE = "spi_mode"
# ESP-NOW-over-hosted shim (esp_now_hosted.cpp). esp-hosted proxies esp_wifi.h
# but not esp_now.h (espressif/esp-hosted-mcu#19), and esp_wifi_remote injects
# the esp_now.h header on the ESP32-P4 host with no implementation, leaving the
# esp_now_* symbols undefined at link. On a P4 host, esp_now_hosted.cpp DEFINES
# those symbols and forwards each call to the co-processor over esp-hosted's
# CustomRpc "peer data transfer" channel, so ESPHome's `espnow` component links
# and runs unchanged (proven on a Tab5, 2026-07-20). The .cpp is guarded to
# CONFIG_IDF_TARGET_ESP32P4 so it compiles to nothing on hosts with a native
# ESP-NOW stack. CustomRpc needs these two host-side Kconfig options. Host
# registers 3 handlers (RESP, RECV, SEND); the coprocessor registers 1 (REQ);
# we ask for 8 to leave room for other CustomRpc extensions alongside.
#
# The coprocessor must run the matching custom firmware (a parallel effort in
# esphome/esp-hosted-firmware). esp_now_hosted_rpc.h here is the canonical copy
# of the wire contract and MUST stay byte-identical to the copy that coprocessor
# firmware uses — the packed structs are the on-wire layout, so any divergence
# silently corrupts every ESP-NOW frame.
_MAX_CUSTOM_MSG_HANDLERS = 8
# Shared fields for both transport modes
BASE_SCHEMA = cv.Schema(
{
@@ -281,23 +262,6 @@ async def to_code(config: ConfigType) -> None:
else:
_configure_spi(config)
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
# gated on the `espnow` component being present: the shim is tiny and the
# esp_now_* symbols/CustomRpc calls it defines require these Kconfig options
# to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so
# coupling the two keeps the build consistent. When `espnow` is absent the
# symbols are simply unused and never register a callback at runtime.
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
add_define("USE_ESP_NOW_HOSTED")
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS
)
# Place the transport mempool in PSRAM. Required on memory-tight host
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
# mempool allocation fails at boot with `sdio_mempool_create` assert.
@@ -1,467 +0,0 @@
/*
* esp_now_hosted host-side shim implementing <esp_now.h> over esp-hosted
* CustomRpc, so ESPHome's `espnow` component can run on a radio-less host
* (e.g. the ESP32-P4) whose radio lives on an esp-hosted co-processor.
*
* A radio-less host has no native ESP-NOW. esp_wifi_remote INJECTS the full
* esp_now.h header (types + declarations) but ships NO implementation, so every
* esp_now_* symbol is an undefined reference at link time. This translation
* unit provides those definitions; each forwards to the co-processor over
* CustomRpc (see esphome/esp-hosted-firmware for the matching coprocessor
* handlers). No esp-hosted or esp_wifi_remote source is patched, and there is no
* duplicate-symbol clash because nothing else defines these symbols here.
*
* See esp_now_hosted_rpc.h for the wire protocol.
*/
#include "sdkconfig.h"
// Only build the shim on the radio-less host. On chips with a native ESP-NOW
// stack (S3, C6, …) the real symbols exist and this file must stay empty to
// avoid duplicate definitions.
#if defined(CONFIG_IDF_TARGET_ESP32P4)
#include <cstring>
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "esp_idf_version.h"
#include "esp_log.h"
#include "esp_timer.h"
#include <esp_now.h> // injected declarations we are now DEFINING
#include <esp_wifi_types.h> // wifi_pkt_rx_ctrl_t, wifi_tx_info_t
// esp_hosted_misc.h (host) ships WITHOUT an extern "C" guard, so including it
// from C++ would give its declarations C++ linkage and the real C symbols in
// libesp_hosted would go unresolved at link. Wrap it. (Verified vs
// esp_hosted 2.12.9.)
extern "C" {
#include "esp_hosted_misc.h" // esp_hosted_{send_custom_data,register_custom_callback}
}
#include "esp_now_hosted_rpc.h"
namespace {
const char *const TAG = "esp_now_hosted";
// One outstanding request at a time. ESPHome drives esp_now_* from the main
// loop; the matching response and the async RECV/SEND events all arrive on the
// single esp-hosted RPC RX thread. Serializing requests keeps the shared
// response slot race-free; a sequence number stops a late/stale response from
// being mistaken for ours.
SemaphoreHandle_t g_req_mutex = nullptr;
SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands
bool g_setup_done = false; // set only after setup fully succeeds
uint8_t g_seq = 0;
volatile uint8_t g_expect_seq = 0;
volatile int32_t g_resp_status = 0;
uint8_t g_resp_ret[16];
volatile uint16_t g_resp_ret_len = 0;
// Written from the main loop (register/unregister/deinit), read from the
// esp-hosted RX thread (on_recv/on_send). volatile for the same reason the
// g_resp_* globals are: force the RX thread to observe an updated pointer
// (e.g. a nulling by esp_now_deinit) rather than a cached one.
volatile esp_now_recv_cb_t g_recv_cb = nullptr;
volatile esp_now_send_cb_t g_send_cb = nullptr;
// Local mirror of the co-processor's peer table. ESPHome's espnow component
// calls esp_now_is_peer_exist() on the main loop for every received frame
// (twice) and every send; forwarding each as a blocking RPC round-trip stalls
// the loop. The shim is the only path that mutates the co-processor peer table
// (add/del/deinit all go through here), so this mirror is authoritative and
// esp_now_is_peer_exist() can answer from it with no round-trip.
//
// esp_now_* are public C symbols: any component or user lambda may call them,
// and although ESPHome's espnow touches peers only from the main loop today
// (its RX/TX callbacks merely enqueue), the shim cannot rely on that. A short
// spinlock keeps the mirror consistent from any task/core, matching native
// esp_now_*'s own internal thread-safety. The critical sections are a bounded
// (<=20-entry) scan, so they stay tiny. ESP_NOW_MAX_TOTAL_PEER_NUM is 20.
constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20;
uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6];
size_t g_peer_count = 0;
portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED;
// Caller must hold g_peer_lock.
int peer_cache_find_locked(const uint8_t *mac) {
for (size_t i = 0; i < g_peer_count; i++) {
if (memcmp(g_peer_cache[i], mac, 6) == 0)
return static_cast<int>(i);
}
return -1;
}
bool peer_cache_contains(const uint8_t *mac) {
portENTER_CRITICAL(&g_peer_lock);
const bool found = peer_cache_find_locked(mac) >= 0;
portEXIT_CRITICAL(&g_peer_lock);
return found;
}
void peer_cache_add(const uint8_t *mac) {
portENTER_CRITICAL(&g_peer_lock);
if (peer_cache_find_locked(mac) < 0 && g_peer_count < ESP_NOW_HOSTED_MAX_PEERS)
memcpy(g_peer_cache[g_peer_count++], mac, 6);
portEXIT_CRITICAL(&g_peer_lock);
}
void peer_cache_remove(const uint8_t *mac) {
portENTER_CRITICAL(&g_peer_lock);
const int idx = peer_cache_find_locked(mac);
if (idx >= 0) {
g_peer_count--;
if (static_cast<size_t>(idx) != g_peer_count) // move the last entry into the gap
memcpy(g_peer_cache[idx], g_peer_cache[g_peer_count], 6);
}
portEXIT_CRITICAL(&g_peer_lock);
}
void peer_cache_clear() {
portENTER_CRITICAL(&g_peer_lock);
g_peer_count = 0;
portEXIT_CRITICAL(&g_peer_lock);
}
// ── CustomRpc event handlers (run on the esp-hosted RPC RX thread) ──────────
// Keep them short and non-blocking. In particular they MUST NOT call back into
// any esp_now_* shim function: that would try to take g_req_mutex / wait on the
// RX thread that delivers the response, and deadlock.
void on_resp(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
if (len < sizeof(esp_now_hosted_resp_t)) {
ESP_LOGW(TAG, "RESP too short: %u bytes", static_cast<unsigned>(len));
return;
}
const auto *r = reinterpret_cast<const esp_now_hosted_resp_t *>(data);
if (r->seq != g_expect_seq) { // late response from a timed-out request (expected)
ESP_LOGV(TAG, "dropping stale RESP seq %u (want %u)", r->seq, g_expect_seq);
return;
}
g_resp_status = r->status;
uint16_t rl = r->ret_len;
if (rl > sizeof(g_resp_ret)) {
// Larger than any real opcode return — a likely wire-format drift signal.
ESP_LOGW(TAG, "RESP ret_len %u exceeds buffer, clamping (wire drift?)", rl);
rl = sizeof(g_resp_ret);
}
if (len >= sizeof(esp_now_hosted_resp_t) + rl) {
memcpy(g_resp_ret, r->ret, rl);
} else {
// Truncated frame: fail closed. Never hand the caller stale bytes left in
// g_resp_ret by a previous response, and don't let request() report a
// zeroed payload as success — override the status to an error.
ESP_LOGW(TAG, "RESP truncated: claims %u ret bytes, frame too short", rl);
rl = 0;
g_resp_status = ESP_ERR_INVALID_RESPONSE;
}
g_resp_ret_len = rl;
xSemaphoreGive(g_resp_sem);
}
void on_recv(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
// Read the volatile pointer once: esp_now_unregister_recv_cb()/deinit() (via
// the espnow component's disable()) can null it on the main loop between the
// guard and the call, which would otherwise turn the call into a null-deref.
const esp_now_recv_cb_t cb = g_recv_cb;
if (cb == nullptr)
return;
if (len < sizeof(esp_now_hosted_recv_evt_t)) {
ESP_LOGW(TAG, "RECV too short: %u bytes", static_cast<unsigned>(len));
return;
}
const auto *e = reinterpret_cast<const esp_now_hosted_recv_evt_t *>(data);
if (len < sizeof(esp_now_hosted_recv_evt_t) + e->data_len) {
ESP_LOGW(TAG, "RECV data_len %u exceeds frame", e->data_len);
return;
}
// ESPHome dereferences info->rx_ctrl->{rssi,timestamp}; give it a real one.
wifi_pkt_rx_ctrl_t rx_ctrl;
memset(&rx_ctrl, 0, sizeof(rx_ctrl));
rx_ctrl.rssi = e->rssi;
rx_ctrl.channel = e->channel;
rx_ctrl.timestamp = static_cast<uint32_t>(esp_timer_get_time());
esp_now_recv_info_t info;
info.src_addr = const_cast<uint8_t *>(e->src_addr);
info.des_addr = const_cast<uint8_t *>(e->des_addr);
info.rx_ctrl = &rx_ctrl;
cb(&info, e->data, static_cast<int>(e->data_len));
}
void on_send(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) {
// Read the volatile pointer once (see on_recv): disable()/deinit() can null it
// on the main loop concurrently with this RX-thread callback.
const esp_now_send_cb_t cb = g_send_cb;
if (cb == nullptr)
return;
if (len < sizeof(esp_now_hosted_send_evt_t)) {
ESP_LOGW(TAG, "SEND evt too short: %u bytes", static_cast<unsigned>(len));
return;
}
const auto *e = reinterpret_cast<const esp_now_hosted_send_evt_t *>(data);
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
// IDF >= 5.5: esp_now_send_cb_t takes esp_now_send_info_t (== wifi_tx_info_t),
// whose des_addr is a POINTER (not an inline array). Point it at the event's
// MAC (valid for this callback) — do NOT memcpy into it (that writes NULL and
// faults). ESPHome reads only info->des_addr.
esp_now_send_info_t si;
memset(&si, 0, sizeof(si));
si.des_addr = const_cast<uint8_t *>(e->des_addr);
cb(&si, static_cast<esp_now_send_status_t>(e->status));
#else
cb(e->des_addr, static_cast<esp_now_send_status_t>(e->status));
#endif
}
esp_err_t ensure_setup() {
// Gate on g_setup_done, not on g_req_mutex: a failure part-way through (a
// semaphore that did not allocate, a callback that did not register) must not
// leave a later call thinking setup completed. Semaphore creation is guarded
// so a retry after a partial failure does not leak the earlier handles.
if (g_setup_done)
return ESP_OK;
if (g_req_mutex == nullptr)
g_req_mutex = xSemaphoreCreateMutex();
if (g_resp_sem == nullptr)
g_resp_sem = xSemaphoreCreateBinary();
if (g_req_mutex == nullptr || g_resp_sem == nullptr)
return ESP_ERR_NO_MEM;
esp_err_t err;
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK)
return err;
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK)
return err;
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK)
return err;
g_setup_done = true;
return ESP_OK;
}
// Send one request envelope. With wait=true (default) block until the matching
// response (or timeout); with wait=false return as soon as the frame is handed
// to the transport (fire-and-forget, used by esp_now_send).
//
// `tail` is an optional second chunk written straight after `payload`. Callers
// with a fixed header plus a bulk body (esp_now_send) pass the two separately
// so they never need a build buffer of their own: both chunks are laid into the
// request buffer here, under g_req_mutex, which keeps concurrent callers from
// racing and saves a full copy of the body on every transmit.
esp_err_t request(uint8_t opcode, const void *payload, uint16_t plen, void *ret, uint16_t ret_cap, uint16_t *ret_len,
bool wait = true, const void *tail = nullptr, uint16_t tail_len = 0) {
esp_err_t err = ensure_setup();
if (err != ESP_OK)
return err;
if (plen > ESP_NOW_HOSTED_MAX_PAYLOAD || tail_len > ESP_NOW_HOSTED_MAX_PAYLOAD - plen)
return ESP_ERR_INVALID_SIZE;
const uint16_t total_len = static_cast<uint16_t>(plen + tail_len);
if (xSemaphoreTake(g_req_mutex, portMAX_DELAY) != pdTRUE)
return ESP_FAIL;
static uint8_t buf[sizeof(esp_now_hosted_req_t) + ESP_NOW_HOSTED_MAX_PAYLOAD]; // guarded by g_req_mutex
auto *req = reinterpret_cast<esp_now_hosted_req_t *>(buf);
req->opcode = opcode;
req->seq = ++g_seq;
req->payload_len = total_len;
if (plen != 0)
memcpy(req->payload, payload, plen);
if (tail_len != 0)
memcpy(req->payload + plen, tail, tail_len);
g_expect_seq = req->seq;
xSemaphoreTake(g_resp_sem, 0); // drain any stale signal before sending
err = esp_hosted_send_custom_data(ESP_NOW_HOSTED_MSG_REQ, buf, sizeof(esp_now_hosted_req_t) + total_len);
if (err != ESP_OK) {
xSemaphoreGive(g_req_mutex);
return err;
}
if (!wait) {
// Fire-and-forget (esp_now_send): the co-processor enqueues the frame and
// reports the real TX result later via the async SEND event, exactly like
// native esp_now_send. Returning here keeps the main loop off the ~100 ms+
// RPC round-trip. The matching RESP is ignored (seq won't match the next
// waited request, so on_resp drops it).
xSemaphoreGive(g_req_mutex);
return ESP_OK;
}
if (xSemaphoreTake(g_resp_sem, pdMS_TO_TICKS(ESP_NOW_HOSTED_TIMEOUT_MS)) != pdTRUE) {
ESP_LOGW(TAG, "opcode %u timed out", opcode);
xSemaphoreGive(g_req_mutex);
return ESP_ERR_TIMEOUT;
}
const int32_t status = g_resp_status;
if (ret != nullptr && ret_cap != 0) {
uint16_t n = g_resp_ret_len < ret_cap ? g_resp_ret_len : ret_cap;
memcpy(ret, const_cast<const uint8_t *>(g_resp_ret), n);
if (ret_len != nullptr)
*ret_len = n;
}
xSemaphoreGive(g_req_mutex);
return static_cast<esp_err_t>(status);
}
} // namespace
// ── The <esp_now.h> surface, defined for the radio-less host ────────────────
extern "C" {
esp_err_t esp_now_init(void) { return request(ESP_NOW_HOSTED_OP_INIT, nullptr, 0, nullptr, 0, nullptr); }
esp_err_t esp_now_deinit(void) {
g_recv_cb = nullptr;
g_send_cb = nullptr;
peer_cache_clear(); // the co-processor drops all peers on deinit
return request(ESP_NOW_HOSTED_OP_DEINIT, nullptr, 0, nullptr, 0, nullptr);
}
esp_err_t esp_now_get_version(uint32_t *version) {
uint32_t v = 0;
uint16_t rl = 0;
esp_err_t err = request(ESP_NOW_HOSTED_OP_GET_VERSION, nullptr, 0, &v, sizeof(v), &rl);
if (version != nullptr)
*version = v;
return err;
}
esp_err_t esp_now_register_recv_cb(esp_now_recv_cb_t cb) {
// Only arm the callback once the CustomRpc handlers are actually registered,
// so a failed setup leaves g_recv_cb null rather than falsely "registered".
esp_err_t err = ensure_setup();
if (err != ESP_OK)
return err;
g_recv_cb = cb;
return ESP_OK;
}
esp_err_t esp_now_unregister_recv_cb(void) {
g_recv_cb = nullptr;
return ESP_OK;
}
esp_err_t esp_now_register_send_cb(esp_now_send_cb_t cb) {
esp_err_t err = ensure_setup();
if (err != ESP_OK)
return err;
g_send_cb = cb;
return ESP_OK;
}
esp_err_t esp_now_unregister_send_cb(void) {
g_send_cb = nullptr;
return ESP_OK;
}
static esp_err_t add_or_mod_peer(uint8_t opcode, const esp_now_peer_info_t *peer, bool wait) {
if (peer == nullptr)
return ESP_ERR_ESPNOW_ARG;
esp_now_hosted_peer_t p;
memset(&p, 0, sizeof(p));
memcpy(p.peer_addr, peer->peer_addr, 6);
memcpy(p.lmk, peer->lmk, 16);
p.channel = peer->channel;
p.ifidx = static_cast<uint8_t>(peer->ifidx);
p.encrypt = peer->encrypt ? 1 : 0;
return request(opcode, &p, sizeof(p), nullptr, 0, nullptr, wait);
}
esp_err_t esp_now_add_peer(const esp_now_peer_info_t *peer) {
// Fire-and-forget (wait=false): adding a peer is a blocking RPC round-trip,
// and ESPHome's espnow calls it on the main loop when a device joins the mesh
// — under co-processor load that stalls the UI (peer-churn stutter). Issue it
// without waiting and mirror it locally. Safe against a following
// esp_now_send to the same peer: both ride the same in-order CustomRpc
// channel (mutex-serialized on the host) and the co-processor processes REQs
// FIFO, so ADD_PEER is applied before the SEND. Trade-off: a co-processor-side
// failure (e.g. peer table full) is no longer reported synchronously — the
// same limitation as esp_now_send — but ESPHome only adds peers it validated.
esp_err_t err = add_or_mod_peer(ESP_NOW_HOSTED_OP_ADD_PEER, peer, /*wait=*/false);
if (err == ESP_OK)
peer_cache_add(peer->peer_addr); // keep the local mirror in sync
return err;
}
esp_err_t esp_now_mod_peer(const esp_now_peer_info_t *peer) {
// mod_peer changes a peer's parameters, not its existence, so the cache is
// unaffected. Kept synchronous — it is not on any hot path (espnow never
// calls it), so the extra round-trip does not matter and the status is useful.
return add_or_mod_peer(ESP_NOW_HOSTED_OP_MOD_PEER, peer, /*wait=*/true);
}
esp_err_t esp_now_del_peer(const uint8_t *peer_addr) {
if (peer_addr == nullptr)
return ESP_ERR_ESPNOW_ARG;
// Fire-and-forget for the same reason as add_peer (peer churn on the main
// loop). Removal is order-independent, so this is strictly safe.
esp_err_t err = request(ESP_NOW_HOSTED_OP_DEL_PEER, peer_addr, 6, nullptr, 0, nullptr, /*wait=*/false);
if (err == ESP_OK)
peer_cache_remove(peer_addr); // keep the local mirror in sync
return err;
}
bool esp_now_is_peer_exist(const uint8_t *peer_addr) {
if (peer_addr == nullptr)
return false;
// Answered from the local mirror — no RPC round-trip. ESPHome's espnow calls
// this on the main loop for every received frame and every send, so a
// blocking round-trip here would stall rendering under mesh traffic.
return peer_cache_contains(peer_addr);
}
esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len) {
if (len > ESP_NOW_HOSTED_MAX_FRAME)
return ESP_ERR_ESPNOW_ARG;
if (data == nullptr && len != 0) // native esp_now_send treats this as an arg error
return ESP_ERR_ESPNOW_ARG;
// Only the small fixed header is built here; the caller's frame goes over as
// the request tail, so request() lays both into its own buffer under
// g_req_mutex. esp_now_send is a public C symbol and may be called from any
// task, and a shared build buffer here would let two callers corrupt each
// other's frame. Passing the body through also drops a full-frame copy per
// transmit, on the path this shim exists to keep quick.
uint8_t hdr[sizeof(esp_now_hosted_send_req_t)];
auto *s = reinterpret_cast<esp_now_hosted_send_req_t *>(hdr);
s->has_addr = peer_addr != nullptr ? 1 : 0;
if (peer_addr != nullptr)
memcpy(s->peer_addr, peer_addr, 6);
else
memset(s->peer_addr, 0, 6);
s->data_len = static_cast<uint16_t>(len);
// Fire-and-forget (wait=false): native esp_now_send returns once the frame is
// queued, with the real TX result delivered later through the send callback.
// The co-processor mirrors that — it acks enqueue immediately and reports the
// outcome via the async SEND event (on_send -> on_send_report). Waiting for
// the RPC RESP here would block the main loop for the full round-trip on
// every transmit.
return request(ESP_NOW_HOSTED_OP_SEND, hdr, sizeof(hdr), nullptr, 0, nullptr, /*wait=*/false, data,
static_cast<uint16_t>(len));
}
esp_err_t esp_now_set_pmk(const uint8_t *pmk) {
if (pmk == nullptr)
return ESP_ERR_ESPNOW_ARG;
return request(ESP_NOW_HOSTED_OP_SET_PMK, pmk, 16, nullptr, 0, nullptr);
}
// Remainder of the <esp_now.h> surface. Not used by ESPHome's espnow component
// today; provided so the whole header links and future callers get a defined
// (if unimplemented) symbol rather than a link error. Wire them through
// CustomRpc if a use case appears.
esp_err_t esp_now_get_peer(const uint8_t * /*peer_addr*/, esp_now_peer_info_t * /*peer*/) {
return ESP_ERR_NOT_SUPPORTED;
}
esp_err_t esp_now_fetch_peer(bool /*from_head*/, esp_now_peer_info_t * /*peer*/) { return ESP_ERR_NOT_SUPPORTED; }
esp_err_t esp_now_get_peer_num(esp_now_peer_num_t * /*num*/) { return ESP_ERR_NOT_SUPPORTED; }
esp_err_t esp_now_set_wake_window(uint16_t /*window*/) {
return ESP_ERR_NOT_SUPPORTED; // power-save wake window is not forwarded; don't claim success
}
esp_err_t esp_now_set_peer_rate_config(const uint8_t * /*peer_addr*/, esp_now_rate_config_t * /*cfg*/) {
return ESP_ERR_NOT_SUPPORTED;
}
esp_err_t esp_wifi_config_espnow_rate(wifi_interface_t /*ifx*/, wifi_phy_rate_t /*rate*/) {
return ESP_ERR_NOT_SUPPORTED;
}
} // extern "C"
#endif // CONFIG_IDF_TARGET_ESP32P4
@@ -1,128 +0,0 @@
/*
* esp_now_hosted ESP-NOW-over-CustomRpc wire protocol.
*
* Shared, byte-for-byte-identical contract between:
* - the host shim (esphome/components/esp32_hosted/esp_now_hosted.cpp)
* - the coprocessor firmware (esphome/esp-hosted-firmware)
*
* It rides esp-hosted's CustomRpc channel (RPC ID 388, "peer data transfer",
* available since esp-hosted v2.8.1), teaching the radio-less host <-> radio
* co-processor link to carry esp_now.h, which esp-hosted itself does not proxy
* (Espressif issue espressif/esp-hosted-mcu#19).
*
* KEEP THE TWO COPIES IN SYNC. The canonical copy lives here; the coprocessor
* firmware uses a verbatim copy. Both sides are little-endian, so these packed
* structs are wire-compatible with no byte-swapping.
*/
#ifndef ESP_NOW_HOSTED_RPC_H
#define ESP_NOW_HOSTED_RPC_H
#ifdef __cplusplus
#include <cstdint>
#else
#include <stdint.h>
#endif
#ifdef __cplusplus
extern "C" {
#endif
/* ── CustomRpc message IDs (any uint32_t except 0xFFFFFFFF) ──────────────────
* One REQ handler slot on the device; three event handler slots on the host.
* The bytes spell "now" + index, a private range unlikely to clash with other
* CustomRpc users (e.g. the stock peer_data_transfer example's 1..6). */
#define ESP_NOW_HOSTED_MSG_REQ 0x6E6F7701u /* host -> device : request envelope */
#define ESP_NOW_HOSTED_MSG_RESP 0x6E6F7702u /* device -> host : reply to a REQ */
#define ESP_NOW_HOSTED_MSG_RECV 0x6E6F7703u /* device -> host : async RX frame */
#define ESP_NOW_HOSTED_MSG_SEND 0x6E6F7704u /* device -> host : async TX status */
/* ── Request opcodes ────────────────────────────────────────────────────── */
enum {
ESP_NOW_HOSTED_OP_INIT = 1, /* esp_now_init + register device recv/send cbs */
ESP_NOW_HOSTED_OP_DEINIT = 2, /* unregister cbs + esp_now_deinit */
ESP_NOW_HOSTED_OP_ADD_PEER = 3, /* payload: esp_now_hosted_peer_t */
ESP_NOW_HOSTED_OP_DEL_PEER = 4, /* payload: 6-byte peer MAC */
ESP_NOW_HOSTED_OP_IS_PEER_EXIST = 5, /* payload: 6-byte MAC; ret: 1 byte bool */
ESP_NOW_HOSTED_OP_SEND = 6, /* payload: esp_now_hosted_send_req_t */
ESP_NOW_HOSTED_OP_GET_VERSION = 7, /* ret: uint32 version */
ESP_NOW_HOSTED_OP_SET_PMK = 8, /* payload: 16-byte PMK */
ESP_NOW_HOSTED_OP_MOD_PEER = 9, /* payload: esp_now_hosted_peer_t */
};
/* Largest ESP-NOW payload we forward. ESP-NOW v2 (IDF >= 5.4) is 1470 B; well
* under esp-hosted's 8166 B CustomRpc cap, so the shim never truncates. */
#define ESP_NOW_HOSTED_MAX_FRAME 1470u
/* Envelope slack for the largest opcode payload (a SEND req wrapping a frame). */
#define ESP_NOW_HOSTED_MAX_PAYLOAD (ESP_NOW_HOSTED_MAX_FRAME + 16u)
/* Host request/response round-trip timeout over the transport. Generous:
* normal RTT is sub-millisecond, but Wi-Fi/BLE contention on the co-processor
* can stall the RX thread. */
#define ESP_NOW_HOSTED_TIMEOUT_MS 2000
/* ── Envelopes ──────────────────────────────────────────────────────────── */
/* These payloads are shared verbatim with the C co-processor firmware, so they
* use C's `typedef struct {...} name;` idiom rather than C++ `using` aliases,
* which would not compile there. Silence clang-tidy's modernize-use-using for
* the shared struct block. */
// NOLINTBEGIN(modernize-use-using)
typedef struct {
uint8_t opcode; /* one of ESP_NOW_HOSTED_OP_* */
uint8_t seq; /* wraps 0..255; echoed in the response for matching */
uint16_t payload_len; /* bytes of opcode-specific payload that follow */
uint8_t payload[]; /* flexible */
} __attribute__((packed)) esp_now_hosted_req_t;
typedef struct {
uint8_t opcode; /* echoes the request opcode */
uint8_t seq; /* echoes the request seq */
int32_t status; /* esp_err_t from the native call on the co-processor */
uint16_t ret_len; /* bytes of return payload that follow */
uint8_t ret[]; /* flexible (e.g. version u32, is_peer_exist bool) */
} __attribute__((packed)) esp_now_hosted_resp_t;
/* ── Opcode payloads ────────────────────────────────────────────────────── */
/* esp_now_peer_info_t minus the host-only `priv` pointer, which is meaningless
* across the transport and never set by ESPHome's espnow component. */
typedef struct {
uint8_t peer_addr[6];
uint8_t lmk[16];
uint8_t channel; /* 0 = current channel */
uint8_t ifidx; /* wifi_interface_t (0=STA, 1=AP) */
uint8_t encrypt; /* bool */
} __attribute__((packed)) esp_now_hosted_peer_t;
typedef struct {
uint8_t has_addr; /* 0 => peer_addr is NULL (broadcast to all peers) */
uint8_t peer_addr[6];
uint16_t data_len;
uint8_t data[]; /* flexible, up to ESP_NOW_HOSTED_MAX_FRAME */
} __attribute__((packed)) esp_now_hosted_send_req_t;
/* ── Async events (device -> host) ──────────────────────────────────────── */
/* Reconstructed on the host into an esp_now_recv_info_t + a minimal
* wifi_pkt_rx_ctrl_t. ESPHome's espnow reads info->src_addr, info->des_addr,
* info->rx_ctrl->rssi and info->rx_ctrl->timestamp. */
typedef struct {
uint8_t src_addr[6];
uint8_t des_addr[6];
int8_t rssi;
uint8_t channel;
uint16_t data_len;
uint8_t data[]; /* flexible */
} __attribute__((packed)) esp_now_hosted_recv_evt_t;
typedef struct {
uint8_t des_addr[6];
uint8_t status; /* esp_now_send_status_t (0 = success) */
} __attribute__((packed)) esp_now_hosted_send_evt_t;
// NOLINTEND(modernize-use-using)
#ifdef __cplusplus
}
#endif
#endif /* ESP_NOW_HOSTED_RPC_H */
@@ -112,7 +112,6 @@ void ESP32ImprovComponent::loop() {
this->state_callback_.call(this->state_, this->error_state_);
#endif
}
this->release_advertising_();
this->incoming_data_.clear();
return;
}
@@ -144,9 +143,8 @@ void ESP32ImprovComponent::loop() {
ESP_LOGV(TAG, "Starting with device name advertising");
this->advertising_device_name_ = true;
this->last_name_adv_time_ = App.get_loop_component_start_time();
// Set the payload before requesting, so advertising starts exactly once
esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
this->request_advertising_();
esp32_ble::global_ble->advertising_start();
// Set initial state based on whether we have an authorizer
this->set_state_(this->get_initial_state_(), false);
@@ -328,8 +326,6 @@ void ESP32ImprovComponent::stop() {
this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] {
if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr)
return;
// Release first so removing the service UUID does not restart advertising on the way out
this->release_advertising_();
this->service_->stop();
this->set_state_(improv::STATE_STOPPED);
});
@@ -524,20 +520,6 @@ void ESP32ImprovComponent::update_advertising_type_() {
}
}
void ESP32ImprovComponent::request_advertising_() {
if (this->advertising_requested_)
return;
this->advertising_requested_ = true;
esp32_ble::global_ble->advertising_start();
}
void ESP32ImprovComponent::release_advertising_() {
if (!this->advertising_requested_)
return;
this->advertising_requested_ = false;
esp32_ble::global_ble->advertising_stop();
}
improv::State ESP32ImprovComponent::get_initial_state_() const {
#ifdef USE_BINARY_SENSOR
// If we have an authorizer, start in awaiting authorization state
@@ -104,11 +104,8 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB
bool status_indicator_state_{false};
uint32_t last_name_adv_time_{0};
bool advertising_device_name_{false};
bool advertising_requested_{false};
void set_status_indicator_state_(bool state);
void update_advertising_type_();
void request_advertising_();
void release_advertising_();
void set_state_(improv::State state, bool update_advertising = true);
void set_error_(improv::Error error);
+41 -191
View File
@@ -3,10 +3,8 @@ from pathlib import Path
import platform
import re
import subprocess
import time
from typing import Any
from esphome.build_helpers.pch import pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -16,7 +14,6 @@ from esphome.const import (
CONF_FRAMEWORK,
CONF_PLATFORM_VERSION,
CONF_SOURCE,
CONF_TOOLCHAIN,
CONF_VERSION,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
@@ -24,7 +21,6 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
PLATFORM_ESP8266,
ThreadModel,
Toolchain,
)
from esphome.core import (
CORE,
@@ -35,13 +31,12 @@ from esphome.core import (
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import IS_MACOS, copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script
from .boards import BOARDS, board_ld_script
from .const import (
BUILD_FLASH_MODES,
CONF_EARLY_PIN_INIT,
CONF_ENABLE_SERIAL,
CONF_ENABLE_SERIAL1,
@@ -49,7 +44,6 @@ from .const import (
KEY_BOARD,
KEY_ESP8266,
KEY_PIN_INITIAL_STATES,
KEY_SCANF_FLOAT,
KEY_SERIAL1_REQUIRED,
KEY_SERIAL_REQUIRED,
KEY_WAVEFORM_REQUIRED,
@@ -109,53 +103,6 @@ def set_core_data(config: ConfigType) -> ConfigType:
return config
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO)
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO)
def _validate_native_toolchain(config: ConfigType) -> ConfigType:
"""Constraints of the native (non-PlatformIO) Arduino toolchain."""
if not CORE.using_toolchain_arduino:
return config
from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION
conf = config[CONF_FRAMEWORK]
version = cv.Version.parse(conf[CONF_VERSION])
if version < MIN_FRAMEWORK_VERSION:
raise cv.Invalid(
"'toolchain: arduino' requires framework version "
f"{MIN_FRAMEWORK_VERSION} or newer"
)
# platform_version is a PlatformIO concept; drop it (as esp32's native
# toolchain does), warning when a custom pin is discarded. The floor
# above guarantees the schema-derived default is the ARDUINO_4 spec.
if (
conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC)
!= _ARDUINO_4_PLATFORM_SPEC
):
_LOGGER.warning(
"'platform_version' is ignored by 'toolchain: arduino'; the native "
"toolchain downloads the framework and compiler directly"
)
if conf[CONF_SOURCE] != _format_framework_arduino_version(version):
raise cv.Invalid(
"'toolchain: arduino' does not support a custom framework source; "
"use 'toolchain: platformio'"
)
# BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is
# a drift guard for the independently regenerated tables
if (
config[CONF_BOARD] not in BOARDS
or config[CONF_BOARD] not in ESP8266_BOARD_BUILD
):
raise cv.Invalid(
f"Board '{config[CONF_BOARD]}' is not supported by "
"'toolchain: arduino'; use 'toolchain: platformio'"
)
return config
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
"""Binary-download entries for a built ESP8266 firmware.
@@ -185,7 +132,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
# a PIO platformio/framework-arduinoespressif8266 value
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
# Same encoding the native toolchain uses for its package download, so a
# custom-source check against this value cannot drift from what it fetches.
# version bump cannot drift between the two paths.
from esphome.arduino8266.framework import framework_package_version
try:
@@ -247,7 +194,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
platform_version = value.get(CONF_PLATFORM_VERSION)
if platform_version is None:
if version >= cv.Version(3, 1, 0):
platform_version = _ARDUINO_4_PLATFORM_SPEC
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
else:
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
value[CONF_PLATFORM_VERSION] = platform_version
@@ -270,10 +217,6 @@ def _parse_platform_version(value: Any) -> str:
return value
# The platform_version derived for every core >= 3.1.0 config
_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
cv.Schema(
{
@@ -290,6 +233,7 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
)
BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"]
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -306,30 +250,15 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
cv.Optional(
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
): _validate_toolchain,
}
),
_resolve_toolchain,
_validate_native_toolchain,
# Until the native toolchain lands, PlatformIO is the only backend;
# reject a --toolchain this platform cannot serve yet.
cv.require_platformio_toolchain("ESP8266"),
set_core_data,
)
def native_toolchain_module():
"""The native build backend for the resolved toolchain, if any.
``__main__`` dispatches from its own toolchain-keyed table; this helper
serves the component's internal callers.
"""
if not CORE.using_toolchain_arduino:
return None
from esphome.arduino8266 import toolchain
return toolchain
def check_rosetta() -> None:
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
@@ -365,13 +294,12 @@ def _choose_ld_script(board: str) -> str:
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config: ConfigType) -> None:
use_platformio = CORE.using_toolchain_platformio
cg.add(esp8266_ns.setup_preferences())
if use_platformio:
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_build_flag("-DUSE_ESP8266")
cg.set_cpp_standard("gnu++20")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
@@ -387,33 +315,28 @@ async def to_code(config: ConfigType) -> None:
"enabling scanf float support (~8KB flash)"
)
# The native generator reads the same decision (KEY_SCANF_FLOAT)
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
if use_platformio:
extra_scripts = [
"pre:ccache.py",
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:relocate_ratetable.py",
]
if not enable_scanf_float:
extra_scripts.append("pre:remove_float_scanf.py")
extra_scripts.extend(pch_extra_scripts())
extra_scripts.append("post:post_build.py")
cg.add_platformio_option("extra_scripts", extra_scripts)
extra_scripts = [
"pre:ccache.py",
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:relocate_ratetable.py",
]
if not enable_scanf_float:
extra_scripts.append("pre:remove_float_scanf.py")
extra_scripts.append("post:post_build.py")
cg.add_platformio_option("extra_scripts", extra_scripts)
conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino")
cg.add_build_flag("-DUSE_ARDUINO")
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
cg.add_build_flag("-Wno-nonnull-compare")
if use_platformio:
cg.add_platformio_option("framework", "arduino")
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option(
"platform_packages",
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
)
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option(
"platform_packages",
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
)
# Default for platformio is LWIP2_LOW_MEMORY with:
# - MSS=536
@@ -451,8 +374,7 @@ async def to_code(config: ConfigType) -> None:
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
# See throw_stubs.h for details. Must be prepended before <string>, so this
# uses build_src_flags with -include. Unconditional: the native build
# generator reads the same option, keeping one source of truth.
# uses build_src_flags with -include.
cg.add_platformio_option(
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
)
@@ -482,8 +404,6 @@ async def to_code(config: ConfigType) -> None:
# implementation in the Arduino ESP8266 core.
cg.add_build_flag("-Wl,--wrap=millis")
# Unconditional: the native build generator reads the same option,
# keeping one source of truth
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
@@ -492,7 +412,7 @@ async def to_code(config: ConfigType) -> None:
cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"),
)
if use_platformio and config[CONF_BOARD] in BOARDS:
if config[CONF_BOARD] in BOARDS:
cg.add_platformio_option(
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
)
@@ -533,24 +453,8 @@ async def finalize_serial_config() -> None:
cg.add_build_flag("-DNO_GLOBAL_SERIAL1")
# Called by __main__.compile_program; returning False falls through to the
# PlatformIO toolchain.
def run_compile(args, config: ConfigType) -> bool:
# Positive check: the native backend only runs when explicitly resolved
toolchain = native_toolchain_module()
if toolchain is None:
return False
if toolchain.run_compile(config, CORE.verbose) != 0:
raise EsphomeError("ESP8266 native build failed")
return True
# Called by writer.py
def copy_files() -> None:
# Native builds skip the PlatformIO extra scripts; the build generator
# carries their logic
if CORE.using_toolchain_arduino:
return
dir = Path(__file__).parent
for script in (
"post_build",
@@ -565,7 +469,6 @@ def copy_files() -> None:
CORE.relative_build_path(f"{script}.py"),
)
copy_ccache_script()
copy_pch_script()
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
@@ -608,75 +511,22 @@ ESP8266_EXCEPTION_CODES = {
}
_DECODE_WARNED_AT: dict[str, float] = {}
def _decode_pc(config: ConfigType, addr: str) -> None:
from esphome.platformio import toolchain
def _warn_decode_problem(key: str, message: str, *args) -> bool:
"""Warn, deduplicated briefly so a burst of stack-dump addresses warns
once but a later dump warns again; returns whether it warned so the
caller can mark suppressed addresses individually."""
now = time.monotonic()
last = _DECODE_WARNED_AT.get(key)
if last is not None and now - last < 30:
return False
_DECODE_WARNED_AT[key] = now
_LOGGER.warning(message, *args)
return True
def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None:
"""Decode one crash address. ``bulk``: the caller is scanning every
8-hex stack word, most of which are not code addresses -- unmappable
ones log at debug so real frames are not buried."""
if (native_toolchain := native_toolchain_module()) is not None:
addr2line = native_toolchain.get_addr2line_path()
elf = native_toolchain.get_elf_path()
for path in (addr2line, elf):
if not path.is_file():
_warn_decode_problem(
str(path), "Cannot decode crash addresses: %s missing", path
)
# The detailed warning names no address; mark named
# registers, but bulk stack words at debug (~150 per dump)
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (toolchain file missing)", addr)
return
addr2line, elf = str(addr2line), str(elf)
else:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_warn_decode_problem(
"no-addr2line",
"Cannot decode crash addresses: no addr2line or ELF in idedata",
)
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (no addr2line or ELF)", addr)
return
addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path
command = [addr2line, "-pfiaC", "-e", elf, addr]
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
# Warn, not debug: a failing addr2line must be visible. The warning
# is rate-limited across a dump, so mark every undecoded address
# inline or the rest read as merely unmappable
if not _warn_decode_problem(
"addr2line-failed", "Could not decode crash address %s (%s)", addr, err
):
# The detailed warning already named this address; mark only
# the rate-limited ones, and bulk stack words at debug
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (addr2line failed)", addr)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
return
if "?? ??:0" in translation:
# A named register that fails to decode is confusing silence; a
# bulk stack word failing is the expected common case
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (address not in %s)", addr, elf)
# Nothing useful
return
translation = translation.replace(" at ??:?", "").replace(":?", "")
_LOGGER.warning("Decoded %s", translation)
@@ -746,6 +596,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
if backtrace_state:
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
_decode_pc(config, addr.group(), bulk=True)
_decode_pc(config, addr.group())
return backtrace_state
+1 -3
View File
@@ -16,6 +16,7 @@ KEY_WAVEFORM_REQUIRED = "waveform_required"
KEY_SERIAL_REQUIRED = "serial_required"
KEY_SERIAL1_REQUIRED = "serial1_required"
# Set for the native (non-PlatformIO) toolchain's build generator
KEY_FLASH_MODE = "flash_mode"
KEY_SCANF_FLOAT = "scanf_float"
# Per-board flash-layout override consumed by board_ld_script()
KEY_LDSCRIPT = "ldscript"
@@ -72,6 +73,3 @@ def enable_serial1() -> None:
enable_serial1()
"""
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout")
+54 -76
View File
@@ -2,12 +2,12 @@ import logging
import esphome.codegen as cg
from esphome.components.noise import (
decode_encryption_key,
encryption_schema,
new_psk_progmem,
static_encryption_key,
is_reserved_key,
)
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
from esphome.config_helpers import filter_source_files_from_defines, merge_config
from esphome.config_helpers import merge_config
import esphome.config_validation as cv
from esphome.const import (
CONF_API,
@@ -31,6 +31,7 @@ import esphome.final_validate as fv
from esphome.types import ConfigType
CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access"
CONF_CAPTIVE_PORTAL = "captive_portal"
_LOGGER = logging.getLogger(__name__)
@@ -40,10 +41,11 @@ DEPENDENCIES = ["network"]
def AUTO_LOAD(config: ConfigType) -> list[str]:
"""Auto-load noise only when encryption is configured; the api key offer
inherits it from the api component."""
"""Auto-load noise only when encryption is configured."""
base = ["sha256", "socket"]
# A falsy config is a tooling probe for the maximal set
# A falsy config is a tooling probe for the maximal set (None from
# dependency resolution, {} from the components-graph platform probe);
# a validated config always carries defaults, never empty
if not config or CONF_ENCRYPTION in config:
return base + ["noise"]
return base
@@ -130,56 +132,12 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
_validate_no_password_with_encryption(ota_conf)
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
_resolve_encryption_key(encryption_conf, api_conf)
elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None:
_LOGGER.warning(
"'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 "
"bytes plus the password on the heap): the device already offers "
"encryption with the '%s' %s %s, which authenticates any uploader "
"that takes it, and a password only matters for uploaders without "
"encryption support; remove '%s' and add '%s' under '%s' so "
"uploads use the key and encryption is required",
CONF_OTA,
CONF_PASSWORD,
CONF_API,
CONF_ENCRYPTION,
CONF_KEY,
CONF_PASSWORD,
CONF_ENCRYPTION,
CONF_OTA,
)
elif (
CONF_PASSWORD in ota_conf
and CONF_ENCRYPTION in api_conf
and not api_conf[CONF_ENCRYPTION].get(CONF_KEY)
):
# The CLI still needs the password; whoever provisions the key skips it
_LOGGER.warning(
"The '%s' %s %s provisioned at runtime also authenticates OTA "
"uploads once provisioned; '%s' %s then only guards plaintext "
"uploads. Whoever provisions the key can upload firmware "
"without the password, so add a 'provisioning:' block to limit "
"when that is possible",
CONF_API,
CONF_ENCRYPTION,
CONF_KEY,
CONF_OTA,
CONF_PASSWORD,
)
# web_server and prometheus keep the shared listener up; the captive
# portal's copy only exists on the fallback AP and is the recovery path
if (
(CONF_WEB_SERVER in full_conf or "prometheus" in full_conf)
and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf)
and any(
CONF_ENCRYPTION in conf
for conf in merged_ota_esphome_configs_by_port.values()
)
if any(
conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf
) and any(
CONF_ENCRYPTION in conf for conf in merged_ota_esphome_configs_by_port.values()
):
_LOGGER.warning(
"OTA encryption does not cover the %s OTA platform; its "
"plaintext /update endpoint accepts the same image",
CONF_WEB_SERVER,
)
_warn_web_server_ota(full_conf)
full_conf[CONF_OTA] = new_ota_conf
fv.full_config.set(full_conf)
@@ -194,11 +152,33 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
)
def _warn_web_server_ota(full_conf: ConfigType) -> None:
"""The web_server ota platform accepts the same image over plaintext HTTP
with basic auth, bypassing the encryption; warn rather than fail so the
operator keeps the recovery path."""
if CONF_CAPTIVE_PORTAL in full_conf and CONF_WEB_SERVER not in full_conf:
# The captive_portal auto-load: the endpoint only exists while the
# fallback AP is active
_LOGGER.warning(
"OTA encryption does not cover the %s OTA platform (auto-loaded "
"by captive_portal); the plaintext /update endpoint stays "
"reachable while the fallback AP is active",
CONF_WEB_SERVER,
)
else:
_LOGGER.warning(
"OTA encryption does not cover the %s OTA platform; its "
"plaintext /update endpoint accepts the same image",
CONF_WEB_SERVER,
)
def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -> None:
"""Resolve the one encryption key per device into the ota block.
An explicit ota key must match the api key, a bare block inherits it,
a runtime provisioned api key cannot be inherited.
a runtime provisioned api key cannot be inherited, and the all-zeros
provisioning sentinel is rejected (the device treats it as no key).
"""
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
if ota_key := encryption_conf.get(CONF_KEY):
@@ -221,6 +201,11 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -
)
else:
encryption_conf[CONF_KEY] = api_key
if is_reserved_key(encryption_conf[CONF_KEY]):
raise cv.Invalid(
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
f"generate a real key with: openssl rand -base64 32"
)
# Also called on merged same-port configs in final validate, where schemas
@@ -282,9 +267,15 @@ CONFIG_SCHEMA = cv.All(
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"}
)
def FILTER_SOURCE_FILES() -> list[str]:
"""Filter out the noise transport when no ota entry configures encryption."""
for ota_conf in CORE.config.get(CONF_OTA, []):
if (
ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_conf.get(CONF_ENCRYPTION) is not None
):
return []
return ["ota_esphome_noise.cpp"]
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
@@ -305,24 +296,11 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_ALLOW_PARTITION_ACCESS):
cg.add_define("USE_OTA_PARTITIONS")
# One key per device: an api encryption block supplies it (static or
# runtime) and offers; the ota block only adds the requirement
api_conf = CORE.config.get(CONF_API) or {}
encryption_conf = config.get(CONF_ENCRYPTION)
own_key = None
if encryption_conf is not None and static_encryption_key(api_conf) is None:
own_key = encryption_conf[CONF_KEY]
if own_key is not None:
if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None:
# A missing key was resolved from the api component in final validate.
key = encryption_conf[CONF_KEY]
cg.add_define("USE_OTA_ENCRYPTION")
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key)))
elif CONF_ENCRYPTION in api_conf:
cg.add_define("USE_OTA_ENCRYPTION")
cg.add_define("USE_OTA_ENCRYPTION_FROM_API")
if static_encryption_key(api_conf) is None:
# The key arrives at runtime, so the offer has to look for it
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
if encryption_conf is not None:
cg.add_define("USE_OTA_ENCRYPTION_REQUIRED")
cg.add(var.set_noise_psk(list(decode_encryption_key(key))))
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
+23 -60
View File
@@ -1,7 +1,4 @@
#include "ota_esphome.h"
#ifdef USE_OTA_ENCRYPTION_FROM_API
#include "esphome/components/api/api_server.h"
#endif
#ifdef USE_OTA
#ifdef USE_OTA_PASSWORD
#include "esphome/components/sha256/sha256.h"
@@ -29,16 +26,6 @@
namespace esphome {
static const char *const TAG = "esphome.ota";
#ifdef USE_OTA_ENCRYPTION
const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
#ifdef USE_OTA_ENCRYPTION_FROM_API
return api::global_api_server->get_noise_ctx();
#else
return this->noise_ctx_;
#endif
}
#endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
@@ -110,30 +97,18 @@ void ESPHomeOTAComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Over-The-Air updates:\n"
" Address: %s:%u\n"
" Version: %d"
#ifdef USE_OTA_ENCRYPTION
"\n Encryption: %s"
#endif
,
network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION
#ifdef USE_OTA_ENCRYPTION_REQUIRED
,
LOG_STR_LITERAL("required")
#elif defined(USE_OTA_ENCRYPTION_PROVISIONED)
// A runtime provisioned key may not exist yet
,
this->noise_context_().has_psk() ? LOG_STR_LITERAL("offered, plaintext accepted")
: LOG_STR_LITERAL("offered once the api key is provisioned")
#elif defined(USE_OTA_ENCRYPTION)
,
LOG_STR_LITERAL("offered, plaintext accepted")
#endif
);
" Version: %d",
network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION);
#ifdef USE_OTA_PASSWORD
if (!this->password_.empty()) {
ESP_LOGCONFIG(TAG, " Password configured");
}
#endif
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ctx_.has_psk()) {
ESP_LOGCONFIG(TAG, " Encryption configured");
}
#endif
#ifdef USE_OTA_PARTITIONS
ESP_LOGCONFIG(TAG,
" Partition access allowed\n"
@@ -179,22 +154,10 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
// Noise needs the extended protocol: the prologue binds the 2-byte feature ack
static constexpr uint8_t CLIENT_NOISE_FEATURES =
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
inline bool ESPHomeOTAComponent::extended_proto_() const {
#ifdef USE_OTA_ENCRYPTION_REQUIRED
// FEATURE_READ already refused every client without the extended protocol
return true;
#else
return (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0;
#endif
}
void ESPHomeOTAComponent::handle_handshake_() {
/// Handle the OTA handshake and authentication.
///
@@ -278,9 +241,12 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->ota_features_ = this->handshake_buf_[0];
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
#ifdef USE_OTA_ENCRYPTION_REQUIRED
// `ota: encryption:` requires the client to negotiate encryption
if ((this->ota_features_ & CLIENT_NOISE_FEATURES) != CLIENT_NOISE_FEATURES) {
#ifdef USE_OTA_ENCRYPTION
// Fail closed: with a PSK configured the client must negotiate encryption
// (which requires the extended protocol); refuse plaintext uploads.
static constexpr uint8_t NOISE_REQUIRED_FEATURES =
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
if (this->noise_ctx_.has_psk() && (this->ota_features_ & NOISE_REQUIRED_FEATURES) != NOISE_REQUIRED_FEATURES) {
ESP_LOGW(TAG, "Client does not support encryption");
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED);
return;
@@ -295,21 +261,18 @@ void ESPHomeOTAComponent::handle_handshake_() {
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
// legacy response.
if (this->extended_proto_()) {
this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0;
if (this->extended_proto_) {
static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack");
this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS;
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
#ifdef USE_OTA_PARTITIONS
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
#endif
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
// A runtime provisioned key may not exist yet
if (this->noise_context_().has_psk()) {
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ctx_.has_psk()) {
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
}
#elif defined(USE_OTA_ENCRYPTION)
// A yaml key always exists: validation rejects the all-zeros key
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
#endif
} else {
this->handshake_buf_[0] =
@@ -321,15 +284,15 @@ void ESPHomeOTAComponent::handle_handshake_() {
case OTAState::FEATURE_ACK: {
static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1;
static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2;
const size_t ack_size = this->extended_proto_() ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE;
const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE;
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
return;
}
#ifdef USE_OTA_ENCRYPTION
// Latch the offer actually sent: a key activating between the two
// states must not start a session the client never expects
if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 &&
(this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) {
// With a PSK configured the rest of the session runs inside the noise
// transport; the client sends the first handshake frame next, so there
// is nothing to do until data arrives.
if (this->noise_ctx_.has_psk()) {
// handshake_buf_ still holds the feature ack composed above; a
// would-block re-entry lands here without rebuilding it
if (!this->noise_start_session_(this->handshake_buf_[1])) {
@@ -449,7 +412,7 @@ void ESPHomeOTAComponent::handle_data_() {
// Acknowledge auth OK - 1 byte
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
if (this->extended_proto_()) {
if (this->extended_proto_) {
// Read ota type, 1 byte
if (!this->data_readall_(buf, 1)) {
this->log_read_error_(LOG_STR("OTA type"));
+3 -10
View File
@@ -44,9 +44,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
}
#endif // USE_OTA_PASSWORD
#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API)
/// psk points at 32 bytes that live in flash for the life of the program
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
#ifdef USE_OTA_ENCRYPTION
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
#endif
/// Manually set the port OTA should listen on
@@ -86,12 +85,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
bool writing{false}; // a produced handshake frame is still being flushed
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
};
// The api server's live context when the api has encryption, else our own
const noise::NoiseContext &noise_context_() const;
bool noise_start_session_(uint8_t server_feature_flags);
bool handle_noise_handshake_();
bool noise_try_read_frame_();
size_t noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len);
bool noise_try_write_frame_();
void noise_send_reject_(const LogString *reason);
ssize_t noise_decrypt_(uint8_t *buf, size_t len);
@@ -148,9 +144,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
std::unique_ptr<uint8_t[]> auth_buf_;
#endif // USE_OTA_PASSWORD
#ifdef USE_OTA_ENCRYPTION
#ifndef USE_OTA_ENCRYPTION_FROM_API
noise::NoiseContext noise_ctx_;
#endif
std::unique_ptr<NoiseSession> noise_;
#endif // USE_OTA_ENCRYPTION
@@ -172,8 +166,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
#endif
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
// Derived from the feature byte; storing it would pad the trailing bytes
bool extended_proto_() const;
#ifdef USE_OTA_PARTITIONS
uint32_t running_app_offset_{0};
size_t running_app_size_{0};
@@ -187,6 +179,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
uint8_t auth_buf_pos_{0};
uint8_t auth_type_{0}; // Store auth type to know which hasher to use
#endif // USE_OTA_PASSWORD
bool extended_proto_{false};
};
} // namespace esphome
@@ -3,7 +3,6 @@
#ifdef USE_OTA_ENCRYPTION
#include "esphome/components/noise/noise.h"
#include "esphome/components/ota/ota_backend.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cstring>
@@ -41,17 +40,24 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
* "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags
*/
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
// A provisioned key cleared between the offer and here is not guarded: the
// session runs on the zero key load_psk fills in and fails the client's MAC.
// Default-init: the frame buffer is written before it is read
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession());
if (this->noise_ == nullptr) {
ESP_LOGW(TAG, "Session allocation failed");
this->cleanup_connection_();
return false;
}
static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version
static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1;
static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags
uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + sizeof(MAGIC_BYTES) + PROLOGUE_ACK_LEN + PROLOGUE_CLIENT_FEATURES_LEN +
PROLOGUE_FEATURE_ACK_LEN];
progmem_memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
#ifdef USE_ESP8266
memcpy_P(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
#else
std::memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
#endif
uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN;
// Magic bytes, already validated in MAGIC_READ
std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES));
@@ -65,13 +71,9 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
*p++ = server_feature_flags;
// The caller only starts a session when the context holds a key
int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY
: this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue));
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue));
if (err != 0) {
// Raw noise codes throughout: the name table would cost flash in builds
// where only the OTA uses noise
ESP_LOGW(TAG, "Session init: %d", err);
ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
@@ -103,16 +105,14 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
s.frame_pos = 0;
s.frame_len = 0;
if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) {
ESP_LOGW(TAG, "Client rejected the handshake: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
this->cleanup_connection_();
return false;
}
int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1);
if (err != 0) {
// A MAC failure here almost always means the uploader has a different key
const LogString *reason = noise::reject_reason_for(err);
ESP_LOGW(TAG, "Handshake read: %s (%d)", LOG_STR_ARG(reason), err);
this->noise_send_reject_(reason);
ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->noise_send_reject_(noise::reject_reason_for(err));
this->cleanup_connection_();
return false;
}
@@ -123,7 +123,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
int err =
s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len);
if (err != 0) {
ESP_LOGW(TAG, "Handshake write: %d", err);
ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
@@ -138,7 +138,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: {
int err = s.handshake.split(s.send_cipher, s.recv_cipher);
if (err != 0) {
ESP_LOGW(TAG, "Handshake split: %d", err);
ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
@@ -154,41 +154,33 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() {
}
}
/// Payload length from a frame header, or 0 (logged) when the indicator or
/// the length is out of range. Callers pass min_len >= 1 so 0 is never valid.
size_t ESPHomeOTAComponent::noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len) {
const size_t payload_len = encode_uint16(header[1], header[2]);
if (header[0] != noise::FRAME_INDICATOR || payload_len < min_len || payload_len > max_len) {
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], payload_len);
return 0;
}
return payload_len;
}
/// Non-blocking read of one handshake frame into the session buffer.
bool ESPHomeOTAComponent::noise_try_read_frame_() {
NoiseSession &s = *this->noise_;
while (true) {
// The header first, then the body once the header says how long it is
const uint16_t want = s.frame_len == 0 ? noise::FRAME_HEADER_SIZE : s.frame_len;
if (s.frame_pos < want) {
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, want - s.frame_pos);
if (!this->handle_read_error_(read, LOG_STR("read noise"))) {
return false;
}
s.frame_pos += read;
continue;
while (s.frame_pos < noise::FRAME_HEADER_SIZE) {
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos);
if (!this->handle_read_error_(read, LOG_STR("read noise header"))) {
return false;
}
if (s.frame_len != 0) {
return true;
}
const size_t payload_len = this->noise_frame_payload_len_(s.frame_buf, 1, 1 + noise::MAX_HANDSHAKE_SIZE);
if (payload_len == 0) {
s.frame_pos += read;
}
if (s.frame_len == 0) {
const uint16_t payload_len = encode_uint16(s.frame_buf[1], s.frame_buf[2]);
if (s.frame_buf[0] != noise::FRAME_INDICATOR || payload_len < 1 || payload_len > 1 + noise::MAX_HANDSHAKE_SIZE) {
ESP_LOGW(TAG, "Bad handshake frame: 0x%02X, %u bytes", s.frame_buf[0], payload_len);
this->cleanup_connection_();
return false;
}
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
}
while (s.frame_pos < s.frame_len) {
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) {
return false;
}
s.frame_pos += read;
}
return true;
}
/// Non-blocking write of the pending session-buffer frame.
@@ -222,7 +214,7 @@ ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) {
noise_buffer_set_inout(mbuf, buf, len, len);
int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Decrypt: %d", err);
ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
return -1;
}
return mbuf.size;
@@ -237,8 +229,9 @@ ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min
if (!this->readall_(header, sizeof(header))) {
return -1;
}
const size_t ciphertext_len = this->noise_frame_payload_len_(header, min_ciphertext, max_ciphertext);
if (ciphertext_len == 0) {
const size_t ciphertext_len = encode_uint16(header[1], header[2]);
if (header[0] != noise::FRAME_INDICATOR || ciphertext_len < min_ciphertext || ciphertext_len > max_ciphertext) {
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], ciphertext_len);
return -1;
}
if (!this->readall_(buf, ciphertext_len)) {
@@ -274,7 +267,7 @@ bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) {
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Encrypt: %d", err);
ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
return false;
}
noise::write_frame_header(frame, mbuf.size);
-20
View File
@@ -3,7 +3,6 @@ from typing import Any
from esphome import automation, core
import esphome.codegen as cg
from esphome.components import wifi
from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant
from esphome.components.udp import CONF_ON_RECEIVE
import esphome.config_validation as cv
from esphome.const import (
@@ -18,7 +17,6 @@ from esphome.const import (
)
from esphome.core import CORE, HexInt
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
@@ -134,24 +132,6 @@ CONFIG_SCHEMA = cv.All(
)
def _validate_variant(config: ConfigType) -> ConfigType:
# ESP-NOW rides the Wi-Fi PHY. Radio-less esp32 variants have no native
# ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that
# supplies the esp_now_* symbols. Fail here with a clear message instead of
# letting the build reach an "undefined reference to esp_now_*" link error.
variant = get_esp32_variant()
if wifi.variant_has_wifi(variant):
return config
if variant != VARIANT_ESP32P4:
raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)")
if "esp32_hosted" not in fv.full_config.get():
raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component")
return config
FINAL_VALIDATE_SCHEMA = _validate_variant
async def _trigger_to_code(config: ConfigType) -> MockObj:
if address := config.get(CONF_ADDRESS):
address = address.parts
@@ -91,14 +91,7 @@ void I2SAudioSpeakerBase::loop() {
this->speaker_task_handle_ = nullptr;
this->stop_i2s_driver_();
// ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at
// the top of loop(): the audio source's task can raise a start at any point above, including
// during stop_i2s_driver_(), and nothing would ever re-issue it.
const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) {
ESP_LOGD(TAG, "Start requested while stopping; keeping the request");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
}
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
this->status_clear_error();
this->on_task_stopped();
+2 -4
View File
@@ -2,7 +2,6 @@ import json
import logging
from pathlib import Path
from esphome.build_helpers.pch import pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -27,7 +26,7 @@ from esphome.const import (
from esphome.core import CORE
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from . import gpio # noqa: F401
@@ -514,7 +513,7 @@ async def component_to_code(config):
# it for project source files only. GCC uses the last -O flag.
build_src_flags += " -Os"
cg.add_platformio_option("build_src_flags", build_src_flags)
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
# On other families, patch_linker.py routes .sram.text into the right
# RAM-executable output section and prints a post-link placement summary.
@@ -620,4 +619,3 @@ def copy_files() -> None:
CORE.relative_build_path("patch_linker.py"),
)
copy_ccache_script()
copy_pch_script()
@@ -5,7 +5,6 @@
#include <esp_log.h>
#include <driver/uart.h>
#include <soc/soc_caps.h>
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
#include <driver/usb_serial_jtag.h>
@@ -77,11 +76,7 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) {
uart_config.parity = UART_PARITY_DISABLE;
uart_config.stop_bits = UART_STOP_BITS_1;
uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE;
#if SOC_UART_SUPPORT_XTAL_CLK
uart_config.source_clk = UART_SCLK_XTAL;
#else
uart_config.source_clk = UART_SCLK_DEFAULT;
#endif
uart_param_config(uart_num, &uart_config);
// The logger only writes to UART, never reads, so use the minimum RX buffer.
// ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes).
+1 -2
View File
@@ -15,7 +15,6 @@ from ..defines import (
from ..types import LvCompound, LvType
from . import Widget, WidgetType, get_widgets
from .buttonmatrix import CONF_BUTTONMATRIX
from .label import CONF_LABEL
from .textarea import CONF_TEXTAREA, lv_textarea_t
CONF_KEYBOARD = "keyboard"
@@ -50,7 +49,7 @@ class KeyboardType(WidgetType):
)
def get_uses(self):
return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL
return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX
async def to_code(self, w: Widget, config: dict):
add_lv_use("KEY_LISTENER")
+1 -2
View File
@@ -10,7 +10,6 @@ from ..types import lv_obj_t
from . import Widget, WidgetType
from .canvas import CONF_CANVAS
from .img import CONF_IMAGE
from .label import CONF_LABEL
CONF_QRCODE = "qrcode"
CONF_DARK_COLOR = "dark_color"
@@ -42,7 +41,7 @@ class QrCodeType(WidgetType):
)
def get_uses(self):
return CONF_CANVAS, CONF_IMAGE, CONF_LABEL
return CONF_CANVAS, CONF_IMAGE
async def to_code(self, w: Widget, config):
await w.set_property(
+1 -2
View File
@@ -28,7 +28,6 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr
from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties
from .button import button_spec
from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec
from .label import CONF_LABEL
from .obj import obj_spec
CONF_TABVIEW = "tabview"
@@ -75,7 +74,7 @@ class TabviewType(WidgetType):
)
def get_uses(self):
return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON, CONF_LABEL
return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON
async def to_code(self, w: Widget, config: dict):
await w.set_property(
-2
View File
@@ -192,8 +192,6 @@ async def to_code(config: ConfigType) -> None:
if CORE.using_arduino:
if CORE.is_esp8266:
cg.add_library("ESP8266mDNS", None)
# No MDNS global in the build; mdns_esp8266.cpp owns a guarded MDNSResponder
cg.add_build_flag("-DNO_GLOBAL_MDNS")
elif CORE.is_rp2:
cg.add_library("LEAmDNS", None)
+6 -45
View File
@@ -13,47 +13,8 @@
namespace esphome::mdns {
// Main-loop calls into LEAmDNS that send (update() and close(); begin(), addService() and
// the scheduled restart never reach a send) can yield inside UdpContext::sendTimeout(); a
// packet arriving then re-enters LEAmDNS from lwIP on the same UdpContext and both sides
// free the same tx pbufs (#18760). Received packets stay queued during such a call and are
// processed from the main loop afterwards.
class GuardedMDNSResponder : public ::esp8266::MDNSImplementation::MDNSResponder {
public:
void update_guarded() { this->run_guarded_(&GuardedMDNSResponder::update); }
void close_guarded() { this->run_guarded_(&GuardedMDNSResponder::close); }
private:
void run_guarded_(bool (GuardedMDNSResponder::*fn)()) {
UdpContext *ctx = this->m_pUDPContext;
if (ctx == nullptr) {
(this->*fn)();
return;
}
// Set every time: a restart replaces the context together with its stock handler. Only
// begin() and the scheduled netif callback restart, never update() or close(), so the
// context cannot change underneath this call.
ctx->onRx([this]() {
if (!this->in_loop_call_) {
this->_callProcess();
}
});
this->in_loop_call_ = true;
(this->*fn)();
// close() releases the context; a yield in here queues further packets for this loop too
while (this->m_pUDPContext != nullptr && this->m_pUDPContext->next()) {
this->_parseMessage();
}
this->in_loop_call_ = false;
}
volatile bool in_loop_call_{false};
};
static GuardedMDNSResponder mdns_responder; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services) {
mdns_responder.begin(App.get_name().c_str());
MDNS.begin(App.get_name().c_str());
for (const auto &service : services) {
// Strip the leading underscore from the proto and service_type. While it is
@@ -69,10 +30,10 @@ static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SER
service_type++;
}
uint16_t port = service.port.value();
mdns_responder.addService(FPSTR(service_type), FPSTR(proto), port);
MDNS.addService(FPSTR(service_type), FPSTR(proto), port);
for (const auto &record : service.txt_records) {
mdns_responder.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)),
FPSTR(MDNS_STR_ARG(record.value)));
MDNS.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)),
FPSTR(MDNS_STR_ARG(record.value)));
}
}
}
@@ -91,7 +52,7 @@ void MDNSComponent::start_polling_window_() {
if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active()))
return;
#endif
mdns_responder.update_guarded();
MDNS.update();
});
this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); });
}
@@ -120,7 +81,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::
#endif
void MDNSComponent::on_shutdown() {
mdns_responder.close_guarded();
MDNS.close();
delay(10);
}
+2 -2
View File
@@ -35,8 +35,8 @@ void MipiDsi::setup() {
.bus_id = 0, // index from 0, specify the DSI host to use
.num_data_lanes =
this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability
// phy_clk_src left at 0 to enable runtime auto-select.
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
};
auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_);
if (err != ESP_OK) {
-3
View File
@@ -67,9 +67,6 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery
if (traits.supports_color_mode(ColorMode::RGB_COLD_WARM_WHITE))
color_modes.add(ESPHOME_F("rgbww"));
if (traits.supports_color_capability(ColorCapability::BRIGHTNESS))
root[ESPHOME_F("brightness")] = true;
if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) ||
traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) {
root[MQTT_MIN_MIREDS] = traits.get_min_mireds();
+6 -1
View File
@@ -14,7 +14,12 @@ from esphome.const import (
)
from esphome.core import CORE, TimePeriod
from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import
from . import ( # noqa: F401 pylint: disable=unused-import
FILTER_SOURCE_FILES,
Nextion,
nextion_ns,
nextion_ref,
)
from .base_component import (
CONF_AUTO_WAKE_ON_TOUCH,
CONF_COMMAND_SPACING,
+12 -28
View File
@@ -4,9 +4,7 @@ from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ENCRYPTION, CONF_KEY
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.const import CONF_KEY
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
@@ -25,14 +23,6 @@ def validate_encryption_key(value: Any) -> str:
if len(decoded) != 32:
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
if not any(decoded):
# The device treats the all-zeros key as no key at all (it is the
# provisioning sentinel), so it must never reach a build
raise cv.Invalid(
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
f"omit the {CONF_KEY} to provision it at runtime, or generate a real "
"key with: openssl rand -base64 32"
)
# Return original data for roundtrip conversion
return value
@@ -55,6 +45,15 @@ def decode_encryption_key(value: str) -> bytes:
return decoded
def is_reserved_key(value: str) -> bool:
"""Whether the key is the reserved all-zeros provisioning sentinel.
The device treats it as no key configured, so consumers that require a
real key must reject it.
"""
return not any(decode_encryption_key(value))
ENCRYPTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
@@ -62,21 +61,6 @@ ENCRYPTION_SCHEMA = cv.Schema(
)
def static_encryption_key(conf: ConfigType) -> str | None:
"""The build time key of a component config; None without one or when
the key is provisioned at runtime."""
return (conf.get(CONF_ENCRYPTION) or {}).get(CONF_KEY) or None
def new_psk_progmem(parent_id: ID, key: str) -> MockObj:
"""Emit the decoded key as a PROGMEM array; the component keeps a pointer
so the key never occupies RAM."""
return cg.progmem_array(
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
list(decode_encryption_key(key)),
)
def encryption_schema(config: ConfigType | None) -> ConfigType:
# A bare `encryption:` block is valid; a missing key means the consumer
# falls back to its keyless behavior (api provisioning, ota inheriting
@@ -88,12 +72,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.24")
cg.add_library("esphome/noise-c", "0.1.21")
# noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json.
cg.add_library("esphome/libsodium", "1.10021.6")
cg.add_library("esphome/libsodium", "1.10021.4")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
-9
View File
@@ -1,6 +1,5 @@
#include "noise.h"
#ifdef USE_NOISE
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
@@ -16,14 +15,6 @@ namespace esphome::noise {
static const char *const TAG = "noise";
void NoiseContext::load_psk(psk_t &out) const {
if (this->psk_ == nullptr) {
out.fill(0);
return;
}
progmem_memcpy(out.data(), this->psk_, out.size());
}
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
+8 -8
View File
@@ -23,16 +23,16 @@ class NoiseContext {
}
return acc == 0;
}
/// psk points at 32 bytes that outlive the context (PROGMEM or caller owned
/// RAM); nullptr means no key. Runtime callers map the all-zeros key to
/// nullptr themselves; validation keeps it out of yaml.
void set_psk(const uint8_t *psk) { this->psk_ = psk; }
/// Copy the key out (flash-aware on ESP8266); all zeros when none is set.
void load_psk(psk_t &out) const;
bool has_psk() const { return this->psk_ != nullptr; }
void set_psk(psk_t psk) {
this->psk_ = psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
protected:
const uint8_t *psk_{nullptr};
psk_t psk_{};
bool has_psk_{false};
};
/// Convert a noise error code to a readable error
+1 -4
View File
@@ -20,7 +20,7 @@ NoiseResponderHandshake::~NoiseResponderHandshake() {
}
}
int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len) {
int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
if (this->handshake_ != nullptr) {
noise_handshakestate_free(this->handshake_);
this->handshake_ = nullptr;
@@ -44,9 +44,6 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err);
return err;
}
// noise-c keeps its own copy, so the key only passes through the stack here
psk_t psk;
ctx.load_psk(psk);
err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size());
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_set_pre_shared_key", err);
+3 -3
View File
@@ -36,9 +36,9 @@ class NoiseResponderHandshake {
NoiseResponderHandshake(const NoiseResponderHandshake &) = delete;
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
/// Create and start the handshake with the context's PSK and the prologue.
/// A repeated call frees the previous handshake state and starts over.
[[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len);
/// Create and start the handshake with the given PSK and prologue. A
/// repeated call frees the previous handshake state and starts over.
[[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len);
/// ACTION_FAILED is the catch-all: returned before init(), after split()
/// has released the state, and when noise-c reports a failed handshake.
[[nodiscard]] Action action() const;
+21 -88
View File
@@ -18,16 +18,6 @@ void RFBridgeComponent::ack_() {
}
bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
if (this->bucket_frame_candidate_ && byte == RF_CODE_START) {
// A queued next frame proves the trailing 0x55 really was the bucket
// frame's terminator: Portisch builds pulse entries from alternating
// signal edges, so the two level bits inside one pulse byte are always
// opposite — 0xAA (two high-level nibbles) cannot occur in pulse data.
// Finalize before this byte starts the new frame, so back-to-back
// deliveries are split even when loop() never observed a quiet gap
// between them.
this->finish_bucket_frame_();
}
size_t at = this->rx_buffer_.size();
this->rx_buffer_.push_back(byte);
const uint8_t *raw = &this->rx_buffer_[0];
@@ -94,21 +84,26 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
break;
}
case RF_CODE_RFIN_BUCKET: {
if (at == 2) {
// The count byte: Portisch sends at most 7 buckets + sync, so 0 or
// >8 cannot be a genuine capture — reject before it can occupy the
// buffer for a full frame timeout.
return byte != 0 && byte <= B1_MAX_BUCKET_COUNT;
if (byte != RF_CODE_STOP) {
return true;
}
// 0x55 is legal DATA inside a B1 frame: bucket durations are sent
// with only their HIGH byte masked to 7 bits, so a duration such as
// 0x0155 puts a raw 0x55 low byte inside the table — the first 0x55
// must therefore not end the capture. The header declares the table
// length (raw[2] pairs), so a 0x55 there is always data; one at or
// past the first pulse index is a terminator CANDIDATE, confirmed
// once the UART goes quiet (finish_bucket_frame_ in loop()).
this->bucket_frame_candidate_ = byte == RF_CODE_STOP && at >= 3 + static_cast<size_t>(raw[2]) * 2;
return true;
uint8_t buckets = raw[2] << 1;
std::string str;
char next_byte[3]; // 2 hex chars + null
for (uint32_t i = 0; i <= at; i++) {
buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]);
str += next_byte;
if ((i > 3) && buckets) {
buckets--;
}
if ((i < 3) || (buckets % 2) || (i == at - 1)) {
str += " ";
}
}
ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str());
break;
}
default:
ESP_LOGW(TAG, "Unknown action: 0x%02X", action);
@@ -124,47 +119,6 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
return false;
}
void RFBridgeComponent::finish_bucket_frame_() {
if (this->rx_buffer_.size() < 4) {
// The candidate flag requires a header + non-empty bucket table, so
// this cannot happen while flag and buffer stay consistent; guard the
// raw[2] / size-1 reads against any future divergence anyway.
this->rx_buffer_.clear();
this->bucket_frame_candidate_ = false;
return;
}
const uint8_t *raw = this->rx_buffer_.data();
const size_t at = this->rx_buffer_.size() - 1;
uint8_t buckets = raw[2] << 1;
std::string str;
char next_byte[3]; // 2 hex chars + null
for (uint32_t i = 0; i <= at; i++) {
buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]);
str += next_byte;
if ((i > 3) && buckets) {
buckets--;
}
if ((i < 3) || (buckets % 2) || (i == at - 1)) {
str += " ";
}
}
ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str());
// Deliberately NOT ACKed: Portisch's B1 command handler leaves its
// last_sniffing_command at the previous mode (RF_CODE_RFIN), and its
// host-ACK handler re-arms sniffing from that stale value — so ACKing a
// bucket delivery silently reverts the radio to standard sniffing and
// ends bucket capture. Its delivery path is fire-and-forget and never
// waits for a host ACK. Stock Itead firmware never sends B1 frames, so
// suppressing this ACK cannot change stock-firmware behavior.
// https://github.com/esphome/esphome/issues/17682
this->rx_buffer_.clear();
this->bucket_frame_candidate_ = false;
}
void RFBridgeComponent::write_byte_str_(const std::string &codes) {
uint8_t code;
int size = codes.length();
@@ -176,31 +130,12 @@ void RFBridgeComponent::write_byte_str_(const std::string &codes) {
void RFBridgeComponent::loop() {
const uint32_t now = App.get_loop_component_start_time();
size_t avail = this->available();
if (avail == 0 && this->bucket_frame_candidate_ && now - this->last_bridge_byte_ > BUCKET_CANDIDATE_QUIET_MS) {
// The trailing 0x55 was followed by UART quiet, so it really was the
// frame terminator and not an interior data byte.
this->finish_bucket_frame_();
this->last_bridge_byte_ = now;
}
const bool receiving_bucket = this->rx_buffer_.size() >= 2 && this->rx_buffer_[1] == RF_CODE_RFIN_BUCKET;
if (receiving_bucket) {
// Never declare an in-progress bucket frame dead while its continuation
// bytes are already queued: a stalled loop() otherwise discards a live
// frame that the UART buffer proves is still arriving.
if (avail == 0 && now - this->last_bridge_byte_ > BUCKET_FRAME_TIMEOUT_MS) {
ESP_LOGD(TAG, "Discarding incomplete RFBridge Bucket frame (%u bytes)",
static_cast<unsigned>(this->rx_buffer_.size()));
this->rx_buffer_.clear();
this->bucket_frame_candidate_ = false;
this->last_bridge_byte_ = now;
}
} else if (now - this->last_bridge_byte_ > 50) {
if (now - this->last_bridge_byte_ > 50) {
this->rx_buffer_.clear();
this->bucket_frame_candidate_ = false;
this->last_bridge_byte_ = now;
}
size_t avail = this->available();
while (avail > 0) {
uint8_t buf[64];
size_t to_read = std::min(avail, sizeof(buf));
@@ -211,14 +146,12 @@ void RFBridgeComponent::loop() {
for (size_t i = 0; i < to_read; i++) {
if (this->rx_buffer_.size() > MAX_RX_BUFFER_SIZE) {
this->rx_buffer_.clear();
this->bucket_frame_candidate_ = false;
}
if (this->parse_bridge_byte_(buf[i])) {
ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]);
this->last_bridge_byte_ = now;
} else {
this->rx_buffer_.clear();
this->bucket_frame_candidate_ = false;
}
}
}
-13
View File
@@ -30,17 +30,6 @@ static const uint8_t RF_CODE_BEEP = 0xC0;
static const uint8_t RF_CODE_STOP = 0x55;
static const uint8_t RF_DEBOUNCE = 200;
static const size_t MAX_RX_BUFFER_SIZE = 512;
// ~10 byte times at 19200 baud: long enough to prove the UART went quiet
// after a possible bucket-frame terminator, short enough to finish well
// before the next radio capture can be delivered.
static const uint32_t BUCKET_CANDIDATE_QUIET_MS = 5;
// Portisch drains a B1 frame's header, bucket table, and pulse data as
// separate UART writes, so an in-progress bucket frame tolerates a longer
// inter-region gap than the generic 50 ms inter-byte timeout.
static const uint32_t BUCKET_FRAME_TIMEOUT_MS = 250;
// Portisch's uart_put_RF_buckets sends at most 7 buckets plus the sync
// bucket, so a B1 count byte above 8 (or 0) is malformed for any protocol.
static const uint8_t B1_MAX_BUCKET_COUNT = 8;
struct RFBridgeData {
uint16_t sync;
@@ -78,12 +67,10 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component {
void ack_();
void decode_();
bool parse_bridge_byte_(uint8_t byte);
void finish_bucket_frame_();
void write_byte_str_(const std::string &codes);
std::vector<uint8_t> rx_buffer_;
uint32_t last_bridge_byte_{0};
bool bucket_frame_candidate_{false};
CallbackManager<void(RFBridgeData)> data_callback_;
CallbackManager<void(RFBridgeAdvancedData)> advanced_data_callback_;
+2 -7
View File
@@ -6,7 +6,6 @@ from string import ascii_letters, digits
import subprocess
from typing import Any
from esphome.build_helpers.pch import pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -34,7 +33,7 @@ from esphome.core import (
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
@@ -341,10 +340,7 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
cg.add_define(ThreadModel.SINGLE)
cg.add_platformio_option(
"extra_scripts",
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
)
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino")
@@ -648,7 +644,6 @@ def copy_files() -> None:
CORE.relative_build_path("inject_lwip_include.py"),
)
copy_ccache_script()
copy_pch_script()
_generate_lwipopts_h()
if generate_pio_files():
path = CORE.relative_src_path("esphome.h")
+34 -3
View File
@@ -18,9 +18,10 @@ from esphome import pins
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME
from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@kbx81"]
@@ -30,14 +31,18 @@ MULTI_CONF = True
serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy")
SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice)
SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap")
api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums")
SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType")
# User-selectable electrical types. USB_SERIAL is deliberately absent: it is derived
# from the uart_id pointing at a usb_uart channel, never set by the user.
SERIAL_PROXY_PORT_TYPES = {
"TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL,
"RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232,
"RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485,
}
PORT_TYPE_USB_SERIAL = SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL
CONF_DTR_PIN = "dtr_pin"
CONF_PORT_TYPE = "port_type"
@@ -62,7 +67,7 @@ CONFIG_SCHEMA = (
{
cv.GenerateID(): cv.declare_id(SerialProxy),
cv.Required(CONF_NAME): cv.string_strict,
cv.Required(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True),
cv.Optional(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True),
cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema,
}
@@ -72,6 +77,26 @@ CONFIG_SCHEMA = (
)
def _uses_usb_uart(config: ConfigType, full_config: ConfigType) -> bool:
from esphome.components.usb_uart import is_usb_uart_channel
return is_usb_uart_channel(config[CONF_UART_ID], full_config)
def _final_validate(config: ConfigType) -> ConfigType:
if _uses_usb_uart(config, fv.full_config.get()):
if CONF_PORT_TYPE in config:
raise cv.Invalid(
f"{CONF_PORT_TYPE} is set automatically for USB serial ports"
)
elif CONF_PORT_TYPE not in config:
raise cv.Invalid(f"{CONF_PORT_TYPE} is required")
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_serial_proxy_count_define() -> None:
"""Emit the SERIAL_PROXY_COUNT define once with the final instance count."""
@@ -86,7 +111,13 @@ async def to_code(config: ConfigType) -> None:
await uart.register_uart_device(var, config)
cg.add(cg.App.register_serial_proxy(var))
cg.add(var.set_name(config[CONF_NAME]))
cg.add(var.set_port_type(config[CONF_PORT_TYPE]))
if _uses_usb_uart(config, CORE.config):
cg.add(var.set_port_type(PORT_TYPE_USB_SERIAL))
channel = await cg.get_variable(config[CONF_UART_ID])
cg.add(var.set_usb_channel(channel))
cg.add_define("USE_SERIAL_PROXY_USB_INFO")
else:
cg.add(var.set_port_type(config[CONF_PORT_TYPE]))
cg.add_define("USE_SERIAL_PROXY")
# Track instance count for the FINAL priority define
+189 -25
View File
@@ -12,6 +12,10 @@
#include "esphome/components/api/api_server.h"
#endif
#ifdef USE_SERIAL_PROXY_USB_INFO
#include "esphome/components/usb_uart/usb_uart.h"
#endif
namespace esphome::serial_proxy {
static const char *const TAG = "serial_proxy";
@@ -29,26 +33,57 @@ void SerialProxy::setup() {
#ifdef USE_API
// instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data
this->outgoing_msg_.instance = this->instance_index_;
#endif
#ifdef USE_SERIAL_PROXY_TAP
// A tap sets itself up before this runs (its setup priority is higher), so it may
// already be waiting on the port -- a boot-time handshake with the device, say. Leaving
// the loop enabled is what lets that finish; without it the tap would stall until a
// client happened to subscribe.
if (this->tap_ != nullptr && this->tap_->tap_needs_port()) {
return;
}
#endif
// No subscriber at startup; disable loop until a client subscribes
this->disable_loop();
}
void SerialProxy::loop() {
#ifdef USE_API
// Safety check — loop should only run when subscribed, but guard against races
if (this->api_connection_ == nullptr) [[unlikely]] {
this->disable_loop();
#ifdef USE_SERIAL_PROXY_TAP
void SerialProxy::reset_mode_() {
// The mode belongs to a session, not to the port. Carrying a departed client's choice
// over to the next one would inject protocol bytes into a stream that never asked for
// them -- a firmware upload, or any client built before this request existed and so
// unable to turn it off. Guessing RAW is the safe direction: a client that wanted
// protocol handling and did not ask for it merely sends its own acknowledgements.
if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) {
return;
}
ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_);
this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW;
}
#endif
void SerialProxy::loop() {
#ifdef USE_API
// Detect subscriber disconnect
if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() ||
!api_is_connected()) {
if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() ||
!this->api_connection_->is_connection_setup() || !api_is_connected())) {
ESP_LOGW(TAG, "Subscriber disconnected");
this->api_connection_ = nullptr;
this->reset_mode_();
}
// With no subscriber there is normally nothing to do, but a tap may still need the port
// read -- it does its protocol work precisely while nobody else is listening.
if (this->api_connection_ == nullptr) [[unlikely]] {
#ifdef USE_SERIAL_PROXY_TAP
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
this->disable_loop();
return;
}
#else
this->disable_loop();
return;
#endif
}
// Read available data from UART and forward to subscribed client
@@ -69,11 +104,54 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) {
if (!this->read_array(buffer, to_read))
return;
#ifdef USE_SERIAL_PROXY_TAP
// Before forwarding, so a tap that answers the device (an acknowledgement, say) is not
// waiting on the network round trip to a subscriber that may not even exist.
if (this->tap_observing_()) {
this->tap_->on_device_rx(buffer, to_read);
}
#endif
if (this->api_connection_ == nullptr) {
return;
}
this->outgoing_msg_.set_data(buffer, to_read);
this->api_connection_->send_serial_proxy_data(this->outgoing_msg_);
}
#endif
#ifdef USE_SERIAL_PROXY_TAP
bool SerialProxy::tap_observing_() const {
if (this->tap_ == nullptr) {
return false;
}
// With no subscriber, a tap doing its own protocol work (the boot-time handshake with
// the device, say) is served regardless of mode -- nobody has chosen one yet. Once a
// subscriber holds the port, the mode alone decides, so RAW stays inert.
if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) {
return true;
}
// Otherwise the mode decides. RAW must be inert: a client that flips to RAW before
// flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes
// into it, and "the tap turned out not to recognise the stream" is not good enough.
return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL;
}
void SerialProxy::tap_pump() {
#ifdef USE_API
// Nothing would consume the bytes; leave them in the FIFO
if (!this->tap_observing_() && this->api_connection_ == nullptr) {
return;
}
const size_t available = this->available();
if (available > 0) {
this->read_and_send_(available);
}
#endif
}
#endif
void SerialProxy::dump_config() {
ESP_LOGCONFIG(TAG,
"Serial Proxy [%" PRIu32 "]:\n"
@@ -82,9 +160,10 @@ void SerialProxy::dump_config() {
" RTS Pin: %s\n"
" DTR Pin: %s",
this->instance_index_, this->name_ != nullptr ? this->name_ : "",
this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485")
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232")
: LOG_STR_LITERAL("TTL"),
this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485")
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232")
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL ? LOG_STR_LITERAL("USB_SERIAL")
: LOG_STR_LITERAL("TTL"),
this->rts_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"),
this->dtr_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"));
}
@@ -92,8 +171,9 @@ void SerialProxy::dump_config() {
SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control,
uint8_t parity, uint8_t stop_bits, uint8_t data_size) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_);
if (!this->is_subscriber_(api_connection)) {
ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]",
this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
@@ -159,24 +239,80 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection,
api::enums::SerialProxyMode mode) {
#ifdef USE_API
// Only the live subscriber may change the mode, so the mode cannot outlive a session
if (!this->is_subscriber_(api_connection)) {
ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
// Values come from a remote client
if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) {
ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast<uint32_t>(mode));
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
// PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows
#ifdef USE_SERIAL_PROXY_TAP
const bool has_tap = this->tap_ != nullptr;
#else
const bool has_tap = false;
#endif
if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) {
ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_,
mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW"));
#ifdef USE_SERIAL_PROXY_TAP
const bool leaving_protocol_mode =
this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW;
this->mode_ = mode;
// Only for an explicit client request, not for reset_mode_() at the end of a session:
// an ordinary disconnect says nothing about the device, whereas a client deliberately
// asking for raw bytes usually precedes changing what the device is.
if (leaving_protocol_mode && this->tap_ != nullptr) {
this->tap_->on_protocol_disabled();
}
#endif
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {
#ifdef USE_API
// Bytes from a client other than the live subscriber would interleave with the
// subscriber's traffic on the wire
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_);
// Bytes from anyone but the live subscriber would interleave with the subscriber's
// traffic -- or with an active tap's -- on the wire
if (!this->is_subscriber_(api_connection)) {
if (this->api_connection_ != nullptr) {
ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_);
} else {
// A legacy client streaming writes without subscribing would flood WARN, one per
// request; writes are the only high-rate, unacknowledged operation, so keep this
// visible without drowning the log
ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_);
}
return;
}
#endif
if (data == nullptr || len == 0)
return;
this->write_array(data, len);
#ifdef USE_SERIAL_PROXY_TAP
// After the write, so the tap observes the same ordering the device does
if (this->tap_observing_()) {
this->tap_->on_client_tx(data, len);
}
#endif
}
SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_);
if (!this->is_subscriber_(api_connection)) {
ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]",
this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
@@ -202,6 +338,27 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
#if defined(USE_SERIAL_PROXY_USB_INFO) && defined(USE_API)
void SerialProxy::get_usb_info(usb_host::UsbDeviceInfo &info, api::SerialProxyGetUsbInfoResponse &resp) const {
if (this->usb_channel_ == nullptr) {
resp.status = api::enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
return;
}
resp.interface_number = this->usb_channel_->get_index();
if (!this->usb_channel_->get_parent()->get_device_info(info)) {
// No device attached right now; not an error
return;
}
resp.connected = true;
resp.vendor_id = info.vendor_id;
resp.product_id = info.product_id;
resp.bcd_device = info.bcd_device;
resp.manufacturer = StringRef(info.manufacturer);
resp.product = StringRef(info.product);
resp.serial_number = StringRef(info.serial_number);
}
#endif
uint32_t SerialProxy::get_modem_pins() const {
return (this->rts_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) |
(this->dtr_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
@@ -210,8 +367,8 @@ uint32_t SerialProxy::get_modem_pins() const {
SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
#ifdef USE_API
// Flushing stalls the port, so it gets the same ownership check as writes
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_);
if (!this->is_subscriber_(api_connection)) {
ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
@@ -230,11 +387,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
}
#ifdef USE_API
bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const {
return this->api_connection_ != nullptr && this->api_connection_ != api_connection &&
this->api_connection_->is_connection_setup();
}
SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection,
api::enums::SerialProxyRequestType type) {
switch (type) {
@@ -252,6 +404,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
// End the dead client's session before starting the new one, so its mode
// cannot leak into a session that never asked for it
this->api_connection_ = nullptr;
this->reset_mode_();
}
this->api_connection_ = api_connection;
this->enable_loop();
@@ -264,7 +420,15 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
this->api_connection_ = nullptr;
this->reset_mode_();
#ifdef USE_SERIAL_PROXY_TAP
// Keep the loop alive for a tap that still needs the port (mirrors loop())
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
this->disable_loop();
}
#else
this->disable_loop();
#endif
ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
default:
+125 -3
View File
@@ -20,12 +20,22 @@
#include "esphome/components/api/api_pb2.h"
#endif
#ifdef USE_SERIAL_PROXY_USB_INFO
namespace esphome::usb_uart {
class USBUartChannel;
} // namespace esphome::usb_uart
namespace esphome::usb_host {
struct UsbDeviceInfo;
} // namespace esphome::usb_host
#endif
// Forward-declare types needed outside the USE_API guard.
namespace esphome::api {
class APIConnection;
namespace enums {
enum SerialProxyPortType : uint32_t;
enum SerialProxyRequestType : uint32_t;
enum SerialProxyMode : uint32_t;
} // namespace enums
} // namespace esphome::api
@@ -52,6 +62,36 @@ enum class SerialProxyResult : uint8_t {
/// Maximum bytes to read from UART in a single loop iteration
inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256;
#ifdef USE_SERIAL_PROXY_TAP
/// Observes a port's traffic without owning it, and may inject bytes of its own.
///
/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without
/// the pipe knowing anything about the protocol: the tap is compiled in only when some
/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it.
///
/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes
/// flowing in either direction, so a misbehaving tap cannot corrupt the stream.
class SerialProxyTap {
public:
/// Bytes read from the device, before they are forwarded to any subscriber.
virtual void on_device_rx(const uint8_t *data, size_t len) = 0;
/// Bytes a subscriber sent towards the device, after they have been written.
virtual void on_client_tx(const uint8_t *data, size_t len) = 0;
/// True when the port must keep reading even with no subscriber attached, so a tap can
/// do its own protocol work while nobody is listening. Honoured only while no
/// subscriber holds the port; with one attached, the port mode alone decides.
virtual bool tap_needs_port() const = 0;
/// A client explicitly turned protocol handling off for this port. Distinct from the
/// automatic reset when a session ends: this one means a client intends to do something
/// else with the device -- reflash it, most likely -- so anything the tap believes about
/// it should be treated as suspect.
virtual void on_protocol_disabled() = 0;
};
#endif
class SerialProxy final : public uart::UARTDevice, public Component {
public:
void setup() override;
@@ -77,6 +117,9 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// Get the port type
api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; }
/// Handle a mode change requested by an API client
SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode);
/// Configure UART parameters and apply them
/// @param api_connection The API connection requesting the change
/// @param baudrate Baud rate in bits per second
@@ -121,13 +164,78 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// Set the DTR GPIO pin (from YAML configuration)
void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; }
#ifdef USE_SERIAL_PROXY_USB_INFO
/// Attach the USB UART channel behind this port (from code generation)
void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; }
#ifdef USE_API
/// Fill a USB info response for this port. The response's strings are views into
/// info, so info must outlive the send.
void get_usb_info(usb_host::UsbDeviceInfo &info, api::SerialProxyGetUsbInfoResponse &resp) const;
#endif
#endif
#ifdef USE_SERIAL_PROXY_TAP
/// Attach a traffic observer. At most one, set once at setup time.
void set_tap(SerialProxyTap *tap) { this->tap_ = tap; }
/// Write bytes originating from the tap rather than from a client. Bypasses the
/// subscriber ownership check, but only while the tap is being served bytes -- so a
/// port in RAW mode with a subscriber attached stays inert. Returns false when the
/// bytes were dropped for that reason.
bool write_from_tap(const uint8_t *data, size_t len) {
if (!this->tap_observing_()) {
return false;
}
this->write_array(data, len);
return true;
}
/// Whether the tap is currently being served bytes. Can flip false with no callback
/// (a subscriber attaching in RAW mode, say), so a tap should check before starting
/// protocol work and when a reply seems overdue.
bool tap_is_observed() const { return this->tap_observing_(); }
/// Resume reading after a tap's needs change. loop() disables itself when there is
/// neither a subscriber nor a tap that wants the port, so a tap starting fresh work
/// must ask for it back. Must be called from the main loop.
void tap_request_port() { this->enable_loop(); }
/// Whether the underlying device is present. On a USB UART this tracks enumeration, so
/// a tap can notice the device being unplugged and plugged back in.
bool is_device_connected() const { return this->parent_->is_connected(); }
/// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the
/// main loop is running -- during setup, for instance, while a component is still
/// blocking on can_proceed(). Must not be called from on_device_rx() or
/// on_client_tx(): each nested cycle costs a 256-byte stack frame.
void tap_pump();
#endif
protected:
#ifdef USE_API
/// Read from UART and send to API client (slow path with 256-byte stack buffer)
/// Read from UART, hand the bytes to any tap, and forward them to a subscriber
/// (slow path with a 256-byte stack buffer)
void read_and_send_(size_t available);
/// True when a live subscriber other than the given connection holds the port
bool port_claimed_by_other_(api::APIConnection *api_connection) const;
/// True when the given connection is the live subscriber. Every port operation
/// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed
/// client can never share the wire with the subscriber or an active tap.
bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; }
#endif
#ifdef USE_SERIAL_PROXY_TAP
/// Return the port to RAW when a subscriber goes away, so the mode never outlives it
void reset_mode_();
#else
/// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is
/// nothing to reset
void reset_mode_() {}
#endif
#ifdef USE_SERIAL_PROXY_TAP
/// True when the tap should be shown the traffic passing through this port
bool tap_observing_() const;
#endif
/// Instance index for identifying this proxy in API messages
@@ -147,6 +255,11 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// Port type
api::enums::SerialProxyPortType port_type_{};
#ifdef USE_SERIAL_PROXY_TAP
/// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW
api::enums::SerialProxyMode mode_{};
#endif
/// Optional GPIO pins for modem control
GPIOPin *rts_pin_{nullptr};
GPIOPin *dtr_pin_{nullptr};
@@ -154,6 +267,15 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// Current modem pin states
bool rts_state_{false};
bool dtr_state_{false};
#ifdef USE_SERIAL_PROXY_TAP
SerialProxyTap *tap_{nullptr};
#endif
#ifdef USE_SERIAL_PROXY_USB_INFO
/// The USB UART channel behind this port; nullptr on non-USB ports
usb_uart::USBUartChannel *usb_channel_{nullptr};
#endif
};
} // namespace esphome::serial_proxy
@@ -1,465 +0,0 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import climate, sensor
from esphome.components.climate import climate_ns
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTION,
CONF_CURRENT_TEMPERATURE,
CONF_CUSTOM_FAN_MODE,
CONF_CUSTOM_FAN_MODES,
CONF_CUSTOM_PRESET,
CONF_CUSTOM_PRESETS,
CONF_FAN_MODE,
CONF_HUMIDITY_SENSOR,
CONF_ID,
CONF_INITIAL_STATE,
CONF_MODE,
CONF_OPTIMISTIC,
CONF_PRESET,
CONF_RESTORE_MODE,
CONF_SENSOR,
CONF_SUPPORTED_FAN_MODES,
CONF_SUPPORTED_MODES,
CONF_SUPPORTED_PRESETS,
CONF_SUPPORTED_SWING_MODES,
CONF_SWING_MODE,
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
CONF_TARGET_TEMPERATURE_LOW,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
from .. import template_ns
CONF_CURRENT_HUMIDITY = "current_humidity"
CONF_TARGET_HUMIDITY = "target_humidity"
CONF_SUPPORTS_ACTION = "supports_action"
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = "supports_two_point_target_temperature"
CONF_SUPPORTS_TARGET_HUMIDITY = "supports_target_humidity"
CONF_SUPPORTS_CURRENT_TEMPERATURE = "supports_current_temperature"
CONF_SUPPORTS_CURRENT_HUMIDITY = "supports_current_humidity"
CONF_SET_MODE_ACTION = "set_mode_action"
CONF_SET_TARGET_TEMPERATURE_ACTION = "set_target_temperature_action"
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION = "set_target_temperature_low_action"
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION = "set_target_temperature_high_action"
CONF_SET_TARGET_HUMIDITY_ACTION = "set_target_humidity_action"
CONF_SET_FAN_MODE_ACTION = "set_fan_mode_action"
CONF_SET_CUSTOM_FAN_MODE_ACTION = "set_custom_fan_mode_action"
CONF_SET_SWING_MODE_ACTION = "set_swing_mode_action"
CONF_SET_PRESET_ACTION = "set_preset_action"
CONF_SET_CUSTOM_PRESET_ACTION = "set_custom_preset_action"
TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Component)
TemplateClimatePublishAction = template_ns.class_(
"TemplateClimatePublishAction",
automation.Action,
cg.Parented.template(TemplateClimate),
)
TemplateClimateRestoreMode = template_ns.enum(
"TemplateClimateRestoreMode", is_class=True
)
CLIMATE_RESTORE_MODES = {
"NO_RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE,
"RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE,
}
# Per-field actions that forward a requested value on. The third item is the type of `x`.
SET_ACTIONS = (
(CONF_SET_MODE_ACTION, "get_set_mode_trigger", climate.ClimateMode),
(
CONF_SET_TARGET_TEMPERATURE_ACTION,
"get_set_target_temperature_trigger",
cg.float_,
),
(
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
"get_set_target_temperature_low_trigger",
cg.float_,
),
(
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
"get_set_target_temperature_high_trigger",
cg.float_,
),
(CONF_SET_TARGET_HUMIDITY_ACTION, "get_set_target_humidity_trigger", cg.float_),
(CONF_SET_FAN_MODE_ACTION, "get_set_fan_mode_trigger", climate.ClimateFanMode),
(
CONF_SET_CUSTOM_FAN_MODE_ACTION,
"get_set_custom_fan_mode_trigger",
cg.StringRef,
),
(
CONF_SET_SWING_MODE_ACTION,
"get_set_swing_mode_trigger",
climate.ClimateSwingMode,
),
(CONF_SET_PRESET_ACTION, "get_set_preset_trigger", climate.ClimatePreset),
(CONF_SET_CUSTOM_PRESET_ACTION, "get_set_custom_preset_trigger", cg.StringRef),
)
# supports_* keys have no default so that an omitted key can mean "derive it from the sensor or
# set action that makes the trait useful", which is not expressible once a default fills it in.
DERIVED_SUPPORTS = (
(CONF_SUPPORTS_CURRENT_TEMPERATURE, (CONF_SENSOR,)),
(CONF_SUPPORTS_CURRENT_HUMIDITY, (CONF_HUMIDITY_SENSOR,)),
(
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
(
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
),
),
(CONF_SUPPORTS_TARGET_HUMIDITY, (CONF_SET_TARGET_HUMIDITY_ACTION,)),
)
# Custom fan modes/presets are opaque user-defined strings with no build-time correctness check
# elsewhere (Climate::set_supported_custom_fan_modes()/set_supported_custom_presets() don't block
# empty entries), so reject empty ones here -- they could never be selected at runtime anyway.
validate_custom_climate_string = cv.All(cv.string_strict, cv.Length(min=1))
def _validate_two_point(config: ConfigType) -> ConfigType:
has_low = CONF_TARGET_TEMPERATURE_LOW in config
has_high = CONF_TARGET_TEMPERATURE_HIGH in config
if has_low != has_high:
raise cv.Invalid(
f"'{CONF_TARGET_TEMPERATURE_LOW}' and '{CONF_TARGET_TEMPERATURE_HIGH}' must be used together"
)
if (has_low or has_high) and CONF_TARGET_TEMPERATURE in config:
raise cv.Invalid(
f"'{CONF_TARGET_TEMPERATURE}' cannot be used together with "
f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}'"
)
return config
def _validate_set_actions(config: ConfigType) -> ConfigType:
has_low = CONF_SET_TARGET_TEMPERATURE_LOW_ACTION in config
has_high = CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION in config
if has_low != has_high:
raise cv.Invalid(
f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}' and "
f"'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}' must be used together"
)
if (has_low or has_high) and CONF_SET_TARGET_TEMPERATURE_ACTION in config:
raise cv.Invalid(
f"'{CONF_SET_TARGET_TEMPERATURE_ACTION}' cannot be used together with "
f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}'/'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}'"
)
return config
def _resolve_supports(config: ConfigType) -> ConfigType:
# An explicit true stays valid without either, since climate.template.publish can report the
# value; an explicit false that contradicts the configuration is an error, not a silent override.
for key, sources in DERIVED_SUPPORTS:
configured = [source for source in sources if source in config]
if key not in config:
config[key] = bool(configured)
elif not config[key] and configured:
raise cv.Invalid(
f"'{key}' cannot be false while '{configured[0]}' is configured",
path=[key],
)
return config
def _validate_initial_state(config: ConfigType) -> ConfigType:
# Climate keeps target_temperature and target_temperature_low in a union, so writing the wrong
# one of the pair corrupts the setpoint with no runtime complaint.
if (initial_state := config.get(CONF_INITIAL_STATE)) is None:
return config
two_point = config[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE]
if two_point and CONF_TARGET_TEMPERATURE in initial_state:
raise cv.Invalid(
f"'{CONF_TARGET_TEMPERATURE}' is not available while "
f"'{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' is enabled; use "
f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}' instead",
path=[CONF_INITIAL_STATE, CONF_TARGET_TEMPERATURE],
)
if not two_point:
for key in (CONF_TARGET_TEMPERATURE_LOW, CONF_TARGET_TEMPERATURE_HIGH):
if key in initial_state:
raise cv.Invalid(
f"'{key}' requires '{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' to be enabled",
path=[CONF_INITIAL_STATE, key],
)
if (
CONF_TARGET_HUMIDITY in initial_state
and not config[CONF_SUPPORTS_TARGET_HUMIDITY]
):
raise cv.Invalid(
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
)
return config
# Same settable fields as climate.template.publish, minus current_temperature/current_humidity/
# action: those are reported values (from a sensor or the device), not meaningful static defaults.
INITIAL_STATE_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_MODE): climate.validate_climate_mode,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
cv.Exclusive(
CONF_CUSTOM_FAN_MODE, "fan_mode"
): validate_custom_climate_string,
cv.Optional(CONF_SWING_MODE): climate.validate_climate_swing_mode,
cv.Exclusive(CONF_PRESET, "preset"): climate.validate_climate_preset,
cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): validate_custom_climate_string,
}
),
_validate_two_point,
)
CONFIG_SCHEMA = cv.All(
climate.climate_schema(TemplateClimate)
.extend(
{
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor),
# action only ever arrives through climate.template.publish, so unlike the other
# supports_* keys there is no set action to derive it from.
cv.Optional(CONF_SUPPORTS_ACTION, default=False): cv.boolean,
cv.Optional(CONF_SUPPORTS_CURRENT_TEMPERATURE): cv.boolean,
cv.Optional(CONF_SUPPORTS_CURRENT_HUMIDITY): cv.boolean,
cv.Optional(CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE): cv.boolean,
cv.Optional(CONF_SUPPORTS_TARGET_HUMIDITY): cv.boolean,
cv.Required(CONF_SUPPORTED_MODES): cv.All(
cv.ensure_list(climate.validate_climate_mode), cv.Unique()
),
cv.Optional(CONF_SUPPORTED_FAN_MODES): cv.All(
cv.ensure_list(climate.validate_climate_fan_mode), cv.Unique()
),
cv.Optional(CONF_CUSTOM_FAN_MODES): cv.All(
cv.ensure_list(validate_custom_climate_string), cv.Unique()
),
cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.All(
cv.ensure_list(climate.validate_climate_swing_mode), cv.Unique()
),
cv.Optional(CONF_SUPPORTED_PRESETS): cv.All(
cv.ensure_list(climate.validate_climate_preset), cv.Unique()
),
cv.Optional(CONF_CUSTOM_PRESETS): cv.All(
cv.ensure_list(validate_custom_climate_string), cv.Unique()
),
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
cv.Optional(CONF_RESTORE_MODE, default="RESTORE"): cv.enum(
CLIMATE_RESTORE_MODES, upper=True
),
cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA,
cv.Optional(CONF_SET_MODE_ACTION): automation.validate_automation(
single=True
),
cv.Optional(
CONF_SET_TARGET_TEMPERATURE_ACTION
): automation.validate_automation(single=True),
cv.Optional(
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION
): automation.validate_automation(single=True),
cv.Optional(
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION
): automation.validate_automation(single=True),
cv.Optional(
CONF_SET_TARGET_HUMIDITY_ACTION
): automation.validate_automation(single=True),
cv.Optional(CONF_SET_FAN_MODE_ACTION): automation.validate_automation(
single=True
),
cv.Optional(
CONF_SET_CUSTOM_FAN_MODE_ACTION
): automation.validate_automation(single=True),
cv.Optional(CONF_SET_SWING_MODE_ACTION): automation.validate_automation(
single=True
),
cv.Optional(CONF_SET_PRESET_ACTION): automation.validate_automation(
single=True
),
cv.Optional(CONF_SET_CUSTOM_PRESET_ACTION): automation.validate_automation(
single=True
),
}
)
.extend(cv.COMPONENT_SCHEMA),
_validate_set_actions,
_resolve_supports,
_validate_initial_state,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await climate.register_climate(var, config)
if (sens := config.get(CONF_SENSOR)) is not None:
cg.add(var.set_sensor(await cg.get_variable(sens)))
if (sens := config.get(CONF_HUMIDITY_SENSOR)) is not None:
cg.add(var.set_humidity_sensor(await cg.get_variable(sens)))
for key, flag in (
(CONF_SUPPORTS_ACTION, climate_ns.CLIMATE_SUPPORTS_ACTION),
(
CONF_SUPPORTS_CURRENT_TEMPERATURE,
climate_ns.CLIMATE_SUPPORTS_CURRENT_TEMPERATURE,
),
(CONF_SUPPORTS_CURRENT_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_CURRENT_HUMIDITY),
(
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
climate_ns.CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
),
(CONF_SUPPORTS_TARGET_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_TARGET_HUMIDITY),
):
if config[key]:
cg.add(var.add_feature_flags(flag))
for mode in config[CONF_SUPPORTED_MODES]:
cg.add(var.add_supported_mode(mode))
for mode in config.get(CONF_SUPPORTED_FAN_MODES, []):
cg.add(var.add_supported_fan_mode(mode))
if CONF_CUSTOM_FAN_MODES in config:
cg.add(
var.set_supported_custom_fan_modes(
cg.ArrayInitializer(*config[CONF_CUSTOM_FAN_MODES])
)
)
for mode in config.get(CONF_SUPPORTED_SWING_MODES, []):
cg.add(var.add_supported_swing_mode(mode))
for preset in config.get(CONF_SUPPORTED_PRESETS, []):
cg.add(var.add_supported_preset(preset))
if CONF_CUSTOM_PRESETS in config:
cg.add(
var.set_supported_custom_presets(
cg.ArrayInitializer(*config[CONF_CUSTOM_PRESETS])
)
)
for key, trigger_getter, arg_type in SET_ACTIONS:
if (conf := config.get(key)) is not None:
await automation.build_automation(
getattr(var, trigger_getter)(), [(arg_type, "x")], conf
)
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))
if (initial_state := config.get(CONF_INITIAL_STATE)) is not None:
if (v := initial_state.get(CONF_MODE)) is not None:
cg.add(var.set_mode(v))
if (v := initial_state.get(CONF_TARGET_TEMPERATURE)) is not None:
cg.add(var.set_target_temperature(v))
if (v := initial_state.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
cg.add(var.set_target_temperature_low(v))
if (v := initial_state.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
cg.add(var.set_target_temperature_high(v))
if (v := initial_state.get(CONF_TARGET_HUMIDITY)) is not None:
cg.add(var.set_target_humidity(v))
if (v := initial_state.get(CONF_FAN_MODE)) is not None:
cg.add(var.set_fan_mode(v))
if (v := initial_state.get(CONF_CUSTOM_FAN_MODE)) is not None:
cg.add(var.set_custom_fan_mode(v))
if (v := initial_state.get(CONF_SWING_MODE)) is not None:
cg.add(var.set_swing_mode(v))
if (v := initial_state.get(CONF_PRESET)) is not None:
cg.add(var.set_preset(v))
if (v := initial_state.get(CONF_CUSTOM_PRESET)) is not None:
cg.add(var.set_custom_preset(v))
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(TemplateClimate),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
climate.validate_climate_fan_mode
),
cv.Exclusive(CONF_CUSTOM_FAN_MODE, "fan_mode"): cv.templatable(
validate_custom_climate_string
),
cv.Optional(CONF_SWING_MODE): cv.templatable(
climate.validate_climate_swing_mode
),
cv.Exclusive(CONF_PRESET, "preset"): cv.templatable(
climate.validate_climate_preset
),
cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): cv.templatable(
validate_custom_climate_string
),
}
),
_validate_two_point,
)
@automation.register_action(
"climate.template.publish",
TemplateClimatePublishAction,
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
synchronous=True,
)
async def climate_template_publish_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
if (v := config.get(CONF_CURRENT_TEMPERATURE)) is not None:
cg.add(var.set_current_temperature(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_CURRENT_HUMIDITY)) is not None:
cg.add(var.set_current_humidity(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_TARGET_TEMPERATURE)) is not None:
cg.add(var.set_target_temperature(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
cg.add(var.set_target_temperature_low(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
cg.add(
var.set_target_temperature_high(await cg.templatable(v, args, cg.float_))
)
if (v := config.get(CONF_TARGET_HUMIDITY)) is not None:
cg.add(var.set_target_humidity(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_MODE)) is not None:
cg.add(var.set_mode(await cg.templatable(v, args, climate.ClimateMode)))
if (v := config.get(CONF_ACTION)) is not None:
cg.add(var.set_action(await cg.templatable(v, args, climate.ClimateAction)))
if (v := config.get(CONF_FAN_MODE)) is not None:
cg.add(var.set_fan_mode(await cg.templatable(v, args, climate.ClimateFanMode)))
if (v := config.get(CONF_CUSTOM_FAN_MODE)) is not None:
cg.add(var.set_custom_fan_mode(await cg.templatable(v, args, cg.std_string)))
if (v := config.get(CONF_SWING_MODE)) is not None:
cg.add(
var.set_swing_mode(await cg.templatable(v, args, climate.ClimateSwingMode))
)
if (v := config.get(CONF_PRESET)) is not None:
cg.add(var.set_preset(await cg.templatable(v, args, climate.ClimatePreset)))
if (v := config.get(CONF_CUSTOM_PRESET)) is not None:
cg.add(var.set_custom_preset(await cg.templatable(v, args, cg.std_string)))
return var
@@ -1,57 +0,0 @@
#pragma once
#include "template_climate.h"
#include "esphome/core/automation.h"
namespace esphome::template_ {
template<typename... Ts>
class TemplateClimatePublishAction final : public Action<Ts...>, public Parented<TemplateClimate> {
public:
TEMPLATABLE_VALUE(float, current_temperature)
TEMPLATABLE_VALUE(float, current_humidity)
TEMPLATABLE_VALUE(float, target_temperature)
TEMPLATABLE_VALUE(float, target_temperature_low)
TEMPLATABLE_VALUE(float, target_temperature_high)
TEMPLATABLE_VALUE(float, target_humidity)
TEMPLATABLE_VALUE(climate::ClimateMode, mode)
TEMPLATABLE_VALUE(climate::ClimateAction, action)
TEMPLATABLE_VALUE(climate::ClimateFanMode, fan_mode)
TEMPLATABLE_VALUE(std::string, custom_fan_mode)
TEMPLATABLE_VALUE(climate::ClimateSwingMode, swing_mode)
TEMPLATABLE_VALUE(climate::ClimatePreset, preset)
TEMPLATABLE_VALUE(std::string, custom_preset)
void play(const Ts &...x) override {
if (this->current_temperature_.has_value())
this->parent_->current_temperature = this->current_temperature_.value(x...);
if (this->current_humidity_.has_value())
this->parent_->current_humidity = this->current_humidity_.value(x...);
if (this->target_temperature_.has_value())
this->parent_->set_target_temperature(this->target_temperature_.value(x...));
if (this->target_temperature_low_.has_value())
this->parent_->set_target_temperature_low(this->target_temperature_low_.value(x...));
if (this->target_temperature_high_.has_value())
this->parent_->set_target_temperature_high(this->target_temperature_high_.value(x...));
if (this->target_humidity_.has_value())
this->parent_->set_target_humidity(this->target_humidity_.value(x...));
if (this->mode_.has_value())
this->parent_->set_mode(this->mode_.value(x...));
if (this->action_.has_value())
this->parent_->action = this->action_.value(x...);
if (this->fan_mode_.has_value())
this->parent_->set_fan_mode(this->fan_mode_.value(x...));
if (this->custom_fan_mode_.has_value())
this->parent_->set_custom_fan_mode(StringRef(this->custom_fan_mode_.value(x...)));
if (this->swing_mode_.has_value())
this->parent_->set_swing_mode(this->swing_mode_.value(x...));
if (this->preset_.has_value())
this->parent_->set_preset(this->preset_.value(x...));
if (this->custom_preset_.has_value())
this->parent_->set_custom_preset(StringRef(this->custom_preset_.value(x...)));
this->parent_->publish_state();
}
};
} // namespace esphome::template_
@@ -1,164 +0,0 @@
#include "template_climate.h"
#include "esphome/core/log.h"
namespace esphome::template_ {
static const char *const TAG = "template.climate";
void TemplateClimate::setup() {
if (this->restore_mode_ == TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE) {
auto restore = this->restore_state_();
if (restore.has_value()) {
restore->apply(this);
}
}
// Sensors publish every reading, not just changes, so only re-publish when the value moved.
// NAN means the sensor went unavailable and is passed through rather than dropped; the second
// check stops an unavailable sensor re-publishing forever, since NAN never equals NAN.
#ifdef USE_SENSOR
if (this->sensor_ != nullptr) {
this->current_temperature = this->sensor_->state;
this->sensor_->add_on_state_callback([this](float state) {
if (state != this->current_temperature && !(std::isnan(state) && std::isnan(this->current_temperature))) {
this->current_temperature = state;
this->publish_state();
}
});
}
if (this->humidity_sensor_ != nullptr) {
this->current_humidity = this->humidity_sensor_->state;
this->humidity_sensor_->add_on_state_callback([this](float state) {
if (state != this->current_humidity && !(std::isnan(state) && std::isnan(this->current_humidity))) {
this->current_humidity = state;
this->publish_state();
}
});
}
#endif
}
void TemplateClimate::dump_config() {
LOG_CLIMATE("", "Template Climate", this);
ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_));
}
void TemplateClimate::control(const climate::ClimateCall &call) {
// Each field present fires its set_*_action; on_control sees the whole call. optimistic: true
// also applies the values right away, false waits for a climate.template.publish report.
if (auto mode = call.get_mode()) {
if (this->optimistic_)
this->mode = *mode;
this->set_mode_trigger_.trigger(*mode);
}
if (auto target_temp = call.get_target_temperature()) {
if (this->optimistic_)
this->target_temperature = *target_temp;
this->set_target_temperature_trigger_.trigger(*target_temp);
}
if (auto target_temp_low = call.get_target_temperature_low()) {
if (this->optimistic_)
this->target_temperature_low = *target_temp_low;
this->set_target_temperature_low_trigger_.trigger(*target_temp_low);
}
if (auto target_temp_high = call.get_target_temperature_high()) {
if (this->optimistic_)
this->target_temperature_high = *target_temp_high;
this->set_target_temperature_high_trigger_.trigger(*target_temp_high);
}
if (auto target_humidity = call.get_target_humidity()) {
if (this->optimistic_)
this->target_humidity = *target_humidity;
this->set_target_humidity_trigger_.trigger(*target_humidity);
}
if (auto fan_mode = call.get_fan_mode()) {
if (this->optimistic_)
this->set_fan_mode_(*fan_mode);
this->set_fan_mode_trigger_.trigger(*fan_mode);
}
if (call.has_custom_fan_mode()) {
if (this->optimistic_)
this->set_custom_fan_mode_(call.get_custom_fan_mode());
this->set_custom_fan_mode_trigger_.trigger(call.get_custom_fan_mode());
}
if (auto swing_mode = call.get_swing_mode()) {
if (this->optimistic_)
this->swing_mode = *swing_mode;
this->set_swing_mode_trigger_.trigger(*swing_mode);
}
if (auto preset = call.get_preset()) {
if (this->optimistic_)
this->set_preset_(*preset);
this->set_preset_trigger_.trigger(*preset);
}
if (call.has_custom_preset()) {
if (this->optimistic_)
this->set_custom_preset_(call.get_custom_preset());
this->set_custom_preset_trigger_.trigger(call.get_custom_preset());
}
if (this->optimistic_)
this->publish_state();
}
// A climate.template.publish report (and initial_state:) never goes through ClimateCall::validate_(),
// so check here instead -- otherwise a typo is published as state the receiving end will reject.
void TemplateClimate::set_mode(climate::ClimateMode mode) {
if (!this->traits_.supports_mode(mode)) {
ESP_LOGW(TAG, "'%s' - Unsupported mode %u", this->get_name().c_str(), static_cast<unsigned>(mode));
return;
}
this->mode = mode;
}
void TemplateClimate::set_swing_mode(climate::ClimateSwingMode swing_mode) {
if (!this->traits_.supports_swing_mode(swing_mode)) {
ESP_LOGW(TAG, "'%s' - Unsupported swing mode %u", this->get_name().c_str(), static_cast<unsigned>(swing_mode));
return;
}
this->swing_mode = swing_mode;
}
void TemplateClimate::set_fan_mode(climate::ClimateFanMode fan_mode) {
if (!this->traits_.supports_fan_mode(fan_mode)) {
ESP_LOGW(TAG, "'%s' - Unsupported fan mode %u", this->get_name().c_str(), static_cast<unsigned>(fan_mode));
return;
}
this->set_fan_mode_(fan_mode);
}
void TemplateClimate::set_preset(climate::ClimatePreset preset) {
if (!this->traits_.supports_preset(preset)) {
ESP_LOGW(TAG, "'%s' - Unsupported preset %u", this->get_name().c_str(), static_cast<unsigned>(preset));
return;
}
this->set_preset_(preset);
}
void TemplateClimate::set_custom_fan_mode(StringRef mode) {
if (this->find_custom_fan_mode_(mode.c_str(), mode.size()) == nullptr) {
ESP_LOGW(TAG, "'%s' - Unsupported custom fan mode '%s'", this->get_name().c_str(), mode.c_str());
return;
}
this->set_custom_fan_mode_(mode);
}
void TemplateClimate::set_custom_preset(StringRef preset) {
if (this->find_custom_preset_(preset.c_str(), preset.size()) == nullptr) {
ESP_LOGW(TAG, "'%s' - Unsupported custom preset '%s'", this->get_name().c_str(), preset.c_str());
return;
}
this->set_custom_preset_(preset);
}
} // namespace esphome::template_
@@ -1,92 +0,0 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
namespace esphome::template_ {
enum class TemplateClimateRestoreMode {
TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE,
TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE,
};
class TemplateClimate final : public climate::Climate, public Component {
public:
void setup() override;
void dump_config() override;
climate::ClimateTraits traits() override { return this->traits_; }
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
#ifdef USE_SENSOR
// The matching feature flag is added from codegen, so the configuration alone decides it.
void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; }
void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; }
#endif
void add_supported_mode(climate::ClimateMode mode) { this->traits_.add_supported_mode(mode); }
void add_supported_fan_mode(climate::ClimateFanMode mode) { this->traits_.add_supported_fan_mode(mode); }
void add_supported_swing_mode(climate::ClimateSwingMode mode) { this->traits_.add_supported_swing_mode(mode); }
void add_supported_preset(climate::ClimatePreset preset) { this->traits_.add_supported_preset(preset); }
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_restore_mode(TemplateClimateRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
// Fired from control() for each field the call carries, so a device-backed config can forward
// it on. Which of these are configured also decides the two-point/target-humidity traits.
Trigger<climate::ClimateMode> *get_set_mode_trigger() { return &this->set_mode_trigger_; }
Trigger<float> *get_set_target_temperature_trigger() { return &this->set_target_temperature_trigger_; }
Trigger<float> *get_set_target_temperature_low_trigger() { return &this->set_target_temperature_low_trigger_; }
Trigger<float> *get_set_target_temperature_high_trigger() { return &this->set_target_temperature_high_trigger_; }
Trigger<float> *get_set_target_humidity_trigger() { return &this->set_target_humidity_trigger_; }
Trigger<climate::ClimateFanMode> *get_set_fan_mode_trigger() { return &this->set_fan_mode_trigger_; }
Trigger<StringRef> *get_set_custom_fan_mode_trigger() { return &this->set_custom_fan_mode_trigger_; }
Trigger<climate::ClimateSwingMode> *get_set_swing_mode_trigger() { return &this->set_swing_mode_trigger_; }
Trigger<climate::ClimatePreset> *get_set_preset_trigger() { return &this->set_preset_trigger_; }
Trigger<StringRef> *get_set_custom_preset_trigger() { return &this->set_custom_preset_trigger_; }
// Used by TemplateClimatePublishAction, which is not a Climate subclass and so cannot reach the
// protected setters, and by codegen to apply `initial_state:` before setup() runs.
void set_target_temperature(float value) { this->target_temperature = value; }
void set_target_temperature_low(float value) { this->target_temperature_low = value; }
void set_target_temperature_high(float value) { this->target_temperature_high = value; }
void set_target_humidity(float value) { this->target_humidity = value; }
void set_mode(climate::ClimateMode mode);
void set_swing_mode(climate::ClimateSwingMode mode);
void set_fan_mode(climate::ClimateFanMode mode);
void set_custom_fan_mode(const char *mode) { this->set_custom_fan_mode(StringRef(mode)); }
void set_custom_fan_mode(StringRef mode);
void set_preset(climate::ClimatePreset preset);
void set_custom_preset(const char *preset) { this->set_custom_preset(StringRef(preset)); }
void set_custom_preset(StringRef preset);
protected:
void control(const climate::ClimateCall &call) override;
climate::ClimateTraits traits_;
bool optimistic_{false};
TemplateClimateRestoreMode restore_mode_{TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE};
#ifdef USE_SENSOR
sensor::Sensor *sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
#endif
Trigger<climate::ClimateMode> set_mode_trigger_;
Trigger<float> set_target_temperature_trigger_;
Trigger<float> set_target_temperature_low_trigger_;
Trigger<float> set_target_temperature_high_trigger_;
Trigger<float> set_target_humidity_trigger_;
Trigger<climate::ClimateFanMode> set_fan_mode_trigger_;
Trigger<StringRef> set_custom_fan_mode_trigger_;
Trigger<climate::ClimateSwingMode> set_swing_mode_trigger_;
Trigger<climate::ClimatePreset> set_preset_trigger_;
Trigger<StringRef> set_custom_preset_trigger_;
};
} // namespace esphome::template_
+3 -14
View File
@@ -1,13 +1,10 @@
#include "tuya.h"
#include "esphome/components/network/util.h"
#include "esphome/core/gpio.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
#ifdef USE_NETWORK
#include "esphome/components/network/util.h"
#endif
#ifdef USE_WIFI
#include "esphome/components/wifi/wifi_component.h"
#endif
@@ -25,14 +22,6 @@ static const int MAX_RETRIES = 5;
// Max bytes to log for datapoint values (larger values are truncated)
static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16;
static bool network_is_connected() {
#ifdef USE_NETWORK
return network::is_connected();
#else
return false;
#endif
}
void Tuya::setup() {
this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); });
if (this->status_pin_ != nullptr) {
@@ -565,14 +554,14 @@ void Tuya::send_empty_command_(TuyaCommandType command) {
}
void Tuya::set_status_pin_() {
bool is_network_ready = network_is_connected() && remote_is_connected();
bool is_network_ready = network::is_connected() && remote_is_connected();
this->status_pin_->digital_write(is_network_ready);
}
uint8_t Tuya::get_wifi_status_code_() {
uint8_t status = 0x02;
if (network_is_connected()) {
if (network::is_connected()) {
status = 0x03;
// Protocol version 3 also supports specifying when connected to "the cloud"
+12 -4
View File
@@ -1,4 +1,5 @@
from typing import Any
from collections.abc import Callable
from typing import Any, NoReturn
from esphome import automation
from esphome.automation import Trigger
@@ -47,10 +48,17 @@ UDP_SCHEMA = cv.Schema(
)
def is_relocated(option: str) -> Callable[[Any], NoReturn]:
def validator(value: Any) -> NoReturn:
raise cv.Invalid(
f"The '{option}' option should now be configured in the 'packet_transport' component"
)
return validator
RELOCATED = {
cv.Optional(x): cv.invalid(
f"The '{x}' option should now be configured in the 'packet_transport' component"
)
cv.Optional(x): is_relocated(x)
for x in (
CONF_PROVIDERS,
CONF_ENCRYPTION,
+18
View File
@@ -117,6 +117,20 @@ struct UsbEvent {
// callback function type.
// USB string descriptors hold at most 126 characters; one more for the terminator
static constexpr size_t DESC_STRING_BUF_SIZE = 128;
/// Identity of a connected USB device, copied out of the descriptors the USB host
/// stack caches for the lifetime of the connection
struct UsbDeviceInfo {
uint16_t vendor_id;
uint16_t product_id;
uint16_t bcd_device;
char manufacturer[DESC_STRING_BUF_SIZE];
char product[DESC_STRING_BUF_SIZE];
char serial_number[DESC_STRING_BUF_SIZE];
};
enum ClientState {
USB_CLIENT_INIT = 0,
USB_CLIENT_OPEN,
@@ -144,6 +158,10 @@ class USBClient : public Component {
bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback,
const std::vector<uint8_t> &data = {});
/// Copy the connected device's identity out of the cached USB descriptors.
/// Returns false when no device is connected.
bool get_device_info(UsbDeviceInfo &info) const;
// Lock-free event queue and pool for USB task to main loop communication
// Must be public for access from static callbacks
LockFreeQueue<UsbEvent, USB_EVENT_QUEUE_SIZE> event_queue;
@@ -143,10 +143,8 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc
} while (next_desc != NULL);
}
#endif
// USB string descriptors: bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType).
// Character count = (bLength - 2) / 2, max 126 chars + null terminator.
static constexpr size_t DESC_STRING_BUF_SIZE = 128;
// bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType),
// so character count = (bLength - 2) / 2.
static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
if (desc == nullptr || desc->bLength < 2)
return "(unspecified)";
@@ -162,6 +160,41 @@ static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span<c
return buffer.data();
}
// A missing descriptor copies as an empty string, unlike the "(unspecified)"
// placeholder the logging helper above uses
static void copy_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
buffer[0] = '\0';
if (desc == nullptr || desc->bLength < 2)
return;
int char_count = (desc->bLength - 2) / 2;
char *p = buffer.data();
char *end = p + buffer.size() - 1;
for (int i = 0; i != char_count && p < end; i++) {
auto c = desc->wData[i];
if (c < 0x100)
*p++ = static_cast<char>(c);
}
*p = '\0';
}
bool USBClient::get_device_info(UsbDeviceInfo &info) const {
if (this->state_ != USB_CLIENT_CONNECTED)
return false;
const usb_device_desc_t *desc;
if (usb_host_get_device_descriptor(this->device_handle_, &desc) != ESP_OK)
return false;
info.vendor_id = desc->idVendor;
info.product_id = desc->idProduct;
info.bcd_device = desc->bcdDevice;
usb_device_info_t dev_info;
if (usb_host_device_info(this->device_handle_, &dev_info) != ESP_OK)
return false;
copy_descriptor_string(dev_info.str_desc_manufacturer, info.manufacturer);
copy_descriptor_string(dev_info.str_desc_product, info.product);
copy_descriptor_string(dev_info.str_desc_serial_num, info.serial_number);
return true;
}
// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task)
static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) {
auto *client = static_cast<USBClient *>(ptr);
+11 -1
View File
@@ -16,7 +16,7 @@ from esphome.const import (
CONF_DUMMY_RECEIVER,
CONF_ID,
)
from esphome.core import CORE
from esphome.core import CORE, ID
from esphome.cpp_types import Component
from esphome.types import ConfigType
@@ -27,6 +27,16 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart")
USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component)
USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent)
def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool:
"""Return True if the given ID refers to a channel of a configured usb_uart device."""
return any(
channel[CONF_ID] == uart_id
for device in full_config.get("usb_uart") or []
for channel in device[CONF_CHANNELS]
)
UARTParityOptions = usb_uart_ns.enum("UARTParityOptions")
UART_PARITY_OPTIONS = {
"NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE,
+5 -7
View File
@@ -434,12 +434,11 @@ void USBUartTypeCdcAcm::on_connected() {
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
channel->cdc_dev_.interrupt_interface_number, 0);
if (err_comm != ESP_OK) {
// Continue anyway: the interface number stays valid for CDC request addressing
ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number,
esp_err_to_name(err_comm));
channel->cdc_dev_.interrupt_interface_number = 0xFF; // Mark as unavailable, but continue anyway
} else {
ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number);
channel->cdc_dev_.interrupt_interface_claimed = true;
}
}
auto err =
@@ -466,15 +465,14 @@ void USBUartTypeCdcAcm::on_disconnected() {
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress);
}
// Only tear down the notify pipe when we claimed its interface ourselves;
// no transfer is ever submitted on it, so there is nothing else to cancel.
if (channel->cdc_dev_.notify_ep != nullptr && channel->cdc_dev_.interrupt_interface_claimed) {
if (channel->cdc_dev_.notify_ep != nullptr) {
usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress);
}
if (channel->cdc_dev_.interrupt_interface_claimed) {
if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number);
channel->cdc_dev_.interrupt_interface_claimed = false;
channel->cdc_dev_.interrupt_interface_number = 0xFF;
}
usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number);
// Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts
+3 -3
View File
@@ -34,10 +34,7 @@ struct CdcEps {
const usb_ep_desc_t *in_ep;
const usb_ep_desc_t *out_ep;
uint8_t bulk_interface_number;
// Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it
// must remain valid even when the interface itself is not claimed.
uint8_t interrupt_interface_number;
bool interrupt_interface_claimed{false};
};
enum CH34xChipType : uint8_t {
@@ -167,6 +164,9 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
/// they arrive, eliminating one full main-loop-wakeup cycle of latency.
void set_rx_callback(std::function<void()> cb) { this->rx_callback_ = std::move(cb); }
/// Channel index on the bridge (interface number on multi-port bridges)
uint8_t get_index() const { return this->index_; }
protected:
// Not directly instantiable; construct a concrete channel type instead.
USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {}
+1 -15
View File
@@ -66,14 +66,13 @@ from esphome.const import (
)
from esphome.core import (
CORE,
ID,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
import esphome.final_validate as fv
from esphome.types import ConfigType, TemplateArgsType
from esphome.types import ConfigType
from . import wpa2_eap
@@ -209,7 +208,6 @@ WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition)
WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition)
WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action)
WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action)
WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action)
WiFiConfigureAction = wifi_ns.class_(
"WiFiConfigureAction", automation.Action, cg.Component
)
@@ -822,18 +820,6 @@ async def wifi_disable_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg)
@automation.register_action(
"wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True
)
async def wifi_roam_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> cg.MockObj:
return cg.new_Pvariable(action_id, template_arg)
KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results"
RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save"
RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression"
-5
View File
@@ -31,11 +31,6 @@ template<typename... Ts> class WiFiDisableAction final : public Action<Ts...> {
void play(const Ts &...x) override { global_wifi_component->disable(); }
};
template<typename... Ts> class WiFiRoamAction final : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_wifi_component->force_roam_check(); }
};
template<typename... Ts> class WiFiConfigureAction final : public Action<Ts...>, public Component {
public:
TEMPLATABLE_VALUE(std::string, ssid)
+11 -27
View File
@@ -846,18 +846,17 @@ void WiFiComponent::loop() {
this->notify_connect_state_listeners_();
#endif
// Post-connect roaming: check for better AP. A scan may have been started by an
// explicit force_roam_check() even when post_connect_roaming_ is disabled, so the
// scan must always be consumed here to avoid leaving roaming_state_ stuck.
if (this->is_roaming_scan_active()) {
if (this->scan_done_) {
this->process_roaming_scan_();
// Post-connect roaming: check for better AP
if (this->post_connect_roaming_) {
if (this->is_roaming_scan_active()) {
if (this->scan_done_) {
this->process_roaming_scan_();
}
// else: scan in progress, wait
} else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
this->check_roaming_(now);
}
// else: scan in progress, wait
} else if (this->post_connect_roaming_ && this->roaming_state_ == RoamingState::IDLE &&
this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
this->check_roaming_(now);
}
}
break;
@@ -2464,17 +2463,6 @@ void WiFiComponent::notify_scan_results_listeners_() {
}
#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
void WiFiComponent::force_roam_check() {
if (!this->is_connected() || this->roaming_state_ != RoamingState::IDLE || this->roaming_suppressed_()) {
ESP_LOGD(TAG, "Roam check requested, but not able to check now");
return;
}
// Reset the attempt counter so a prior run of failed roams doesn't block this explicit request
// Note that this re-arms automatic roaming if enabled.
this->roaming_attempts_ = 0;
this->check_roaming_(millis());
}
void WiFiComponent::check_roaming_(uint32_t now) {
// Guard: not for hidden networks (may not appear in scan)
const WiFiAP *selected = this->get_selected_sta_();
@@ -2496,11 +2484,7 @@ void WiFiComponent::check_roaming_(uint32_t now) {
ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::SCANNING;
if (!this->wifi_scan_start_(this->passive_scan_)) {
// Scan failed to start (e.g. busy) - don't get stuck in SCANNING forever
ESP_LOGD(TAG, "Roam scan failed to start");
this->roaming_state_ = RoamingState::IDLE;
}
this->wifi_scan_start_(this->passive_scan_);
}
void WiFiComponent::process_roaming_scan_() {
-6
View File
@@ -565,12 +565,6 @@ class WiFiComponent final : public Component {
void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; }
void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; }
/** Force an immediate post-connect roaming check, bypassing the periodic interval and the
* per-connection attempt limit. Does nothing (besides a debug log) if not connected, if a
* roam scan or connect is already in progress, or if roaming is currently suppressed.
*/
void force_roam_check();
#ifdef USE_WIFI_CONNECT_TRIGGER
Trigger<> *get_connect_trigger() { return &this->connect_trigger_; }
#endif
@@ -0,0 +1,80 @@
import esphome.codegen as cg
from esphome.components import serial_proxy
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI
import esphome.final_validate as fv
CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["api", "serial_proxy"]
CONF_INITIAL_TIMEOUT = "initial_timeout"
CONF_MIN_TIMEOUT = "min_timeout"
CONF_MAX_TIMEOUT = "max_timeout"
CONF_SERIAL_PROXY_ID = "serial_proxy_id"
# Default ACK timeout values for the boot-time metadata harvest
_DEFAULT_INITIAL_TIMEOUT = 1600
_DEFAULT_MIN_TIMEOUT = 400
_DEFAULT_MAX_TIMEOUT = 3200
zigbee_proxy_ns = cg.esphome_ns.namespace("zigbee_proxy")
ZigbeeProxy = zigbee_proxy_ns.class_(
"ZigbeeProxy", cg.Component, serial_proxy.SerialProxyTap
)
def final_validate(config):
full_config = fv.full_config.get()
if (wifi_conf := full_config.get(CONF_WIFI)) and (
wifi_conf.get(CONF_POWER_SAVE_MODE, "").lower() != "none"
):
raise cv.Invalid(
f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Zigbee proxy"
)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ZigbeeProxy),
cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy),
cv.Optional(CONF_BUFFER_SIZE): cv.SplitDefault(
cv.int_range(min=256, max=2048),
esp8266=512,
default=1024,
),
cv.Optional(
CONF_INITIAL_TIMEOUT, default=_DEFAULT_INITIAL_TIMEOUT
): cv.int_range(min=10, max=10000),
cv.Optional(CONF_MIN_TIMEOUT, default=_DEFAULT_MIN_TIMEOUT): cv.int_range(
min=10, max=5000
),
cv.Optional(CONF_MAX_TIMEOUT, default=_DEFAULT_MAX_TIMEOUT): cv.int_range(
min=50, max=10000
),
}
).extend(cv.COMPONENT_SCHEMA),
)
FINAL_VALIDATE_SCHEMA = final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID])
cg.add(var.set_serial_proxy(sp))
cg.add_define("USE_ZIGBEE_PROXY")
# Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe
cg.add_define("USE_SERIAL_PROXY_TAP")
# Set buffer size via define for compile-time allocation
if CONF_BUFFER_SIZE in config:
cg.add_define("ZIGBEE_PROXY_BUFFER_SIZE", config[CONF_BUFFER_SIZE])
cg.add(var.set_initial_timeout(config[CONF_INITIAL_TIMEOUT]))
cg.add(var.set_min_timeout(config[CONF_MIN_TIMEOUT]))
cg.add(var.set_max_timeout(config[CONF_MAX_TIMEOUT]))

Some files were not shown because too many files have changed in this diff Show More