mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 18:22:48 +00:00
Compare commits
195
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4b1f3ed5 | ||
|
|
0df7b0b366 | ||
|
|
bd4b32873a | ||
|
|
9e580db434 | ||
|
|
87187c5391 | ||
|
|
60402614cd | ||
|
|
2727d35655 | ||
|
|
fb1ef29d54 | ||
|
|
7c0f63ea20 | ||
|
|
5d75828ceb | ||
|
|
bd2e8d184d | ||
|
|
3c7ce71d6f | ||
|
|
58e03a875f | ||
|
|
41a9fca881 | ||
|
|
ec58e864f5 | ||
|
|
375fbd829e | ||
|
|
4bae89bd4a | ||
|
|
897b2f16a9 | ||
|
|
93c8796ae8 | ||
|
|
654afb536e | ||
|
|
97154f9e5f | ||
|
|
c9e49b8e8f | ||
|
|
3ae52e21cf | ||
|
|
f2395f9001 | ||
|
|
3d248496ab | ||
|
|
34eca948c4 | ||
|
|
58815575ef | ||
|
|
28b012389d | ||
|
|
54f86537ad | ||
|
|
3284d93a33 | ||
|
|
01686a5f67 | ||
|
|
3b0c485701 | ||
|
|
e61d348f0c | ||
|
|
22e594bc62 | ||
|
|
061733672f | ||
|
|
b1f5da9561 | ||
|
|
db517059a3 | ||
|
|
0b40892775 | ||
|
|
8e6ed71e2a | ||
|
|
cf569702c4 | ||
|
|
2affc04e62 | ||
|
|
a648822027 | ||
|
|
c6244fa4b9 | ||
|
|
f6746b4977 | ||
|
|
f1f1a77151 | ||
|
|
ba1af627fd | ||
|
|
b82dfd7e64 | ||
|
|
7de2bcb584 | ||
|
|
d5d11e1077 | ||
|
|
ab66b20944 | ||
|
|
baf1682635 | ||
|
|
eea11834f8 | ||
|
|
1d86cc39e9 | ||
|
|
686252f151 | ||
|
|
f5c15f66a5 | ||
|
|
9d6b50320d | ||
|
|
b903c54be4 | ||
|
|
63b4a59588 | ||
|
|
adc6255fd4 | ||
|
|
b28e7a1357 | ||
|
|
e7ba9a5726 | ||
|
|
2586e78127 | ||
|
|
23f6dedb42 | ||
|
|
10f30ddb59 | ||
|
|
96a66f3ddd | ||
|
|
5bf8ceac66 | ||
|
|
4d42352ec5 | ||
|
|
479232b6c0 | ||
|
|
33cf262616 | ||
|
|
5e4990badc | ||
|
|
16568c0453 | ||
|
|
677c089614 | ||
|
|
6db2e781ce | ||
|
|
3296bccd3c | ||
|
|
0323fd36d4 | ||
|
|
1ba323e9a3 | ||
|
|
6f940a486b | ||
|
|
82cffe81f3 | ||
|
|
887e3c302f | ||
|
|
2560a4436e | ||
|
|
6dec759261 | ||
|
|
1c0d406cbb | ||
|
|
ae6ebd2d9d | ||
|
|
13a8efd694 | ||
|
|
678975b6b2 | ||
|
|
006f5f0fc9 | ||
|
|
cf96c53279 | ||
|
|
72413494b0 | ||
|
|
51dbd3a63f | ||
|
|
19561d44a2 | ||
|
|
1661f84deb | ||
|
|
f0035c1db6 | ||
|
|
b78f00a037 | ||
|
|
40809ffa10 | ||
|
|
e4d8913680 | ||
|
|
d234a4d51e | ||
|
|
3cc89a35a2 | ||
|
|
15aac378df | ||
|
|
0e04b230fe | ||
|
|
f3f79c301b | ||
|
|
923700dcc9 | ||
|
|
19dce8840d | ||
|
|
e040647530 | ||
|
|
ab5a4b4f04 | ||
|
|
cb1b4b0bd7 | ||
|
|
597a4e46b1 | ||
|
|
e68a499429 | ||
|
|
a56c85949a | ||
|
|
4eeb110247 | ||
|
|
194273955c | ||
|
|
490d478e54 | ||
|
|
1520d228bc | ||
|
|
61ac0cb753 | ||
|
|
2b02602a49 | ||
|
|
cad670785b | ||
|
|
980fed9e96 | ||
|
|
f670993fe9 | ||
|
|
fb20966353 | ||
|
|
74d0a90308 | ||
|
|
fef3804aa5 | ||
|
|
e66ef45df7 | ||
|
|
1f53ccb572 | ||
|
|
b63794b53c | ||
|
|
a69c648c6f | ||
|
|
29090d50a7 | ||
|
|
9b1a75b0ed | ||
|
|
b7ddd1daa2 | ||
|
|
6002c28a8d | ||
|
|
718b6651fe | ||
|
|
6191b7553e | ||
|
|
082a0b746a | ||
|
|
8d8bd73112 | ||
|
|
3a3707926d | ||
|
|
80dc93d6ce | ||
|
|
40af90ad25 | ||
|
|
4097f45677 | ||
|
|
039f07d332 | ||
|
|
cdcc7bbf74 | ||
|
|
9af9414dcc | ||
|
|
cf334e8758 | ||
|
|
888043762e | ||
|
|
7af96c8d36 | ||
|
|
a778baddca | ||
|
|
fc441646f4 | ||
|
|
4a47a984a0 | ||
|
|
8735aee847 | ||
|
|
c8eaf518ba | ||
|
|
bb8c23805a | ||
|
|
f334b9ea11 | ||
|
|
666cf169b9 | ||
|
|
a058937fe0 | ||
|
|
417fdd9dc7 | ||
|
|
bc388b1de5 | ||
|
|
9820896430 | ||
|
|
4258b226a4 | ||
|
|
5be0aca6d2 | ||
|
|
1110857ffa | ||
|
|
9a97419cfc | ||
|
|
ca74e71348 | ||
|
|
00303fb72b | ||
|
|
aa02a0996a | ||
|
|
59b49d34cc | ||
|
|
862047386c | ||
|
|
bbd05e4811 | ||
|
|
a02fa71dc8 | ||
|
|
99fd1bca3e | ||
|
|
2d73ea24ca | ||
|
|
645144ad4d | ||
|
|
88378ab354 | ||
|
|
f91ac5ed1d | ||
|
|
3b319240c6 | ||
|
|
d9b3f299e0 | ||
|
|
6f2d2a2001 | ||
|
|
8b9d51cf93 | ||
|
|
66eec7af15 | ||
|
|
60e511282a | ||
|
|
e4be4c7fcb | ||
|
|
99bf080092 | ||
|
|
0017b9c439 | ||
|
|
ad1b6ab37e | ||
|
|
acf7257293 | ||
|
|
b36c5441cb | ||
|
|
feedea4954 | ||
|
|
4f9cd7821a | ||
|
|
31e0a31c65 | ||
|
|
e09cab94d4 | ||
|
|
e708c85100 | ||
|
|
cfbbd13b86 | ||
|
|
8f0e032b5e | ||
|
|
0dc10d9f30 | ||
|
|
c4aa8fa9c5 | ||
|
|
9b83f37b78 | ||
|
|
a1a4474a01 | ||
|
|
3a06318b65 | ||
|
|
2f9e37d6a9 |
@@ -626,7 +626,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
|
||||
uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -893,14 +893,20 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 1/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 1
|
||||
name: Run script/clang-tidy for ESP32 IDF 1/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 1
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 2/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 2
|
||||
name: Run script/clang-tidy for ESP32 IDF 2/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 2
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 3/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 3
|
||||
name: Run script/clang-tidy for ESP32 IDF 3/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 3
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 4/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 4
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 5/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 5
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
|
||||
@@ -125,14 +125,20 @@ jobs:
|
||||
}
|
||||
|
||||
async function getEsphomeAndComponentChanges(github, owner, repo, prNumber) {
|
||||
const changedFiles = await github.rest.pulls.listFiles({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const changedFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
}
|
||||
);
|
||||
|
||||
const esphomeChanges = changedFiles.data
|
||||
.filter(file => file.filename !== "esphome/core/defines.h" && file.filename.startsWith('esphome/'))
|
||||
// Files used only for development and CI, which do not affect use as an external component
|
||||
const ignoredFiles = ["esphome/core/defines.h", "esphome/idf_component.yml"];
|
||||
const esphomeChanges = changedFiles
|
||||
.filter(file => !ignoredFiles.includes(file.filename) && file.filename.startsWith('esphome/'))
|
||||
.map(file => {
|
||||
const match = file.filename.match(/esphome\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
@@ -144,7 +150,7 @@ jobs:
|
||||
}
|
||||
|
||||
const uniqueEsphomeChanges = [...new Set(esphomeChanges)];
|
||||
const componentChanges = changedFiles.data
|
||||
const componentChanges = changedFiles
|
||||
.filter(file => file.filename.startsWith('esphome/components/'))
|
||||
.map(file => {
|
||||
const match = file.filename.match(/esphome\/components\/([^/]+)\//);
|
||||
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check blocking labels
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.9
|
||||
rev: v0.16.10
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
+9
-1
@@ -187,10 +187,12 @@ esphome/components/esp32_rmt/* @jesserockz
|
||||
esphome/components/esp32_rmt_led_strip/* @jesserockz
|
||||
esphome/components/esp8266/* @esphome/core
|
||||
esphome/components/esp_ldo/* @clydebarrow
|
||||
esphome/components/espectre/* @francescopace
|
||||
esphome/components/espnow/* @jesserockz
|
||||
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
|
||||
esphome/components/ethernet_info/* @gtjadsonsantos
|
||||
esphome/components/event/* @nohat
|
||||
esphome/components/exponential_moving_average/* @clydebarrow
|
||||
esphome/components/exposure_notifications/* @OttoWinter
|
||||
esphome/components/ezo/* @ssieb
|
||||
esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
@@ -245,8 +247,11 @@ esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/button/* @jesserockz
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/select/* @jesserockz
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
esphome/components/homeassistant/text/* @jesserockz
|
||||
esphome/components/honeywell_hih_i2c/* @Benichou34
|
||||
esphome/components/honeywellabp/* @RubyBailey
|
||||
esphome/components/honeywellabp2_i2c/* @jpfaff
|
||||
@@ -282,6 +287,7 @@ esphome/components/inkplate/* @jesserockz @JosipKuci
|
||||
esphome/components/integration/* @OttoWinter
|
||||
esphome/components/internal_temperature/* @Mat931
|
||||
esphome/components/interval/* @esphome/core
|
||||
esphome/components/ir_rf_base/* @bdraco @kbx81
|
||||
esphome/components/ir_rf_proxy/* @kbx81
|
||||
esphome/components/it8951/* @koosoli @limengdu @Passific
|
||||
esphome/components/jsn_sr04t/* @Mafus1
|
||||
@@ -562,7 +568,7 @@ esphome/components/tca9555/* @mobrembski
|
||||
esphome/components/tcl112/* @glmnet
|
||||
esphome/components/tcp_uart/* @Bascht74
|
||||
esphome/components/tee501/* @Stock-M
|
||||
esphome/components/teleinfo/* @0hax
|
||||
esphome/components/teleinfo/* @0hax @FredM67
|
||||
esphome/components/tem3200/* @bakerkj
|
||||
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
|
||||
esphome/components/template/datetime/* @rfdarter
|
||||
@@ -608,6 +614,7 @@ esphome/components/ufire_ec/* @pvizeli
|
||||
esphome/components/ufire_ise/* @pvizeli
|
||||
esphome/components/ufm01/* @ljungqvist
|
||||
esphome/components/ultrasonic/* @ssieb @swoboda1337
|
||||
esphome/components/unicode/* @esphome/core
|
||||
esphome/components/update/* @jesserockz
|
||||
esphome/components/uponor_smatrix/* @kroimon
|
||||
esphome/components/usb_cdc_acm/* @kbx81
|
||||
@@ -651,6 +658,7 @@ esphome/components/xiaomi_body_scale/* @dckiller51
|
||||
esphome/components/xiaomi_hhccjcy10/* @fariouche
|
||||
esphome/components/xiaomi_lywsd02mmc/* @juanluss31
|
||||
esphome/components/xiaomi_lywsd03mmc/* @ahpohl
|
||||
esphome/components/xiaomi_mccgq02hl/* @ahpohl @morph027
|
||||
esphome/components/xiaomi_mhoc303/* @drug123
|
||||
esphome/components/xiaomi_mhoc401/* @vevsvevs
|
||||
esphome/components/xiaomi_rtcgq02lm/* @jesserockz
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.10.0-dev
|
||||
PROJECT_NUMBER = 2026.10.0b3
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+1
-1
@@ -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.19.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+34
-8
@@ -7,6 +7,7 @@ import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
@@ -54,7 +55,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, coroutine
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.helpers import get_bool_env, indent, is_ip_address
|
||||
from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address
|
||||
from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.types import ConfigType
|
||||
@@ -829,9 +830,9 @@ def write_cpp_file() -> int:
|
||||
|
||||
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf):
|
||||
# Info, not a warning: an orchestrator cannot see YAML toolchain
|
||||
# overrides, this is its expected no-op, and a full build is safe.
|
||||
_LOGGER.info(
|
||||
# Debug only: an orchestrator cannot see YAML toolchain overrides,
|
||||
# so this is its expected no-op, and a full build is safe.
|
||||
_LOGGER.debug(
|
||||
"--skip-bootloader ignored: only supported on ESP32 with the "
|
||||
"esp-idf toolchain"
|
||||
)
|
||||
@@ -1311,9 +1312,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
# platform's final-validate hook merges duplicates anyway.
|
||||
available: dict[str, None] = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
platform = ota_item.get(CONF_PLATFORM)
|
||||
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
|
||||
available[platform] = None
|
||||
ota_platform = ota_item.get(CONF_PLATFORM)
|
||||
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
|
||||
available[ota_platform] = None
|
||||
|
||||
if not available:
|
||||
raise EsphomeError(
|
||||
@@ -1945,7 +1946,10 @@ def run_multiple_configs(
|
||||
safe_print()
|
||||
|
||||
cmd = command_builder(f)
|
||||
rc = run_external_process(*cmd)
|
||||
# The parent already logged the Intel macOS warning; children skip it.
|
||||
rc = run_external_process(
|
||||
*cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"}
|
||||
)
|
||||
|
||||
if rc == 0:
|
||||
print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}")
|
||||
@@ -2614,6 +2618,27 @@ def _warn_if_source_tree_mismatch() -> None:
|
||||
)
|
||||
|
||||
|
||||
_INTEL_MACOS_REMOVAL = "2027.6.0"
|
||||
_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED"
|
||||
|
||||
|
||||
def _warn_if_intel_macos() -> None:
|
||||
"""Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL."""
|
||||
if (
|
||||
not IS_MACOS
|
||||
or platform.machine() != "x86_64"
|
||||
or _INTEL_MACOS_WARNED_ENV in os.environ
|
||||
):
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"Support for Intel Macs will end in ESPHome %s or earlier. The Python "
|
||||
"packages ESPHome depends on have stopped publishing Intel macOS builds, "
|
||||
"so future releases will not install on this machine. On an Apple "
|
||||
"Silicon Mac, switch to a native arm64 Python.",
|
||||
_INTEL_MACOS_REMOVAL,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
@@ -2633,6 +2658,7 @@ def run_esphome(argv):
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
_warn_if_source_tree_mismatch()
|
||||
_warn_if_intel_macos()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
|
||||
@@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple):
|
||||
# Arduino core version -> its build in esphome-libs/arduino-esp8266
|
||||
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
|
||||
Version(3, 1, 2): FrameworkRelease(
|
||||
"3.1.2-esphome.1",
|
||||
"e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582",
|
||||
37189311,
|
||||
"3.1.2-esphome.3",
|
||||
"3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db",
|
||||
37185650,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
+33
-52
@@ -21,7 +21,7 @@ from esphome.const import (
|
||||
CONF_TYPE_ID,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda
|
||||
from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda
|
||||
from esphome.cpp_generator import (
|
||||
FlashStringLiteral,
|
||||
LambdaExpression,
|
||||
@@ -35,6 +35,32 @@ from esphome.types import ConfigType, SafeExpType
|
||||
from esphome.util import Registry
|
||||
|
||||
|
||||
def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj:
|
||||
"""Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr."""
|
||||
if not data:
|
||||
return cg.nullptr
|
||||
return cg.shared_progmem_array(
|
||||
name, cg.uint8, cg.ArrayInitializer(*(HexInt(x) for x in data))
|
||||
)
|
||||
|
||||
|
||||
async def templatable_bytes(
|
||||
value: Any,
|
||||
args: TemplateArgsType,
|
||||
set_template: MockObj,
|
||||
set_static: MockObj,
|
||||
table_name: str,
|
||||
) -> None:
|
||||
"""Set a TemplatableBytes: a lambda via set_template, constant bytes via set_static."""
|
||||
if cg.is_template(value):
|
||||
fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8))
|
||||
cg.add(set_template(fn))
|
||||
elif len(value) > 0xFFFF:
|
||||
raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535")
|
||||
else:
|
||||
cg.add(set_static(progmem_bytes(table_name, value), len(value)))
|
||||
|
||||
|
||||
def maybe_simple_id(*validators):
|
||||
"""Allow a raw ID to be specified in place of a config block.
|
||||
If the value that's being validated is a dictionary, it's passed as-is to the specified validators. Otherwise, it's
|
||||
@@ -646,52 +672,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition)
|
||||
|
||||
|
||||
@register_condition("and", AndCondition, validate_condition_list)
|
||||
async def and_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("or", OrCondition, validate_condition_list)
|
||||
async def or_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("all", AndCondition, validate_condition_list)
|
||||
async def all_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("or", OrCondition, validate_condition_list)
|
||||
@register_condition("any", OrCondition, validate_condition_list)
|
||||
async def any_condition_to_code(
|
||||
@register_condition("xor", XorCondition, validate_condition_list)
|
||||
async def condition_group_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
# A group of one is that condition for and, or and xor; skip the wrapper.
|
||||
if len(conditions) == 1:
|
||||
return conditions[0]
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
@@ -708,19 +702,6 @@ async def not_condition_to_code(
|
||||
return cg.new_Pvariable(condition_id, template_arg, condition)
|
||||
|
||||
|
||||
@register_condition("xor", XorCondition, validate_condition_list)
|
||||
async def xor_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
|
||||
async def lambda_condition_to_code(
|
||||
config: ConfigType,
|
||||
|
||||
@@ -54,6 +54,7 @@ from esphome.components.esp8266.const import (
|
||||
KEY_ESP8266,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_SCANF_FLOAT,
|
||||
THROW_STUBS_HEADER,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_project_cxx_compile_flags
|
||||
@@ -1056,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
cxx = (toolchain_tool(paths.toolchain, "g++"),)
|
||||
lines = [
|
||||
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
|
||||
*compile_rule_lines(),
|
||||
*compile_rule_lines(ccache),
|
||||
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
|
||||
*pch_rule_lines(),
|
||||
"rule link",
|
||||
@@ -1087,32 +1088,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines:
|
||||
core_exclude |= _CORE_EXCLUDE_WAVEFORM
|
||||
|
||||
archives = []
|
||||
# variant_dir existence was already enforced with the include dirs
|
||||
variant_sources = collect_sources(variant_dir)
|
||||
if variant_sources:
|
||||
objs = compile_edges(lines, variant_sources, variant_dir, "variant")
|
||||
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
|
||||
archives.append("libFrameworkArduinoVariant.a")
|
||||
|
||||
core_objs = compile_edges(
|
||||
lines, collect_sources(core_dir, core_exclude), core_dir, "core"
|
||||
)
|
||||
if not core_objs:
|
||||
# An empty archive would link into a wall of undefined references
|
||||
# (app_entry, the exception vectors) far from the cause
|
||||
raise EsphomeError(
|
||||
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
|
||||
)
|
||||
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
|
||||
archives.append("libFrameworkArduino.a")
|
||||
|
||||
lib_archives, direct_objs = library_edges(lines, libraries)
|
||||
archives += lib_archives
|
||||
|
||||
# One source of truth with the PlatformIO path: esp8266/__init__ pins
|
||||
# build_src_flags (the throw_stubs force-include); -include paths
|
||||
# resolve against the source root
|
||||
# esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on
|
||||
# the PlatformIO path; -include paths resolve against the source root
|
||||
src_other: list[str] = []
|
||||
src_includes: list[str] = []
|
||||
src_it = iter(
|
||||
@@ -1131,7 +1108,45 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
src_includes.append(tok[len("-include") :])
|
||||
else:
|
||||
src_other.append(_shell_token(tok))
|
||||
# The throw stubs abort: none anywhere with exceptions on, else the core and
|
||||
# libraries get them too (src keeps them after its pch include)
|
||||
if config.exceptions:
|
||||
src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER]
|
||||
include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes]
|
||||
framework_flags = ""
|
||||
if THROW_STUBS_HEADER in src_includes:
|
||||
stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)]
|
||||
lines.append(f"frameworkflags = {stubs_flag}")
|
||||
framework_flags = "$frameworkflags"
|
||||
archives = []
|
||||
# variant_dir existence was already enforced with the include dirs
|
||||
variant_sources = collect_sources(variant_dir)
|
||||
if variant_sources:
|
||||
objs = compile_edges(
|
||||
lines, variant_sources, variant_dir, "variant", flags=framework_flags
|
||||
)
|
||||
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
|
||||
archives.append("libFrameworkArduinoVariant.a")
|
||||
|
||||
core_objs = compile_edges(
|
||||
lines,
|
||||
collect_sources(core_dir, core_exclude),
|
||||
core_dir,
|
||||
"core",
|
||||
flags=framework_flags,
|
||||
)
|
||||
if not core_objs:
|
||||
# An empty archive would link into a wall of undefined references
|
||||
# (app_entry, the exception vectors) far from the cause
|
||||
raise EsphomeError(
|
||||
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
|
||||
)
|
||||
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
|
||||
archives.append("libFrameworkArduino.a")
|
||||
|
||||
lib_archives, direct_objs = library_edges(lines, libraries, framework_flags)
|
||||
archives += lib_archives
|
||||
|
||||
# One shared variable instead of repeating the flags line on every src
|
||||
# edge (hundreds of edges in a real project)
|
||||
lines.append(f"srcflags = {' '.join(src_other + include_flags)}")
|
||||
|
||||
@@ -160,7 +160,7 @@ def _cmake_quote(value: str) -> str:
|
||||
|
||||
# CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be
|
||||
# cleared at the CMake level (the same state IDF's RAM-app build type uses).
|
||||
# The macro is IDF's __build_process_project_includes plus two added lines;
|
||||
# The macro is IDF's __build_process_project_includes plus a few added lines;
|
||||
# the flag is ignored and the bootloader builds as usual if IDF changes it.
|
||||
IDF_BOOTLOADER_OVERRIDE = """\
|
||||
# ESPHome bootloader skip switch; see esphome/espidf/toolchain.py.
|
||||
@@ -173,6 +173,9 @@ if(ESPHOME_SKIP_BOOTLOADER)
|
||||
# skipped) bootloader project_include leaks; keep it defined, or
|
||||
# its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /.
|
||||
idf_build_get_property(idf_target IDF_TARGET)
|
||||
# partition_table's V1 ECDSA signing reads this key, which the
|
||||
# skipped bootloader project_include also sets.
|
||||
get_filename_component(SECURE_BOOT_SIGNING_KEY "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")
|
||||
idf_build_get_property(build_properties __BUILD_PROPERTIES)
|
||||
foreach(build_property ${build_properties})
|
||||
idf_build_get_property(val ${build_property})
|
||||
@@ -198,6 +201,10 @@ endif()
|
||||
BOOTLOADER_OVERRIDE_ADDED_LINES = (
|
||||
'set(CONFIG_APP_BUILD_BOOTLOADER "")',
|
||||
"idf_build_get_property(idf_target IDF_TARGET)",
|
||||
(
|
||||
"get_filename_component(SECURE_BOOT_SIGNING_KEY"
|
||||
' "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")'
|
||||
),
|
||||
)
|
||||
|
||||
_MACRO = re.compile(
|
||||
|
||||
@@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
|
||||
|
||||
lines = [
|
||||
*tool_lines(compilers.cc, compilers.cxx, ccache),
|
||||
*compile_rule_lines(),
|
||||
*compile_rule_lines(ccache),
|
||||
*pch_rule_lines(),
|
||||
"rule link",
|
||||
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
|
||||
|
||||
@@ -74,33 +74,40 @@ def tool_lines(
|
||||
# path gets
|
||||
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
|
||||
f"buildtool = {_q(_BUILD_TOOL)}",
|
||||
f"ccache = {_q(ccache) if ccache else ''}",
|
||||
*([f"ccache = {_q(ccache)}"] if ccache else []),
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def compile_rule_lines() -> list[str]:
|
||||
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
|
||||
def compile_rule_lines(ccache: str | None) -> list[str]:
|
||||
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
|
||||
|
||||
``ccache`` is the launcher ``tool_lines`` was given; the same test here
|
||||
keeps the variable and the rules in step. It is spliced in rather than
|
||||
left as an empty variable: a leading space on the command line makes
|
||||
CreateProcess fail on Windows.
|
||||
"""
|
||||
launcher = "$ccache " if ccache else ""
|
||||
return [
|
||||
"rule c",
|
||||
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CC $out",
|
||||
"rule cxx",
|
||||
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CXX $out",
|
||||
"rule aspp",
|
||||
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = AS $out",
|
||||
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
|
||||
# depfile and no $flags (defines/includes) either
|
||||
"rule asm",
|
||||
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
|
||||
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
|
||||
" description = AS $out",
|
||||
]
|
||||
|
||||
@@ -207,10 +214,12 @@ def compile_edges(
|
||||
|
||||
|
||||
def library_edges(
|
||||
lines: list[str], libraries: list[ArduinoLibrary]
|
||||
lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = ""
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Emit every library's compile and archive edges.
|
||||
|
||||
``extra_flags`` (raw ninja text) precede each library's own flags.
|
||||
|
||||
Returns the archive names and the objects that link directly. A
|
||||
library's own include dirs lead its compile lines, as PlatformIO searched
|
||||
them first: the include path is one global list, so another library's
|
||||
@@ -232,7 +241,7 @@ def library_edges(
|
||||
lib.sources,
|
||||
common_parent(lib.sources),
|
||||
f"lib/{lib.name}",
|
||||
flags=" ".join(_shell_token(f) for f in lib.flags),
|
||||
flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])),
|
||||
own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs),
|
||||
)
|
||||
if not lib.lib_archive:
|
||||
|
||||
@@ -31,6 +31,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
add_global,
|
||||
add_library,
|
||||
add_platformio_option,
|
||||
extern_progmem_array,
|
||||
get_variable,
|
||||
get_variable_with_full_id,
|
||||
is_template,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
@@ -5,7 +6,7 @@ from typing import Any
|
||||
from esphome import automation
|
||||
from esphome.automation import Condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_DESCRIPTION
|
||||
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
|
||||
from esphome.components.logger import request_log_listener
|
||||
|
||||
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
|
||||
@@ -26,6 +27,8 @@ from esphome.const import (
|
||||
CONF_CAPTURE_RESPONSE,
|
||||
CONF_DATA,
|
||||
CONF_DATA_TEMPLATE,
|
||||
CONF_DELAY,
|
||||
CONF_ENABLE_IPV6,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_EVENT,
|
||||
CONF_ID,
|
||||
@@ -48,10 +51,14 @@ from esphome.const import (
|
||||
CONF_VARIABLES,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.helpers import fnv1_hash
|
||||
from esphome.cpp_generator import Expression, MockObj, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import cpp_string_escape, fnv1_hash
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigFragmentType, ConfigType
|
||||
|
||||
from . import wizard
|
||||
|
||||
# Compat alias: downstream consumers (e.g. device-builder) referenced the
|
||||
# schema by its old private name before it moved to the noise component
|
||||
_encryption_schema = encryption_schema
|
||||
@@ -86,6 +93,7 @@ APIServer = api_ns.class_("APIServer", cg.Component)
|
||||
HomeAssistantServiceCallAction = api_ns.class_(
|
||||
"HomeAssistantServiceCallAction", automation.Action
|
||||
)
|
||||
HomeAssistantField = api_ns.struct("HomeAssistantField")
|
||||
ActionResponse = api_ns.class_("ActionResponse")
|
||||
HomeAssistantActionResponseTrigger = api_ns.class_(
|
||||
"HomeAssistantActionResponseTrigger", automation.Trigger
|
||||
@@ -135,6 +143,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
||||
CONF_LISTEN_BACKLOG = "listen_backlog"
|
||||
CONF_MAX_SEND_QUEUE = "max_send_queue"
|
||||
CONF_OUTGOING_CONNECTION = "outgoing_connection"
|
||||
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
|
||||
# Schema defaults that also match the C++ initializers in api_server.h; codegen
|
||||
@@ -142,6 +151,7 @@ CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
DEFAULT_PORT = 6053
|
||||
DEFAULT_REBOOT_TIMEOUT = "15min"
|
||||
DEFAULT_BATCH_DELAY = "100ms"
|
||||
DEFAULT_LISTEN_BACKLOG = 4
|
||||
|
||||
|
||||
def _register_provisioning_source(config: ConfigType) -> ConfigType:
|
||||
@@ -292,9 +302,72 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
||||
# (not max_connections, which is the upper limit rarely reached)
|
||||
socket.consume_sockets(3, "api")(config)
|
||||
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
|
||||
if CONF_OUTGOING_CONNECTION in config:
|
||||
socket.consume_sockets(1, "api_outgoing_connection")(config)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
|
||||
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None:
|
||||
return config
|
||||
if CONF_ENCRYPTION not in config:
|
||||
raise cv.Invalid(
|
||||
"outgoing_connection requires 'encryption' so the peer is verified by key",
|
||||
path=[CONF_OUTGOING_CONNECTION],
|
||||
)
|
||||
# A device with no client reboots once reboot_timeout passes, so a delay that
|
||||
# reaches it would reboot the device before it ever dials
|
||||
reboot_timeout = config[CONF_REBOOT_TIMEOUT]
|
||||
delay = outgoing[CONF_DELAY]
|
||||
if reboot_timeout.total_milliseconds and delay >= reboot_timeout:
|
||||
raise cv.Invalid(
|
||||
f"delay must be shorter than reboot_timeout ({reboot_timeout}), "
|
||||
"otherwise the device reboots before it dials",
|
||||
path=[CONF_OUTGOING_CONNECTION, CONF_DELAY],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address:
|
||||
"""Only accept an address the device itself can parse.
|
||||
|
||||
Python accepts a scope id, which neither `inet_pton` nor lwIP's `inet6_aton`
|
||||
takes, and a v4-mapped address is dialed as plain IPv4, needing no IPv6 build.
|
||||
"""
|
||||
address = cv.ipaddress(value)
|
||||
if isinstance(address, IPv6Address):
|
||||
if address.scope_id is not None:
|
||||
raise cv.Invalid(
|
||||
f"{value} carries a scope id, which the device cannot parse; "
|
||||
"give the address without the '%' part"
|
||||
)
|
||||
if (mapped := address.ipv4_mapped) is not None:
|
||||
return mapped
|
||||
return address
|
||||
|
||||
|
||||
_OUTGOING_CONNECTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_HOST): _validate_outgoing_host,
|
||||
cv.Optional(CONF_PORT, default=6054): cv.port,
|
||||
# Bounded against reboot_timeout in _validate_outgoing_connection
|
||||
cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
|
||||
# A bare `outgoing_connection:` block is valid; without a host the device
|
||||
# dials the remembered last dial-back client
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# Let the language-schema dumper walk host, port and delay
|
||||
return _OUTGOING_CONNECTION_SCHEMA
|
||||
if config is None:
|
||||
config = {}
|
||||
return _OUTGOING_CONNECTION_SCHEMA(config)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -319,6 +392,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
): ACTIONS_SCHEMA,
|
||||
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
||||
cv.Optional(CONF_ENCRYPTION): encryption_schema,
|
||||
cv.Optional(wizard.CONF_WIZARD): wizard.WIZARD_SCHEMA,
|
||||
cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema,
|
||||
cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||
@@ -374,6 +449,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
|
||||
_validate_outgoing_connection,
|
||||
_consume_api_sockets,
|
||||
_register_provisioning_source,
|
||||
)
|
||||
@@ -430,7 +506,34 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
|
||||
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
|
||||
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
|
||||
if (
|
||||
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
|
||||
or (host := outgoing.get(CONF_HOST)) is None
|
||||
or host.version != 6
|
||||
):
|
||||
return config
|
||||
network_conf = fv.full_config.get().get("network") or {}
|
||||
if not network_conf.get(CONF_ENABLE_IPV6):
|
||||
raise cv.Invalid(
|
||||
"outgoing_connection host is an IPv6 address but IPv6 is not "
|
||||
"enabled; set 'network: enable_ipv6: true'",
|
||||
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_wizard(config: ConfigType) -> ConfigType:
|
||||
wizard.final_validate(config)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
_validate_esp8266_action_strings,
|
||||
_validate_outgoing_host_ipv6,
|
||||
_validate_wizard,
|
||||
)
|
||||
|
||||
|
||||
def _add_action_strings(
|
||||
@@ -477,8 +580,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_reboot_timeout(reboot_timeout))
|
||||
if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY):
|
||||
cg.add(var.set_batch_delay(batch_delay))
|
||||
if CONF_LISTEN_BACKLOG in config:
|
||||
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
|
||||
if (
|
||||
listen_backlog := config.get(CONF_LISTEN_BACKLOG)
|
||||
) is not None and listen_backlog != DEFAULT_LISTEN_BACKLOG:
|
||||
cg.add(var.set_listen_backlog(listen_backlog))
|
||||
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
|
||||
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
|
||||
|
||||
@@ -583,6 +688,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action
|
||||
cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1))
|
||||
|
||||
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
|
||||
await wizard.to_code(wizard_config)
|
||||
|
||||
if CONF_ON_CLIENT_CONNECTED in config:
|
||||
cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER")
|
||||
await automation.build_automation(
|
||||
@@ -618,6 +726,13 @@ async def to_code(config: ConfigType) -> None:
|
||||
else:
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
|
||||
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None:
|
||||
cg.add_define("USE_API_OUTGOING_CONNECTION")
|
||||
if (host := outgoing.get(CONF_HOST)) is not None:
|
||||
cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host))
|
||||
cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT])
|
||||
cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY])
|
||||
|
||||
cg.add_define("USE_API")
|
||||
cg.add_global(api_ns.using)
|
||||
|
||||
@@ -658,6 +773,11 @@ VARIABLES_SCHEMA = cv.Schema(
|
||||
{cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
|
||||
)
|
||||
|
||||
# The action stores each map's entry count in a uint8_t
|
||||
_FIELD_MAP_MAX = 255
|
||||
DATA_FIELDS_SCHEMA = cv.All(KEY_VALUE_SCHEMA, cv.Length(max=_FIELD_MAP_MAX))
|
||||
VARIABLES_FIELDS_SCHEMA = cv.All(VARIABLES_SCHEMA, cv.Length(max=_FIELD_MAP_MAX))
|
||||
|
||||
|
||||
def _validate_response_config(config: ConfigType) -> ConfigType:
|
||||
# Validate dependencies:
|
||||
@@ -692,9 +812,9 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.templatable(
|
||||
cv.string
|
||||
),
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
|
||||
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
|
||||
@@ -707,6 +827,62 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _field_string(value: str) -> Expression:
|
||||
# ESP8266 can only keep a string in flash as its own PROGMEM array
|
||||
literal = cg.RawExpression(cpp_string_escape(value))
|
||||
if CORE.is_esp8266:
|
||||
return cg.shared_progmem_array("ha_field_str", cg.char, literal)
|
||||
return literal
|
||||
|
||||
|
||||
async def _new_service_call_action(
|
||||
server_id: ID,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
is_event: bool,
|
||||
service: Any,
|
||||
data: dict[str, Any],
|
||||
data_template: dict[str, Any],
|
||||
variables: dict[str, Any],
|
||||
) -> MockObj:
|
||||
"""Create the action with its name and fields in one shared flash table."""
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(server_id)
|
||||
field_type = HomeAssistantField.template(template_arg)
|
||||
groups = [data, data_template, variables]
|
||||
# A lambda may keep static state, so a table with lambdas is never shared
|
||||
has_lambda = cg.is_template(service) or any(
|
||||
cg.is_template(value) for group in groups for value in group.values()
|
||||
)
|
||||
|
||||
async def field(key: str | None, value: Any, output_type: Any = None) -> Expression:
|
||||
key_exp = cg.nullptr if key is None else _field_string(key)
|
||||
if cg.is_template(value):
|
||||
# output_type=None lets lambdas return numbers or char pointers; C++ converts them
|
||||
lam = await cg.process_lambda(value, args, return_type=output_type)
|
||||
return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})")
|
||||
return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr)
|
||||
|
||||
entries = [await field(None, service, cg.std_string)]
|
||||
for group in groups:
|
||||
for key, value in group.items():
|
||||
entries.append(await field(key, value))
|
||||
table = cg.shared_progmem_array(
|
||||
"ha_action_fields",
|
||||
field_type,
|
||||
cg.ArrayInitializer(*entries, multiline=True),
|
||||
share=not has_lambda,
|
||||
)
|
||||
return cg.new_Pvariable(
|
||||
action_id, template_arg, serv, is_event, table, *(len(g) for g in groups)
|
||||
)
|
||||
|
||||
|
||||
def _service_call_fields(config: ConfigType) -> tuple[dict[str, Any], ...]:
|
||||
return config[CONF_DATA], config[CONF_DATA_TEMPLATE], config[CONF_VARIABLES]
|
||||
|
||||
|
||||
# synchronous=False: when on_success/on_error is configured, play() stores the
|
||||
# trigger args until the HomeassistantActionResponse arrives, so non-owning args
|
||||
# (StringRef into the API receive buffer) must not be used.
|
||||
@@ -728,36 +904,15 @@ async def homeassistant_service_to_code(
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, False)
|
||||
templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string)
|
||||
cg.add(var.set_service(templ))
|
||||
|
||||
# Initialize FixedVectors with exact sizes from config
|
||||
cg.add(var.init_data(len(config[CONF_DATA])))
|
||||
for key, value in config[CONF_DATA].items():
|
||||
# output_type=None because lambdas can return non-string types (int,
|
||||
# float, char*) that TemplatableStringValue converts via to_string.
|
||||
# Static strings are manually wrapped for PROGMEM on ESP8266.
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
|
||||
for key, value in config[CONF_DATA_TEMPLATE].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
var = await _new_service_call_action(
|
||||
config[CONF_ID],
|
||||
action_id,
|
||||
template_arg,
|
||||
args,
|
||||
False,
|
||||
config[CONF_ACTION],
|
||||
*_service_call_fields(config),
|
||||
)
|
||||
|
||||
if on_error := config.get(CONF_ON_ERROR):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES")
|
||||
@@ -809,9 +964,9 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(APIServer),
|
||||
cv.Required(CONF_EVENT): validate_homeassistant_event,
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -830,38 +985,15 @@ async def homeassistant_event_to_code(
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||
templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string)
|
||||
cg.add(var.set_service(templ))
|
||||
|
||||
# Initialize FixedVectors with exact sizes from config
|
||||
cg.add(var.init_data(len(config[CONF_DATA])))
|
||||
for key, value in config[CONF_DATA].items():
|
||||
# output_type=None because lambdas can return non-string types (int,
|
||||
# float, char*) that TemplatableStringValue converts via to_string.
|
||||
# Static strings are manually wrapped for PROGMEM on ESP8266.
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
|
||||
for key, value in config[CONF_DATA_TEMPLATE].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
return var
|
||||
return await _new_service_call_action(
|
||||
config[CONF_ID],
|
||||
action_id,
|
||||
template_arg,
|
||||
args,
|
||||
True,
|
||||
config[CONF_EVENT],
|
||||
*_service_call_fields(config),
|
||||
)
|
||||
|
||||
|
||||
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
@@ -885,15 +1017,17 @@ async def homeassistant_tag_scanned_to_code(
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||
cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned")))
|
||||
# Initialize FixedVector with exact size (1 data field)
|
||||
cg.add(var.init_data(1))
|
||||
templ = await cg.templatable(config[CONF_TAG], args, cg.std_string)
|
||||
cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ))
|
||||
return var
|
||||
return await _new_service_call_action(
|
||||
config[CONF_ID],
|
||||
action_id,
|
||||
template_arg,
|
||||
args,
|
||||
True,
|
||||
"esphome.tag_scanned",
|
||||
{"tag_id": config[CONF_TAG]},
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
CONF_SUCCESS = "success"
|
||||
@@ -1004,6 +1138,8 @@ _define_filter = filter_source_files_from_defines(
|
||||
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
|
||||
"api_frame_helper_noise.cpp": "USE_API_NOISE",
|
||||
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
|
||||
"api_wizard.cpp": "USE_API_WIZARD",
|
||||
"api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ service APIConnection {
|
||||
option (needs_authentication) = false;
|
||||
}
|
||||
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
|
||||
rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {}
|
||||
rpc wizard_input_set (WizardInputSetRequest) returns (void) {}
|
||||
rpc list_entities (ListEntitiesRequest) returns (void) {}
|
||||
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
|
||||
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
|
||||
@@ -76,8 +78,11 @@ service APIConnection {
|
||||
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 subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {}
|
||||
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
|
||||
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
|
||||
|
||||
rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +118,11 @@ message HelloRequest {
|
||||
string client_info = 1;
|
||||
uint32 api_version_major = 2;
|
||||
uint32 api_version_minor = 3;
|
||||
|
||||
// Set by clients that can accept connections the device opens to them
|
||||
// (see api: outgoing_connection:). The device remembers this client's
|
||||
// address as the target to dial when no such client is connected.
|
||||
bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
|
||||
}
|
||||
|
||||
// Confirmation of successful connection request.
|
||||
@@ -228,6 +238,7 @@ enum SerialProxyPortType {
|
||||
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; // since API 1.18
|
||||
}
|
||||
|
||||
message SerialProxyInfo {
|
||||
@@ -332,6 +343,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"];
|
||||
|
||||
// Device is built with the api outgoing_connection option and can open
|
||||
// the TCP connection to a dial-back target itself
|
||||
bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE CAPABILITIES ====================
|
||||
@@ -380,6 +395,16 @@ message ZWaveProxyCapabilities {
|
||||
uint32 home_id = 2;
|
||||
}
|
||||
|
||||
message WizardCapabilities {
|
||||
// True when the device has a wizard configured, so DeviceWizardRequest will be answered
|
||||
bool configured = 1;
|
||||
}
|
||||
|
||||
message SendspinCapabilities {
|
||||
// Bitmask of the Sendspin features this device supports
|
||||
uint32 feature_flags = 1;
|
||||
}
|
||||
|
||||
message DeviceCapabilitiesResponse {
|
||||
option (id) = 150;
|
||||
option (source) = SOURCE_SERVER;
|
||||
@@ -389,6 +414,102 @@ message DeviceCapabilitiesResponse {
|
||||
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
repeated SerialProxyInfo serial_proxies = 4
|
||||
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"];
|
||||
SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE WIZARD ====================
|
||||
|
||||
// Asks the device for the onboarding wizard that Home Assistant presents when the
|
||||
// device is added.
|
||||
//
|
||||
// The wizard is read once, when the device is added, whereas capabilities are read
|
||||
// on every connect, so it is a separate message rather than part of
|
||||
// DeviceCapabilitiesResponse. It is only served on an authenticated connection.
|
||||
//
|
||||
// A device without a wizard does not have this message at all and never answers it,
|
||||
// as a device ignores messages it does not know. Clients therefore check
|
||||
// DeviceCapabilitiesResponse.wizard.configured before sending it.
|
||||
message DeviceWizardRequest {
|
||||
option (id) = 156;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_API_WIZARD";
|
||||
// Empty
|
||||
}
|
||||
|
||||
// The wizard, as UTF-8 JSON compressed into a single zstd frame. The device builds it
|
||||
// when it is compiled and sends it unchanged, so its size varies with the wizard and
|
||||
// its format can grow without new fields here. Clients decompress it, then read:
|
||||
//
|
||||
// {"version": 1,
|
||||
// "pages": [{"title": "...", "description": "...",
|
||||
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
|
||||
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
|
||||
// "entity_filters": [{"integration": "...", "domain": ["..."],
|
||||
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
|
||||
//
|
||||
// Values that are empty or unset are left out, and so are empty lists. Strings may be
|
||||
// Home Assistant translation placeholders such as "[%key:component::domain::name%]",
|
||||
// passed through verbatim. The pages are shown in the order listed. A client that does
|
||||
// not know the version must not use the wizard.
|
||||
//
|
||||
// An entity is an entity of this device that the page shows to the user (for example to
|
||||
// turn it on or off). Its key is the key ListEntitiesResponse sends for it. Its
|
||||
// device_id is the device_id ListEntitiesResponse sends, and is left out when that is 0.
|
||||
//
|
||||
// An input is filled in by the user with the id of a Home Assistant entity. It either
|
||||
// stands for one entity of the device's homeassistant platform (sensor, binary_sensor,
|
||||
// text_sensor, number, switch, text, select or button) that sets no entity_id of its own,
|
||||
// as Home Assistant supplies it, or it is a standalone input that only stores the entity
|
||||
// id for the device's own automations. Its key is the FNV-1 hash
|
||||
// of the input's ESPHome id, not an entity key. The client sends the chosen entity id to
|
||||
// the device with a WizardInputSetRequest. entity_filters says which Home Assistant
|
||||
// entities the user may choose, and mirrors Home Assistant's EntityFilterSelectorConfig:
|
||||
// an entity matches a filter when it satisfies every field that is set, and an entity
|
||||
// that matches any one of the filters is accepted.
|
||||
//
|
||||
// An input with "multiple": true lets the user pick several entities. The client sends
|
||||
// them in one WizardInputSetRequest as a comma separated list with no spaces
|
||||
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
|
||||
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
|
||||
// longest list in bytes the device accepts. The version stays 1: a client that does not
|
||||
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
|
||||
// multiple input accepts.
|
||||
//
|
||||
// The device keeps the entity id of an input in RAM only and does not store it across
|
||||
// restarts. The client therefore stores the choice itself and, after every connection
|
||||
// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest
|
||||
// for each input. It sends one again whenever the user changes the choice.
|
||||
message DeviceWizardResponse {
|
||||
option (id) = 157;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_API_WIZARD";
|
||||
|
||||
// The data is in flash, and the device sends it from there
|
||||
bytes data = 1 [(pointer_to_buffer) = true, (progmem) = true];
|
||||
}
|
||||
|
||||
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
|
||||
// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no
|
||||
// reply. The device ignores a request whose key matches no input. A single input is
|
||||
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
|
||||
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
|
||||
// spaces, the device trims spaces around each id and stores the list without them. The
|
||||
// whole request is ignored when any id is empty or has no '.', or when the list without
|
||||
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
|
||||
// or list in RAM,
|
||||
// not across restarts, and when the input belongs to a homeassistant platform entity
|
||||
// it subscribes to that entity's state. Clients send this for every input right
|
||||
// after connecting, before SubscribeHomeAssistantStatesRequest. A request that
|
||||
// arrives later still works: the device then sends the affected state subscriptions
|
||||
// again.
|
||||
message WizardInputSetRequest {
|
||||
option (id) = 158;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_API_WIZARD_INPUTS";
|
||||
|
||||
fixed32 key = 1;
|
||||
string entity_id = 2 [(max_data_length) = 4095];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
@@ -1439,7 +1560,7 @@ message ListEntitiesSelectResponse {
|
||||
reserved 4; // Deprecated: was string unique_id
|
||||
|
||||
string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
|
||||
repeated string options = 6 [(container_pointer_no_template) = "FixedVector<const char *>"];
|
||||
repeated string options = 6 [(container_pointer_no_template) = "std::span<const char *const>"];
|
||||
bool disabled_by_default = 7;
|
||||
EntityCategory entity_category = 8;
|
||||
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
|
||||
@@ -2683,7 +2804,7 @@ message ListEntitiesInfraredResponse {
|
||||
message InfraredRFTransmitRawTimingsRequest {
|
||||
option (id) = 136;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
|
||||
@@ -2697,7 +2818,7 @@ message InfraredRFTransmitRawTimingsRequest {
|
||||
message InfraredRFReceiveEvent {
|
||||
option (id) = 137;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
option (no_delay) = true;
|
||||
option (speed_optimized) = true;
|
||||
|
||||
@@ -2706,6 +2827,28 @@ message InfraredRFReceiveEvent {
|
||||
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector<int32_t>"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
|
||||
}
|
||||
|
||||
// Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the
|
||||
// transmitter reports that the transmission (all repeats) has finished, or immediately with
|
||||
// success=false if it was refused before reaching the transmitter (unknown key, no transmitter,
|
||||
// no or invalid timings) or the transmitter could not send it (not set up, hardware error).
|
||||
// success=false also answers a request superseded by a newer request on the same entity, from
|
||||
// any client, and a transmit that reported nothing within 30 s past its expected air time, so a
|
||||
// client should not retry on it blindly. The device serializes transmits per transmitter and
|
||||
// several entities may share one, so a client should keep at most one transmit outstanding per
|
||||
// device, not per entity. A reply for an unknown key or a refused request is sent once and can be lost
|
||||
// when the device's send buffer is full, so a client should also stop waiting on its own after the
|
||||
// expected air time plus a margin. Lets clients pace requests instead of estimating durations (since API 1.18)
|
||||
message InfraredRFTransmitCompleteResponse {
|
||||
option (id) = 153;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
option (no_delay) = true;
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key of the transmitter entity from the request
|
||||
bool success = 3; // false if the request was refused, not sent, superseded, or never reported
|
||||
}
|
||||
|
||||
// ==================== RADIO FREQUENCY ====================
|
||||
|
||||
// Lists available radio frequency entity instances
|
||||
@@ -2876,6 +3019,56 @@ message SerialProxySetModeRequest {
|
||||
SerialProxyMode mode = 2;
|
||||
}
|
||||
|
||||
// Subscribe to the identity of every serial proxy port. The device answers with one
|
||||
// SerialProxyIdentity per port, then sends another whenever a port's identity changes,
|
||||
// for the life of the connection (since API 1.18).
|
||||
message SubscribeSerialProxyIdentityRequest {
|
||||
option (id) = 154;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
}
|
||||
|
||||
// Where a port's identity comes from
|
||||
enum SerialProxyIdentitySource {
|
||||
SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0; // The port carries no identity
|
||||
SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1; // Reserved for identities stated in the device configuration; not sent yet
|
||||
SERIAL_PROXY_IDENTITY_SOURCE_USB = 2; // Read from the descriptors of the USB device behind the port;
|
||||
// changes when a device is attached or removed
|
||||
}
|
||||
|
||||
enum SerialProxyIdentityFlag {
|
||||
SERIAL_PROXY_IDENTITY_FLAG_NONE = 0;
|
||||
SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1; // The backend believes the device is present and usable on this port
|
||||
SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2; // The USB host stack refused the descriptor query; the strings and
|
||||
// IDs below are empty
|
||||
}
|
||||
|
||||
// The descriptor fields of a USB device as seen by the host stack
|
||||
message UsbDeviceDescriptor {
|
||||
uint32 vendor_id = 1;
|
||||
uint32 product_id = 2;
|
||||
uint32 bcd_device = 3;
|
||||
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to
|
||||
}
|
||||
|
||||
// The identity of the device behind a port. Identifies one physical device among others of
|
||||
// the same kind, so a client matches on manufacturer and product regardless of source. The
|
||||
// strings are empty for source NONE, and for source USB while nothing is connected
|
||||
// (since API 1.18).
|
||||
message SerialProxyIdentity {
|
||||
option (id) = 155;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
|
||||
uint32 instance = 1;
|
||||
SerialProxyIdentitySource source = 2;
|
||||
uint32 flags = 3; // Bitmask of SerialProxyIdentityFlag
|
||||
string manufacturer = 4;
|
||||
string product = 5;
|
||||
string serial_number = 6;
|
||||
UsbDeviceDescriptor usb = 7; // Only sent for source USB
|
||||
}
|
||||
|
||||
// ==================== BLUETOOTH CONNECTION PARAMS ====================
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
@@ -2897,3 +3090,34 @@ message BluetoothSetConnectionParamsResponse {
|
||||
uint64 address = 1;
|
||||
int32 error = 2;
|
||||
}
|
||||
|
||||
// ==================== SENDSPIN ====================
|
||||
enum SendspinPairingTokenStatus {
|
||||
// The Sendspin client has not started yet, so the token does not exist yet. Ask again later.
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0;
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1;
|
||||
// The device has no API encryption key, so it will not send the token. Ask again once a key is set.
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2;
|
||||
// Sendspin is turned off and has not started since boot, so the token does not exist yet.
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3;
|
||||
// The Sendspin client failed to start, and will not start until the device reboots.
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4;
|
||||
}
|
||||
|
||||
// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device
|
||||
message SendspinPairingTokenRequest {
|
||||
option (id) = 159;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SENDSPIN";
|
||||
// Empty
|
||||
}
|
||||
|
||||
message SendspinPairingTokenResponse {
|
||||
option (id) = 160;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_SENDSPIN";
|
||||
|
||||
SendspinPairingTokenStatus status = 1;
|
||||
// The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK.
|
||||
string token = 2 [(max_data_length) = 127];
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
#include "esphome/components/zwave_proxy/zwave_proxy.h"
|
||||
#endif
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
#endif
|
||||
#ifdef USE_WATER_HEATER
|
||||
#include "esphome/components/water_heater/water_heater.h"
|
||||
#endif
|
||||
@@ -198,6 +201,17 @@ APIConnection::~APIConnection() {
|
||||
proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// entities holding a transmit reply for this client must not answer into a freed connection
|
||||
#ifdef USE_INFRARED
|
||||
for (auto *infrared : App.get_infrareds()) {
|
||||
infrared->on_api_connection_closed(this);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
for (auto *radio_frequency : App.get_radio_frequencies()) {
|
||||
radio_frequency->on_api_connection_closed(this);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -990,7 +1004,9 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection
|
||||
uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
||||
auto *select = static_cast<select::Select *>(entity);
|
||||
ListEntitiesSelectResponse msg;
|
||||
msg.options = &select->traits.get_options();
|
||||
const auto &opts = select->traits.get_options();
|
||||
const std::span<const char *const> options(opts.data(), opts.size());
|
||||
msg.options = &options;
|
||||
return fill_and_encode_entity_info(select, msg, conn, remaining_size);
|
||||
}
|
||||
void APIConnection::on_select_command_request(const SelectCommandRequest &msg) {
|
||||
@@ -1389,6 +1405,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
bool APIConnection::send_sendspin_pairing_token_response_() {
|
||||
SendspinPairingTokenResponse resp;
|
||||
#ifdef USE_API_NOISE
|
||||
// has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions
|
||||
// never reach hello, and activating a key closes every session opened before it.
|
||||
if (!this->parent_->get_noise_ctx().has_psk()) {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
|
||||
return this->send_message(resp);
|
||||
}
|
||||
auto token = sendspin_::global_sendspin_hub->get_pairing_token();
|
||||
if (token.has_value()) {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK;
|
||||
resp.token = StringRef(*token);
|
||||
} else if (sendspin_::global_sendspin_hub->status_has_error()) {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED;
|
||||
} else if (sendspin_::global_sendspin_hub->is_disabled()) {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED;
|
||||
} else {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY;
|
||||
}
|
||||
#else
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
|
||||
#endif
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
void APIConnection::on_sendspin_pairing_token_request() {
|
||||
if (!this->send_sendspin_pairing_token_response_()) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
#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,
|
||||
@@ -1515,8 +1565,10 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) {
|
||||
// Clients on API 1.18+ are told when the frame has left the transmitter; the entity owns that reply
|
||||
const bool want_reply = this->client_supports_api_version(1, 18);
|
||||
// Dispatch by key: infrared entities are checked first, then radio frequency entities.
|
||||
// The key is unique across all entity instances on a device, so at most one lookup will succeed.
|
||||
#ifdef USE_INFRARED
|
||||
@@ -1526,6 +1578,7 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
|
||||
call.set_carrier_frequency(msg.carrier_frequency);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.set_api_connection(want_reply ? this : nullptr);
|
||||
call.perform();
|
||||
return;
|
||||
}
|
||||
@@ -1538,13 +1591,38 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
|
||||
call.set_modulation(static_cast<radio_frequency::RadioFrequencyModulation>(msg.modulation));
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.set_api_connection(want_reply ? this : nullptr);
|
||||
call.perform();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
ESP_LOGW(TAG, "IR/RF transmit for unknown key %" PRIu32, msg.key);
|
||||
if (want_reply) {
|
||||
// nothing will ever report for an unknown key, so answer as not started right away
|
||||
#ifdef USE_DEVICES
|
||||
const uint32_t device_id = msg.device_id;
|
||||
#else
|
||||
const uint32_t device_id = 0;
|
||||
#endif
|
||||
if (!this->send_infrared_rf_transmit_complete(device_id, msg.key, false)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "IR/RF reply");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool APIConnection::send_infrared_rf_transmit_complete([[maybe_unused]] uint32_t device_id, uint32_t key,
|
||||
bool success) {
|
||||
InfraredRFTransmitCompleteResponse resp{};
|
||||
#ifdef USE_DEVICES
|
||||
resp.device_id = device_id;
|
||||
#endif
|
||||
resp.key = key;
|
||||
resp.success = success;
|
||||
return this->send_message(resp);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
// V: fires per decoded frame with no subscription gate, so a warning
|
||||
@@ -1552,6 +1630,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
|
||||
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
@@ -1643,6 +1722,22 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_subscribe_serial_proxy_identity_request() {
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
// Only USB ports change identity after this snapshot
|
||||
this->flags_.serial_proxy_identity_subscription = true;
|
||||
#endif
|
||||
for (auto *proxy : App.get_serial_proxies()) {
|
||||
proxy->send_identity(this);
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_identity(const SerialProxyIdentity &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy identity");
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
@@ -1814,7 +1909,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 17;
|
||||
resp.api_version_minor = 19;
|
||||
// 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());
|
||||
@@ -1837,6 +1932,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
|
||||
this->complete_authentication_();
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// With a PSK set only key-verified transports reach hello: plaintext and
|
||||
// zero-PSK are rejected, and pre-activation sessions are force-closed
|
||||
if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) {
|
||||
if (this->parent_->get_noise_ctx().has_psk()) {
|
||||
this->flags_.outgoing_connection_target = true;
|
||||
this->parent_->on_outgoing_target_client(this);
|
||||
} else {
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active"));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
@@ -1959,6 +2067,9 @@ bool APIConnection::send_device_info_response_() {
|
||||
// one) so this advertisement survives the plaintext removal in 2027.2.0.
|
||||
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
resp.api_outgoing_connection_supported = true;
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_DEVICES
|
||||
size_t device_index = 0;
|
||||
@@ -2011,6 +2122,12 @@ bool APIConnection::send_device_capabilities_response_() {
|
||||
info.port_type = proxy->get_port_type();
|
||||
info.configured_line_states = proxy->get_configured_modem_pins();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD
|
||||
resp.wizard.configured = true;
|
||||
#endif
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
|
||||
#endif
|
||||
return this->send_message(resp);
|
||||
}
|
||||
@@ -2225,7 +2342,13 @@ void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSet
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; }
|
||||
void APIConnection::on_subscribe_home_assistant_states_request() {
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
// Remember it, as a client that subscribed also gets the subscriptions again when a wizard input is set
|
||||
this->flags_.home_assistant_states = true;
|
||||
#endif
|
||||
state_subs_at_ = 0;
|
||||
}
|
||||
#endif
|
||||
bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
|
||||
delay(0);
|
||||
@@ -2269,8 +2392,7 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type,
|
||||
#endif
|
||||
// Capacity reserved above, cannot fail
|
||||
(void) shared_buf.resize(write_start + payload_size);
|
||||
ProtoWriteBuffer buffer{&shared_buf, write_start};
|
||||
uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
uint8_t *end = encode_fn(msg, shared_buf.data() + write_start PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
proto_check_encode_end(end, shared_buf.data() + shared_buf.size());
|
||||
#else
|
||||
@@ -2668,6 +2790,13 @@ void APIConnection::process_state_subscriptions_() {
|
||||
}
|
||||
|
||||
const auto &it = subs[this->state_subs_at_];
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
// An entity id that is not set yet (a wizard input) has nothing to subscribe to; it is sent once it is set
|
||||
if (it.entity_id[0] == '\0') {
|
||||
this->state_subs_at_++;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
SubscribeHomeAssistantStateResponse resp;
|
||||
resp.entity_id = StringRef(it.entity_id);
|
||||
|
||||
|
||||
@@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#endif
|
||||
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
void on_sendspin_pairing_token_request();
|
||||
#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);
|
||||
@@ -233,9 +237,12 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
|
||||
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
|
||||
// Reply to an InfraredRFTransmitRawTimingsRequest (API 1.18+); false when the TCP buffer is
|
||||
// full, the entity that owns the reply retries it then
|
||||
[[nodiscard]] bool send_infrared_rf_transmit_complete(uint32_t device_id, uint32_t key, bool success);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
@@ -243,6 +250,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_subscribe_serial_proxy_identity_request();
|
||||
/// Send a port identity to this client
|
||||
void send_serial_proxy_identity(const SerialProxyIdentity &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);
|
||||
@@ -273,6 +283,12 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_ping_request();
|
||||
void on_device_info_request();
|
||||
void on_device_capabilities_request();
|
||||
#ifdef USE_API_WIZARD
|
||||
void on_device_wizard_request();
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
void on_wizard_input_set_request(const WizardInputSetRequest &msg);
|
||||
#endif
|
||||
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
|
||||
void on_subscribe_states_request() {
|
||||
this->flags_.state_subscription = true;
|
||||
@@ -302,6 +318,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void on_subscribe_home_assistant_states_request();
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
/// Tell this client about the subscriptions whose entity id is stored in the given buffer, as the buffer changed
|
||||
void resend_state_subscriptions(const char *entity_id);
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
void on_execute_service_request(const ExecuteServiceRequest &msg);
|
||||
@@ -345,7 +365,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_no_setup_connection();
|
||||
|
||||
// Function pointer type for type-erased message encoding
|
||||
using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM);
|
||||
using MessageEncodeFn = ProtoEncodeFn;
|
||||
// Function pointer type for type-erased size calculation
|
||||
using CalculateSizeFn = uint32_t (*)(const void *);
|
||||
|
||||
@@ -378,6 +398,23 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
return this->helper_->get_peername_to(buf);
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
/// Get the peer address itself, for remembering a dial-back target
|
||||
int getpeername(struct sockaddr *addr, socklen_t *addrlen) const { return this->helper_->getpeername(addr, addrlen); }
|
||||
/// Outgoing connection: send our server hello immediately so the peer can
|
||||
/// pick the matching key. Outgoing connections are only dialed when a PSK
|
||||
/// is set, so the helper is always the noise helper. Call after start().
|
||||
void mark_outgoing() {
|
||||
if (this->flags_.remove) {
|
||||
return; // start() failed; the connection is already being torn down
|
||||
}
|
||||
APIError err = static_cast<APINoiseFrameHelper *>(this->helper_.get())->send_server_hello_first();
|
||||
if (err != APIError::OK) {
|
||||
this->fatal_error_with_log_(LOG_STR("Server hello failed"), err);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool try_to_clear_buffer_slow_(bool log_out_of_space);
|
||||
|
||||
@@ -399,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg);
|
||||
#endif
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
bool send_sendspin_pairing_token_response_();
|
||||
#endif
|
||||
|
||||
#ifdef USE_CAMERA
|
||||
void try_send_camera_image_();
|
||||
@@ -734,12 +774,21 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
uint8_t batch_first_message : 1; // For batch buffer allocation
|
||||
uint8_t should_try_send_immediately : 1; // True after initial states are sent
|
||||
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
uint8_t home_assistant_states : 1; // Client subscribed to Home Assistant states
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello
|
||||
#endif
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
uint8_t log_only_mode : 1;
|
||||
#endif
|
||||
} flags_{}; // 2 bytes total
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
uint8_t serial_proxy_identity_subscription : 1;
|
||||
#endif
|
||||
} flags_{}; // 2 bytes; 3 with HAS_PROTO_MESSAGE_DUMP + USE_API_OUTGOING_CONNECTION + USE_SERIAL_PROXY_USB_IDENTITY
|
||||
|
||||
// 2-byte type immediately after flags_ (no padding between them)
|
||||
// 2-byte type immediately after flags_ (one padding byte when flags_ is 3 bytes)
|
||||
uint16_t batch_message_type_{0}; // Current message type during batch encoding
|
||||
// 1-byte types to fill remaining space before next 4-byte boundary
|
||||
// Client API versions are clamped to 255 on receive (see send_hello_response_)
|
||||
|
||||
@@ -45,8 +45,8 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
|
||||
conn->fatal_out_of_memory_();
|
||||
return 0;
|
||||
}
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
uint8_t *end =
|
||||
encode_fn(msg, shared_buf.data() + shared_buf.size() - calculated_size PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
// A body that writes fewer bytes than calculate_size() promised would ship stale buffer bytes
|
||||
proto_check_encode_end(end, shared_buf.data() + shared_buf.size());
|
||||
|
||||
@@ -285,7 +285,8 @@ class APIFrameHelper {
|
||||
DATA = 5,
|
||||
CLOSED = 6,
|
||||
FAILED = 7,
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn)
|
||||
};
|
||||
|
||||
// Fast inline state check for read_packet/write_protobuf_messages hot path.
|
||||
|
||||
@@ -70,6 +70,13 @@ APIError APINoiseFrameHelper::init() {
|
||||
state_ = State::CLIENT_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
APIError APINoiseFrameHelper::send_server_hello_first() {
|
||||
// The peer needs our name and MAC to pick the key before its first message
|
||||
this->state_ = State::CLIENT_HELLO_OUTGOING;
|
||||
return this->send_server_hello_frame_();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
|
||||
APIError err = this->init();
|
||||
@@ -242,6 +249,9 @@ APIError APINoiseFrameHelper::state_action_() {
|
||||
HELPER_LOG("Bad state for method: %d", (int) this->state_);
|
||||
return APIError::BAD_STATE;
|
||||
case State::CLIENT_HELLO:
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
case State::CLIENT_HELLO_OUTGOING:
|
||||
#endif
|
||||
return this->state_action_client_hello_();
|
||||
case State::SERVER_HELLO:
|
||||
return this->state_action_server_hello_();
|
||||
@@ -274,11 +284,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
std::memcpy(dst + 2, this->rx_buf_.data(), rx_size);
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (this->state_ == State::CLIENT_HELLO_OUTGOING) {
|
||||
// Server hello already went out at handoff
|
||||
return this->start_handshake_();
|
||||
}
|
||||
#endif
|
||||
state_ = State::SERVER_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// send server hello
|
||||
APIError APINoiseFrameHelper::send_server_hello_frame_() {
|
||||
const auto &name = App.get_name();
|
||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac);
|
||||
@@ -302,15 +317,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// node mac, terminated by null byte
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
APIError aerr = write_frame_(msg, total_size);
|
||||
return write_frame_(msg, total_size);
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
APIError aerr = this->send_server_hello_frame_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
return this->start_handshake_();
|
||||
}
|
||||
APIError APINoiseFrameHelper::start_handshake_() {
|
||||
APIError aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
state_ = State::HANDSHAKE;
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Seeds the already-read header bytes and pumps the handshake state machine
|
||||
// until it would block.
|
||||
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Send the server hello immediately so the peer can pick the key before
|
||||
// its PSK-mixed message. Call after init(); the mode is tracked in state_
|
||||
// so the helper does not grow.
|
||||
APIError send_server_hello_first();
|
||||
#endif
|
||||
APIError loop() override;
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
@@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
APIError state_action_();
|
||||
APIError state_action_client_hello_();
|
||||
APIError state_action_server_hello_();
|
||||
APIError send_server_hello_frame_();
|
||||
APIError start_handshake_();
|
||||
APIError state_action_handshake_();
|
||||
APIError state_action_handshake_read_();
|
||||
APIError state_action_handshake_write_();
|
||||
|
||||
@@ -122,4 +122,9 @@ extend google.protobuf.FieldOptions {
|
||||
// to true when the field arrives, so an all-default submessage can be told apart
|
||||
// from an absent one (e.g. a UTC ParsedTimezone, which is all zeros).
|
||||
optional bool track_presence = 50020 [default=false];
|
||||
|
||||
// progmem: The pointer_to_buffer bytes field points into flash (PROGMEM).
|
||||
// The generated encode and dump copy it with progmem_memcpy, as ESP8266
|
||||
// cannot read flash with ordinary loads.
|
||||
optional bool progmem = 50021 [default=false];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
#include "api_outgoing_connection.h"
|
||||
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_server.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cinttypes>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
static const char *const TAG = "api.outgoing";
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
static constexpr uint32_t OUTGOING_TARGET_PREF_HASH = 629847102UL;
|
||||
#endif
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
// Read the connection's peer address into target; false when unavailable or
|
||||
// of a family this build cannot dial
|
||||
static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) {
|
||||
// Zeroed because the raw lwIP getpeername() leaves sin6_scope_id untouched
|
||||
struct sockaddr_storage peer = {};
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
if (conn->getpeername((struct sockaddr *) &peer, &peer_len) != 0) {
|
||||
return false;
|
||||
}
|
||||
const sa_family_t family = ((struct sockaddr *) &peer)->sa_family;
|
||||
#if USE_NETWORK_IPV6
|
||||
if (family == AF_INET6) {
|
||||
const auto *addr6 = reinterpret_cast<const struct sockaddr_in6 *>(&peer);
|
||||
const auto *bytes = reinterpret_cast<const uint8_t *>(&addr6->sin6_addr);
|
||||
uint32_t prefix[3];
|
||||
memcpy(prefix, bytes, sizeof(prefix));
|
||||
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d
|
||||
if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == htonl(0xFFFFUL)) {
|
||||
target.family = AF_INET;
|
||||
memcpy(target.addr, bytes + sizeof(prefix), sizeof(struct in_addr));
|
||||
return true;
|
||||
}
|
||||
// A link-local target is only reachable through the interface it came in
|
||||
// on. Device platforms number interfaces from one; a host build can hand
|
||||
// out an index too large to store, and a truncated one dials the wrong
|
||||
// interface, so that target is not remembered at all.
|
||||
if (addr6->sin6_scope_id > UINT8_MAX) {
|
||||
return false;
|
||||
}
|
||||
target.family = AF_INET6;
|
||||
memcpy(target.addr, bytes, sizeof(target.addr));
|
||||
target.scope_id = static_cast<uint8_t>(addr6->sin6_scope_id);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (family != AF_INET) {
|
||||
return false;
|
||||
}
|
||||
const auto *addr4 = reinterpret_cast<const struct sockaddr_in *>(&peer);
|
||||
target.family = AF_INET;
|
||||
memcpy(target.addr, &addr4->sin_addr, sizeof(addr4->sin_addr));
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *addr) const {
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
// Validation only lets through a literal both inet_pton and inet6_aton
|
||||
// accept, so this cannot fail
|
||||
return socket::set_sockaddr((struct sockaddr *) addr, sizeof(*addr), API_OUTGOING_CONNECTION_HOST,
|
||||
API_OUTGOING_CONNECTION_PORT);
|
||||
#else
|
||||
#if USE_NETWORK_IPV6
|
||||
if (this->saved_.family == AF_INET6) {
|
||||
auto *addr6 = reinterpret_cast<struct sockaddr_in6 *>(addr);
|
||||
memset(addr6, 0, sizeof(*addr6));
|
||||
addr6->sin6_family = AF_INET6;
|
||||
addr6->sin6_port = htons(API_OUTGOING_CONNECTION_PORT);
|
||||
memcpy(&addr6->sin6_addr, this->saved_.addr, sizeof(this->saved_.addr));
|
||||
addr6->sin6_scope_id = this->saved_.scope_id;
|
||||
return sizeof(*addr6);
|
||||
}
|
||||
#endif
|
||||
if (this->saved_.family != AF_INET) {
|
||||
return 0;
|
||||
}
|
||||
auto *addr4 = reinterpret_cast<struct sockaddr_in *>(addr);
|
||||
memset(addr4, 0, sizeof(*addr4));
|
||||
addr4->sin_family = AF_INET;
|
||||
addr4->sin_port = htons(API_OUTGOING_CONNECTION_PORT);
|
||||
memcpy(&addr4->sin_addr, this->saved_.addr, sizeof(addr4->sin_addr));
|
||||
return sizeof(*addr4);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
void OutgoingConnectionManager::format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const {
|
||||
struct sockaddr_storage addr;
|
||||
socklen_t addr_len = this->target_sockaddr_(&addr);
|
||||
if (addr_len == 0) {
|
||||
buf[0] = '\0';
|
||||
return;
|
||||
}
|
||||
// Clears buf itself if it cannot format the address
|
||||
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, buf);
|
||||
}
|
||||
#endif
|
||||
|
||||
void OutgoingConnectionManager::setup() {
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
this->target_pref_ = global_preferences->make_preference<SavedOutgoingTarget>(OUTGOING_TARGET_PREF_HASH, true);
|
||||
struct sockaddr_storage addr;
|
||||
// dump_config() prints whichever target this leaves in place
|
||||
if (this->target_pref_.load(&this->saved_) && this->target_sockaddr_(&addr) != 0) {
|
||||
this->host_persisted_ = true;
|
||||
} else {
|
||||
// Never saved, failed its size or CRC check, or holds an unknown family
|
||||
this->saved_ = {};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::loop(APIServer *server) {
|
||||
if (server->has_outgoing_target_client_()) {
|
||||
return; // on_target_client() already reset the dial state
|
||||
}
|
||||
if (this->dialed_conn_ != nullptr) {
|
||||
// A live dialed session (flagged or not, e.g. a host: peer) is the
|
||||
// target; a silent one dies on the handshake timeout
|
||||
return;
|
||||
}
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
switch (this->state_) {
|
||||
case DialState::DIAL_STATE_IDLE:
|
||||
// Target went away; give it the configured delay to reconnect first
|
||||
this->schedule_wait_(now, IDLE_WAIT_MS);
|
||||
break;
|
||||
case DialState::DIAL_STATE_WAITING:
|
||||
if (now - this->state_ts_ >= this->wait_) {
|
||||
this->try_dial_(server, now);
|
||||
}
|
||||
break;
|
||||
case DialState::DIAL_STATE_CONNECTING:
|
||||
this->poll_connect_(server, now);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
|
||||
if (!network::is_connected()) {
|
||||
// Flips within seconds of boot; recheck fast so a deep sleep wake
|
||||
// window is not spent waiting
|
||||
this->schedule_wait_(now, NETWORK_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
struct sockaddr_storage addr;
|
||||
socklen_t addr_len = this->target_sockaddr_(&addr);
|
||||
const bool at_limit = server->at_client_limit_();
|
||||
// No target is the steady state until a dial-back client has ever connected
|
||||
if (addr_len == 0 || at_limit || !server->noise_ctx_.has_psk()) {
|
||||
// Repeats for as long as the reason holds, so keep it out of debug logs
|
||||
ESP_LOGV(TAG, "Not dialing: %s",
|
||||
addr_len == 0 ? LOG_STR_LITERAL("no target")
|
||||
: (at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key")));
|
||||
// Not a dial failure; retry without escalating the backoff
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) {
|
||||
ESP_LOGW(TAG, "Socket %s failed: errno %d",
|
||||
this->dial_socket_ ? LOG_STR_LITERAL("setblocking") : LOG_STR_LITERAL("create"), errno);
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
ESP_LOGD(TAG, "Dialing " API_OUTGOING_CONNECTION_HOST ":%u", API_OUTGOING_CONNECTION_PORT);
|
||||
#else
|
||||
char host[socket::SOCKADDR_STR_LEN];
|
||||
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, host);
|
||||
ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT);
|
||||
#endif
|
||||
int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len);
|
||||
if (err == 0) {
|
||||
// Immediate success (possible for localhost)
|
||||
this->handoff_(server, now);
|
||||
return;
|
||||
}
|
||||
if (errno != EINPROGRESS) {
|
||||
ESP_LOGW(TAG, "Connect failed: %d", errno);
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
this->state_ = DialState::DIAL_STATE_CONNECTING;
|
||||
this->state_ts_ = now;
|
||||
this->last_poll_ = now;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
|
||||
if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Connect timeout");
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
this->last_poll_ = now;
|
||||
int err = 0;
|
||||
switch (socket::poll_connect(*this->dial_socket_, err)) {
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED:
|
||||
this->handoff_(server, now);
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR:
|
||||
ESP_LOGW(TAG, "Connect failed: %d", err);
|
||||
this->schedule_retry_(now);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) {
|
||||
this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_));
|
||||
if (this->dialed_conn_ == nullptr) {
|
||||
// Only preconditions (slot limit, key cleared) refuse the handoff; the
|
||||
// peer is reachable, so do not escalate the backoff
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
// Connected; dialed_conn_ gates further dialing until the session settles
|
||||
this->state_ = DialState::DIAL_STATE_IDLE;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) {
|
||||
this->dial_socket_.reset(); // no-op when the socket was handed off
|
||||
this->state_ = DialState::DIAL_STATE_WAITING;
|
||||
this->state_ts_ = now;
|
||||
this->wait_ = wait;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::schedule_retry_(uint32_t now) {
|
||||
// +/-20% jitter so a fleet of devices does not retry one server in lockstep
|
||||
const uint32_t jitter_span = this->backoff_ / 5;
|
||||
this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)));
|
||||
this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS);
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) {
|
||||
if (conn != this->dialed_conn_) {
|
||||
return;
|
||||
}
|
||||
this->dialed_conn_ = nullptr;
|
||||
if (was_authenticated) {
|
||||
// A working peer (e.g. a host: target that never sends the flag)
|
||||
// disconnected normally; state is IDLE, so loop() applies the delay
|
||||
this->backoff_ = BACKOFF_MIN_MS;
|
||||
} else {
|
||||
this->schedule_retry_(App.get_loop_component_start_time());
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::on_target_client(APIConnection *conn) {
|
||||
// The target is connected; stop any dial in flight and reset the backoff.
|
||||
// A dialed connection stays tracked unless it is this one: an inbound
|
||||
// target must not orphan a still-open dial.
|
||||
this->dial_socket_.reset();
|
||||
if (conn == this->dialed_conn_) {
|
||||
this->dialed_conn_ = nullptr;
|
||||
}
|
||||
this->state_ = DialState::DIAL_STATE_IDLE;
|
||||
this->backoff_ = BACKOFF_MIN_MS;
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
SavedOutgoingTarget target{};
|
||||
if (!peer_to_target(conn, target)) {
|
||||
ESP_LOGW(TAG, "Not remembering this target; its address cannot be dialed");
|
||||
return;
|
||||
}
|
||||
if (this->host_persisted_ && memcmp(&target, &this->saved_, sizeof(target)) == 0) {
|
||||
return; // unchanged and already on flash; avoid flash wear
|
||||
}
|
||||
// Use the fresh address this boot even if the flash write fails; a failed
|
||||
// write is retried on the next flagged hello via host_persisted_
|
||||
this->saved_ = target;
|
||||
if (!this->persist_target_()) {
|
||||
ESP_LOGW(TAG, "Failed to save target");
|
||||
return;
|
||||
}
|
||||
char host[socket::SOCKADDR_STR_LEN];
|
||||
this->format_target_(host);
|
||||
ESP_LOGD(TAG, "Remembered %s as the dial target", host);
|
||||
#endif
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::dump_config() const {
|
||||
// The boot delay differs from delay: on deep sleep builds, so print the
|
||||
// value that actually applies
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Outgoing connection port: %u\n"
|
||||
" Outgoing connection boot delay: %" PRIu32 "ms",
|
||||
API_OUTGOING_CONNECTION_PORT, BOOT_WAIT_MS);
|
||||
// Both forms keep their text out of RAM on ESP8266: in the format string,
|
||||
// or through LOG_STR_LITERAL
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
ESP_LOGCONFIG(TAG, " Outgoing connection host: " API_OUTGOING_CONNECTION_HOST);
|
||||
#else
|
||||
char buf[socket::SOCKADDR_STR_LEN];
|
||||
this->format_target_(buf);
|
||||
ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", buf[0] == '\0' ? LOG_STR_LITERAL("none remembered yet") : buf);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API && USE_API_OUTGOING_CONNECTION
|
||||
@@ -0,0 +1,120 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
|
||||
|
||||
#ifndef USE_API_NOISE
|
||||
#error "api outgoing_connection needs noise encryption so the peer is verified by key"
|
||||
#endif
|
||||
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
class APIServer;
|
||||
class APIConnection;
|
||||
|
||||
// Room for an IPv6 address in every build, so a remembered IPv4 target is
|
||||
// still dialed after enable_ipv6 is turned on. A size that followed the build
|
||||
// would also shift every preference registered after this one on ESP8266,
|
||||
// where slots are positional. An IPv6 target on a build without IPv6 is
|
||||
// dropped by target_sockaddr_() and relearned.
|
||||
// Bytes in an IPv6 address
|
||||
static constexpr size_t TARGET_ADDR_LEN = 16;
|
||||
|
||||
struct SavedOutgoingTarget {
|
||||
// 0 when none is remembered, else AF_INET or AF_INET6
|
||||
uint8_t family;
|
||||
// Network order, IPv4 in the first four bytes and the rest zero
|
||||
uint8_t addr[TARGET_ADDR_LEN];
|
||||
// Interface a link-local IPv6 target is reachable on, 0 when it needs none.
|
||||
// Free in flash: the record still rounds up to the same five words.
|
||||
uint8_t scope_id;
|
||||
} PACKED; // NOLINT
|
||||
|
||||
/// Dials out when no dial-back target client is connected. Only the TCP
|
||||
/// direction flips: the device stays the Noise responder, so both sides
|
||||
/// still verify by key. Targets the YAML host or the last remembered client.
|
||||
class OutgoingConnectionManager {
|
||||
public:
|
||||
void setup();
|
||||
void loop(APIServer *server);
|
||||
/// A key-verified client declared itself a dial-back target; last one wins
|
||||
void on_target_client(APIConnection *conn);
|
||||
/// Clears the dialed-connection gate; dying unauthenticated escalates the backoff
|
||||
void on_client_removed(APIConnection *conn, bool was_authenticated);
|
||||
void on_shutdown() { this->dial_socket_.reset(); }
|
||||
void dump_config() const;
|
||||
|
||||
protected:
|
||||
enum class DialState : uint8_t {
|
||||
DIAL_STATE_IDLE,
|
||||
DIAL_STATE_WAITING,
|
||||
DIAL_STATE_CONNECTING,
|
||||
};
|
||||
|
||||
static constexpr uint32_t BACKOFF_MIN_MS = 5000;
|
||||
static constexpr uint32_t BACKOFF_MAX_MS = 300000;
|
||||
static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000;
|
||||
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
|
||||
static constexpr uint32_t NETWORK_RETRY_MS = 500;
|
||||
static constexpr uint32_t PRECONDITION_RETRY_MS = 5000;
|
||||
// A deep sleep wake window is too short to spend on the delay, so those
|
||||
// builds dial out as soon as the target is gone
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
static constexpr uint32_t BOOT_WAIT_MS = 0;
|
||||
static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS;
|
||||
#else
|
||||
static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
|
||||
static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
|
||||
#endif
|
||||
|
||||
void try_dial_(APIServer *server, uint32_t now);
|
||||
void poll_connect_(APIServer *server, uint32_t now);
|
||||
// Hand the connected socket to the server and gate on the new connection
|
||||
void handoff_(APIServer *server, uint32_t now);
|
||||
// Close any half-open dial and wait a jittered backoff before retrying
|
||||
void schedule_retry_(uint32_t now);
|
||||
// Wait without escalating the backoff (used for unmet preconditions)
|
||||
void schedule_wait_(uint32_t now, uint32_t wait);
|
||||
/// Fill addr with the target and return its length, or 0 when there is none
|
||||
socklen_t target_sockaddr_(struct sockaddr_storage *addr) const;
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
// Write saved_ to flash, tracking success in host_persisted_
|
||||
bool persist_target_() {
|
||||
this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync();
|
||||
return this->host_persisted_;
|
||||
}
|
||||
/// Format the remembered target for a log line; empty when there is none
|
||||
void format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const;
|
||||
#endif
|
||||
|
||||
// Pointers first (4 bytes each on 32-bit)
|
||||
std::unique_ptr<socket::Socket> dial_socket_;
|
||||
// Compared only, never dereferenced
|
||||
APIConnection *dialed_conn_{nullptr};
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
ESPPreferenceObject target_pref_;
|
||||
#endif
|
||||
|
||||
// 4-byte types
|
||||
uint32_t backoff_{BACKOFF_MIN_MS};
|
||||
uint32_t wait_{BOOT_WAIT_MS};
|
||||
uint32_t state_ts_{0};
|
||||
uint32_t last_poll_{0};
|
||||
|
||||
// Byte-aligned types last
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
SavedOutgoingTarget saved_{};
|
||||
// False while saved_ holds a value the flash write failed for; retried on
|
||||
// the next flagged hello
|
||||
bool host_persisted_{false};
|
||||
#endif
|
||||
DialState state_{DialState::DIAL_STATE_WAITING};
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API && USE_API_OUTGOING_CONNECTION
|
||||
+278
-218
File diff suppressed because it is too large
Load Diff
+404
-203
File diff suppressed because it is too large
Load Diff
@@ -114,15 +114,26 @@ template<typename T> static void dump_field(DumpBuffer &out, const char *field_n
|
||||
out.append("\n");
|
||||
}
|
||||
|
||||
// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer
|
||||
static constexpr size_t DUMP_BYTES_MAX = 160;
|
||||
|
||||
// Helper for bytes fields - uses stack buffer to avoid heap allocation
|
||||
// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer
|
||||
// field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms)
|
||||
static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) {
|
||||
char hex_buf[format_hex_pretty_size(160)];
|
||||
char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)];
|
||||
append_field_prefix(out, field_name, indent);
|
||||
format_hex_pretty_to(hex_buf, data, len);
|
||||
out.append(hex_buf).append("\n");
|
||||
}
|
||||
|
||||
// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first
|
||||
static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len,
|
||||
int indent = 2) {
|
||||
uint8_t data_buf[DUMP_BYTES_MAX];
|
||||
len = std::min(len, sizeof(data_buf));
|
||||
progmem_memcpy(data_buf, data, len);
|
||||
dump_bytes_field(out, field_name, data_buf, len, indent);
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
|
||||
@@ -143,6 +154,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");
|
||||
}
|
||||
@@ -890,6 +903,49 @@ template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::Seria
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyIdentitySource>(enums::SerialProxyIdentitySource value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_IDENTITY_SOURCE_NONE:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_NONE");
|
||||
case enums::SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED");
|
||||
case enums::SERIAL_PROXY_IDENTITY_SOURCE_USB:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_USB");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyIdentityFlag>(enums::SerialProxyIdentityFlag value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_IDENTITY_FLAG_NONE:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_NONE");
|
||||
case enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_CONNECTED");
|
||||
case enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_ERROR");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#ifdef USE_SENDSPIN
|
||||
template<>
|
||||
const char *proto_enum_to_string<enums::SendspinPairingTokenStatus>(enums::SendspinPairingTokenStatus value) {
|
||||
switch (value) {
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY");
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK");
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED");
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED");
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -897,6 +953,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("client_info"), this->client_info);
|
||||
dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major);
|
||||
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
const char *HelloResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -1020,6 +1079,9 @@ 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_API_OUTGOING_CONNECTION
|
||||
dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
@@ -1046,6 +1108,20 @@ const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD
|
||||
const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("configured"), this->configured);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN
|
||||
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
@@ -1069,9 +1145,34 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
|
||||
it.dump_to(out);
|
||||
out.append("\n");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": ");
|
||||
this->wizard.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": ");
|
||||
this->sendspin.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
#ifdef USE_API_WIZARD
|
||||
const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse"));
|
||||
dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
const char *WizardInputSetRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("WizardInputSetRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("key"), this->key);
|
||||
dump_field(out, ESPHOME_PSTR("entity_id"), this->entity_id);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
|
||||
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
|
||||
return out.c_str();
|
||||
@@ -2714,7 +2815,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest"));
|
||||
#ifdef USE_DEVICES
|
||||
@@ -2743,6 +2844,15 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
|
||||
}
|
||||
return out.c_str();
|
||||
}
|
||||
const char *InfraredRFTransmitCompleteResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitCompleteResponse"));
|
||||
#ifdef USE_DEVICES
|
||||
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("key"), this->key);
|
||||
dump_field(out, ESPHOME_PSTR("success"), this->success);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -2826,6 +2936,27 @@ const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
|
||||
return out.c_str();
|
||||
}
|
||||
const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("UsbDeviceDescriptor"));
|
||||
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);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyIdentity"));
|
||||
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
|
||||
dump_field(out, ESPHOME_PSTR("source"), static_cast<enums::SerialProxyIdentitySource>(this->source));
|
||||
dump_field(out, ESPHOME_PSTR("flags"), this->flags);
|
||||
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);
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("usb")).append(": ");
|
||||
this->usb.dump_to(out);
|
||||
out.append("\n");
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -2844,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN
|
||||
const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse"));
|
||||
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SendspinPairingTokenStatus>(this->status));
|
||||
dump_field(out, ESPHOME_PSTR("token"), this->token);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
// Standard library includes that might be needed
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -628,7 +628,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: {
|
||||
InfraredRFTransmitRawTimingsRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -722,6 +722,44 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
this->on_serial_proxy_set_mode_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
case 154 /* SubscribeSerialProxyIdentityRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_subscribe_serial_proxy_identity_request"));
|
||||
#endif
|
||||
this->on_subscribe_serial_proxy_identity_request();
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD
|
||||
case 156 /* DeviceWizardRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_device_wizard_request"));
|
||||
#endif
|
||||
this->on_device_wizard_request();
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
case WizardInputSetRequest::MESSAGE_TYPE: {
|
||||
WizardInputSetRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_wizard_input_set_request"), msg);
|
||||
#endif
|
||||
this->on_wizard_input_set_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN
|
||||
case 159 /* SendspinPairingTokenRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request"));
|
||||
#endif
|
||||
this->on_sendspin_pairing_token_request();
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -29,6 +29,13 @@ class APIServerConnectionBase {
|
||||
|
||||
void on_device_capabilities_request(){};
|
||||
|
||||
#ifdef USE_API_WIZARD
|
||||
void on_device_wizard_request(){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
void on_wizard_input_set_request(const WizardInputSetRequest &value){};
|
||||
#endif
|
||||
void on_list_entities_request(){};
|
||||
|
||||
void on_subscribe_states_request(){};
|
||||
@@ -213,7 +220,7 @@ class APIServerConnectionBase {
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){};
|
||||
#endif
|
||||
|
||||
@@ -238,9 +245,17 @@ class APIServerConnectionBase {
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_subscribe_serial_proxy_identity_request(){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN
|
||||
void on_sendspin_pairing_token_request(){};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -56,7 +56,12 @@ APIServer::APIServer() { global_api_server = this; }
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
||||
this->destroy_socket_();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Dial-out needs no listener; degrade instead of stopping the component
|
||||
this->status_set_error(LOG_STR("listen socket failed"));
|
||||
#else
|
||||
this->mark_failed();
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIServer::setup() {
|
||||
@@ -72,43 +77,6 @@ void APIServer::setup() {
|
||||
#endif
|
||||
#endif
|
||||
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->socket_ == nullptr) {
|
||||
this->socket_failed_(LOG_STR("creation"));
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
|
||||
// we can still continue
|
||||
}
|
||||
err = this->socket_->setblocking(false);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("nonblocking"));
|
||||
return;
|
||||
}
|
||||
|
||||
struct sockaddr_storage server;
|
||||
|
||||
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
|
||||
if (sl == 0) {
|
||||
this->socket_failed_(LOG_STR("set sockaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->bind((struct sockaddr *) &server, sl);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("bind"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->listen(this->listen_backlog_);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("listen"));
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_LOGGER
|
||||
if (logger::global_logger != nullptr) {
|
||||
logger::global_logger->add_log_callback(
|
||||
@@ -154,6 +122,47 @@ void APIServer::setup() {
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
}
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.setup();
|
||||
#endif
|
||||
|
||||
// Listener last: on failure socket_failed_() returns early, and an
|
||||
// outgoing_connection build keeps dialing out without one
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->socket_ == nullptr) {
|
||||
this->socket_failed_(LOG_STR("creation"));
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
|
||||
// we can still continue
|
||||
}
|
||||
err = this->socket_->setblocking(false);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("nonblocking"));
|
||||
return;
|
||||
}
|
||||
|
||||
struct sockaddr_storage server;
|
||||
|
||||
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
|
||||
if (sl == 0) {
|
||||
this->socket_failed_(LOG_STR("set sockaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->bind((struct sockaddr *) &server, sl);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("bind"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->listen(this->listen_backlog_);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("listen"));
|
||||
}
|
||||
}
|
||||
|
||||
void APIServer::loop() {
|
||||
@@ -169,6 +178,12 @@ void APIServer::loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (!this->shutting_down_) {
|
||||
this->outgoing_conn_.loop(this);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->api_connection_count_ == 0) {
|
||||
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time).
|
||||
@@ -241,6 +256,15 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
std::string client_peername(client->get_peername_to(peername_buf));
|
||||
#endif
|
||||
|
||||
// Read before the swap-and-reset below destroys the connection
|
||||
const bool was_authenticated = client->is_authenticated();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (client->flags_.outgoing_connection_target) {
|
||||
this->outgoing_target_count_--;
|
||||
}
|
||||
this->outgoing_conn_.on_client_removed(client.get(), was_authenticated);
|
||||
#endif
|
||||
|
||||
// Close socket now (was deferred from on_fatal_error to allow getpeername)
|
||||
client->helper_->close();
|
||||
|
||||
@@ -259,9 +283,15 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
|
||||
// Last client disconnected - set warning and start tracking for reboot timeout
|
||||
// (suppressed while provisioning is pending - see loop()).
|
||||
// Refresh on every authenticated removal, not just the last one, so an
|
||||
// unauthenticated straggler removed later (e.g. a port scan, or a dial to
|
||||
// a host that accepts TCP but never speaks the API) cannot discard a
|
||||
// healthy session's timestamp and trigger a spurious reboot
|
||||
if (was_authenticated) {
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
@@ -283,7 +313,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
sock->getpeername_to(peername);
|
||||
|
||||
// Check if we're at the connection limit
|
||||
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
|
||||
if (this->at_client_limit_()) {
|
||||
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
|
||||
// Immediately close - socket destructor will handle cleanup
|
||||
sock.reset();
|
||||
@@ -292,18 +322,47 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
|
||||
ESP_LOGD(TAG, "Accept %s", peername);
|
||||
|
||||
auto *conn = new APIConnection(std::move(sock), this);
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning and update timestamp
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
this->add_client_(std::move(sock));
|
||||
}
|
||||
}
|
||||
|
||||
APIConnection *APIServer::add_client_(std::unique_ptr<socket::Socket> sock) {
|
||||
auto *conn = new APIConnection(std::move(sock), this); // NOLINT(cppcoreguidelines-owning-memory)
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning. The reboot watchdog timestamp is
|
||||
// refreshed when an authenticated client is removed (see remove_client_),
|
||||
// never on bare TCP connects.
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> sock) {
|
||||
// Re-check at the handoff: inbound clients may have taken the last slot and
|
||||
// the PSK may have been cleared since the dial started (mark_outgoing()
|
||||
// needs the noise helper)
|
||||
const bool at_limit = this->at_client_limit_();
|
||||
if (at_limit || !this->noise_ctx_.has_psk()) {
|
||||
ESP_LOGW(TAG, "Dropping outgoing connection (%s)",
|
||||
at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key"));
|
||||
return nullptr;
|
||||
}
|
||||
auto *conn = this->add_client_(std::move(sock));
|
||||
// After start(): sends our server hello first so the peer can pick the key
|
||||
conn->mark_outgoing();
|
||||
return conn;
|
||||
}
|
||||
|
||||
void APIServer::on_outgoing_target_client(APIConnection *conn) {
|
||||
this->outgoing_target_count_++;
|
||||
this->outgoing_conn_.on_target_client(conn);
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIServer::dump_config() {
|
||||
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
@@ -320,6 +379,9 @@ void APIServer::dump_config() {
|
||||
#else
|
||||
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.dump_config();
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIServer::handle_disconnect(APIConnection *conn) {}
|
||||
@@ -442,7 +504,16 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
void APIServer::send_serial_proxy_identity(const SerialProxyIdentity &msg) {
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (c->flags_.serial_proxy_identity_subscription)
|
||||
c->send_serial_proxy_identity(msg);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
|
||||
const std::vector<int32_t> *timings) {
|
||||
InfraredRFReceiveEvent resp{};
|
||||
@@ -455,6 +526,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
|
||||
for (auto &c : this->active_clients())
|
||||
c->send_infrared_rf_receive_event(resp);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
@@ -616,6 +688,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
// Force it: a session from before the key was active must not survive
|
||||
c->flags_.next_close = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -717,6 +791,9 @@ void APIServer::on_shutdown() {
|
||||
|
||||
// Close the listening socket to prevent new connections
|
||||
this->destroy_socket_();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.on_shutdown();
|
||||
#endif
|
||||
|
||||
// Change batch delay to 5ms for quick flushing during shutdown
|
||||
this->batch_delay_ = 5;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "api_outgoing_connection.h"
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -90,6 +91,10 @@ class APIServer final : public Component
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Called by APIConnection when a client declares itself a dial-back target in its hello
|
||||
void on_outgoing_target_client(APIConnection *conn);
|
||||
#endif
|
||||
|
||||
void handle_disconnect(APIConnection *conn);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
@@ -198,7 +203,11 @@ class APIServer final : public Component
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#endif
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
/// Tell every subscribed client that a serial proxy port's identity changed
|
||||
void send_serial_proxy_identity(const SerialProxyIdentity &msg);
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
|
||||
#endif
|
||||
|
||||
@@ -267,6 +276,16 @@ class APIServer final : public Component
|
||||
protected:
|
||||
// Accept incoming socket connections. Only called when socket has pending connections.
|
||||
void __attribute__((noinline)) accept_new_connections_();
|
||||
/// Takes the socket into a new connection and starts it; callers must have
|
||||
/// checked at_client_limit_() first
|
||||
APIConnection *add_client_(std::unique_ptr<socket::Socket> sock);
|
||||
bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; }
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Returns the new connection, or nullptr (socket dropped) when at the limit
|
||||
APIConnection *add_outgoing_client_(std::unique_ptr<socket::Socket> sock);
|
||||
bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; }
|
||||
friend class OutgoingConnectionManager;
|
||||
#endif
|
||||
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
|
||||
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
||||
|
||||
@@ -307,6 +326,8 @@ class APIServer final : public Component
|
||||
delete this->socket_;
|
||||
this->socket_ = nullptr;
|
||||
}
|
||||
/// Log the failure, drop the listen socket, and mark the component failed
|
||||
/// unless this build can still dial out
|
||||
void socket_failed_(const LogString *msg);
|
||||
// Pointers and pointer-like types first (4 bytes each)
|
||||
socket::ListenSocket *socket_{nullptr};
|
||||
@@ -357,11 +378,13 @@ class APIServer final : public Component
|
||||
// Group smaller types together
|
||||
uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py
|
||||
uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py
|
||||
// Connection limits - these defaults will be overridden by config values
|
||||
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
|
||||
uint8_t listen_backlog_{4};
|
||||
uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py
|
||||
bool shutting_down_ = false;
|
||||
uint8_t api_connection_count_{0};
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Connected clients whose hello declared them a dial-back target
|
||||
uint8_t outgoing_target_count_{0};
|
||||
#endif
|
||||
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||
// Index assigned by the provisioning manager for reporting this transport's state.
|
||||
uint8_t provisioning_source_{0};
|
||||
@@ -377,6 +400,9 @@ class APIServer final : public Component
|
||||
#endif
|
||||
ESPPreferenceObject noise_pref_;
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
OutgoingConnectionManager outgoing_conn_;
|
||||
#endif
|
||||
};
|
||||
|
||||
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#include "api_wizard.h"
|
||||
|
||||
#ifdef USE_API_WIZARD
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_pb2.h"
|
||||
#include "api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
static const char *const TAG = "api.wizard";
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
|
||||
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
|
||||
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
|
||||
size_t joined = 0;
|
||||
const char *end = value + length;
|
||||
while (true) {
|
||||
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
|
||||
const char *id_end = comma != nullptr ? comma : end;
|
||||
while (value < id_end && *value == ' ')
|
||||
value++;
|
||||
while (id_end > value && id_end[-1] == ' ')
|
||||
id_end--;
|
||||
size_t id_length = id_end - value;
|
||||
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
|
||||
return 0;
|
||||
if (joined != 0) {
|
||||
if (out != nullptr)
|
||||
out[joined] = ',';
|
||||
joined++;
|
||||
}
|
||||
if (out != nullptr)
|
||||
memcpy(out + joined, value, id_length);
|
||||
joined += id_length;
|
||||
if (comma == nullptr)
|
||||
return joined;
|
||||
value = comma + 1;
|
||||
}
|
||||
}
|
||||
#endif // USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
|
||||
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
|
||||
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
|
||||
}
|
||||
|
||||
const char *wizard_set_input(const WizardInputSetRequest &msg) {
|
||||
const char *value = msg.entity_id.c_str();
|
||||
const size_t length = msg.entity_id.size();
|
||||
for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) {
|
||||
// The table is in flash, which ESP8266 can only read through progmem_memcpy
|
||||
WizardInputEntry entry;
|
||||
progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry));
|
||||
if (entry.key != msg.key)
|
||||
continue;
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
if (entry.size != 0) {
|
||||
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
|
||||
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
|
||||
if (joined == 0 || joined >= entry.size)
|
||||
break;
|
||||
wizard_join_entity_ids(value, length, entry.entity_id);
|
||||
entry.entity_id[joined] = '\0';
|
||||
return entry.entity_id;
|
||||
}
|
||||
#endif
|
||||
if (!wizard_entity_id_valid(value, length))
|
||||
break;
|
||||
memcpy(entry.entity_id, value, length);
|
||||
entry.entity_id[length] = '\0';
|
||||
return entry.entity_id;
|
||||
}
|
||||
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
|
||||
return nullptr;
|
||||
}
|
||||
#endif // USE_API_WIZARD_INPUTS
|
||||
|
||||
void APIConnection::on_device_wizard_request() {
|
||||
DeviceWizardResponse resp;
|
||||
resp.data = API_WIZARD_DATA;
|
||||
resp.data_len = API_WIZARD_DATA_SIZE;
|
||||
if (!this->send_message(resp)) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
void APIConnection::on_wizard_input_set_request(const WizardInputSetRequest &msg) {
|
||||
const char *entity_id = wizard_set_input(msg);
|
||||
if (entity_id == nullptr)
|
||||
return;
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
// Entities subscribed to the buffer before it held an entity id, so every client needs to learn of it now
|
||||
for (auto &client : this->parent_->active_clients()) {
|
||||
client->resend_state_subscriptions(entity_id);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
void APIConnection::resend_state_subscriptions(const char *entity_id) {
|
||||
if (!this->flags_.home_assistant_states)
|
||||
return;
|
||||
for (const auto &it : this->parent_->get_state_subs()) {
|
||||
if (it.entity_id != entity_id)
|
||||
continue;
|
||||
SubscribeHomeAssistantStateResponse resp;
|
||||
resp.entity_id = StringRef(it.entity_id);
|
||||
resp.attribute = it.attribute != nullptr ? StringRef(it.attribute) : StringRef("");
|
||||
resp.once = it.once;
|
||||
if (!this->send_message(resp)) {
|
||||
// Could not send now: send every subscription again from the loop
|
||||
this->state_subs_at_ = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
#endif // USE_API_WIZARD
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_API_WIZARD
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
#include <cstring>
|
||||
#endif
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "proto.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
class WizardInputSetRequest;
|
||||
|
||||
/// Size of the buffer holding the entity id of a wizard input. Home Assistant entity ids are at most 255 bytes.
|
||||
static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256;
|
||||
|
||||
/// The wizard as zstd compressed JSON (see DeviceWizardResponse), built by the generated code
|
||||
/// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long.
|
||||
extern const uint8_t API_WIZARD_DATA[] PROGMEM;
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
/// Where the entity id of an input is kept, found by the key the client uses for it.
|
||||
struct WizardInputEntry {
|
||||
uint32_t key; // FNV-1 hash of the ESPHome id of the input
|
||||
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
uint16_t size; // Buffer size of a multiple input, 0 for a single input
|
||||
#endif
|
||||
};
|
||||
|
||||
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
|
||||
extern const WizardInputEntry API_WIZARD_INPUTS[] PROGMEM;
|
||||
|
||||
/// Apply a WizardInputSetRequest: validate it and copy the entity id into the input's buffer. Nothing is stored
|
||||
/// across restarts, so the client sends the choices again after every connection.
|
||||
/// Returns the buffer, or nullptr when the request was ignored.
|
||||
const char *wizard_set_input(const WizardInputSetRequest &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_WIZARD_STANDALONE_INPUTS
|
||||
/// An input of the wizard that is not tied to an entity of the device. The entity ID the user picks is only ever read
|
||||
/// by lambdas, for example `id(input).entity_id()`. It lives in the same RAM buffer a linked input uses.
|
||||
class WizardInput {
|
||||
public:
|
||||
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
|
||||
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
|
||||
/// comma separated list, which homeassistant.action data can use as it is.
|
||||
StringRef entity_id() const { return StringRef(this->entity_id_); }
|
||||
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
/// The number of entity IDs in the list, 0 until the wizard sets one.
|
||||
size_t entity_id_count() const {
|
||||
if (!this->has_entity_id())
|
||||
return 0;
|
||||
size_t count = 1;
|
||||
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
|
||||
/// not null terminated, so print it with "%.*s".
|
||||
template<typename F> void for_each_entity_id(F &&callback) const {
|
||||
// The device stores the list without spaces or empty entries
|
||||
const char *start = this->entity_id_;
|
||||
while (*start != '\0') {
|
||||
const char *end = strchr(start, ',');
|
||||
if (end == nullptr)
|
||||
end = start + strlen(start);
|
||||
callback(StringRef(start, end - start));
|
||||
start = *end == ',' ? end + 1 : end;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
const char *entity_id_;
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
#endif // USE_API_WIZARD
|
||||
@@ -4,64 +4,40 @@
|
||||
#ifdef USE_API
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include "api_pb2.h"
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
#include "esphome/components/json/json_util.h"
|
||||
#endif
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> {
|
||||
// Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation)
|
||||
// rather than being wrapped in a lambda. The base class constructor for const char* is more
|
||||
// specialized than the templated constructor here, so it should be selected.
|
||||
static_assert(std::is_constructible_v<TemplatableValue<std::string, X...>, const char *>,
|
||||
"Base class must have const char* constructor for STATIC_STRING optimization");
|
||||
// Converts a lambda result to the string sent to Home Assistant
|
||||
template<typename T>
|
||||
requires(!std::is_pointer_v<std::remove_cvref_t<T>>) std::string field_to_string(T &&val) {
|
||||
return to_string(std::forward<T>(val)); // NOLINT
|
||||
}
|
||||
inline std::string field_to_string(const char *val) { return val ? std::string(val) : std::string(); }
|
||||
inline std::string field_to_string(std::string val) { return val; }
|
||||
inline std::string field_to_string(StringRef val) { return val.str(); }
|
||||
|
||||
private:
|
||||
// Helper to convert value to string - handles the case where value is already a string
|
||||
template<typename T> static std::string value_to_string(T &&val) {
|
||||
return to_string(std::forward<T>(val)); // NOLINT
|
||||
/// A key and value from codegen; on ESP8266 the table and its strings are in flash.
|
||||
/// The value is the constant `value`, or the result of `fn` when it is set.
|
||||
template<typename... Ts> struct HomeAssistantField {
|
||||
const char *key;
|
||||
const char *value;
|
||||
std::string (*fn)(const Ts &...);
|
||||
|
||||
template<typename F> static constexpr HomeAssistantField from_lambda(const char *key, F /*lambda*/) {
|
||||
return {key, nullptr, &call_lambda<F>};
|
||||
}
|
||||
|
||||
// Overloads for string types - needed because std::to_string doesn't support them
|
||||
static std::string value_to_string(char *val) {
|
||||
return val ? std::string(val) : std::string();
|
||||
} // For lambdas returning char* (e.g., itoa)
|
||||
static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str()
|
||||
static std::string value_to_string(const std::string &val) { return val; }
|
||||
static std::string value_to_string(std::string &&val) { return std::move(val); }
|
||||
static std::string value_to_string(const StringRef &val) { return val.str(); }
|
||||
static std::string value_to_string(StringRef &&val) { return val.str(); }
|
||||
|
||||
public:
|
||||
TemplatableStringValue() : TemplatableValue<std::string, X...>() {}
|
||||
|
||||
template<typename F, enable_if_t<!is_invocable<F, X...>::value, int> = 0>
|
||||
TemplatableStringValue(F value) : TemplatableValue<std::string, X...>(value) {}
|
||||
|
||||
template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0>
|
||||
TemplatableStringValue(F f)
|
||||
: TemplatableValue<std::string, X...>([f](X... x) -> std::string { return value_to_string(f(x...)); }) {}
|
||||
};
|
||||
|
||||
template<typename... Ts> class TemplatableKeyValuePair {
|
||||
public:
|
||||
// Default constructor needed for FixedVector::emplace_back()
|
||||
TemplatableKeyValuePair() = default;
|
||||
|
||||
// Keys are always string literals from YAML dictionary keys (e.g., "code", "event")
|
||||
// and never templatable values or lambdas. Only the value parameter can be a lambda/template.
|
||||
// Using const char* avoids std::string heap allocation - keys remain in flash.
|
||||
template<typename T> TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {}
|
||||
|
||||
const char *key{nullptr};
|
||||
TemplatableStringValue<Ts...> value;
|
||||
template<typename F> static std::string call_lambda(const Ts &...x) { return field_to_string(F{}(x...)); }
|
||||
};
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
@@ -106,45 +82,20 @@ template<typename... Ts> using ActionResponseCallback = std::function<void(const
|
||||
|
||||
template<typename... Ts> class HomeAssistantServiceCallAction final : public Action<Ts...> {
|
||||
public:
|
||||
explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) {
|
||||
using Field = HomeAssistantField<Ts...>;
|
||||
|
||||
/// `fields` is a codegen table: the action or event name (no key), then the data, data_template
|
||||
/// and variables entries.
|
||||
HomeAssistantServiceCallAction(APIServer *parent, bool is_event, const Field *fields, uint8_t data_count,
|
||||
uint8_t data_template_count, uint8_t variables_count)
|
||||
: parent_(parent),
|
||||
fields_(fields),
|
||||
data_count_(data_count),
|
||||
data_template_count_(data_template_count),
|
||||
variables_count_(variables_count) {
|
||||
this->flags_.is_event = is_event;
|
||||
}
|
||||
|
||||
template<typename T> void set_service(T service) { this->service_ = service; }
|
||||
|
||||
// Initialize FixedVector members - called from Python codegen with compile-time known sizes.
|
||||
// Must be called before any add_* methods; capacity must match the number of subsequent add_* calls.
|
||||
void init_data(size_t count) { this->data_.init(count); }
|
||||
void init_data_template(size_t count) { this->data_template_.init(count); }
|
||||
void init_variables(size_t count) { this->variables_.init(count); }
|
||||
|
||||
// Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))).
|
||||
// The value parameter can be a lambda/template, but keys are never templatable.
|
||||
// Using const char* for keys avoids std::string heap allocation - keys remain in flash.
|
||||
template<typename V> void add_data(const char *key, V &&value) {
|
||||
this->add_kv_(this->data_, key, std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_data_template(const char *key, V &&value) {
|
||||
this->add_kv_(this->data_template_, key, std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_variable(const char *key, V &&value) {
|
||||
this->add_kv_(this->variables_, key, std::forward<V>(value));
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, ESPHOME_F() returns __FlashStringHelper* (PROGMEM pointer).
|
||||
// Store as const char* — populate_service_map copies from PROGMEM at play() time.
|
||||
template<typename V> void add_data(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->data_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_data_template(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->data_template_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_variable(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->variables_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
template<typename T> void set_response_template(T response_template) {
|
||||
this->response_template_ = response_template;
|
||||
@@ -162,19 +113,61 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
const Field *fields = this->fields_;
|
||||
const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_;
|
||||
|
||||
// Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send
|
||||
size_t lambda_count = 0;
|
||||
#ifdef USE_ESP8266
|
||||
size_t flash_len = 0;
|
||||
#endif
|
||||
for (size_t i = 0; i < total; i++) {
|
||||
lambda_count += fields[i].fn != nullptr;
|
||||
#ifdef USE_ESP8266
|
||||
if (fields[i].fn == nullptr)
|
||||
flash_len += ESPHOME_strlen_P(fields[i].value);
|
||||
if (fields[i].key != nullptr)
|
||||
flash_len += ESPHOME_strlen_P(fields[i].key);
|
||||
#endif
|
||||
}
|
||||
FixedVector<std::string> results;
|
||||
results.init(lambda_count);
|
||||
#ifdef USE_ESP8266
|
||||
SmallBufferWithHeapFallback<128, char> flash_copy(flash_len);
|
||||
char *cursor = flash_copy.get();
|
||||
#endif
|
||||
auto string_ref = [&](const char *str) {
|
||||
#ifdef USE_ESP8266
|
||||
size_t len = ESPHOME_strlen_P(str);
|
||||
memcpy_P(cursor, str, len);
|
||||
StringRef ref(cursor, len);
|
||||
cursor += len;
|
||||
return ref;
|
||||
#else
|
||||
return StringRef(str);
|
||||
#endif
|
||||
};
|
||||
auto value_ref = [&](const Field &field) {
|
||||
if (field.fn == nullptr)
|
||||
return string_ref(field.value);
|
||||
results.push_back(field.fn(x...));
|
||||
return StringRef(results.back());
|
||||
};
|
||||
auto fill = [&](FixedVector<HomeassistantServiceMap> &dest, uint8_t count) {
|
||||
dest.init(count);
|
||||
for (uint8_t i = 0; i < count; i++, fields++) {
|
||||
auto &kv = dest.emplace_back();
|
||||
kv.key = string_ref(fields->key);
|
||||
kv.value = value_ref(*fields);
|
||||
}
|
||||
};
|
||||
|
||||
HomeassistantActionRequest resp;
|
||||
std::string service_value = this->service_.value(x...);
|
||||
resp.service = StringRef(service_value);
|
||||
resp.service = value_ref(*fields++);
|
||||
resp.is_event = this->flags_.is_event;
|
||||
|
||||
// Local storage for lambda-evaluated strings - lives until after send
|
||||
FixedVector<std::string> data_storage;
|
||||
FixedVector<std::string> data_template_storage;
|
||||
FixedVector<std::string> variables_storage;
|
||||
|
||||
this->populate_service_map(resp.data, this->data_, data_storage, x...);
|
||||
this->populate_service_map(resp.data_template, this->data_template_, data_template_storage, x...);
|
||||
this->populate_service_map(resp.variables, this->variables_, variables_storage, x...);
|
||||
fill(resp.data, this->data_count_);
|
||||
fill(resp.data_template, this->data_template_count_);
|
||||
fill(resp.variables, this->variables_count_);
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
@@ -223,90 +216,26 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
|
||||
}
|
||||
|
||||
protected:
|
||||
// Helper to add key-value pairs to FixedVectors
|
||||
// Keys are always string literals (const char*), values can be lambdas/templates
|
||||
template<typename V> void add_kv_(FixedVector<TemplatableKeyValuePair<Ts...>> &vec, const char *key, V &&value) {
|
||||
auto &kv = vec.emplace_back();
|
||||
kv.key = key;
|
||||
kv.value = std::forward<V>(value);
|
||||
}
|
||||
|
||||
template<typename VectorType, typename SourceType>
|
||||
static void populate_service_map(VectorType &dest, SourceType &source, FixedVector<std::string> &value_storage,
|
||||
Ts... x) {
|
||||
dest.init(source.size());
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, all static strings from codegen are FLASH_STRING (PROGMEM),
|
||||
// so is_static_string() is always false — the zero-copy STATIC_STRING fast
|
||||
// path from the non-ESP8266 branch cannot trigger. We copy all keys and
|
||||
// values unconditionally: keys via _P functions (may be in PROGMEM), values
|
||||
// via value() which handles FLASH_STRING internally.
|
||||
value_storage.init(source.size() * 2);
|
||||
|
||||
for (auto &it : source) {
|
||||
auto &kv = dest.emplace_back();
|
||||
|
||||
// Key: copy from possible PROGMEM
|
||||
{
|
||||
size_t key_len = strlen_P(it.key);
|
||||
value_storage.push_back(std::string(key_len, '\0'));
|
||||
memcpy_P(value_storage.back().data(), it.key, key_len);
|
||||
kv.key = StringRef(value_storage.back());
|
||||
}
|
||||
|
||||
// Value: value() handles FLASH_STRING via _P functions internally
|
||||
value_storage.push_back(it.value.value(x...));
|
||||
kv.value = StringRef(value_storage.back());
|
||||
}
|
||||
#else
|
||||
// On non-ESP8266, strings are directly readable from flash-mapped memory.
|
||||
// Count non-static strings to allocate exact storage needed.
|
||||
size_t lambda_count = 0;
|
||||
for (const auto &it : source) {
|
||||
if (!it.value.is_static_string()) {
|
||||
lambda_count++;
|
||||
}
|
||||
}
|
||||
value_storage.init(lambda_count);
|
||||
|
||||
for (auto &it : source) {
|
||||
auto &kv = dest.emplace_back();
|
||||
kv.key = StringRef(it.key);
|
||||
|
||||
if (it.value.is_static_string()) {
|
||||
// Static string — pointer directly readable, zero allocation
|
||||
kv.value = StringRef(it.value.get_static_string());
|
||||
} else {
|
||||
// Lambda — evaluate and store result
|
||||
value_storage.push_back(it.value.value(x...));
|
||||
kv.value = StringRef(value_storage.back());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
APIServer *parent_;
|
||||
TemplatableStringValue<Ts...> service_{};
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> data_;
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> data_template_;
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> variables_;
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
TemplatableStringValue<Ts...> response_template_{""};
|
||||
Trigger<JsonObjectConst, Ts...> success_trigger_with_response_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
Trigger<Ts...> success_trigger_;
|
||||
Trigger<std::string, Ts...> error_trigger_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
|
||||
const Field *fields_;
|
||||
uint8_t data_count_;
|
||||
uint8_t data_template_count_;
|
||||
uint8_t variables_count_;
|
||||
struct Flags {
|
||||
uint8_t is_event : 1;
|
||||
uint8_t wants_status : 1;
|
||||
uint8_t wants_response : 1;
|
||||
uint8_t has_response_template : 1;
|
||||
uint8_t reserved : 5;
|
||||
uint8_t reserved : 4;
|
||||
} flags_{0};
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
TemplatableValue<std::string, Ts...> response_template_{};
|
||||
Trigger<JsonObjectConst, Ts...> success_trigger_with_response_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
Trigger<Ts...> success_trigger_;
|
||||
Trigger<std::string, Ts...> error_trigger_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -119,7 +119,7 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
|
||||
}
|
||||
|
||||
// Single-pass encode for repeated submessage elements (non-template core).
|
||||
// Writes field tag, reserves 1 byte for length varint, encodes the submessage body,
|
||||
// Reserves 1 byte for length varint, encodes the submessage body,
|
||||
// then backpatches the actual length. For the common case (body < 128 bytes), this is
|
||||
// just a single byte write with no memmove — all current repeated submessage types
|
||||
// (BLE advertisements at ~47B, GATT descriptors at ~24B, service args, etc.) take
|
||||
@@ -143,51 +143,34 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
|
||||
//
|
||||
// After writing 2-byte varint at len_pos:
|
||||
// [tag][v1][v2][body ..... body]
|
||||
// ^-- pos_ = element end, within buffer
|
||||
void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *,
|
||||
ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) {
|
||||
this->encode_field_raw(field_id, 2);
|
||||
// Reserve 1 byte for length varint (optimistic: submessage < 128 bytes)
|
||||
uint8_t *len_pos = this->pos_;
|
||||
this->debug_check_bounds_(1);
|
||||
this->pos_++;
|
||||
uint8_t *body_start = this->pos_;
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
uint32_t body_size = static_cast<uint32_t>(this->pos_ - body_start);
|
||||
if (body_size < 128) [[likely]] {
|
||||
// ^-- returned cursor = element end, within buffer
|
||||
uint8_t *ProtoEncode::encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *value,
|
||||
ProtoEncodeFn encode_fn) {
|
||||
// Reserve 1 byte for the length varint (optimistic: submessage < 128 bytes)
|
||||
uint8_t *len_pos = pos;
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
uint8_t *body_start = pos + 1;
|
||||
uint8_t *after_body = encode_fn(value, body_start PROTO_ENCODE_DEBUG_ARG);
|
||||
uint32_t body_size = static_cast<uint32_t>(after_body - body_start);
|
||||
if (body_size < VARINT_MAX_1_BYTE) [[likely]] {
|
||||
// Common case: 1-byte varint, just backpatch
|
||||
*len_pos = static_cast<uint8_t>(body_size);
|
||||
return;
|
||||
return after_body;
|
||||
}
|
||||
// Compute extra bytes needed for varint beyond the 1 already reserved
|
||||
// Shift the body forward to make room for the extra length varint bytes
|
||||
uint8_t extra = ProtoSize::varint(body_size) - 1;
|
||||
// Shift body forward to make room for the extra varint bytes
|
||||
this->debug_check_bounds_(extra);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(after_body, extra);
|
||||
std::memmove(body_start + extra, body_start, body_size);
|
||||
uint8_t *end = this->pos_ + extra;
|
||||
// Write the full varint at len_pos
|
||||
this->pos_ = len_pos;
|
||||
this->encode_varint_raw(body_size);
|
||||
this->pos_ = end;
|
||||
(void) encode_varint_raw_loop(len_pos PROTO_ENCODE_DEBUG_ARG, body_size);
|
||||
return after_body + extra;
|
||||
}
|
||||
|
||||
// Non-template core for encode_optional_sub_message.
|
||||
void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *,
|
||||
ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) {
|
||||
if (nested_size == 0)
|
||||
return;
|
||||
this->encode_field_raw(field_id, 2);
|
||||
this->encode_varint_raw(nested_size);
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *start = this->pos_;
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
if (static_cast<uint32_t>(this->pos_ - start) != nested_size)
|
||||
this->debug_check_encode_size_(field_id, nested_size, this->pos_ - start);
|
||||
#else
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
#endif
|
||||
uint8_t *ProtoEncode::encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t nested_size, const void *value, ProtoEncodeFn encode_fn) {
|
||||
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, nested_size);
|
||||
return encode_fn(value, pos PROTO_ENCODE_DEBUG_ARG);
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
@@ -201,6 +184,14 @@ void proto_check_encode_end(const uint8_t *end, const uint8_t *expected) {
|
||||
ESP_LOGE(TAG, "Proto encode ended %td bytes off the calculated size", end - expected);
|
||||
abort();
|
||||
}
|
||||
void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end) {
|
||||
ptrdiff_t actual = end - (len_pos + ProtoSize::varint(expected));
|
||||
if (actual == static_cast<ptrdiff_t>(expected))
|
||||
return;
|
||||
ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id,
|
||||
expected, actual);
|
||||
abort();
|
||||
}
|
||||
void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) {
|
||||
if (this->pos_ + bytes > this->buffer_->data() + this->buffer_->size()) {
|
||||
ESP_LOGE(TAG, "ProtoWriteBuffer bounds check failed in %s: bytes=%zu offset=%td buf_size=%zu", caller, bytes,
|
||||
@@ -208,11 +199,6 @@ void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) {
|
||||
abort();
|
||||
}
|
||||
}
|
||||
void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual) {
|
||||
ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id,
|
||||
expected, actual);
|
||||
abort();
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "api_pb2_defines.h"
|
||||
#include "api_buffer.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
@@ -56,22 +57,25 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) {
|
||||
return (value & 1) ? static_cast<int64_t>(~(value >> 1)) : static_cast<int64_t>(value >> 1);
|
||||
}
|
||||
|
||||
/// Count number of varints in a packed buffer
|
||||
inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
|
||||
uint16_t count = 0;
|
||||
while (len > 0) {
|
||||
// Skip varint bytes until we find one without continuation bit
|
||||
while (len > 0 && (*data & 0x80)) {
|
||||
data++;
|
||||
len--;
|
||||
}
|
||||
if (len > 0) {
|
||||
data++;
|
||||
len--;
|
||||
count++;
|
||||
/// Count varints in a packed buffer: len minus bytes with the continuation bit, summed a word at a time.
|
||||
/// Word is a template parameter so tests can cover the 32-bit path on a 64-bit host.
|
||||
template<typename Word = size_t> inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
|
||||
constexpr size_t word_size = sizeof(Word);
|
||||
constexpr Word lane_ones = ~Word{0} / 0xFF; // 0x01..01
|
||||
const uint8_t *end = data + len;
|
||||
size_t continuations = 0;
|
||||
while (data != end) {
|
||||
// Unaligned word loads fault on Xtensa
|
||||
if ((reinterpret_cast<uintptr_t>(data) & (word_size - 1)) == 0 && static_cast<size_t>(end - data) >= word_size) {
|
||||
Word word;
|
||||
memcpy(&word, __builtin_assume_aligned(data, word_size), word_size);
|
||||
continuations += (((word >> 7) & lane_ones) * lane_ones) >> (word_size * 8 - 8);
|
||||
data += word_size;
|
||||
} else {
|
||||
continuations += *data++ >> 7;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
return static_cast<uint16_t>(len - continuations);
|
||||
}
|
||||
|
||||
/// Encode a varint directly into a pre-allocated buffer.
|
||||
@@ -228,6 +232,8 @@ void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t *
|
||||
/// Aborts unless an encode body ended exactly where calculate_size() promised. A plain check rather than
|
||||
/// assert(), so NDEBUG cannot switch it off.
|
||||
void proto_check_encode_end(const uint8_t *end, const uint8_t *expected);
|
||||
/// Aborts unless a sized sub-message (length prefix at len_pos) ended where its calculated size said.
|
||||
void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end);
|
||||
#else
|
||||
#define PROTO_ENCODE_DEBUG_PARAM
|
||||
#define PROTO_ENCODE_DEBUG_ARG
|
||||
@@ -260,21 +266,6 @@ class ProtoWriteBuffer {
|
||||
* Following https://protobuf.dev/programming-guides/encoding/#structure
|
||||
*/
|
||||
void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw(proto_tag(field_id, type)); }
|
||||
/// Single-pass encode for repeated submessage elements.
|
||||
/// Thin template wrapper; all buffer work is in the non-template core.
|
||||
template<typename T> void encode_sub_message(uint32_t field_id, const T &value);
|
||||
/// Encode an optional singular submessage field — skips if empty.
|
||||
/// Thin template wrapper; all buffer work is in the non-template core.
|
||||
template<typename T> void encode_optional_sub_message(uint32_t field_id, const T &value);
|
||||
|
||||
// NOLINTBEGIN(readability-identifier-naming)
|
||||
// Non-template core for encode_sub_message — backpatch approach.
|
||||
void encode_sub_message(uint32_t field_id, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM));
|
||||
// Non-template core for encode_optional_sub_message.
|
||||
void encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM));
|
||||
// NOLINTEND(readability-identifier-naming)
|
||||
APIBuffer *get_buffer() const { return buffer_; }
|
||||
uint8_t *get_pos() const { return pos_; }
|
||||
void set_pos(uint8_t *pos) { pos_ = pos; }
|
||||
@@ -285,7 +276,6 @@ class ProtoWriteBuffer {
|
||||
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
void debug_check_bounds_(size_t bytes, const char *caller = __builtin_FUNCTION());
|
||||
void debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual);
|
||||
#else
|
||||
void debug_check_bounds_([[maybe_unused]] size_t bytes) {}
|
||||
#endif
|
||||
@@ -310,6 +300,9 @@ class ProtoWriteBuffer {
|
||||
constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128
|
||||
constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
|
||||
|
||||
/// Generated encode body: writes the fields at pos, returns the cursor past them.
|
||||
using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
|
||||
|
||||
/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and
|
||||
/// returns it advanced, so outlined calls at -Os chain through the return register instead of a
|
||||
/// stack slot. Helpers without a _force suffix skip fields holding the proto3 default.
|
||||
@@ -473,6 +466,21 @@ class ProtoEncode {
|
||||
uint32_t field_id, const uint8_t *data, size_t len) {
|
||||
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast<const char *>(data), len);
|
||||
}
|
||||
/// encode_bytes for data in flash (PROGMEM), which ESP8266 can only read with progmem_memcpy.
|
||||
[[nodiscard]] static inline uint8_t *encode_progmem_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const uint8_t *data, size_t len) {
|
||||
if (len == 0)
|
||||
return pos;
|
||||
return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, data, len);
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_progmem_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const uint8_t *data, size_t len) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited
|
||||
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, len);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, len);
|
||||
progmem_memcpy(pos, data, len);
|
||||
return pos + len;
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, uint32_t value) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
|
||||
@@ -572,22 +580,36 @@ class ProtoEncode {
|
||||
uint32_t field_id, int64_t value) {
|
||||
return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value));
|
||||
}
|
||||
/// Sub-message encoding: sync pos to buffer, delegate, read the cursor back.
|
||||
/// Repeated sub-message element; the constant tag is written inline.
|
||||
template<typename T>
|
||||
[[nodiscard]] static inline uint8_t *encode_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) {
|
||||
buffer.set_pos(pos);
|
||||
buffer.encode_sub_message(field_id, value);
|
||||
return buffer.get_pos();
|
||||
uint32_t field_id, const T &value) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2);
|
||||
return encode_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, &value, &T::encode_msg);
|
||||
}
|
||||
/// Singular sub-message field, skipped when it encodes to nothing.
|
||||
template<typename T>
|
||||
[[nodiscard]] static inline uint8_t *encode_optional_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
ProtoWriteBuffer &buffer, uint32_t field_id,
|
||||
const T &value) {
|
||||
buffer.set_pos(pos);
|
||||
buffer.encode_optional_sub_message(field_id, value);
|
||||
return buffer.get_pos();
|
||||
uint32_t field_id, const T &value) {
|
||||
uint32_t nested_size = T::calc_size_msg(&value);
|
||||
if (nested_size == 0)
|
||||
return pos;
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2);
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *end = encode_sized_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, nested_size, &value, &T::encode_msg);
|
||||
proto_check_sub_message_size(field_id, nested_size, pos, end);
|
||||
return end;
|
||||
#else
|
||||
return encode_sized_sub_message_body(pos, nested_size, &value, &T::encode_msg);
|
||||
#endif
|
||||
}
|
||||
/// Length and body, length backpatched after the body is written.
|
||||
[[nodiscard]] static uint8_t *encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
const void *value, ProtoEncodeFn encode_fn);
|
||||
/// Length and body for a precomputed size.
|
||||
[[nodiscard]] static uint8_t *encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t nested_size, const void *value,
|
||||
ProtoEncodeFn encode_fn);
|
||||
|
||||
private:
|
||||
/// Unaligned little endian store of four bytes: byte stores where the outlined helper lives (ESP-IDF, ARM
|
||||
@@ -701,9 +723,7 @@ class ProtoMessage {
|
||||
public:
|
||||
// Non-virtual defaults for messages with no fields; generated classes hide all four. The
|
||||
// static encode_msg/calc_size_msg take const void * so &T::encode_msg needs no thunk.
|
||||
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {
|
||||
return buffer.get_pos();
|
||||
}
|
||||
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM) { return pos; }
|
||||
static uint32_t calc_size_msg(const void *self) { return 0; }
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return buffer.get_pos(); }
|
||||
uint32_t calculate_size() const { return 0; }
|
||||
@@ -957,18 +977,6 @@ class ProtoSize {
|
||||
}
|
||||
};
|
||||
|
||||
// Implementation of methods that depend on ProtoSize being fully defined
|
||||
|
||||
// Thin template wrapper; delegates to non-template core in proto.cpp.
|
||||
template<typename T> inline void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const T &value) {
|
||||
this->encode_sub_message(field_id, &value, &T::encode_msg);
|
||||
}
|
||||
|
||||
// Thin template wrapper; delegates to non-template core.
|
||||
template<typename T> inline void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, const T &value) {
|
||||
this->encode_optional_sub_message(field_id, T::calc_size_msg(&value), &value, &T::encode_msg);
|
||||
}
|
||||
|
||||
template<typename T> const char *proto_enum_to_string(T value);
|
||||
|
||||
// ProtoService removed — its methods were inlined into APIConnection.
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
"""The device wizard of the api component: schema, validation and code generation.
|
||||
|
||||
Home Assistant shows the wizard when the device is added. See api_wizard.h for the C++ side.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import importlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_DESCRIPTION
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_ID,
|
||||
CONF_INTERNAL,
|
||||
CONF_MAX_LENGTH,
|
||||
CONF_MULTIPLE,
|
||||
CONF_NAME,
|
||||
CONF_PAGES,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import (
|
||||
fnv1_hash,
|
||||
fnv1_hash_object_id,
|
||||
fnv1a_32bit_hash,
|
||||
zstd_module,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
API_DOMAIN = "api"
|
||||
|
||||
CONF_ENTITIES = "entities"
|
||||
CONF_ENTITY = "entity"
|
||||
CONF_INPUTS = "inputs"
|
||||
CONF_INTEGRATION = "integration"
|
||||
CONF_SUPPORTED_FEATURES = "supported_features"
|
||||
CONF_TITLE = "title"
|
||||
CONF_WIZARD = "wizard"
|
||||
|
||||
_API = cg.esphome_ns.namespace("api")
|
||||
WizardInput = _API.class_("WizardInput")
|
||||
|
||||
WIZARD_ENTITY_ID_BUFFER_SIZE = (
|
||||
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
|
||||
)
|
||||
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
|
||||
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
|
||||
# api.proto max_data_length documents the same value
|
||||
WIZARD_MULTIPLE_MAX_LENGTH = 4095
|
||||
# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes,
|
||||
# Noise) and footer (16 bytes, Noise MAC)
|
||||
WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16
|
||||
# Version of the JSON the wizard is sent as, see wizard_document()
|
||||
WIZARD_JSON_VERSION = 1
|
||||
# zstd level the JSON is compressed with. Output is deterministic for a given zstd version.
|
||||
WIZARD_ZSTD_LEVEL = 19
|
||||
|
||||
# Wizard string limits; api.proto documents the same values
|
||||
WIZARD_TITLE_MAX_LENGTH = 127
|
||||
WIZARD_DESCRIPTION_MAX_LENGTH = 255
|
||||
WIZARD_FILTER_MAX_LENGTH = 63
|
||||
WIZARD_SUPPORTED_FEATURE_MAX_LENGTH = 127
|
||||
|
||||
|
||||
def _wizard_text(max_length: int) -> Callable[[Any], str]:
|
||||
"""A string passed to Home Assistant verbatim, so it may be a [%key:...%] translation placeholder."""
|
||||
return cv.All(cv.string_strict, cv.Length(max=max_length))
|
||||
|
||||
|
||||
def _wizard_strings(max_length: int) -> Callable[[Any], list[str]]:
|
||||
"""A single string or a list of strings, always validated to a non-empty list."""
|
||||
return cv.All(
|
||||
cv.ensure_list(cv.All(cv.string_strict, cv.Length(min=1, max=max_length))),
|
||||
cv.Length(min=1),
|
||||
)
|
||||
|
||||
|
||||
# Mirrors Home Assistant's EntityFilterSelectorConfig
|
||||
WIZARD_ENTITY_FILTER_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_INTEGRATION): cv.All(
|
||||
cv.string_strict, cv.Length(min=1, max=WIZARD_FILTER_MAX_LENGTH)
|
||||
),
|
||||
cv.Optional(CONF_DOMAIN): _wizard_strings(WIZARD_FILTER_MAX_LENGTH),
|
||||
cv.Optional(CONF_DEVICE_CLASS): _wizard_strings(WIZARD_FILTER_MAX_LENGTH),
|
||||
cv.Optional(CONF_SUPPORTED_FEATURES): _wizard_strings(
|
||||
WIZARD_SUPPORTED_FEATURE_MAX_LENGTH
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(
|
||||
CONF_INTEGRATION, CONF_DOMAIN, CONF_DEVICE_CLASS, CONF_SUPPORTED_FEATURES
|
||||
),
|
||||
)
|
||||
|
||||
WIZARD_ENTITY_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(cg.EntityBase),
|
||||
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_multiple(conf: ConfigType) -> ConfigType:
|
||||
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
|
||||
raise cv.Invalid(
|
||||
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
|
||||
"is only for standalone inputs (id)",
|
||||
path=[CONF_MULTIPLE],
|
||||
)
|
||||
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
|
||||
raise cv.Invalid(
|
||||
"max_length only applies to an input with multiple: true",
|
||||
path=[CONF_MAX_LENGTH],
|
||||
)
|
||||
return conf
|
||||
|
||||
|
||||
# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no
|
||||
# entity_id of its own: Home Assistant sets it
|
||||
WIZARD_INPUT_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ID): cv.declare_id(WizardInput),
|
||||
cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase),
|
||||
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
|
||||
cv.Optional(CONF_MULTIPLE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
|
||||
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
|
||||
),
|
||||
cv.Optional(CONF_TARGET): cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ENTITY): cv.All(
|
||||
cv.ensure_list(WIZARD_ENTITY_FILTER_SCHEMA), cv.Length(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
|
||||
_validate_multiple,
|
||||
)
|
||||
|
||||
WIZARD_PAGE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_TITLE): _wizard_text(WIZARD_TITLE_MAX_LENGTH),
|
||||
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
|
||||
cv.Optional(CONF_ENTITIES): cv.All(
|
||||
cv.ensure_list(WIZARD_ENTITY_SCHEMA), cv.Length(min=1)
|
||||
),
|
||||
cv.Optional(CONF_INPUTS): cv.All(
|
||||
cv.ensure_list(WIZARD_INPUT_SCHEMA), cv.Length(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_ENTITIES, CONF_INPUTS),
|
||||
)
|
||||
|
||||
|
||||
def _wizard_inputs(wizard: ConfigType) -> list[ConfigType]:
|
||||
return [conf for page in wizard[CONF_PAGES] for conf in page.get(CONF_INPUTS, [])]
|
||||
|
||||
|
||||
def _wizard_input_id(conf: ConfigType) -> ID:
|
||||
"""The ID that names an input: the one a standalone input declares, or its linked entity."""
|
||||
return conf[CONF_ID] if CONF_ID in conf else conf[CONF_ENTITY]
|
||||
|
||||
|
||||
def _validate_unique_wizard_inputs(wizard: ConfigType) -> ConfigType:
|
||||
"""An input is identified on the wire by a hash of its ID, so IDs and hashes must be unique."""
|
||||
seen: dict[int, str] = {}
|
||||
for conf in _wizard_inputs(wizard):
|
||||
input_id = _wizard_input_id(conf).id
|
||||
if (key := fnv1_hash(input_id)) in seen:
|
||||
if seen[key] == input_id:
|
||||
raise cv.Invalid(f"Wizard input '{input_id}' is used more than once")
|
||||
raise cv.Invalid(
|
||||
f"Wizard inputs '{seen[key]}' and '{input_id}' have the same hash, rename one"
|
||||
)
|
||||
seen[key] = input_id
|
||||
return wizard
|
||||
|
||||
|
||||
WIZARD_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_PAGES): cv.All(
|
||||
cv.ensure_list(WIZARD_PAGE_SCHEMA), cv.Length(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
_validate_unique_wizard_inputs,
|
||||
)
|
||||
|
||||
# Platforms of the homeassistant component that a wizard input can stand for
|
||||
WIZARD_INPUT_DOMAINS = (
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"number",
|
||||
"select",
|
||||
"sensor",
|
||||
"switch",
|
||||
"text",
|
||||
"text_sensor",
|
||||
)
|
||||
# Platforms that act on one family of Home Assistant domains, which the input's filters must stay within
|
||||
WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text")
|
||||
|
||||
|
||||
def wizard_input_ids(api_config: ConfigType) -> set[str]:
|
||||
"""The IDs of the entities that are linked inputs of the wizard in the given api config."""
|
||||
if (wizard := api_config.get(CONF_WIZARD)) is None:
|
||||
return set()
|
||||
return {
|
||||
conf[CONF_ENTITY].id for conf in _wizard_inputs(wizard) if CONF_ENTITY in conf
|
||||
}
|
||||
|
||||
|
||||
def _wizard_buffer_name(entity_id: ID) -> str:
|
||||
return f"api_wizard_input_{entity_id.id}"
|
||||
|
||||
|
||||
def wizard_input_buffer(entity_id: ID) -> str | None:
|
||||
"""Name of the RAM buffer holding the Home Assistant entity ID of a wizard input.
|
||||
|
||||
Returns None when the entity is not an input of the wizard. The buffer is defined by the api
|
||||
codegen, and the homeassistant entity of the input is given it in place of a constant.
|
||||
"""
|
||||
if entity_id.id not in wizard_input_ids(CORE.config.get(API_DOMAIN, {})):
|
||||
return None
|
||||
return _wizard_buffer_name(entity_id)
|
||||
|
||||
|
||||
def _wizard_input_declaration(
|
||||
config: fv.FinalValidateConfig, entity_id: ID
|
||||
) -> tuple[str, ConfigType]:
|
||||
"""The domain (like sensor) and the config an input ID is declared in."""
|
||||
path = config.get_path_for_id(entity_id)[:-1]
|
||||
return path[0], config.get_config_for_path(path)
|
||||
|
||||
|
||||
def _wizard_input_filters(
|
||||
conf: ConfigType, config: fv.FinalValidateConfig
|
||||
) -> list[ConfigType]:
|
||||
"""The entity filters of an input, with the defaults of a domain limited linked platform."""
|
||||
if (filters := conf.get(CONF_TARGET, {}).get(CONF_ENTITY)) is not None:
|
||||
return filters
|
||||
if CONF_ENTITY in conf:
|
||||
domain, _ = _wizard_input_declaration(config, conf[CONF_ENTITY])
|
||||
if domains := _wizard_default_domains(domain):
|
||||
return [{CONF_DOMAIN: domains}]
|
||||
return []
|
||||
|
||||
|
||||
def _wizard_defines(wizard: ConfigType) -> set[str]:
|
||||
"""The defines for the parts of the wizard the configuration uses, so the rest is not compiled."""
|
||||
inputs = _wizard_inputs(wizard)
|
||||
defines = {"USE_API_WIZARD"}
|
||||
if inputs:
|
||||
defines.add("USE_API_WIZARD_INPUTS")
|
||||
if any(CONF_ENTITY in conf for conf in inputs):
|
||||
defines.add("USE_API_WIZARD_LINKED_INPUTS")
|
||||
if any(CONF_ID in conf for conf in inputs):
|
||||
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
|
||||
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
|
||||
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
|
||||
return defines
|
||||
|
||||
|
||||
def _wizard_default_domains(domain: str) -> list[str] | None:
|
||||
"""The domains Home Assistant entities can be picked from when the input sets no target."""
|
||||
if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS:
|
||||
platform = importlib.import_module(f"esphome.components.homeassistant.{domain}")
|
||||
return list(platform.SUPPORTED_DOMAINS)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_wizard_input(conf: ConfigType) -> ConfigType:
|
||||
if CONF_ENTITY not in conf:
|
||||
return conf
|
||||
domain, declaration = _wizard_input_declaration(
|
||||
fv.full_config.get(), conf[CONF_ENTITY]
|
||||
)
|
||||
if (
|
||||
declaration.get(CONF_PLATFORM) != "homeassistant"
|
||||
or domain not in WIZARD_INPUT_DOMAINS
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Wizard input '{conf[CONF_ENTITY].id}' must be a homeassistant "
|
||||
f"{', '.join(WIZARD_INPUT_DOMAINS)} entity"
|
||||
)
|
||||
if CONF_ENTITY_ID in declaration:
|
||||
# An entity_id in the configuration is a static entry, which is kept apart from the dynamic ones
|
||||
raise cv.Invalid(
|
||||
f"'{conf[CONF_ENTITY].id}' has an entity_id set in its configuration, so it cannot be a "
|
||||
"wizard input. Remove entity_id to let Home Assistant set it through the wizard."
|
||||
)
|
||||
if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS:
|
||||
supported = _wizard_default_domains(domain)
|
||||
for entity_filter in conf.get(CONF_TARGET, {}).get(CONF_ENTITY, []):
|
||||
if not (domains := entity_filter.get(CONF_DOMAIN)):
|
||||
raise cv.Invalid(
|
||||
f"Every filter of a homeassistant {domain} input must set domain"
|
||||
)
|
||||
if unsupported := [d for d in domains if d not in supported]:
|
||||
raise cv.Invalid(
|
||||
f"The homeassistant {domain} does not support the domain(s) "
|
||||
f"{', '.join(unsupported)}. Supported: {', '.join(supported)}"
|
||||
)
|
||||
return conf
|
||||
|
||||
|
||||
def _validate_wizard_entity_exposed(value: ID) -> ID:
|
||||
"""Reject entities that are internal, as they are not exposed over the API, or have no name.
|
||||
|
||||
The key a client knows an entity by is a hash of its name. Without a name of its own, the
|
||||
device works the name out at runtime from its friendly name, which can add the MAC address,
|
||||
so the key cannot be known when the wizard is built.
|
||||
"""
|
||||
_, declaration = _wizard_input_declaration(fv.full_config.get(), value)
|
||||
if declaration.get(CONF_INTERNAL, False):
|
||||
raise cv.Invalid(
|
||||
f"Entity '{value.id}' is internal, so it is not exposed over the API "
|
||||
"and cannot be used in the wizard"
|
||||
)
|
||||
if not declaration.get(CONF_NAME):
|
||||
raise cv.Invalid(
|
||||
f"Entity '{value.id}' has no name of its own, so its key is not known "
|
||||
"when the wizard is built. Give it a name to use it in the wizard"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
_WIZARD_FINAL_VALIDATE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WIZARD): {
|
||||
cv.Optional(CONF_PAGES): [
|
||||
{
|
||||
cv.Optional(CONF_ENTITIES): [
|
||||
{cv.Optional(CONF_ID): _validate_wizard_entity_exposed}
|
||||
],
|
||||
cv.Optional(CONF_INPUTS): [_validate_wizard_input],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config: ConfigType) -> None:
|
||||
"""Final validation of the wizard in the given api config, if it has one."""
|
||||
_WIZARD_FINAL_VALIDATE_SCHEMA(config)
|
||||
if (wizard := config.get(CONF_WIZARD)) is not None:
|
||||
size = len(wizard_blob(wizard, fv.full_config.get()))
|
||||
if size > WIZARD_RESPONSE_MAX_SIZE:
|
||||
raise cv.Invalid(
|
||||
f"The compressed wizard is {size} bytes, {size - WIZARD_RESPONSE_MAX_SIZE} "
|
||||
f"bytes over the {WIZARD_RESPONSE_MAX_SIZE} bytes one API message can hold. "
|
||||
"Shorten the texts or use fewer pages, entities or filters",
|
||||
path=[CONF_WIZARD],
|
||||
)
|
||||
|
||||
|
||||
WIZARD_INPUT_IS_SET_SCHEMA = cv.maybe_simple_value(
|
||||
{cv.Required(CONF_ID): cv.use_id(WizardInput)}, key=CONF_ID
|
||||
)
|
||||
|
||||
# Only for standalone inputs: a linked input is read through its homeassistant entity
|
||||
automation.register_apply_condition(
|
||||
"api.wizard.input_is_set", WIZARD_INPUT_IS_SET_SCHEMA, "has_entity_id()"
|
||||
)
|
||||
|
||||
|
||||
def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
|
||||
"""An entity of the device that the page shows, keyed as ListEntitiesResponse keys it."""
|
||||
_, declaration = _wizard_input_declaration(config, conf[CONF_ID])
|
||||
document: ConfigType = {"key": fnv1_hash_object_id(declaration[CONF_NAME])}
|
||||
if (device := declaration.get(CONF_DEVICE_ID)) is not None:
|
||||
document["device_id"] = fnv1a_32bit_hash(device.id)
|
||||
if description := conf.get(CONF_DESCRIPTION):
|
||||
document[CONF_DESCRIPTION] = description
|
||||
return document
|
||||
|
||||
|
||||
def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
|
||||
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
|
||||
if description := conf.get(CONF_DESCRIPTION):
|
||||
document[CONF_DESCRIPTION] = description
|
||||
if conf.get(CONF_MULTIPLE):
|
||||
document["multiple"] = True
|
||||
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
|
||||
document["max_length"] = max_length
|
||||
if filters := _wizard_input_filters(conf, config):
|
||||
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
|
||||
return document
|
||||
|
||||
|
||||
def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
|
||||
"""The wizard as the JSON document the device sends, before it is serialised.
|
||||
|
||||
This is the format Home Assistant reads, and api.proto documents it for clients. Version 1:
|
||||
|
||||
{"version": 1,
|
||||
"pages": [{"title": "...", "description": "...",
|
||||
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
|
||||
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
|
||||
"entity_filters": [{"integration": "...", "domain": ["..."],
|
||||
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
|
||||
|
||||
Anything empty or unset, and every empty list, is left out. Strings are passed through as
|
||||
written, so they may be Home Assistant translation placeholders.
|
||||
|
||||
- An entity key is the key ListEntitiesResponse sends for the entity: the FNV-1 hash of the
|
||||
object id made from its name (entity_helpers). device_id is the hash of the ESPHome id of
|
||||
the device it belongs to (esphome/core/config.py), and is left out for the main device.
|
||||
- An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked
|
||||
entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets.
|
||||
- multiple means the user may pick several entities. The client sends them in one
|
||||
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
|
||||
device trims spaces around each id and stores the list without them. The whole request is
|
||||
ignored when any id is empty or has no '.', or when the list without spaces does not fit
|
||||
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
|
||||
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
|
||||
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
|
||||
accepts.
|
||||
- entity_filters are the filters of the input, or the default filters of a linked switch,
|
||||
number, text, select or button.
|
||||
"""
|
||||
pages: list[ConfigType] = []
|
||||
for page in wizard[CONF_PAGES]:
|
||||
document: ConfigType = {}
|
||||
for key in (CONF_TITLE, CONF_DESCRIPTION):
|
||||
if value := page.get(key):
|
||||
document[key] = value
|
||||
if entities := [
|
||||
_entity_document(e, config) for e in page.get(CONF_ENTITIES, [])
|
||||
]:
|
||||
document[CONF_ENTITIES] = entities
|
||||
if inputs := [_input_document(i, config) for i in page.get(CONF_INPUTS, [])]:
|
||||
document[CONF_INPUTS] = inputs
|
||||
pages.append(document)
|
||||
return {"version": WIZARD_JSON_VERSION, CONF_PAGES: pages}
|
||||
|
||||
|
||||
def wizard_blob(wizard: ConfigType, config: fv.FinalValidateConfig) -> bytes:
|
||||
"""The wizard document as compact, sorted UTF-8 JSON in a single zstd frame."""
|
||||
text = json.dumps(
|
||||
wizard_document(wizard, config),
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return zstd_module().compress(text.encode("utf-8"), level=WIZARD_ZSTD_LEVEL)
|
||||
|
||||
|
||||
async def to_code(wizard: ConfigType) -> None:
|
||||
"""Emit the compressed wizard, the table of inputs and the defines.
|
||||
|
||||
The API reads both tables from its own sources, so they are externally linked PROGMEM arrays.
|
||||
"""
|
||||
blob = wizard_blob(wizard, CORE.config)
|
||||
cg.extern_progmem_array("esphome::api::API_WIZARD_DATA", cg.uint8, list(blob))
|
||||
cg.add_define("API_WIZARD_DATA_SIZE", len(blob))
|
||||
for define in sorted(_wizard_defines(wizard)):
|
||||
cg.add_define(define)
|
||||
entries: list[cg.RawExpression] = []
|
||||
for conf in _wizard_inputs(wizard):
|
||||
input_id = _wizard_input_id(conf)
|
||||
# Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id.
|
||||
buffer = _wizard_buffer_name(input_id)
|
||||
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
|
||||
cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";'))
|
||||
if CONF_ID in conf:
|
||||
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
|
||||
key = fnv1_hash(input_id.id)
|
||||
if conf[CONF_MULTIPLE]:
|
||||
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
|
||||
else:
|
||||
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
|
||||
if entries:
|
||||
cg.extern_progmem_array(
|
||||
"esphome::api::API_WIZARD_INPUTS",
|
||||
cg.esphome_ns.namespace("api").struct("WizardInputEntry"),
|
||||
entries,
|
||||
)
|
||||
cg.add_define("API_WIZARD_INPUT_COUNT", len(entries))
|
||||
@@ -6,7 +6,7 @@ import esphome.codegen as cg
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_component,
|
||||
add_idf_sdkconfig_option,
|
||||
include_builtin_idf_component,
|
||||
request_http_client,
|
||||
require_certificate_bundle,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
@@ -334,8 +334,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
|
||||
include_builtin_idf_component("esp_http_client")
|
||||
request_http_client()
|
||||
# HTTPS streams verify the server against the root certificate bundle
|
||||
require_certificate_bundle()
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t AudioReader::start(AudioFile *audio_file, AudioFileType &file_type) {
|
||||
esp_err_t AudioReader::start(const AudioFile *audio_file, AudioFileType &file_type) {
|
||||
file_type = AudioFileType::NONE;
|
||||
|
||||
this->current_audio_file_ = audio_file;
|
||||
|
||||
@@ -47,7 +47,7 @@ class AudioReader {
|
||||
/// @param audio_file AudioFile struct containing the file.
|
||||
/// @param file_type AudioFileType variable passed-by-reference indicating the type of file being read.
|
||||
/// @return ESP_OK
|
||||
esp_err_t start(AudioFile *audio_file, AudioFileType &file_type);
|
||||
esp_err_t start(const AudioFile *audio_file, AudioFileType &file_type);
|
||||
|
||||
/// @brief Reads new file data from the source and sends to the ring buffer sink.
|
||||
/// @return AudioReaderState
|
||||
@@ -69,7 +69,7 @@ class AudioReader {
|
||||
|
||||
esp_http_client_handle_t client_{nullptr};
|
||||
|
||||
AudioFile *current_audio_file_{nullptr};
|
||||
const AudioFile *current_audio_file_{nullptr};
|
||||
AudioFileType audio_file_type_{AudioFileType::NONE};
|
||||
const uint8_t *file_current_{nullptr};
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ from esphome.const import (
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import CORE, ID, HexInt
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_generator import MockObj, ProgmemAssignmentExpression
|
||||
from esphome.external_files import download_web_files_in_config
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -151,11 +151,14 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
CONF_FILE_DATA_ID = "file_data_id"
|
||||
|
||||
MEDIA_FILE_TYPE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(audio.AudioFile),
|
||||
cv.Required(CONF_FILE): _file_schema,
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
cv.GenerateID(CONF_FILE_DATA_ID): cv.declare_id(audio.AudioFile),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -217,9 +220,9 @@ def audio_files_schema() -> cv.All:
|
||||
|
||||
|
||||
def generate_audio_file_code(file_config: ConfigType) -> MockObj:
|
||||
"""Generate the progmem data, AudioFile struct, and Pvariable for one file.
|
||||
"""Generate the progmem data and a flash AudioFile for one file.
|
||||
|
||||
Returns the created Pvariable. Caller is responsible for any further
|
||||
Returns a const pointer to the AudioFile. Caller is responsible for any further
|
||||
registration (the audio_file component additionally registers each file in
|
||||
its named C++ registry; other consumers may skip that).
|
||||
"""
|
||||
@@ -230,17 +233,33 @@ def generate_audio_file_code(file_config: ConfigType) -> MockObj:
|
||||
else:
|
||||
data, media_file_type = read_audio_file_and_type(file_config)
|
||||
|
||||
rhs = [HexInt(x) for x in data]
|
||||
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
|
||||
|
||||
media_files_struct = cg.StructInitializer(
|
||||
# Global constants so the AudioFile lives in flash; the id stays a plain pointer
|
||||
# because actions render id arguments as ``::<id>``.
|
||||
data_id = file_config[CONF_RAW_DATA_ID]
|
||||
cg.add_global(
|
||||
ProgmemAssignmentExpression(
|
||||
data_id.type, data_id, cg.safe_exp([HexInt(x) for x in data])
|
||||
)
|
||||
)
|
||||
media_file = cg.StructInitializer(
|
||||
audio.AudioFile,
|
||||
("data", prog_arr),
|
||||
("length", len(rhs)),
|
||||
("data", MockObj(data_id, ".")),
|
||||
("length", len(data)),
|
||||
("file_type", media_file_type),
|
||||
)
|
||||
|
||||
return cg.new_Pvariable(file_config[CONF_ID], media_files_struct)
|
||||
file_var_id = file_config[CONF_ID]
|
||||
storage = file_config[CONF_FILE_DATA_ID]
|
||||
cg.add_global(
|
||||
cg.RawStatement(f"static constexpr {storage.type} {storage} = {media_file};")
|
||||
)
|
||||
cg.add_global(
|
||||
cg.RawStatement(
|
||||
f"static const {storage.type} *const {file_var_id} = &{storage};"
|
||||
)
|
||||
)
|
||||
var = MockObj(file_var_id, "->")
|
||||
CORE.register_variable(file_var_id, var)
|
||||
return var
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
namespace esphome::audio_file {
|
||||
|
||||
struct NamedAudioFile {
|
||||
audio::AudioFile *file;
|
||||
const audio::AudioFile *file;
|
||||
const char *file_id;
|
||||
};
|
||||
|
||||
inline StaticVector<NamedAudioFile, AUDIO_FILE_MAX_FILES>
|
||||
named_audio_files; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
inline void add_named_audio_file(audio::AudioFile *file, const char *file_id) {
|
||||
inline void add_named_audio_file(const audio::AudioFile *file, const char *file_id) {
|
||||
named_audio_files.push_back({file, file_id});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class AudioFileMediaSource final : public Component,
|
||||
protected:
|
||||
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
|
||||
audio::AudioStreamInfo stream_info_;
|
||||
audio::AudioFile *current_file_{nullptr};
|
||||
const audio::AudioFile *current_file_{nullptr};
|
||||
|
||||
// Written from the main loop in handle_command(), read from the decoder task in
|
||||
// on_audio_write(). Must be atomic to avoid a data race.
|
||||
|
||||
@@ -33,6 +33,9 @@ void AudioHTTPMediaSource::dump_config() {
|
||||
" Persistent Ring Buffer: %s\n"
|
||||
" Decoder Task Stack in PSRAM: %s",
|
||||
this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_));
|
||||
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
|
||||
ESP_LOGCONFIG(TAG, " Custom CA Certificate: %s", YESNO(this->http_ca_certificate_ != nullptr));
|
||||
#endif
|
||||
}
|
||||
|
||||
void AudioHTTPMediaSource::setup() {
|
||||
@@ -52,6 +55,12 @@ void AudioHTTPMediaSource::setup() {
|
||||
config.reader_stack_size = READER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
|
||||
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
|
||||
// micro-decoder verifies HTTPS against this PEM only, skipping the built-in certificate bundle.
|
||||
if (this->http_ca_certificate_ != nullptr) {
|
||||
config.http_ca_certificate = this->http_ca_certificate_;
|
||||
}
|
||||
#endif
|
||||
|
||||
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
|
||||
if (this->decoder_ == nullptr) {
|
||||
|
||||
@@ -34,6 +34,11 @@ class AudioHTTPMediaSource final : public Component,
|
||||
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
|
||||
void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; }
|
||||
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
|
||||
/// PEM-encoded CA certificate(s) used as the sole trust anchor for HTTPS playback URLs,
|
||||
/// replacing the built-in certificate bundle.
|
||||
void set_http_ca_certificate(const char *ca_certificate) { this->http_ca_certificate_ = ca_certificate; }
|
||||
#endif
|
||||
|
||||
// MediaSource interface implementation
|
||||
bool play_uri(const std::string &uri) override;
|
||||
@@ -56,6 +61,9 @@ class AudioHTTPMediaSource final : public Component,
|
||||
std::atomic<bool> pause_{false};
|
||||
bool decoder_task_stack_in_psram_{false};
|
||||
bool persistent_ring_buffer_{false};
|
||||
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
|
||||
const char *http_ca_certificate_{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::audio_http
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
AUTO_LOAD = ["audio"]
|
||||
|
||||
CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer"
|
||||
CONF_CA_CERTIFICATE_PATH = "ca_certificate_path"
|
||||
CONF_HTTP_REQUEST = "http_request"
|
||||
CONF_VERIFY_SSL = "verify_ssl"
|
||||
|
||||
audio_http_ns = cg.esphome_ns.namespace("audio_http")
|
||||
AudioHTTPMediaSource = audio_http_ns.class_(
|
||||
@@ -20,6 +29,38 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType:
|
||||
# Default to the CA certificate configured on the http_request component so
|
||||
# HTTPS playback verifies against the same trust anchor without repeating
|
||||
# the option on every source. Needed because audio_http sources are often
|
||||
# declared by device packages and cannot be extended from the device config.
|
||||
# The PEM replaces the built-in certificate bundle as the sole trust anchor,
|
||||
# so make the inheritance visible in the log.
|
||||
if CONF_CA_CERTIFICATE_PATH in config:
|
||||
return config
|
||||
fconf = fv.full_config.get()
|
||||
if CONF_HTTP_REQUEST not in fconf:
|
||||
return config
|
||||
http_request_config = fconf[CONF_HTTP_REQUEST]
|
||||
# Mirror http_request's own semantics: it only applies its CA when SSL
|
||||
# verification is enabled, so neither does the inheritance.
|
||||
if not http_request_config.get(CONF_VERIFY_SSL, True):
|
||||
return config
|
||||
if ca_cert_path := http_request_config.get(CONF_CA_CERTIFICATE_PATH):
|
||||
_LOGGER.info(
|
||||
"audio_http source '%s' is inheriting ca_certificate_path from the "
|
||||
"http_request component; HTTPS playback will verify against that PEM "
|
||||
"instead of the built-in certificate bundle",
|
||||
config[CONF_ID].id,
|
||||
)
|
||||
# Already validated by http_request's schema; the value is a resolved Path.
|
||||
config[CONF_CA_CERTIFICATE_PATH] = ca_cert_path
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _inherit_ca_certificate_path
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioHTTPMediaSource,
|
||||
@@ -31,6 +72,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean,
|
||||
cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.file_,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
@@ -47,5 +89,16 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
psram.request_external_task_stack()
|
||||
|
||||
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
|
||||
cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER]))
|
||||
|
||||
# Embed the certificate content, like http_request does. Passed to
|
||||
# micro_decoder's DecoderConfig::http_ca_certificate, which then uses it as
|
||||
# the sole trust anchor for HTTPS playback URLs instead of the certificate
|
||||
# bundle.
|
||||
if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH):
|
||||
cg.add_define("USE_AUDIO_HTTP_CA_CERTIFICATE")
|
||||
with Path(ca_cert_path).open(encoding="utf-8") as f:
|
||||
ca_cert_content = f.read()
|
||||
cg.add(var.set_http_ca_certificate(ca_cert_content))
|
||||
|
||||
@@ -11,29 +11,34 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <initializer_list>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
/// True if `address` is in `table`, a list of MACs ended by 0.
|
||||
inline bool mac_in_table(const uint64_t *table, uint64_t address) {
|
||||
for (; *table != 0; table++) {
|
||||
if (*table == address)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
|
||||
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
|
||||
/// Table of MACs ended by 0; must outlive the trigger.
|
||||
void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; }
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(),
|
||||
device.address_uint64()) == this->addresses_.end()) {
|
||||
if (this->addresses_ != nullptr && !mac_in_table(this->addresses_, device.address_uint64()))
|
||||
return false;
|
||||
}
|
||||
this->trigger(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
FixedVector<uint64_t> addresses_;
|
||||
const uint64_t *addresses_{nullptr};
|
||||
};
|
||||
|
||||
// on_ble_service_data_advertise: fires when an advertisement contains service
|
||||
|
||||
@@ -69,13 +69,24 @@ def uuid_trigger_schema(
|
||||
)
|
||||
|
||||
|
||||
def _filter_mac(value: Any) -> Any:
|
||||
mac = cv.mac_address(value)
|
||||
if not any(mac.parts):
|
||||
# 0 ends the flash MAC table, and it is never a real device address.
|
||||
raise cv.Invalid("00:00:00:00:00:00 cannot be used as a MAC address filter")
|
||||
return mac
|
||||
|
||||
|
||||
MAC_FILTER_LIST = cv.ensure_list(_filter_mac)
|
||||
|
||||
|
||||
def advertise_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]:
|
||||
"""on_ble_advertise schema: multi-mac list filter, unlike the single-mac
|
||||
uuid_trigger_schema() — pairs with advertise_trigger_to_code()."""
|
||||
return automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
|
||||
cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -94,11 +105,18 @@ def scan_end_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]
|
||||
_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE)
|
||||
|
||||
|
||||
def mac_filter_table(macs: list) -> cg.MockObj:
|
||||
"""Shared flash table of MACs ended by 0 (never a valid address), so triggers store a pointer."""
|
||||
return cg.shared_progmem_array(
|
||||
"ble_mac_filter", cg.uint64, [*(mac.as_hex for mac in macs), 0]
|
||||
)
|
||||
|
||||
|
||||
async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
|
||||
"""Build an on_ble_advertise trigger (optional multi-mac filter)."""
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
if (macs := conf.get(CONF_MAC_ADDRESS)) is not None:
|
||||
cg.add(trigger.set_addresses([it.as_hex for it in macs]))
|
||||
if macs := conf.get(CONF_MAC_ADDRESS):
|
||||
cg.add(trigger.set_addresses(mac_filter_table(macs)))
|
||||
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
|
||||
_count_listener()
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request"
|
||||
CONF_CANBUS_ID = "canbus_id"
|
||||
CONF_BIT_RATE = "bit_rate"
|
||||
CONF_ON_FRAME = "on_frame"
|
||||
CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop"
|
||||
DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h
|
||||
DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h
|
||||
|
||||
|
||||
def validate_id(config: ConfigType) -> ConfigType:
|
||||
@@ -86,8 +89,13 @@ CANBUS_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(CanbusComponent),
|
||||
cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF),
|
||||
cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True),
|
||||
cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum(
|
||||
CAN_SPEEDS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP
|
||||
): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_ON_FRAME): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger),
|
||||
@@ -108,9 +116,13 @@ CANBUS_SCHEMA.add_extra(validate_id)
|
||||
|
||||
async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_can_id([config[CONF_CAN_ID]]))
|
||||
cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]]))
|
||||
cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]]))
|
||||
cg.add(var.set_can_id(config[CONF_CAN_ID]))
|
||||
if config[CONF_USE_EXTENDED_ID]:
|
||||
cg.add(var.set_use_extended_id(True))
|
||||
if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE:
|
||||
cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate]))
|
||||
if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP:
|
||||
cg.add(var.set_max_frames_per_loop(max_frames))
|
||||
|
||||
for conf in config.get(CONF_ON_FRAME, []):
|
||||
can_id = conf[CONF_CAN_ID]
|
||||
|
||||
@@ -67,16 +67,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) {
|
||||
|
||||
void Canbus::loop() {
|
||||
struct CanFrame can_message;
|
||||
// read all messages until queue is empty
|
||||
int message_counter = 0;
|
||||
while (this->read_message(&can_message) == canbus::ERROR_OK) {
|
||||
message_counter++;
|
||||
uint32_t message_counter = 0;
|
||||
for (; message_counter < this->max_frames_per_loop_; message_counter++) {
|
||||
if (this->read_message(&can_message) != canbus::ERROR_OK) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (can_message.use_extended_id) {
|
||||
ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter,
|
||||
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1,
|
||||
can_message.can_id, can_message.can_data_length_code);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id,
|
||||
can_message.can_data_length_code);
|
||||
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1,
|
||||
can_message.can_id, can_message.can_data_length_code);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> data;
|
||||
@@ -100,6 +102,12 @@ void Canbus::loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message_counter == this->max_frames_per_loop_) {
|
||||
ESP_LOGW(TAG,
|
||||
"Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped",
|
||||
this->max_frames_per_loop_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::canbus
|
||||
|
||||
@@ -78,6 +78,7 @@ class Canbus : public Component {
|
||||
void set_can_id(uint32_t can_id) { this->can_id_ = can_id; }
|
||||
void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; }
|
||||
void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; }
|
||||
void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; }
|
||||
|
||||
void add_trigger(CanbusTrigger *trigger);
|
||||
/**
|
||||
@@ -96,8 +97,9 @@ class Canbus : public Component {
|
||||
template<typename... Ts> friend class CanbusSendAction;
|
||||
std::vector<CanbusTrigger *> triggers_{};
|
||||
uint32_t can_id_;
|
||||
bool use_extended_id_;
|
||||
CanSpeed bit_rate_;
|
||||
uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py
|
||||
CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py
|
||||
bool use_extended_id_{false};
|
||||
CallbackManager<void(uint32_t can_id, bool extended_id, bool rtr, const std::vector<uint8_t> &data)>
|
||||
callback_manager_{};
|
||||
|
||||
|
||||
@@ -253,11 +253,17 @@ void CC1101Component::dump_config() {
|
||||
}
|
||||
|
||||
void CC1101Component::begin_tx() {
|
||||
// Read the mode before the PKTCTRL0 write below overwrites it in state_
|
||||
const bool packet_mode = this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO);
|
||||
// Ensure Packet Format is 3 (Async Serial)
|
||||
this->write_(Register::PKTCTRL0, 0x32);
|
||||
ESP_LOGV(TAG, "Beginning TX sequence");
|
||||
if (this->gdo0_pin_ != nullptr) {
|
||||
this->gdo0_pin_->detach_interrupt();
|
||||
// setup() only attaches the interrupt in packet mode. In async serial mode the pin may be
|
||||
// shared with remote_receiver, and detaching here would remove the receiver's interrupt.
|
||||
if (packet_mode) {
|
||||
this->gdo0_pin_->detach_interrupt();
|
||||
}
|
||||
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
|
||||
}
|
||||
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
@@ -18,8 +16,6 @@ from esphome.const import (
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@Cat-Ion", "@kahrendt"]
|
||||
|
||||
combination_ns = cg.esphome_ns.namespace("combination")
|
||||
@@ -75,33 +71,19 @@ KALMAN_SOURCE_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def _migrate_coeffecient(config: ConfigType) -> ConfigType:
|
||||
"""Migrate deprecated 'coeffecient' spelling to 'coefficient'."""
|
||||
if CONF_COEFFECIENT in config:
|
||||
if CONF_COEFFICIENT in config:
|
||||
raise cv.Invalid(
|
||||
f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'"
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0",
|
||||
CONF_COEFFECIENT,
|
||||
CONF_COEFFICIENT,
|
||||
)
|
||||
config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT)
|
||||
elif CONF_COEFFICIENT not in config:
|
||||
raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option")
|
||||
return config
|
||||
|
||||
|
||||
LINEAR_SOURCE_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_COEFFECIENT,
|
||||
CONF_COEFFICIENT,
|
||||
removed_in="2026.12.0",
|
||||
component="combination",
|
||||
),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor),
|
||||
cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_),
|
||||
cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_),
|
||||
cv.Required(CONF_COEFFICIENT): cv.templatable(cv.float_),
|
||||
}
|
||||
),
|
||||
_migrate_coeffecient,
|
||||
)
|
||||
|
||||
SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema(
|
||||
|
||||
@@ -16,6 +16,7 @@ from esphome.const import (
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_AMPERE,
|
||||
UNIT_CELSIUS,
|
||||
@@ -70,7 +71,7 @@ SENSOR_CONFIGS = {
|
||||
"E": {
|
||||
CONF_UNIT_OF_MEASUREMENT: UNIT_WATT_HOURS,
|
||||
CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
|
||||
CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
|
||||
CONF_STATE_CLASS: STATE_CLASS_TOTAL,
|
||||
CONF_ACCURACY_DECIMALS: 0,
|
||||
},
|
||||
"V": {
|
||||
|
||||
@@ -62,6 +62,12 @@ automation.register_apply_action(
|
||||
automation.ApplyCall("request_full_update()"),
|
||||
)
|
||||
|
||||
automation.register_apply_condition(
|
||||
"epaper_spi.is_updating",
|
||||
automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(EPaperBase)}),
|
||||
"is_updating()",
|
||||
)
|
||||
|
||||
# Import all models dynamically from the models package
|
||||
for module_info in pkgutil.iter_modules(models.__path__):
|
||||
importlib.import_module(f".models.{module_info.name}", package=__package__)
|
||||
|
||||
@@ -109,6 +109,8 @@ class EPaperBase : public Display,
|
||||
void draw_pixel_at(int x, int y, Color color) override;
|
||||
// Make the next update a full one. Applied when that update starts, so one in progress is not affected.
|
||||
void request_full_update() { this->full_update_requested_ = true; }
|
||||
// True from the start of an update until the display has refreshed and gone back to sleep.
|
||||
bool is_updating() const { return this->state_ != EPaperState::IDLE; }
|
||||
|
||||
protected:
|
||||
int get_height_internal() override { return this->height_; };
|
||||
|
||||
@@ -26,3 +26,15 @@ goodisplay_gdey042t81 = ssd1683.extend(
|
||||
width=400,
|
||||
height=300,
|
||||
)
|
||||
|
||||
# The SSD1681 shares the SSD1683 command set for everything this driver uses:
|
||||
# the same 0x01/0x11/0x18 initialisation, the same 0x44/0x45 window and
|
||||
# 0x4E/0x4F cursor registers with a byte-addressed X and a 16-bit Y, and the
|
||||
# same 0x22/0x20 update. It only differs in the panel sizes it drives.
|
||||
ssd1681 = SSD1683("ssd1681")
|
||||
|
||||
waveshare_1_54in_v2 = ssd1681.extend(
|
||||
"waveshare-1.54in-v2",
|
||||
width=200,
|
||||
height=200,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from collections.abc import Callable, Iterable
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
@@ -57,7 +57,7 @@ from esphome.const import (
|
||||
from esphome.core import CORE, EsphomeError, HexInt
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_components
|
||||
from esphome.espidf.component import IDFComponent, generate_idf_components
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
@@ -76,6 +76,7 @@ from .const import (
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_FULL_CERT_BUNDLE,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_MBEDTLS_SDKCONFIG,
|
||||
KEY_NETWORK_SDKCONFIG,
|
||||
KEY_PATH,
|
||||
KEY_REF,
|
||||
@@ -237,7 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
|
||||
"cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing
|
||||
"console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured
|
||||
"driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers
|
||||
"esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf
|
||||
"esp-tls", # TLS wrapper - re-included by request_tls()
|
||||
"esp_adc", # ADC driver - only needed by adc component
|
||||
"esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back
|
||||
"esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back
|
||||
@@ -742,9 +743,10 @@ def is_idf_sdkconfig_option_enabled(name: str) -> bool:
|
||||
def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None:
|
||||
"""Set an sdkconfig option unless it is already set.
|
||||
|
||||
For the FINAL priority reconcile jobs: they run after every to_code,
|
||||
including the user's sdkconfig_options, and must not override an
|
||||
existing value.
|
||||
User sdkconfig_options take precedence regardless of to_code order:
|
||||
esp32.to_code applies them unconditionally, and this helper preserves
|
||||
values that are already set. FINAL priority reconcile jobs use the same
|
||||
guard because they run after every to_code, including the user's options.
|
||||
"""
|
||||
if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]:
|
||||
add_idf_sdkconfig_option(name, value)
|
||||
@@ -811,6 +813,97 @@ def request_software_coexistence() -> None:
|
||||
include_builtin_idf_component("esp_coex")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MbedtlsSdkconfigData:
|
||||
"""Inputs for the mbedTLS sdkconfig flags, reconciled at FINAL.
|
||||
|
||||
Components call the require_mbedtls_*() helpers (and request_tls(), which
|
||||
sets tls_required and also un-excludes the esp-tls component) rather than
|
||||
writing the CONFIG_MBEDTLS_* flags directly; _reconcile_mbedtls_sdkconfig()
|
||||
decides the final values once every to_code has run.
|
||||
"""
|
||||
|
||||
ecp_required: bool = False # ECDH/ECDSA without TLS (openthread SRP host key)
|
||||
tls_required: bool = False # mbedTLS TLS role needed without the esp-tls wrapper
|
||||
tls_server_required: bool = False # server-side TLS/DTLS handshake
|
||||
tls_extras_required: set[str] = field(default_factory=set) # kept TLS extras
|
||||
peer_cert_required: bool = False # keep the peer certificate after the handshake
|
||||
pkcs7_required: bool = False # PKCS#7 parsing
|
||||
sha512_required: bool = False # SHA-384/SHA-512
|
||||
# esp32 advanced disable_mbedtls_* options
|
||||
disable_tls: bool = True
|
||||
disable_tls_server: bool = True
|
||||
disable_tls_extras: bool = True
|
||||
disable_peer_cert: bool = True
|
||||
disable_pkcs7: bool = True
|
||||
|
||||
|
||||
def _mbedtls_sdkconfig() -> MbedtlsSdkconfigData:
|
||||
data = CORE.data[KEY_ESP32]
|
||||
if KEY_MBEDTLS_SDKCONFIG not in data:
|
||||
data[KEY_MBEDTLS_SDKCONFIG] = MbedtlsSdkconfigData()
|
||||
return data[KEY_MBEDTLS_SDKCONFIG]
|
||||
|
||||
|
||||
# IDF components that reference esp_tls symbols from their own code, so
|
||||
# re-including any of them is an implicit TLS request.
|
||||
_ESP_TLS_LINKING_COMPONENTS = (
|
||||
"esp-tls",
|
||||
"esp_http_client",
|
||||
"esp_https_ota",
|
||||
"esp_https_server",
|
||||
"esp_local_ctrl",
|
||||
"mqtt",
|
||||
)
|
||||
|
||||
|
||||
def _mbedtls_tls_required() -> bool:
|
||||
"""TLS stays in the build: requested, or an esp_tls-linking component was re-included.
|
||||
|
||||
The exclusion-set signal keeps external components working whose only
|
||||
obligation before request_tls() existed was include_builtin_idf_component()
|
||||
of esp-tls or of a component that links it (esp_http_client, IDF mqtt).
|
||||
"""
|
||||
if _mbedtls_sdkconfig().tls_required:
|
||||
return True
|
||||
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
return any(name not in excluded for name in _ESP_TLS_LINKING_COMPONENTS)
|
||||
|
||||
|
||||
def _mbedtls_tls_compiled_out() -> bool:
|
||||
"""True when this build removes the TLS stack from mbedTLS entirely."""
|
||||
return (
|
||||
not CORE.using_arduino
|
||||
and _mbedtls_sdkconfig().disable_tls
|
||||
and not _mbedtls_tls_required()
|
||||
)
|
||||
|
||||
|
||||
def require_mbedtls_tls() -> None:
|
||||
"""Keep the mbedTLS TLS stack without compiling the esp-tls wrapper.
|
||||
|
||||
For code that talks to mbedTLS directly (wpa_supplicant's EAP client).
|
||||
Components that use esp_tls call request_tls() instead.
|
||||
"""
|
||||
_mbedtls_sdkconfig().tls_required = True
|
||||
|
||||
|
||||
def request_tls() -> None:
|
||||
"""Request the mbedTLS TLS stack and the esp-tls wrapper.
|
||||
|
||||
Without a request TLS and its ECP/PEM-write/CRL/CSR crypto compile out;
|
||||
hashes, AES and RSA stay available.
|
||||
"""
|
||||
require_mbedtls_tls()
|
||||
include_builtin_idf_component("esp-tls")
|
||||
|
||||
|
||||
def request_http_client() -> None:
|
||||
"""Request ESP-IDF's HTTP client; it links esp_tls even for plain http."""
|
||||
include_builtin_idf_component("esp_http_client")
|
||||
request_tls()
|
||||
|
||||
|
||||
def add_idf_component(
|
||||
*,
|
||||
name: str,
|
||||
@@ -1846,6 +1939,7 @@ CONF_DISABLE_OCD_AWARE = "disable_ocd_aware"
|
||||
CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary"
|
||||
CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs"
|
||||
CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert"
|
||||
CONF_DISABLE_MBEDTLS_TLS = "disable_mbedtls_tls"
|
||||
CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
|
||||
CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server"
|
||||
CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras"
|
||||
@@ -1861,12 +1955,7 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required"
|
||||
# Feature requirement tracking - components can call require_* functions to re-enable
|
||||
# These are stored in CORE.data[KEY_ESP32] dict
|
||||
KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required"
|
||||
KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
|
||||
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
|
||||
KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required"
|
||||
KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required"
|
||||
KEY_FATFS_REQUIRED = "fatfs_required"
|
||||
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
|
||||
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
|
||||
KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required"
|
||||
|
||||
@@ -1905,6 +1994,9 @@ def require_certificate_bundle() -> None:
|
||||
certificates (http_request, audio streaming) call this so the bundle is
|
||||
compiled and gen_crt_bundle runs only when something uses it.
|
||||
"""
|
||||
# esp_crt_bundle.c lives in the mbedtls component and calls
|
||||
# mbedtls_ssl_conf_*, so a bundle needs the TLS role but not esp-tls.
|
||||
require_mbedtls_tls()
|
||||
CORE.data[KEY_ESP32][KEY_CERT_BUNDLE] = True
|
||||
|
||||
|
||||
@@ -1930,33 +2022,37 @@ def require_usb_serial_jtag_secondary() -> None:
|
||||
CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True
|
||||
|
||||
|
||||
def require_mbedtls_peer_cert() -> None:
|
||||
"""Mark that mbedTLS peer certificate retention is required by a component.
|
||||
def require_mbedtls_ecp() -> None:
|
||||
"""Keep mbedTLS elliptic curve support (ECDH/ECDSA) without requesting TLS.
|
||||
|
||||
Call this from components that need access to the peer certificate after
|
||||
the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE
|
||||
from being disabled.
|
||||
Call this from components that sign or verify with ECDSA outside a TLS
|
||||
handshake (openthread's SRP host key). WiFi, Bluetooth and secure boot
|
||||
select it through Kconfig on their own.
|
||||
"""
|
||||
CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True
|
||||
_mbedtls_sdkconfig().ecp_required = True
|
||||
|
||||
|
||||
def require_mbedtls_peer_cert() -> None:
|
||||
"""Keep the peer certificate after the TLS handshake (CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE).
|
||||
|
||||
A user sdkconfig_options value takes precedence.
|
||||
"""
|
||||
_mbedtls_sdkconfig().peer_cert_required = True
|
||||
|
||||
|
||||
def require_mbedtls_pkcs7() -> None:
|
||||
"""Mark that mbedTLS PKCS#7 support is required by a component.
|
||||
|
||||
Call this from components that need PKCS#7 certificate validation.
|
||||
This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled.
|
||||
"""
|
||||
CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True
|
||||
"""Keep mbedTLS PKCS#7 support (CONFIG_MBEDTLS_PKCS7_C). A user sdkconfig_options value takes precedence."""
|
||||
_mbedtls_sdkconfig().pkcs7_required = True
|
||||
|
||||
|
||||
def require_mbedtls_tls_server() -> None:
|
||||
"""Mark that the mbedTLS server-side TLS/DTLS handshake is required.
|
||||
"""Widen the TLS role to include the server-side handshake.
|
||||
|
||||
Call this from components that accept TLS connections (OpenThread's DTLS
|
||||
commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from
|
||||
being selected.
|
||||
Only affects builds where TLS is compiled in; it prevents
|
||||
CONFIG_MBEDTLS_TLS_CLIENT_ONLY from being selected. A component that
|
||||
actually opens or accepts TLS/DTLS sessions must also call request_tls().
|
||||
"""
|
||||
CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True
|
||||
_mbedtls_sdkconfig().tls_server_required = True
|
||||
|
||||
|
||||
def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None:
|
||||
@@ -1969,18 +2065,14 @@ def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None:
|
||||
servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied
|
||||
sdkconfig_options value is never overridden either.
|
||||
"""
|
||||
required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set())
|
||||
required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options)
|
||||
_mbedtls_sdkconfig().tls_extras_required.update(
|
||||
MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options
|
||||
)
|
||||
|
||||
|
||||
def require_mbedtls_sha512() -> None:
|
||||
"""Mark that mbedTLS SHA-384/SHA-512 support is required by a component.
|
||||
|
||||
Call this from components that need to verify TLS certificates or signatures
|
||||
using SHA-384 or SHA-512 algorithms. This prevents CONFIG_MBEDTLS_SHA384_C
|
||||
and CONFIG_MBEDTLS_SHA512_C from being disabled.
|
||||
"""
|
||||
CORE.data[KEY_ESP32][KEY_MBEDTLS_SHA512_REQUIRED] = True
|
||||
"""Keep mbedTLS SHA-384/SHA-512 (CONFIG_MBEDTLS_SHA384_C / CONFIG_MBEDTLS_SHA512_C)."""
|
||||
_mbedtls_sdkconfig().sha512_required = True
|
||||
|
||||
|
||||
def idf_version() -> cv.Version:
|
||||
@@ -2134,6 +2226,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_TLS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean,
|
||||
@@ -2499,34 +2592,103 @@ MBEDTLS_TLS_ROLE_OPTIONS = (
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _reconcile_mbedtls_tls_sdkconfig(
|
||||
disable_tls_server: bool, disable_tls_extras: bool
|
||||
) -> None:
|
||||
"""Trim mbedTLS to what a TLS client needs unless a component asked otherwise.
|
||||
# User sdkconfig_options that mean "keep TLS on" when set to y. The
|
||||
# OpenThread entries compile its DTLS secure transport in, which links
|
||||
# mbedtls_ssl_*.
|
||||
_MBEDTLS_TLS_ON_OPTIONS = (
|
||||
"CONFIG_MBEDTLS_TLS_ENABLED",
|
||||
"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT",
|
||||
"CONFIG_MBEDTLS_TLS_SERVER_ONLY",
|
||||
"CONFIG_MBEDTLS_TLS_CLIENT_ONLY",
|
||||
"CONFIG_OPENTHREAD_COMMISSIONER",
|
||||
"CONFIG_OPENTHREAD_JOINER",
|
||||
"CONFIG_OPENTHREAD_BORDER_AGENT_ENABLE",
|
||||
# Border router defaults the border agent (and its DTLS) on.
|
||||
"CONFIG_OPENTHREAD_BORDER_ROUTER",
|
||||
# Enterprise WiFi selects TLS back on (see the TLS-off block).
|
||||
"CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT",
|
||||
)
|
||||
# Any user option under these prefixes only makes sense with TLS compiled in.
|
||||
_TLS_OPTION_PREFIXES = ("CONFIG_ESP_TLS_", "CONFIG_MBEDTLS_SSL_", "CONFIG_ESP_HTTPS_")
|
||||
|
||||
Runs at FINAL priority so every require_mbedtls_tls_server() and
|
||||
require_mbedtls_tls_extras() call has happened. Only the server-side
|
||||
handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS
|
||||
connections, but OpenThread's DTLS commissioner does. A user-supplied
|
||||
sdkconfig_options value always wins; for the TLS role choice, any member
|
||||
the user set leaves the whole choice alone so the pair cannot conflict.
|
||||
|
||||
def _user_sdkconfig_wants_tls(options: dict[str, Any]) -> bool:
|
||||
"""True when sdkconfig_options turn TLS on or tune something under it; an `n` is never a request."""
|
||||
return any(
|
||||
(name in _MBEDTLS_TLS_ON_OPTIONS and value == "y")
|
||||
or (name == "CONFIG_MBEDTLS_TLS_DISABLED" and value == "n")
|
||||
or (name.startswith(_TLS_OPTION_PREFIXES) and value != "n")
|
||||
for name, value in options.items()
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _reconcile_mbedtls_sdkconfig() -> None:
|
||||
"""Reconcile the mbedTLS sdkconfig flags after every request_tls() / require_mbedtls_*() call.
|
||||
|
||||
mbedtls cannot be excluded from an IDF build (bootloader_support needs its
|
||||
SHA-256), but with no TLS user the ssl_*.c sources and the TLS-only crypto
|
||||
compile to empty objects. When TLS stays in, it is trimmed to the client
|
||||
role and the legacy handshake extras are dropped. User sdkconfig_options
|
||||
win; a user-chosen TLS role leaves the whole choice alone.
|
||||
"""
|
||||
data = CORE.data[KEY_ESP32]
|
||||
sdkconfig = data[KEY_SDKCONFIG_OPTIONS]
|
||||
if (
|
||||
disable_tls_server
|
||||
and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False)
|
||||
and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS)
|
||||
data = _mbedtls_sdkconfig()
|
||||
idf6 = idf_version() >= cv.Version(6, 0, 0)
|
||||
opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
|
||||
if _mbedtls_tls_compiled_out():
|
||||
# IDF 6 made CONFIG_MBEDTLS_TLS_ENABLED a normal bool; on IDF 5 it has
|
||||
# no prompt and is only reachable through the "None" TLS role choice.
|
||||
if idf6:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_ENABLED", False)
|
||||
else:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_DISABLED", True)
|
||||
# Enterprise WiFi selects TLS back on; wifi writes this itself, but
|
||||
# esp_wifi can also be in the build without a wifi: block (openthread).
|
||||
set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False)
|
||||
# WiFi (ESP_WIFI_MBEDTLS_CRYPTO) and Bluetooth deliberately stay on
|
||||
# the select-wins path: an unconditional request from request_wifi()
|
||||
# would defeat the ECP trim for users who disable that select.
|
||||
if not data.ecp_required:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_ECP_C", False)
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_PEM_WRITE_C", False)
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CRL_PARSE_C", False)
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CSR_PARSE_C", False)
|
||||
elif (
|
||||
# TLS stays in: trim it to the client role unless a component accepts
|
||||
# TLS connections or the user already chose a role.
|
||||
data.disable_tls_server
|
||||
and not data.tls_server_required
|
||||
and not any(option in opts for option in MBEDTLS_TLS_ROLE_OPTIONS)
|
||||
):
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True)
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False)
|
||||
if disable_tls_extras:
|
||||
required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set())
|
||||
|
||||
# The extras run either way: CCM and deterministic ECDSA are plain
|
||||
# crypto, not TLS-gated, so they matter even with TLS compiled out.
|
||||
if data.disable_tls_extras:
|
||||
for option in MBEDTLS_TLS_EXTRA_OPTIONS:
|
||||
if option not in required:
|
||||
if option not in data.tls_extras_required:
|
||||
set_idf_sdkconfig_default(option, False)
|
||||
|
||||
# Keeping the peer certificate costs ~4KB heap per connection.
|
||||
if data.peer_cert_required:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True)
|
||||
elif data.disable_peer_cert:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False)
|
||||
|
||||
if data.pkcs7_required:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", True)
|
||||
elif data.disable_pkcs7:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", False)
|
||||
|
||||
# SHA-384 shares the SHA-512 compression function, so both go together.
|
||||
# Only IDF 6.0's PSA engine links a ~3KB software fallback for them; on
|
||||
# IDF 5 they are a single hardware-only option with no code size cost.
|
||||
if idf6 and not data.sha512_required:
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA384_C", False)
|
||||
set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA512_C", False)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _reconcile_network_sdkconfig() -> None:
|
||||
@@ -2763,6 +2925,11 @@ async def to_code(config):
|
||||
# produce reproducible outputs and downstream tooling can reuse artifacts.
|
||||
add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True)
|
||||
|
||||
# Static destructors never run, so skip registering them. See atexit_stubs.cpp.
|
||||
# --undefined: libsrc.a is scanned before the IDF libraries that also register them.
|
||||
cg.add_build_flag("-Wl,--wrap=__cxa_atexit")
|
||||
cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit")
|
||||
|
||||
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
|
||||
cg.add_build_flag("-DUSE_ESP_IDF")
|
||||
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF")
|
||||
@@ -2873,6 +3040,8 @@ async def to_code(config):
|
||||
"CONFIG_ESP32P4_SELECTS_REV_LESS_V3",
|
||||
config.get(CONF_ENGINEERING_SAMPLE, False),
|
||||
)
|
||||
# Work around ESP-IDF bug: see https://github.com/espressif/esp-idf/issues/19020
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_MAIN_TASK_STACK_SIZE", 8192)
|
||||
|
||||
# ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not
|
||||
# understand %zu or %lld and crashes on any %s that follows one.
|
||||
@@ -2910,6 +3079,10 @@ async def to_code(config):
|
||||
# Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms
|
||||
add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000)
|
||||
|
||||
# Main loop wakes use notification index 1; index 0 stays free for ESP-IDF waits
|
||||
# such as pthread_join(), which a stray main-loop wake would otherwise end early.
|
||||
add_idf_sdkconfig_option("CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES", 2)
|
||||
|
||||
# Place non-ISR FreeRTOS functions into flash instead of IRAM
|
||||
# This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM.
|
||||
# In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH
|
||||
@@ -3147,6 +3320,10 @@ async def to_code(config):
|
||||
|
||||
for key, flag in SIGNING_SCHEMES.items():
|
||||
add_idf_sdkconfig_option(flag, scheme == key)
|
||||
if scheme in (SIGNING_SCHEME_ECDSA256, SIGNING_SCHEME_ECDSA_V1):
|
||||
# SECURE_SIGNED_APPS selects ECP in Kconfig anyway; requesting it
|
||||
# keeps the resolved sdkconfig consistent with what ESPHome wrote.
|
||||
require_mbedtls_ecp()
|
||||
|
||||
if CONF_SIGNING_KEY in signed_ota:
|
||||
# Private key mode — auto-sign binaries during build
|
||||
@@ -3217,38 +3394,6 @@ async def to_code(config):
|
||||
if advanced[CONF_DISABLE_DEV_NULL_VFS]:
|
||||
add_idf_sdkconfig_option("CONFIG_VFS_INITIALIZE_DEV_NULL", False)
|
||||
|
||||
# Disable keeping peer certificate after TLS handshake
|
||||
# Saves ~4KB heap per connection, but prevents certificate inspection after handshake
|
||||
# Components that need it can call require_mbedtls_peer_cert()
|
||||
if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PEER_CERT_REQUIRED, False):
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True)
|
||||
elif advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]:
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False)
|
||||
|
||||
# Disable PKCS#7 support in mbedTLS
|
||||
# Only needed for specific certificate validation scenarios
|
||||
# Components that need it can call require_mbedtls_pkcs7()
|
||||
if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PKCS7_REQUIRED, False):
|
||||
# Component called require_mbedtls_pkcs7() - enable regardless of user setting
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", True)
|
||||
elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]:
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False)
|
||||
|
||||
# Disable SHA-384 and SHA-512 in mbedTLS
|
||||
# ESPHome doesn't use either algorithm. SHA-384 shares the same
|
||||
# compression function as SHA-512 (mbedtls_internal_sha512_process),
|
||||
# so both must be disabled to eliminate the ~3KB software fallback
|
||||
# that IDF 6.0's PSA parallel engine always links in.
|
||||
# On IDF < 6.0 these are a single config and hardware-only (no
|
||||
# software fallback), so there was no code size cost to leaving
|
||||
# them enabled.
|
||||
# Components that need SHA-384/SHA-512 can call require_mbedtls_sha512()
|
||||
if idf_version() >= cv.Version(6, 0, 0) and not CORE.data[KEY_ESP32].get(
|
||||
KEY_MBEDTLS_SHA512_REQUIRED, False
|
||||
):
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False)
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False)
|
||||
|
||||
# FINAL priority: runs after every require_libc_picolibc_newlib_compat() call
|
||||
CORE.add_job(_set_libc_picolibc_newlib_compat)
|
||||
|
||||
@@ -3258,12 +3403,14 @@ async def to_code(config):
|
||||
# FINAL priority: runs after every require_certificate_bundle() call
|
||||
CORE.add_job(_reconcile_certificate_bundle_sdkconfig)
|
||||
|
||||
# FINAL priority: runs after every require_mbedtls_tls_*() call
|
||||
CORE.add_job(
|
||||
_reconcile_mbedtls_tls_sdkconfig,
|
||||
advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER],
|
||||
advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS],
|
||||
)
|
||||
# FINAL priority: runs after every request_tls() / require_mbedtls_*() call
|
||||
mbedtls = _mbedtls_sdkconfig()
|
||||
mbedtls.disable_tls = advanced[CONF_DISABLE_MBEDTLS_TLS]
|
||||
mbedtls.disable_tls_server = advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER]
|
||||
mbedtls.disable_tls_extras = advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS]
|
||||
mbedtls.disable_peer_cert = advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]
|
||||
mbedtls.disable_pkcs7 = advanced[CONF_DISABLE_MBEDTLS_PKCS7]
|
||||
CORE.add_job(_reconcile_mbedtls_sdkconfig)
|
||||
|
||||
# FINAL: require_*() calls can come from to_code at or below this priority, so an
|
||||
# inline read would be iteration-order-dependent; reconcile once after every job ran.
|
||||
@@ -3296,6 +3443,8 @@ async def to_code(config):
|
||||
# so it still gets the CMN variant pinned.
|
||||
if conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE") == "y":
|
||||
require_certificate_bundle()
|
||||
if _user_sdkconfig_wants_tls(conf[CONF_SDKCONFIG_OPTIONS]):
|
||||
request_tls()
|
||||
|
||||
# Components from YAML are added in a separate coroutine with FINAL priority
|
||||
# Schedule it to run after all other components
|
||||
@@ -3537,6 +3686,30 @@ def _write_idf_component_yml():
|
||||
yml_path = CORE.relative_build_path("src/idf_component.yml")
|
||||
dependencies: dict[str, dict] = {}
|
||||
|
||||
converted: list[IDFComponent] = []
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Convert the PlatformIO libraries to ESP-IDF components as a batch so
|
||||
# PlatformIO resolves the whole dependency tree at once -- deduplicating
|
||||
# shared transitive deps (e.g. esphome/libsodium pulled by both noise-c
|
||||
# and esp_wireguard) to a single version instead of clashing
|
||||
# override_path entries.
|
||||
libraries = [
|
||||
library
|
||||
for name, library in CORE.platformio_libraries.items()
|
||||
# Don't process arduino libraries
|
||||
if name not in ARDUINO_DISABLED_LIBRARIES
|
||||
]
|
||||
# A library also declared as a managed component is not converted too, or
|
||||
# IDF sees the same requirement twice; converted components reach it through
|
||||
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
|
||||
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
|
||||
converted = generate_idf_components(libraries, managed=managed)
|
||||
# IDF names a component after its directory and a later registration of the
|
||||
# same name replaces the earlier one, so a stub beside a converted library of
|
||||
# the same name (espressif/libsodium vs esphome/libsodium) would win or lose
|
||||
# on path order. Such a stub points at the converted library instead.
|
||||
converted_by_name = {component.path.name: component for component in converted}
|
||||
|
||||
# For Arduino builds, override unused managed components from the Arduino framework
|
||||
# by pointing them to empty stub directories using override_path
|
||||
# This prevents the IDF component manager from downloading the real components
|
||||
@@ -3560,8 +3733,17 @@ def _write_idf_component_yml():
|
||||
# always writes, and ninja keeps triggering CMake re-runs on
|
||||
# otherwise-cached rebuilds.
|
||||
for component_name in sorted(components_to_stub):
|
||||
stub_name = _idf_component_stub_name(component_name)
|
||||
stub_path = stubs_dir / stub_name
|
||||
if (component := converted_by_name.get(stub_name)) is not None:
|
||||
if stub_path.exists():
|
||||
rmtree(stub_path)
|
||||
dependencies[_idf_component_dep_name(component_name)] = {
|
||||
"version": "*",
|
||||
"override_path": str(component.path),
|
||||
}
|
||||
continue
|
||||
# Create stub directory with minimal CMakeLists.txt
|
||||
stub_path = stubs_dir / _idf_component_stub_name(component_name)
|
||||
stub_path.mkdir(exist_ok=True)
|
||||
stub_cmake = stub_path / "CMakeLists.txt"
|
||||
if not stub_cmake.exists():
|
||||
@@ -3603,26 +3785,10 @@ def _write_idf_component_yml():
|
||||
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
)
|
||||
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Convert the PlatformIO libraries to ESP-IDF components as a batch so
|
||||
# PlatformIO resolves the whole dependency tree at once -- deduplicating
|
||||
# shared transitive deps (e.g. esphome/libsodium pulled by both noise-c
|
||||
# and esp_wireguard) to a single version instead of clashing
|
||||
# override_path entries.
|
||||
libraries = [
|
||||
library
|
||||
for name, library in CORE.platformio_libraries.items()
|
||||
# Don't process arduino libraries
|
||||
if name not in ARDUINO_DISABLED_LIBRARIES
|
||||
]
|
||||
# A library also declared as a managed component is not converted too, or
|
||||
# IDF sees the same requirement twice; converted components reach it through
|
||||
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
|
||||
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
|
||||
for component in generate_idf_components(libraries, managed=managed):
|
||||
dependencies[component.get_sanitized_name()] = {
|
||||
"override_path": str(component.path)
|
||||
}
|
||||
for component in converted:
|
||||
dependencies[component.get_sanitized_name()] = {
|
||||
"override_path": str(component.path)
|
||||
}
|
||||
|
||||
if CORE.data[KEY_ESP32][KEY_COMPONENTS]:
|
||||
components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Linker wrap stub for __cxa_atexit().
|
||||
*
|
||||
* GCC registers the destructor of every global and function local static
|
||||
* object with __cxa_atexit(). The firmware never returns from main or calls
|
||||
* exit(), so these destructors never run, but the first registration links
|
||||
* newlib's exit handler tables (about 400 bytes of RAM on ESP32).
|
||||
*/
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::esp32 {}
|
||||
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; }
|
||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -23,6 +23,7 @@ KEY_EXTRA_BUILD_FILES = "extra_build_files"
|
||||
KEY_CERT_BUNDLE = "cert_bundle"
|
||||
KEY_FULL_CERT_BUNDLE = "full_cert_bundle"
|
||||
KEY_NETWORK_SDKCONFIG = "network_sdkconfig"
|
||||
KEY_MBEDTLS_SDKCONFIG = "mbedtls_sdkconfig"
|
||||
|
||||
VARIANT_ESP32 = "ESP32"
|
||||
VARIANT_ESP32C2 = "ESP32C2"
|
||||
|
||||
@@ -17,8 +17,7 @@ extern "C" __attribute__((weak)) void initArduino() {}
|
||||
namespace esphome {
|
||||
|
||||
// HAL functions live in hal.cpp. This file keeps only the loop task setup.
|
||||
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StackType_t
|
||||
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -40,11 +39,10 @@ extern "C" void app_main() {
|
||||
initArduino();
|
||||
esp32::setup_preferences();
|
||||
#if CONFIG_FREERTOS_UNICORE
|
||||
loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
|
||||
&loop_task_tcb);
|
||||
xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, &loop_task_tcb);
|
||||
#else
|
||||
loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1,
|
||||
loop_task_stack, &loop_task_tcb, 1);
|
||||
xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
|
||||
&loop_task_tcb, 1);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -531,6 +531,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
|
||||
// Xtensa: walk the backtrace using the public API
|
||||
if (info->frame != nullptr) {
|
||||
auto *xt_frame = (XtExcFrame *) info->frame;
|
||||
// IDF leaves addr unset for SoC-level panics (cache error, interrupt WDT)
|
||||
if (info->pseudo_excause)
|
||||
s_raw_crash_data.pc = xt_frame->pc;
|
||||
if (!g_panic_abort) {
|
||||
// Abort-class frames carry no useful cause/vaddr: TWDT task snapshots
|
||||
// never wrote them and abort() traps describe only the synthetic trap.
|
||||
|
||||
@@ -235,14 +235,14 @@ class ESP32BLE final : public Component {
|
||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||
BLEAdvertising *advertising_{}; // 4 bytes (pointer)
|
||||
#endif
|
||||
const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash)
|
||||
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum)
|
||||
uint32_t advertising_cycle_time_{}; // 4 bytes
|
||||
const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash)
|
||||
uint32_t advertising_cycle_time_{}; // 4 bytes
|
||||
|
||||
// 2-byte aligned members
|
||||
uint16_t appearance_{0}; // 2 bytes
|
||||
|
||||
// 1-byte aligned members (grouped together to minimize padding)
|
||||
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef)
|
||||
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
|
||||
bool enable_on_boot_{}; // 1 byte
|
||||
#ifdef USE_ESP32_BLE_ADVERTISING
|
||||
|
||||
@@ -150,6 +150,8 @@ void BLEClientBase::connect() {
|
||||
// A registration whose event never arrived must not block this connection's release.
|
||||
this->services_released_ = false;
|
||||
this->pending_notify_regs_ = 0;
|
||||
// A CONNECT_EVT seen while idle can leave the id of a link this attempt does not own.
|
||||
this->conn_id_ = UNSET_CONN_ID;
|
||||
// Enable loop for state processing
|
||||
this->enable_loop();
|
||||
// Immediately transition to CONNECTING to prevent duplicate connection attempts
|
||||
@@ -350,7 +352,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
if (!this->check_addr(param->open.remote_bda))
|
||||
return false;
|
||||
this->log_gattc_lifecycle_event_("OPEN");
|
||||
// conn_id was already set in ESP_GATTC_CONNECT_EVT
|
||||
this->service_count_ = 0;
|
||||
|
||||
// ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an
|
||||
@@ -362,19 +363,24 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->state() != espbt::ClientState::CONNECTING) {
|
||||
// This should not happen but lets log it in case it does
|
||||
// because it means we have a bad assumption about how the
|
||||
// ESP BT stack works.
|
||||
const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN;
|
||||
// Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT,
|
||||
// so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption
|
||||
// about how the ESP BT stack works, so log it.
|
||||
const bool expected_failure =
|
||||
open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID;
|
||||
if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) {
|
||||
ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_,
|
||||
this->address_str_, espbt::client_state_to_string(this->state()), param->open.status);
|
||||
}
|
||||
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
|
||||
if (open_failed) {
|
||||
this->log_gattc_warning_("Connection open", param->open.status);
|
||||
// Connection was never established so CLOSE_EVT may not follow
|
||||
this->set_idle_();
|
||||
break;
|
||||
}
|
||||
// An open on a link that is already up sends no CONNECT_EVT, so take the id here.
|
||||
this->conn_id_ = param->open.conn_id;
|
||||
if (this->want_disconnect_) {
|
||||
// Disconnect was requested after connecting started,
|
||||
// but before the connection was established. Now that we have
|
||||
|
||||
@@ -8,6 +8,10 @@ from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base, esp32_ble, ota
|
||||
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW
|
||||
from esphome.components.ble_device_base.automation import (
|
||||
MAC_FILTER_LIST,
|
||||
mac_filter_table,
|
||||
)
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
@@ -277,7 +281,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESPBTAdvertiseTrigger
|
||||
),
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
|
||||
cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST,
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
@@ -381,9 +385,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
|
||||
_request_listener_slot()
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
if CONF_MAC_ADDRESS in conf:
|
||||
addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]]
|
||||
cg.add(trigger.set_addresses(addr_list))
|
||||
if macs := conf.get(CONF_MAC_ADDRESS):
|
||||
cg.add(trigger.set_addresses(mac_filter_table(macs)))
|
||||
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
|
||||
for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []):
|
||||
_request_listener_slot()
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/components/ble_device_base/automation.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::esp32_ble_tracker {
|
||||
@@ -12,22 +11,18 @@ namespace esphome::esp32_ble_tracker {
|
||||
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); }
|
||||
void set_addresses(std::initializer_list<uint64_t> addresses) { this->address_vec_ = addresses; }
|
||||
/// Table of MACs ended by 0; must outlive the trigger.
|
||||
void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; }
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
uint64_t u64_addr = device.address_uint64();
|
||||
if (!address_vec_.empty()) {
|
||||
if (std::find(address_vec_.begin(), address_vec_.end(), u64_addr) == address_vec_.end()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->addresses_ != nullptr && !ble_device_base::mac_in_table(this->addresses_, device.address_uint64()))
|
||||
return false;
|
||||
this->trigger(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
std::vector<uint64_t> address_vec_;
|
||||
const uint64_t *addresses_{nullptr};
|
||||
};
|
||||
|
||||
class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
|
||||
|
||||
@@ -53,6 +53,7 @@ from .const import (
|
||||
KEY_SERIAL1_REQUIRED,
|
||||
KEY_SERIAL_REQUIRED,
|
||||
KEY_WAVEFORM_REQUIRED,
|
||||
THROW_STUBS_HEADER,
|
||||
enable_serial,
|
||||
enable_serial1,
|
||||
esp8266_ns,
|
||||
@@ -114,9 +115,37 @@ _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO)
|
||||
|
||||
|
||||
# Known boards keyed by spelling with case and "-" / "_" folded away
|
||||
_FOLDED_BOARDS = {board.lower().replace("-", "_"): board for board in BOARDS}
|
||||
|
||||
|
||||
def _resolve_board(config: ConfigType) -> ConfigType:
|
||||
"""Fix a misspelled board, else build an unknown one with PlatformIO."""
|
||||
board = config[CONF_BOARD]
|
||||
if board in BOARDS:
|
||||
return config
|
||||
if canonical := _FOLDED_BOARDS.get(board.lower().replace("-", "_")):
|
||||
_LOGGER.warning(
|
||||
"Board '%s' is not a known ESP8266 board; using '%s'", board, canonical
|
||||
)
|
||||
return {**config, CONF_BOARD: canonical}
|
||||
if CORE.toolchain is None and CONF_TOOLCHAIN not in config:
|
||||
return {**config, CONF_TOOLCHAIN: Toolchain.PLATFORMIO}
|
||||
return config
|
||||
|
||||
|
||||
def _warn_platformio_toolchain(config: ConfigType) -> ConfigType:
|
||||
# Remove before 2027.4.0
|
||||
if CORE.using_toolchain_platformio:
|
||||
if not CORE.using_toolchain_platformio:
|
||||
return config
|
||||
if config[CONF_BOARD] not in BOARDS:
|
||||
_LOGGER.warning(
|
||||
"Board '%s' is not supported by the native 'arduino' toolchain, so it "
|
||||
"builds with the deprecated 'platformio' toolchain, which will be "
|
||||
"removed in ESPHome 2027.4.0",
|
||||
config[CONF_BOARD],
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"The 'platformio' toolchain for ESP8266 is deprecated and will be "
|
||||
"removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the "
|
||||
@@ -329,6 +358,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
): _validate_toolchain,
|
||||
}
|
||||
),
|
||||
_resolve_board,
|
||||
_resolve_toolchain,
|
||||
_warn_platformio_toolchain,
|
||||
_validate_native_toolchain,
|
||||
@@ -448,11 +478,9 @@ 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. Unconditional: the native build generator reads
|
||||
# the same option, keeping one source of truth.
|
||||
cg.add_platformio_option(
|
||||
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
|
||||
)
|
||||
# See throw_stubs.h. The native build generator reads this option, also
|
||||
# passes it to the core and libraries, and drops it with exceptions on.
|
||||
cg.add_platformio_option("build_src_flags", f"-include {THROW_STUBS_HEADER}")
|
||||
|
||||
# In testing mode, fake larger memory to allow linking grouped component tests
|
||||
# Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing
|
||||
|
||||
@@ -17,6 +17,8 @@ KEY_SERIAL_REQUIRED = "serial_required"
|
||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||
KEY_SCANF_FLOAT = "scanf_float"
|
||||
# Force-included std::__throw_* overrides, relative to the src dir
|
||||
THROW_STUBS_HEADER = "esphome/components/esp8266/throw_stubs.h"
|
||||
# Per-board flash-layout override consumed by board_ld_script()
|
||||
KEY_LDSCRIPT = "ldscript"
|
||||
# Crash-decode tools, resolved once per run by __init__
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* unused, dead-strip the load, and drop the string entirely -- no LTO needed.
|
||||
* Behavior is identical to today: a bare abort() (the message was never
|
||||
* printed). This header MUST be force-included before <string>, so it is
|
||||
* wired up via build_src_flags "-include ..." in this component's __init__.py.
|
||||
* wired up via build_src_flags "-include ..." in this component's __init__.py;
|
||||
* the native toolchain also force-includes it into the core and libraries.
|
||||
*
|
||||
* Note: this defines functions in namespace std (technically UB). It is safe
|
||||
* here because the definitions match the existing abort() behavior exactly.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, wifi
|
||||
from esphome.components.esp32.const import (
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32C3,
|
||||
VARIANT_ESP32C5,
|
||||
VARIANT_ESP32C6,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_WIFI
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DOMAIN = "espectre"
|
||||
CODEOWNERS = ["@francescopace"]
|
||||
DEPENDENCIES = ["esp32", "wifi"]
|
||||
|
||||
CONF_ESPECTRE_ID = "espectre_id"
|
||||
CONF_DETECTION_ALGORITHM = "detection_algorithm"
|
||||
CONF_CSI_CAPTURE_PROFILE = "csi_capture_profile"
|
||||
CONF_TRAFFIC_GENERATOR_MODE = "traffic_generator_mode"
|
||||
CONF_TRAFFIC_GENERATOR_TARGET_IP = "traffic_generator_target_ip"
|
||||
CONF_CSI_TRAFFIC_MULTICAST_GROUP = "csi_traffic_multicast_group"
|
||||
CONF_MOTION_ON_HITS = "motion_on_hits"
|
||||
CONF_MOTION_OFF_HITS = "motion_off_hits"
|
||||
|
||||
# Fully qualified: a bare espectre:: in main.cpp would clash with the SDK namespace.
|
||||
espectre_ns = cg.global_ns.namespace("esphome").namespace("espectre")
|
||||
ESPectreComponent = espectre_ns.class_("ESPectreComponent", cg.Component)
|
||||
sdk_ns = cg.global_ns.namespace("::espectre")
|
||||
DetectionAlgorithm = sdk_ns.enum("DetectionAlgorithm", is_class=True)
|
||||
CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True)
|
||||
TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True)
|
||||
WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True)
|
||||
|
||||
DETECTION_ALGORITHMS = {
|
||||
"lightweight": DetectionAlgorithm.LIGHTWEIGHT,
|
||||
"high_accuracy": DetectionAlgorithm.HIGH_ACCURACY,
|
||||
}
|
||||
CSI_CAPTURE_PROFILES = {
|
||||
"auto": CsiCapturePolicy.AUTO,
|
||||
"lltf": CsiCapturePolicy.LLTF,
|
||||
"ht_vht": CsiCapturePolicy.HT_VHT,
|
||||
}
|
||||
TRAFFIC_GENERATOR_MODES = {
|
||||
"ping": TrafficGeneratorMode.PING,
|
||||
"dns": TrafficGeneratorMode.DNS,
|
||||
"dns_tcp": TrafficGeneratorMode.DNS_TCP,
|
||||
"wifi_raw": TrafficGeneratorMode.WIFI_RAW,
|
||||
"external": TrafficGeneratorMode.EXTERNAL_HOST,
|
||||
}
|
||||
|
||||
|
||||
def validate_target_ip(value: str) -> str:
|
||||
value = str(cv.ipv4address(value))
|
||||
first_octet = int(IPv4Address(value)) >> 24
|
||||
if first_octet in (0, 127) or first_octet >= 224 or value == "255.255.255.255":
|
||||
raise cv.Invalid("ESPectre traffic target must be a unicast IPv4 address")
|
||||
return value
|
||||
|
||||
|
||||
def validate_multicast_group(value: str) -> str:
|
||||
"""An IPv4 multicast group, or an empty string to skip joining one."""
|
||||
if not (value := cv.string_strict(value).strip()):
|
||||
return value
|
||||
value = str(cv.ipv4address(value))
|
||||
if not IPv4Address(value).is_multicast:
|
||||
raise cv.Invalid("ESPectre multicast group must be an IPv4 multicast address")
|
||||
return value
|
||||
|
||||
|
||||
def supported_traffic_generator_modes(config: ConfigType) -> list[str]:
|
||||
"""Traffic generator modes available with this chip and CSI capture profile."""
|
||||
wifi_raw = (
|
||||
esp32.get_esp32_variant() != VARIANT_ESP32C6
|
||||
and config[CONF_CSI_CAPTURE_PROFILE] != "ht_vht"
|
||||
)
|
||||
return [mode for mode in TRAFFIC_GENERATOR_MODES if wifi_raw or mode != "wifi_raw"]
|
||||
|
||||
|
||||
def validate_config(config: ConfigType) -> ConfigType:
|
||||
mode = config[CONF_TRAFFIC_GENERATOR_MODE]
|
||||
if mode not in supported_traffic_generator_modes(config):
|
||||
raise cv.Invalid(
|
||||
"wifi_raw traffic is not supported on ESP32-C6 "
|
||||
"or with the ht_vht CSI capture profile"
|
||||
)
|
||||
if mode in ("wifi_raw", "external") and CONF_TRAFFIC_GENERATOR_TARGET_IP in config:
|
||||
raise cv.Invalid(f"{mode} traffic does not use a target IP address")
|
||||
if mode != "external" and CONF_CSI_TRAFFIC_MULTICAST_GROUP in config:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_CSI_TRAFFIC_MULTICAST_GROUP} requires "
|
||||
f"{CONF_TRAFFIC_GENERATOR_MODE}: external"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ESPectreComponent),
|
||||
cv.Optional(CONF_DETECTION_ALGORITHM, default="lightweight"): cv.enum(
|
||||
DETECTION_ALGORITHMS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_CSI_CAPTURE_PROFILE, default="auto"): cv.enum(
|
||||
CSI_CAPTURE_PROFILES, lower=True
|
||||
),
|
||||
cv.Optional(CONF_TRAFFIC_GENERATOR_MODE, default="ping"): cv.enum(
|
||||
TRAFFIC_GENERATOR_MODES, lower=True
|
||||
),
|
||||
cv.Optional(CONF_TRAFFIC_GENERATOR_TARGET_IP): validate_target_ip,
|
||||
cv.Optional(CONF_CSI_TRAFFIC_MULTICAST_GROUP): validate_multicast_group,
|
||||
cv.Optional(CONF_MOTION_ON_HITS): cv.int_range(min=1, max=20),
|
||||
cv.Optional(CONF_MOTION_OFF_HITS): cv.int_range(min=1, max=20),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
esp32.only_on_variant(
|
||||
supported=[
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32C3,
|
||||
VARIANT_ESP32C5,
|
||||
VARIANT_ESP32C6,
|
||||
],
|
||||
msg_prefix="ESPectre",
|
||||
),
|
||||
# Arduino 3.3.7 is the first release built on ESP-IDF 5.5.3.
|
||||
cv.require_framework_version(
|
||||
esp_idf=cv.Version(5, 5, 3), esp32_arduino=cv.Version(3, 3, 7)
|
||||
),
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config: ConfigType) -> None:
|
||||
wifi.force_power_save_off(
|
||||
"ESPectre needs the radio awake to receive a steady flow of CSI packets"
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add_define("USE_ESPECTRE")
|
||||
cg.add(var.set_detection_algorithm(config[CONF_DETECTION_ALGORITHM]))
|
||||
cg.add(var.set_csi_capture_profile(config[CONF_CSI_CAPTURE_PROFILE]))
|
||||
cg.add(var.set_traffic_generator_mode(config[CONF_TRAFFIC_GENERATOR_MODE]))
|
||||
if (target_ip := config.get(CONF_TRAFFIC_GENERATOR_TARGET_IP)) is not None:
|
||||
cg.add(var.set_traffic_generator_target_ip(target_ip))
|
||||
if (group := config.get(CONF_CSI_TRAFFIC_MULTICAST_GROUP)) is not None:
|
||||
cg.add(var.set_csi_traffic_multicast_group(group))
|
||||
if (on_hits := config.get(CONF_MOTION_ON_HITS)) is not None:
|
||||
cg.add(var.set_motion_on_hits(on_hits))
|
||||
if (off_hits := config.get(CONF_MOTION_OFF_HITS)) is not None:
|
||||
cg.add(var.set_motion_off_hits(off_hits))
|
||||
if esp32.get_esp32_variant() == VARIANT_ESP32C5:
|
||||
band = CORE.config[CONF_WIFI].get(wifi.CONF_BAND_MODE, "AUTO")
|
||||
cg.add(
|
||||
var.set_wifi_band_policy(
|
||||
{
|
||||
"2.4GHZ": WifiBandPolicy.BAND_2G,
|
||||
"5GHZ": WifiBandPolicy.BAND_5G,
|
||||
"AUTO": WifiBandPolicy.AUTO,
|
||||
}[band]
|
||||
)
|
||||
)
|
||||
|
||||
wifi.enable_runtime_roaming_suppression()
|
||||
esp32.add_idf_component(name="francescopace/espectre", ref="3.0.0")
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_CSI_ENABLED", True)
|
||||
# CSI is reported once per received transmission, so aggregation hides frames from sensing.
|
||||
# Disabling TX aggregation also lets the SDK fix the station TX rate (6.5 Mbps on ESP32).
|
||||
# Both may lower Wi-Fi throughput for the whole firmware.
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", False)
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", False)
|
||||
# Keep the radio awake while disconnected too, matching force_power_save_off().
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE", False)
|
||||
@@ -0,0 +1,32 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MOTION, DEVICE_CLASS_MOTION, ENTITY_CATEGORY_DIAGNOSTIC
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_ESPECTRE_ID, ESPectreComponent
|
||||
|
||||
DEPENDENCIES = ["espectre"]
|
||||
CONF_CALIBRATING = "calibrating"
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent),
|
||||
cv.Optional(CONF_MOTION): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_MOTION
|
||||
),
|
||||
cv.Optional(CONF_CALIBRATING): binary_sensor.binary_sensor_schema(
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_MOTION, CONF_CALIBRATING),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ESPECTRE_ID])
|
||||
binary_sensors = binary_sensor.sub_binary_sensors(config)
|
||||
await binary_sensors(CONF_MOTION, hub.set_motion_binary_sensor)
|
||||
await binary_sensors(CONF_CALIBRATING, hub.set_calibrating_binary_sensor)
|
||||
@@ -0,0 +1,20 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ENTITY_CATEGORY_CONFIG
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns
|
||||
|
||||
DEPENDENCIES = ["espectre"]
|
||||
|
||||
RecalibrateButton = espectre_ns.class_("RecalibrateButton", button.Button)
|
||||
|
||||
CONFIG_SCHEMA = button.button_schema(
|
||||
RecalibrateButton, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)})
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await button.new_button(config)
|
||||
await cg.register_parented(var, config[CONF_ESPECTRE_ID])
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include "esphome/components/button/button.h"
|
||||
#include "../espectre.h"
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
class RecalibrateButton final : public button::Button, public Parented<ESPectreComponent> {
|
||||
protected:
|
||||
void press_action() override { this->parent_->recalibrate(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,305 @@
|
||||
#include "espectre.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
static const char *const TAG = "espectre";
|
||||
|
||||
struct TrafficModeOption {
|
||||
const char *name;
|
||||
::espectre::TrafficGeneratorMode mode;
|
||||
};
|
||||
// Option names match the traffic_generator_mode YAML values.
|
||||
static constexpr TrafficModeOption TRAFFIC_MODE_OPTIONS[] = {
|
||||
{"ping", ::espectre::TrafficGeneratorMode::PING},
|
||||
{"dns", ::espectre::TrafficGeneratorMode::DNS},
|
||||
{"dns_tcp", ::espectre::TrafficGeneratorMode::DNS_TCP},
|
||||
{"wifi_raw", ::espectre::TrafficGeneratorMode::WIFI_RAW},
|
||||
{"external", ::espectre::TrafficGeneratorMode::EXTERNAL_HOST},
|
||||
};
|
||||
|
||||
static const char *traffic_mode_name(::espectre::TrafficGeneratorMode mode) {
|
||||
for (const auto &option : TRAFFIC_MODE_OPTIONS) {
|
||||
if (option.mode == mode)
|
||||
return option.name;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static const char *csi_capture_profile_name(::espectre::CsiCapturePolicy profile) {
|
||||
switch (profile) {
|
||||
case ::espectre::CsiCapturePolicy::LLTF:
|
||||
return LOG_STR_LITERAL("lltf");
|
||||
case ::espectre::CsiCapturePolicy::HT_VHT:
|
||||
return LOG_STR_LITERAL("ht_vht");
|
||||
default:
|
||||
return LOG_STR_LITERAL("auto");
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_SELECT
|
||||
void ESPectreComponent::request_traffic_generator_mode(const char *name) {
|
||||
for (const auto &option : TRAFFIC_MODE_OPTIONS) {
|
||||
if (strcmp(option.name, name) == 0) {
|
||||
this->pending_traffic_mode_ = option.mode;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static int log_level(::espectre::LogLevel level) {
|
||||
switch (level) {
|
||||
case ::espectre::LogLevel::ERROR:
|
||||
return ESPHOME_LOG_LEVEL_ERROR;
|
||||
case ::espectre::LogLevel::WARNING:
|
||||
return ESPHOME_LOG_LEVEL_WARN;
|
||||
case ::espectre::LogLevel::INFO:
|
||||
return ESPHOME_LOG_LEVEL_INFO;
|
||||
case ::espectre::LogLevel::DEBUG:
|
||||
return ESPHOME_LOG_LEVEL_DEBUG;
|
||||
default:
|
||||
return ESPHOME_LOG_LEVEL_VERBOSE;
|
||||
}
|
||||
}
|
||||
|
||||
void ESPectreComponent::setup() {
|
||||
// ESPHome owns scan results, including scans requested by the SDK's CSI recovery.
|
||||
this->runtime_.config().wifi_scan_results_managed_externally = true;
|
||||
#ifdef USE_SELECT
|
||||
this->restore_traffic_mode_();
|
||||
#endif
|
||||
this->start_runtime_();
|
||||
}
|
||||
|
||||
void ESPectreComponent::start_runtime_() {
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
|
||||
// A sensor stays in one place, and each roaming scan takes the radio off-channel for
|
||||
// seconds, emptying the CSI window. Losing the access point still reconnects normally.
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->request_roaming_suppression();
|
||||
this->roaming_suppressed_ = true;
|
||||
}
|
||||
#endif
|
||||
::espectre::set_log_sink({
|
||||
.context = nullptr,
|
||||
.enabled = [](void *, ::espectre::LogLevel level, const char *) { return log_level(level) <= ESPHOME_LOG_LEVEL; },
|
||||
.write = [](void *, ::espectre::LogLevel level, const char *tag, int line, const char *format,
|
||||
va_list args) { esp_log_vprintf_(log_level(level), tag, line, format, args); },
|
||||
});
|
||||
if (!this->runtime_.setup(this)) {
|
||||
ESP_LOGE(TAG, "Runtime setup failed");
|
||||
this->stop_();
|
||||
this->schedule_restart_();
|
||||
return;
|
||||
}
|
||||
this->running_ = true;
|
||||
this->status_clear_error();
|
||||
#ifdef USE_SELECT
|
||||
this->publish_traffic_mode_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPectreComponent::schedule_restart_() {
|
||||
// A fault can be transient, such as a Wi-Fi stall, so retry instead of failing for good.
|
||||
this->runtime_fault_ = false;
|
||||
this->status_set_error(LOG_STR("Runtime stopped"));
|
||||
ESP_LOGW(TAG, "Restarting the runtime in %" PRIu32 " s", RESTART_DELAY_MS / 1000);
|
||||
this->restart_pending_ = true;
|
||||
this->restart_requested_ms_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
#ifdef USE_SELECT
|
||||
void ESPectreComponent::restore_traffic_mode_() {
|
||||
if (this->traffic_mode_select_ == nullptr)
|
||||
return;
|
||||
// Keyed by the YAML mode, so changing it in YAML discards a mode saved from the select.
|
||||
const auto yaml_mode = this->runtime_.config().traffic_generator_mode;
|
||||
this->traffic_mode_pref_ =
|
||||
this->traffic_mode_select_->make_entity_preference<uint8_t>(static_cast<uint32_t>(yaml_mode) + 1);
|
||||
uint8_t saved;
|
||||
if (!this->traffic_mode_pref_.load(&saved))
|
||||
return;
|
||||
const auto mode = static_cast<::espectre::TrafficGeneratorMode>(saved);
|
||||
const char *name = traffic_mode_name(mode);
|
||||
if (name != nullptr && this->traffic_mode_select_->has_option(name))
|
||||
this->runtime_.config().traffic_generator_mode = mode;
|
||||
}
|
||||
|
||||
void ESPectreComponent::apply_pending_traffic_mode_() {
|
||||
if (!this->pending_traffic_mode_.has_value())
|
||||
return;
|
||||
const auto mode = *this->pending_traffic_mode_;
|
||||
this->pending_traffic_mode_.reset();
|
||||
if (this->runtime_.set_traffic_generator_mode(mode)) {
|
||||
const auto saved = static_cast<uint8_t>(mode);
|
||||
this->traffic_mode_pref_.save(&saved);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Traffic generator mode %s was rejected", traffic_mode_name(mode));
|
||||
}
|
||||
this->publish_traffic_mode_();
|
||||
}
|
||||
|
||||
void ESPectreComponent::publish_traffic_mode_() {
|
||||
if (this->traffic_mode_select_ == nullptr)
|
||||
return;
|
||||
const char *name = traffic_mode_name(this->runtime_.config().traffic_generator_mode);
|
||||
if (name != nullptr)
|
||||
this->traffic_mode_select_->publish_state(name);
|
||||
}
|
||||
#endif
|
||||
|
||||
void ESPectreComponent::loop() {
|
||||
if (this->runtime_fault_) {
|
||||
this->stop_();
|
||||
this->schedule_restart_();
|
||||
return;
|
||||
}
|
||||
if (!this->running_) {
|
||||
// Without a backend this only reaps a traffic worker that outlived the last runtime.
|
||||
this->runtime_.loop();
|
||||
if (this->restart_pending_ &&
|
||||
App.get_loop_component_start_time() - this->restart_requested_ms_ >= RESTART_DELAY_MS) {
|
||||
this->restart_pending_ = false;
|
||||
this->start_runtime_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this->recalibrate_pending_) {
|
||||
this->recalibrate_pending_ = false;
|
||||
if (!this->runtime_.trigger_recalibration()) {
|
||||
ESP_LOGW(TAG, "Recalibration is not available");
|
||||
}
|
||||
}
|
||||
#ifdef USE_SELECT
|
||||
this->apply_pending_traffic_mode_();
|
||||
#endif
|
||||
this->runtime_.loop();
|
||||
if (this->runtime_fault_)
|
||||
return;
|
||||
|
||||
// Read once after the SDK finishes dispatching callbacks, including readiness changes.
|
||||
const auto &snapshot = this->runtime_.snapshot();
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->calibrating_binary_sensor_ != nullptr &&
|
||||
(!this->calibrating_published_ || this->calibrating_state_ != snapshot.calibrating)) {
|
||||
this->calibrating_state_ = snapshot.calibrating;
|
||||
this->calibrating_published_ = true;
|
||||
this->calibrating_binary_sensor_->publish_state(snapshot.calibrating);
|
||||
}
|
||||
#endif
|
||||
if (!snapshot.ready_to_publish) {
|
||||
if (this->ready_)
|
||||
this->invalidate_sensing_();
|
||||
this->movement_pending_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
const bool motion = snapshot.motion_state == ::espectre::MotionState::MOTION;
|
||||
if (this->motion_binary_sensor_ != nullptr && (!this->ready_ || this->motion_state_ != motion)) {
|
||||
this->motion_state_ = motion;
|
||||
this->motion_binary_sensor_->publish_state(motion);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
if (this->movement_sensor_ != nullptr && (this->movement_pending_ || !this->ready_))
|
||||
this->movement_sensor_->publish_state(snapshot.movement_metric);
|
||||
#endif
|
||||
this->movement_pending_ = false;
|
||||
this->ready_ = true;
|
||||
}
|
||||
|
||||
void ESPectreComponent::invalidate_sensing_() {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->motion_binary_sensor_ != nullptr)
|
||||
this->motion_binary_sensor_->invalidate_state();
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
if (this->movement_sensor_ != nullptr)
|
||||
this->movement_sensor_->publish_state(NAN);
|
||||
#endif
|
||||
this->ready_ = false;
|
||||
}
|
||||
|
||||
void ESPectreComponent::on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) {
|
||||
if (success) {
|
||||
this->calibrated_ = true;
|
||||
this->status_clear_warning();
|
||||
ESP_LOGI(TAG, "Calibration complete");
|
||||
return;
|
||||
}
|
||||
// After a successful calibration, a failed one keeps that calibrated threshold.
|
||||
if (!this->calibrated_)
|
||||
this->status_set_warning(LOG_STR("Calibration failed"));
|
||||
ESP_LOGW(TAG, "Calibration failed; retaining the previous threshold");
|
||||
}
|
||||
|
||||
void ESPectreComponent::on_runtime_fault(const char *message) {
|
||||
ESP_LOGE(TAG, "Runtime fault: %s", message);
|
||||
this->runtime_fault_ = true;
|
||||
}
|
||||
|
||||
void ESPectreComponent::stop_() {
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
|
||||
if (this->roaming_suppressed_ && wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->release_roaming_suppression();
|
||||
}
|
||||
#endif
|
||||
this->roaming_suppressed_ = false;
|
||||
this->running_ = false;
|
||||
// The next runtime starts from the default threshold and publishes its calibration again.
|
||||
this->calibrated_ = false;
|
||||
this->calibrating_published_ = false;
|
||||
this->runtime_.shutdown();
|
||||
::espectre::clear_log_sink();
|
||||
this->invalidate_sensing_();
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->calibrating_binary_sensor_ != nullptr)
|
||||
this->calibrating_binary_sensor_->invalidate_state();
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPectreComponent::on_shutdown() {
|
||||
this->restart_pending_ = false;
|
||||
this->stop_();
|
||||
}
|
||||
|
||||
void ESPectreComponent::dump_config() {
|
||||
const auto &config = this->runtime_.config();
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"ESPectre:\n"
|
||||
" Detection algorithm: %s\n"
|
||||
" CSI capture profile: %s\n"
|
||||
" Traffic generator mode: %s\n"
|
||||
" Motion on/off hits: %u/%u",
|
||||
config.detection_algorithm == ::espectre::DetectionAlgorithm::LIGHTWEIGHT
|
||||
? LOG_STR_LITERAL("lightweight")
|
||||
: LOG_STR_LITERAL("high_accuracy"),
|
||||
csi_capture_profile_name(config.csi_capture_policy), traffic_mode_name(config.traffic_generator_mode),
|
||||
config.motion_on_hits, config.motion_off_hits);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Motion", this->motion_binary_sensor_);
|
||||
LOG_BINARY_SENSOR(" ", "Calibrating", this->calibrating_binary_sensor_);
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
LOG_SENSOR(" ", "Movement score", this->movement_sensor_);
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
LOG_SELECT(" ", "Traffic generator mode", this->traffic_mode_select_);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/optional.h"
|
||||
#include <espectre_sdk.h>
|
||||
#include <string>
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
#include "esphome/components/select/select.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
class ESPectreComponent final : public Component, public ::espectre::IRuntimeListener {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
void on_shutdown() override;
|
||||
// Register the SDK's Wi-Fi event handlers after network setup, before station startup.
|
||||
float get_setup_priority() const override { return setup_priority::WIFI + 1.0f; }
|
||||
|
||||
void set_detection_algorithm(::espectre::DetectionAlgorithm value) {
|
||||
this->runtime_.config().detection_algorithm = value;
|
||||
}
|
||||
void set_csi_capture_profile(::espectre::CsiCapturePolicy value) {
|
||||
this->runtime_.config().csi_capture_policy = value;
|
||||
}
|
||||
void set_traffic_generator_mode(::espectre::TrafficGeneratorMode value) {
|
||||
this->runtime_.config().traffic_generator_mode = value;
|
||||
}
|
||||
void set_traffic_generator_target_ip(const std::string &value) {
|
||||
this->runtime_.config().traffic_generator_target_ip = value;
|
||||
}
|
||||
void set_csi_traffic_multicast_group(const std::string &value) {
|
||||
this->runtime_.config().csi_traffic_multicast_group = value;
|
||||
}
|
||||
void set_motion_on_hits(uint8_t value) { this->runtime_.config().motion_on_hits = value; }
|
||||
void set_motion_off_hits(uint8_t value) { this->runtime_.config().motion_off_hits = value; }
|
||||
void set_wifi_band_policy(::espectre::WifiBandPolicy value) { this->runtime_.config().wifi_band_policy = value; }
|
||||
// Queue controls so entity automations cannot re-enter the SDK from a listener callback.
|
||||
void recalibrate() { this->recalibrate_pending_ = true; }
|
||||
#ifdef USE_SELECT
|
||||
void set_traffic_mode_select(select::Select *value) { this->traffic_mode_select_ = value; }
|
||||
void request_traffic_generator_mode(::espectre::TrafficGeneratorMode mode) { this->pending_traffic_mode_ = mode; }
|
||||
/// Request a mode by its traffic_generator_mode YAML name; unknown names are ignored.
|
||||
void request_traffic_generator_mode(const char *name);
|
||||
#endif
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
SUB_BINARY_SENSOR(motion)
|
||||
SUB_BINARY_SENSOR(calibrating)
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
SUB_SENSOR(movement)
|
||||
#endif
|
||||
/// Latest one-second runtime diagnostics sample, or nullptr before the runtime starts.
|
||||
const ::espectre::RuntimeDiagnosticsSample *diagnostics_sample() const { return this->runtime_.diagnostics_sample(); }
|
||||
|
||||
protected:
|
||||
// Called once per SDK detector evaluation (every 250 ms), which bounds the movement publish rate.
|
||||
void on_live_telemetry(const ::espectre::RuntimeSnapshot &snapshot) override { this->movement_pending_ = true; }
|
||||
void on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) override;
|
||||
void on_runtime_fault(const char *message) override;
|
||||
void start_runtime_();
|
||||
void schedule_restart_();
|
||||
void invalidate_sensing_();
|
||||
void stop_();
|
||||
#ifdef USE_SELECT
|
||||
void restore_traffic_mode_();
|
||||
void apply_pending_traffic_mode_();
|
||||
void publish_traffic_mode_();
|
||||
#endif
|
||||
|
||||
static constexpr uint32_t RESTART_DELAY_MS = 30000;
|
||||
|
||||
::espectre::RuntimeFrontendController runtime_;
|
||||
uint32_t restart_requested_ms_{0};
|
||||
#ifdef USE_SELECT
|
||||
select::Select *traffic_mode_select_{nullptr};
|
||||
ESPPreferenceObject traffic_mode_pref_;
|
||||
optional<::espectre::TrafficGeneratorMode> pending_traffic_mode_;
|
||||
#endif
|
||||
bool recalibrate_pending_{false};
|
||||
bool movement_pending_{false};
|
||||
bool ready_{false};
|
||||
bool motion_state_{false};
|
||||
bool calibrating_state_{false};
|
||||
bool calibrating_published_{false};
|
||||
bool runtime_fault_{false};
|
||||
bool running_{false};
|
||||
bool restart_pending_{false};
|
||||
bool calibrated_{false};
|
||||
bool roaming_suppressed_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,32 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ENTITY_CATEGORY_CONFIG
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_ESPECTRE_ID,
|
||||
DOMAIN,
|
||||
ESPectreComponent,
|
||||
espectre_ns,
|
||||
supported_traffic_generator_modes,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["espectre"]
|
||||
|
||||
TrafficModeSelect = espectre_ns.class_(
|
||||
"TrafficModeSelect", select.Select, cg.Parented.template(ESPectreComponent)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = select.select_schema(
|
||||
TrafficModeSelect, entity_category=ENTITY_CATEGORY_CONFIG
|
||||
).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)})
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
options = supported_traffic_generator_modes(CORE.config[DOMAIN])
|
||||
var = await select.new_select(config, options=options)
|
||||
await cg.register_parented(var, config[CONF_ESPECTRE_ID])
|
||||
parent = await cg.get_variable(config[CONF_ESPECTRE_ID])
|
||||
cg.add(parent.set_traffic_mode_select(var))
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "espectre_select.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
void TrafficModeSelect::control(size_t index) { this->parent_->request_traffic_generator_mode(this->option_at(index)); }
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include "esphome/components/select/select.h"
|
||||
#include "../espectre.h"
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
class TrafficModeSelect final : public select::Select, public Parented<ESPectreComponent> {
|
||||
protected:
|
||||
void control(size_t index) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,78 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns
|
||||
|
||||
DEPENDENCIES = ["espectre"]
|
||||
|
||||
CONF_MOVEMENT = "movement"
|
||||
CONF_DIAGNOSTICS = "diagnostics"
|
||||
CONF_GENERATOR_RATE = "generator_rate"
|
||||
CONF_TRAFFIC_TX_RATE = "traffic_tx_rate"
|
||||
CONF_TRAFFIC_RX_RATE = "traffic_rx_rate"
|
||||
CONF_CSI_ACCEPTED_RATE = "csi_accepted_rate"
|
||||
CONF_CSI_OCCUPANCY = "csi_occupancy"
|
||||
UNIT_PACKETS_PER_SECOND = "pps"
|
||||
|
||||
DiagnosticsUpdater = espectre_ns.class_("DiagnosticsUpdater", cg.PollingComponent)
|
||||
|
||||
|
||||
def _diagnostic_schema(unit: str, accuracy_decimals: int) -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
unit_of_measurement=unit,
|
||||
accuracy_decimals=accuracy_decimals,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
|
||||
|
||||
DIAGNOSTIC_SENSORS = {
|
||||
CONF_GENERATOR_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
|
||||
CONF_TRAFFIC_TX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
|
||||
CONF_TRAFFIC_RX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
|
||||
CONF_CSI_ACCEPTED_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
|
||||
CONF_CSI_OCCUPANCY: _diagnostic_schema(UNIT_PERCENT, 0),
|
||||
}
|
||||
|
||||
# Diagnostics are rarely watched, so they publish only on request unless an interval is set.
|
||||
DIAGNOSTICS_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DiagnosticsUpdater),
|
||||
**{cv.Optional(key): schema for key, schema in DIAGNOSTIC_SENSORS.items()},
|
||||
}
|
||||
).extend(cv.polling_component_schema("never")),
|
||||
cv.has_at_least_one_key(*DIAGNOSTIC_SENSORS),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent),
|
||||
cv.Optional(CONF_MOVEMENT): sensor.sensor_schema(
|
||||
accuracy_decimals=3, state_class=STATE_CLASS_MEASUREMENT
|
||||
),
|
||||
cv.Optional(CONF_DIAGNOSTICS): DIAGNOSTICS_SCHEMA,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_MOVEMENT, CONF_DIAGNOSTICS),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ESPECTRE_ID])
|
||||
await sensor.sub_sensors(config)(CONF_MOVEMENT, hub.set_movement_sensor)
|
||||
if (diagnostics_config := config.get(CONF_DIAGNOSTICS)) is not None:
|
||||
updater = cg.new_Pvariable(diagnostics_config[CONF_ID], hub)
|
||||
await cg.register_component(updater, diagnostics_config)
|
||||
diagnostic_sensors = sensor.sub_sensors(diagnostics_config)
|
||||
for key in DIAGNOSTIC_SENSORS:
|
||||
await diagnostic_sensors(key, getattr(updater, f"set_{key}_sensor"))
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "espectre_diagnostics.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include <cmath>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
static const char *const TAG = "espectre.sensor";
|
||||
|
||||
static void publish_diagnostic(sensor::Sensor *sensor, const ::espectre::RuntimeDiagnosticsSample *sample,
|
||||
float ::espectre::RuntimeDiagnosticsSample::*field, float scale = 1.0f) {
|
||||
if (sensor != nullptr)
|
||||
sensor->publish_state(sample != nullptr ? sample->*field * scale : NAN);
|
||||
}
|
||||
|
||||
void DiagnosticsUpdater::update() {
|
||||
using Sample = ::espectre::RuntimeDiagnosticsSample;
|
||||
const auto *sample = this->parent_->diagnostics_sample();
|
||||
publish_diagnostic(this->generator_rate_sensor_, sample, &Sample::generator_pps);
|
||||
publish_diagnostic(this->traffic_tx_rate_sensor_, sample, &Sample::traffic_tx_pps);
|
||||
publish_diagnostic(this->traffic_rx_rate_sensor_, sample, &Sample::traffic_rx_pps);
|
||||
publish_diagnostic(this->csi_accepted_rate_sensor_, sample, &Sample::csi_accepted_pps);
|
||||
publish_diagnostic(this->csi_occupancy_sensor_, sample, &Sample::csi_occupancy_ratio, 100.0f);
|
||||
}
|
||||
|
||||
void DiagnosticsUpdater::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "ESPectre diagnostics:");
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
LOG_SENSOR(" ", "Generator rate", this->generator_rate_sensor_);
|
||||
LOG_SENSOR(" ", "Traffic TX rate", this->traffic_tx_rate_sensor_);
|
||||
LOG_SENSOR(" ", "Traffic RX rate", this->traffic_rx_rate_sensor_);
|
||||
LOG_SENSOR(" ", "CSI accepted rate", this->csi_accepted_rate_sensor_);
|
||||
LOG_SENSOR(" ", "CSI occupancy", this->csi_occupancy_sensor_);
|
||||
}
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESPECTRE
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "../espectre.h"
|
||||
|
||||
namespace esphome::espectre {
|
||||
|
||||
/// Publishes all diagnostic sensors together from the same runtime sample.
|
||||
class DiagnosticsUpdater final : public PollingComponent {
|
||||
public:
|
||||
explicit DiagnosticsUpdater(ESPectreComponent *parent) : parent_(parent) {}
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
|
||||
SUB_SENSOR(generator_rate)
|
||||
SUB_SENSOR(traffic_tx_rate)
|
||||
SUB_SENSOR(traffic_rx_rate)
|
||||
SUB_SENSOR(csi_accepted_rate)
|
||||
SUB_SENSOR(csi_occupancy)
|
||||
|
||||
protected:
|
||||
ESPectreComponent *parent_;
|
||||
};
|
||||
|
||||
} // namespace esphome::espectre
|
||||
|
||||
#endif // USE_ESPECTRE
|
||||
@@ -133,20 +133,12 @@ def ota_esphome_final_validate(config: ConfigType) -> 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",
|
||||
"'%s' %s wastes significant flash and RAM; "
|
||||
"using '%s' instead is recommended - "
|
||||
"see https://esphome.io/components/ota/esphome/#configuration-variables",
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_API,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_KEY,
|
||||
CONF_PASSWORD,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_OTA,
|
||||
)
|
||||
elif (
|
||||
CONF_PASSWORD in ota_conf
|
||||
|
||||
@@ -24,7 +24,6 @@ from esphome.types import ConfigType
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
AUTO_LOAD = ["network"]
|
||||
|
||||
byte_vector = cg.std_vector.template(cg.uint8)
|
||||
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
|
||||
|
||||
espnow_ns = cg.esphome_ns.namespace("espnow")
|
||||
@@ -304,11 +303,12 @@ async def send_action(
|
||||
|
||||
await register_peer(var, config, args)
|
||||
|
||||
data = config.get(CONF_DATA, [])
|
||||
data = config[CONF_DATA]
|
||||
if isinstance(data, str):
|
||||
data = list(data.encode())
|
||||
templ = await cg.templatable(data, args, byte_vector, byte_vector)
|
||||
cg.add(var.set_data(templ))
|
||||
await automation.templatable_bytes(
|
||||
data, args, var.set_data_template, var.set_data_static, "espnow_data"
|
||||
)
|
||||
|
||||
cg.add(var.set_wait_for_sent(config[CONF_WAIT_FOR_SENT]))
|
||||
cg.add(var.set_continue_on_error(config[CONF_CONTINUE_ON_ERROR]))
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace esphome::espnow {
|
||||
|
||||
template<typename... Ts> class SendAction final : public Action<Ts...>, public Parented<ESPNowComponent> {
|
||||
TEMPLATABLE_VALUE(peer_address_t, address);
|
||||
TEMPLATABLE_VALUE(std::vector<uint8_t>, data);
|
||||
TEMPLATABLE_BYTES(data)
|
||||
|
||||
public:
|
||||
void add_on_sent(const std::initializer_list<Action<Ts...> *> &actions) {
|
||||
@@ -58,8 +58,10 @@ template<typename... Ts> class SendAction final : public Action<Ts...>, public P
|
||||
}
|
||||
};
|
||||
peer_address_t address = this->address_.value(x...);
|
||||
std::vector<uint8_t> data = this->data_.value(x...);
|
||||
esp_err_t err = this->parent_->send(address.data(), data, send_callback);
|
||||
esp_err_t err = ESP_OK;
|
||||
this->data_.visit(
|
||||
[&](const uint8_t *data, size_t len) { err = this->parent_->send(address.data(), data, len, send_callback); },
|
||||
x...);
|
||||
if (err != ESP_OK) {
|
||||
send_callback(err);
|
||||
} else if (!this->flags_.wait_for_sent) {
|
||||
|
||||
@@ -636,6 +636,19 @@ def phy_register(address: int, value: int, page: int) -> cg.StructInitializer:
|
||||
)
|
||||
|
||||
|
||||
def _add_phy_registers(var: cg.MockObj, config: ConfigType) -> None:
|
||||
if not (registers := config.get(CONF_PHY_REGISTERS)):
|
||||
return
|
||||
cg.add_define("ESPHOME_ETHERNET_PHY_REGISTER_COUNT", len(registers))
|
||||
for register_value in registers:
|
||||
reg = phy_register(
|
||||
register_value.get(CONF_ADDRESS),
|
||||
register_value.get(CONF_VALUE),
|
||||
register_value.get(CONF_PAGE_ID),
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.COMMUNICATION)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -741,13 +754,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
|
||||
cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN]))
|
||||
if CONF_POWER_PIN in config:
|
||||
cg.add(var.set_power_pin(config[CONF_POWER_PIN]))
|
||||
for register_value in config.get(CONF_PHY_REGISTERS, []):
|
||||
reg = phy_register(
|
||||
register_value.get(CONF_ADDRESS),
|
||||
register_value.get(CONF_VALUE),
|
||||
register_value.get(CONF_PAGE_ID),
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
_add_phy_registers(var, config)
|
||||
else:
|
||||
cg.add(var.set_phy_addr(config[CONF_PHY_ADDR]))
|
||||
cg.add(var.set_mdc_pin(config[CONF_MDC_PIN]))
|
||||
@@ -756,13 +763,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
|
||||
cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN]))
|
||||
if CONF_POWER_PIN in config:
|
||||
cg.add(var.set_power_pin(config[CONF_POWER_PIN]))
|
||||
for register_value in config.get(CONF_PHY_REGISTERS, []):
|
||||
reg = phy_register(
|
||||
register_value.get(CONF_ADDRESS),
|
||||
register_value.get(CONF_VALUE),
|
||||
register_value.get(CONF_PAGE_ID),
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
_add_phy_registers(var, config)
|
||||
|
||||
# Register Ethernet with the esp32 sdkconfig reconciler. It disables the
|
||||
# WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi,
|
||||
|
||||
@@ -193,7 +193,9 @@ class EthernetComponent final : public Component {
|
||||
void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; }
|
||||
void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
|
||||
void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; }
|
||||
void add_phy_register(PHYRegister register_value);
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
void add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); }
|
||||
#endif
|
||||
#endif // USE_ETHERNET_SPI
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -255,8 +257,10 @@ class EthernetComponent final : public Component {
|
||||
/// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling.
|
||||
void yt8531_phy_init_();
|
||||
#endif
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
/// @brief Set arbitratry PHY registers from config.
|
||||
void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data);
|
||||
#endif
|
||||
|
||||
#ifdef USE_ETHERNET_SPI
|
||||
uint8_t clk_pin_;
|
||||
@@ -280,7 +284,9 @@ class EthernetComponent final : public Component {
|
||||
// Group all 32-bit members first
|
||||
int power_pin_{-1};
|
||||
emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN};
|
||||
std::vector<PHYRegister> phy_registers_{};
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
StaticVector<PHYRegister, ESPHOME_ETHERNET_PHY_REGISTER_COUNT> phy_registers_{};
|
||||
#endif
|
||||
|
||||
// Group all 8-bit members together
|
||||
uint8_t clk_pin_{0};
|
||||
|
||||
@@ -462,9 +462,11 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
}
|
||||
#endif // USE_ETHERNET_KSZ8081
|
||||
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
for (const auto &phy_register : this->phy_registers_) {
|
||||
this->write_phy_register_(mac, phy_register);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
#ifdef USE_ETHERNET_GENERIC
|
||||
@@ -1043,10 +1045,6 @@ void EthernetComponent::dump_connect_params_() {
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
}
|
||||
|
||||
#ifndef USE_ETHERNET_SPI
|
||||
void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); }
|
||||
#endif
|
||||
|
||||
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
if (!this->ethernet_initialized_) {
|
||||
// External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless
|
||||
@@ -1144,6 +1142,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) {
|
||||
}
|
||||
#endif // USE_ETHERNET_KSZ8081
|
||||
|
||||
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) {
|
||||
esp_err_t err;
|
||||
|
||||
@@ -1168,6 +1167,7 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif // ESPHOME_ETHERNET_PHY_REGISTER_COUNT
|
||||
|
||||
#ifdef USE_ETHERNET_YT8531
|
||||
void EthernetComponent::yt8531_phy_init_() {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
@@ -0,0 +1,116 @@
|
||||
#include "exponential_moving_average_sensor.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::exponential_moving_average {
|
||||
|
||||
static const char *const TAG = "exponential_moving_average";
|
||||
|
||||
const LogString *time_weighting_to_string(TimeWeighting weighting) {
|
||||
switch (weighting) {
|
||||
case TIME_WEIGHTING_PREVIOUS:
|
||||
return LOG_STR("previous");
|
||||
case TIME_WEIGHTING_LINEAR:
|
||||
return LOG_STR("linear");
|
||||
default:
|
||||
return LOG_STR("new");
|
||||
}
|
||||
}
|
||||
|
||||
ScaledDuration scale_duration(uint32_t ms) {
|
||||
if (ms < 1000)
|
||||
return {static_cast<float>(ms), LOG_STR("ms"), 0};
|
||||
if (ms < 60 * 1000)
|
||||
return {ms / 1000.0f, LOG_STR("s"), 1};
|
||||
if (ms < 60 * 60 * 1000)
|
||||
return {ms / (60 * 1000.0f), LOG_STR("min"), 1};
|
||||
return {ms / (60 * 60 * 1000.0f), LOG_STR("h"), 1};
|
||||
}
|
||||
|
||||
void ExponentialMovingAverageSensor::setup() {
|
||||
if (this->restore_) {
|
||||
this->pref_ = this->make_entity_preference<float>();
|
||||
float restored;
|
||||
if (this->pref_.load(&restored) && std::isfinite(restored)) {
|
||||
this->accumulator_ = restored;
|
||||
this->publish_state(restored);
|
||||
}
|
||||
}
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->last_update_ = now;
|
||||
this->source_->add_on_state_callback(
|
||||
[this](float value) { this->process_(value, App.get_loop_component_start_time()); });
|
||||
// The source may have published during its own setup(), before the callback was added.
|
||||
if (this->source_->has_state())
|
||||
this->process_(this->source_->state, now);
|
||||
}
|
||||
|
||||
void ExponentialMovingAverageSensor::dump_config() {
|
||||
LOG_SENSOR("", "Exponential Moving Average Sensor", this);
|
||||
if (this->time_constant_ms_ != 0) {
|
||||
const ScaledDuration time_constant = scale_duration(this->time_constant_ms_);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Time Constant: %.*f %s\n"
|
||||
" Time Weighting: %s",
|
||||
time_constant.decimals, time_constant.value, LOG_STR_ARG(time_constant.unit),
|
||||
LOG_STR_ARG(time_weighting_to_string(this->time_weighting_)));
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Alpha: %.3f", this->alpha_);
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_));
|
||||
}
|
||||
|
||||
void ExponentialMovingAverageSensor::reset() { this->publish_and_save_(NAN); }
|
||||
|
||||
void ExponentialMovingAverageSensor::process_(float value, uint32_t now) {
|
||||
if (std::isnan(value))
|
||||
return;
|
||||
// After a reboot the downtime is unknown, so the first interval is measured from setup().
|
||||
const uint32_t dt = now - this->last_update_;
|
||||
this->last_update_ = now;
|
||||
const float previous = this->previous_value_;
|
||||
this->previous_value_ = value;
|
||||
if (std::isnan(this->accumulator_)) {
|
||||
this->publish_and_save_(value);
|
||||
return;
|
||||
}
|
||||
if (this->time_constant_ms_ == 0) {
|
||||
this->publish_and_save_(this->alpha_ * value + (1.0f - this->alpha_) * this->accumulator_);
|
||||
return;
|
||||
}
|
||||
// Computed in double with expm1(): when the interval is short compared to the time constant, the weights are
|
||||
// tiny and float rounding of exp() would swamp them.
|
||||
const double x = static_cast<double>(dt) / this->time_constant_ms_;
|
||||
// The share of the old average replaced during this interval.
|
||||
const double gain = -std::expm1(-x);
|
||||
const double average = this->accumulator_;
|
||||
// After a reboot there is no previous reading, so only the new value can be used.
|
||||
const TimeWeighting weighting = std::isnan(previous) ? TIME_WEIGHTING_NEW : this->time_weighting_;
|
||||
double result;
|
||||
switch (weighting) {
|
||||
case TIME_WEIGHTING_PREVIOUS:
|
||||
result = average + gain * (previous - average);
|
||||
break;
|
||||
case TIME_WEIGHTING_LINEAR: {
|
||||
// Exact result for a value moving in a straight line from the previous reading to the new one.
|
||||
const double weight_new = x > 0.0 ? (x + std::expm1(-x)) / x : 0.0;
|
||||
result = average + (gain - weight_new) * (previous - average) + weight_new * (value - average);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
result = average + gain * (value - average);
|
||||
break;
|
||||
}
|
||||
this->publish_and_save_(static_cast<float>(result));
|
||||
}
|
||||
|
||||
void ExponentialMovingAverageSensor::publish_and_save_(float value) {
|
||||
this->accumulator_ = value;
|
||||
this->publish_state(value);
|
||||
if (this->restore_)
|
||||
this->pref_.save(&value);
|
||||
}
|
||||
|
||||
} // namespace esphome::exponential_moving_average
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome::exponential_moving_average {
|
||||
|
||||
/// Which value is assumed to apply during the time between two readings, when a time constant is used.
|
||||
enum TimeWeighting : uint8_t {
|
||||
TIME_WEIGHTING_NEW = 0,
|
||||
TIME_WEIGHTING_PREVIOUS,
|
||||
TIME_WEIGHTING_LINEAR,
|
||||
};
|
||||
|
||||
const LogString *time_weighting_to_string(TimeWeighting weighting);
|
||||
|
||||
/// A duration in the largest of ms, s, min or h that keeps the value at 1 or more.
|
||||
struct ScaledDuration {
|
||||
float value;
|
||||
const LogString *unit;
|
||||
uint8_t decimals;
|
||||
};
|
||||
|
||||
ScaledDuration scale_duration(uint32_t ms);
|
||||
|
||||
class ExponentialMovingAverageSensor : public sensor::Sensor, public Component {
|
||||
public:
|
||||
explicit ExponentialMovingAverageSensor(sensor::Sensor *source) : source_(source) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_alpha(float alpha) { this->alpha_ = alpha; }
|
||||
/// When non-zero, each sample is weighted by the time since the previous one instead of by a fixed alpha.
|
||||
void set_time_constant(uint32_t time_constant_ms) { this->time_constant_ms_ = time_constant_ms; }
|
||||
void set_time_weighting(TimeWeighting weighting) { this->time_weighting_ = weighting; }
|
||||
void set_restore(bool restore) { this->restore_ = restore; }
|
||||
/// Clear the average; the next sample starts it again.
|
||||
void reset();
|
||||
|
||||
protected:
|
||||
void process_(float value, uint32_t now);
|
||||
void publish_and_save_(float value);
|
||||
|
||||
sensor::Sensor *source_;
|
||||
ESPPreferenceObject pref_;
|
||||
float alpha_{0.1f};
|
||||
float accumulator_{NAN};
|
||||
float previous_value_{NAN};
|
||||
uint32_t time_constant_ms_{0};
|
||||
uint32_t last_update_{0};
|
||||
TimeWeighting time_weighting_{TIME_WEIGHTING_NEW};
|
||||
bool restore_{true};
|
||||
};
|
||||
|
||||
} // namespace esphome::exponential_moving_average
|
||||
@@ -0,0 +1,103 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACCURACY_DECIMALS,
|
||||
CONF_ALPHA,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_RESTORE,
|
||||
CONF_SENSOR,
|
||||
CONF_STATE_CLASS,
|
||||
CONF_TIME_CONSTANT,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
)
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.types import ConfigType
|
||||
|
||||
exponential_moving_average_ns = cg.esphome_ns.namespace("exponential_moving_average")
|
||||
ExponentialMovingAverageSensor = exponential_moving_average_ns.class_(
|
||||
"ExponentialMovingAverageSensor", sensor.Sensor, cg.Component
|
||||
)
|
||||
|
||||
TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting")
|
||||
TIME_WEIGHTINGS: dict[str, cg.MockObj] = {
|
||||
"new": TimeWeighting.TIME_WEIGHTING_NEW,
|
||||
"previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS,
|
||||
"linear": TimeWeighting.TIME_WEIGHTING_LINEAR,
|
||||
}
|
||||
|
||||
CONF_TIME_WEIGHTING: str = "time_weighting"
|
||||
|
||||
DEFAULT_ALPHA: float = 0.1
|
||||
|
||||
|
||||
def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int:
|
||||
# An average carries more precision than the individual readings.
|
||||
return decimals + 1
|
||||
|
||||
|
||||
def validate_time_weighting(config: ConfigType) -> ConfigType:
|
||||
if CONF_TIME_WEIGHTING in config and CONF_TIME_CONSTANT not in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_TIME_WEIGHTING}' can only be used with '{CONF_TIME_CONSTANT}'",
|
||||
path=[CONF_TIME_WEIGHTING],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
sensor.sensor_schema(ExponentialMovingAverageSensor)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||
cv.Optional(CONF_ALPHA): cv.All(
|
||||
cv.float_, cv.Range(min=0, min_included=False, max=1)
|
||||
),
|
||||
cv.Optional(CONF_TIME_CONSTANT): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_TIME_WEIGHTING): cv.enum(TIME_WEIGHTINGS, lower=True),
|
||||
cv.Optional(CONF_RESTORE, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
cv.has_at_most_one_key(CONF_ALPHA, CONF_TIME_CONSTANT),
|
||||
validate_time_weighting,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
inherit_property_from(CONF_ICON, CONF_SENSOR),
|
||||
inherit_property_from(CONF_UNIT_OF_MEASUREMENT, CONF_SENSOR),
|
||||
inherit_property_from(
|
||||
CONF_ACCURACY_DECIMALS, CONF_SENSOR, transform=inherit_accuracy_decimals
|
||||
),
|
||||
inherit_property_from(CONF_DEVICE_CLASS, CONF_SENSOR),
|
||||
inherit_property_from(CONF_STATE_CLASS, CONF_SENSOR),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
source = await cg.get_variable(config[CONF_SENSOR])
|
||||
var = cg.new_Pvariable(config[CONF_ID], source)
|
||||
await cg.register_component(var, config)
|
||||
await sensor.register_sensor(var, config)
|
||||
|
||||
if (time_constant := config.get(CONF_TIME_CONSTANT)) is not None:
|
||||
cg.add(var.set_time_constant(time_constant))
|
||||
if (weighting := config.get(CONF_TIME_WEIGHTING)) is not None:
|
||||
cg.add(var.set_time_weighting(weighting))
|
||||
else:
|
||||
cg.add(var.set_alpha(config.get(CONF_ALPHA, DEFAULT_ALPHA)))
|
||||
cg.add(var.set_restore(config[CONF_RESTORE]))
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"sensor.exponential_moving_average.reset",
|
||||
automation.maybe_simple_id(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(ExponentialMovingAverageSensor),
|
||||
}
|
||||
),
|
||||
automation.ApplyCall("reset()"),
|
||||
)
|
||||
@@ -41,6 +41,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "font"
|
||||
MULTI_CONF = True
|
||||
AUTO_LOAD = ["unicode"]
|
||||
|
||||
CODEOWNERS = ["@esphome/core", "@clydebarrow"]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "esphome/core/color.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/components/unicode/unicode.h"
|
||||
|
||||
namespace esphome::font {
|
||||
static const char *const TAG = "font";
|
||||
@@ -121,108 +122,6 @@ const Glyph *Font::get_glyph_data_(uint32_t unicode_letter) {
|
||||
}
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string.
|
||||
* If successful, return the codepoint and set the length to the number of bytes read.
|
||||
* If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to
|
||||
* 0.
|
||||
*
|
||||
* @param utf8_str The input string
|
||||
* @param length Pointer to length storage
|
||||
* @return The extracted code point
|
||||
*/
|
||||
static uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) {
|
||||
// Safely cast to uint8_t* for correct bitwise operations on bytes
|
||||
const uint8_t *current = reinterpret_cast<const uint8_t *>(utf8_str);
|
||||
uint32_t code_point = 0;
|
||||
uint8_t c1 = *current++;
|
||||
|
||||
// check for end of string
|
||||
if (c1 == 0) {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- 1-Byte Sequence: 0xxxxxxx (ASCII) ---
|
||||
if (c1 < 0x80) {
|
||||
// Valid ASCII byte.
|
||||
code_point = c1;
|
||||
// Optimization: No need to check for continuation bytes.
|
||||
}
|
||||
// --- 2-Byte Sequence: 110xxxxx 10xxxxxx ---
|
||||
else if ((c1 & 0xE0) == 0xC0) {
|
||||
uint8_t c2 = *current++;
|
||||
|
||||
// Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx)
|
||||
if ((c2 & 0xC0) != 0x80) {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
code_point = (c1 & 0x1F) << 6;
|
||||
code_point |= (c2 & 0x3F);
|
||||
|
||||
// Error Check 2: Overlong check (2-byte must be > 0x7F)
|
||||
if (code_point <= 0x7F) {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
// --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx ---
|
||||
else if ((c1 & 0xF0) == 0xE0) {
|
||||
uint8_t c2 = *current++;
|
||||
uint8_t c3 = *current++;
|
||||
|
||||
// Error Check 1: Check continuation bytes
|
||||
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
code_point = (c1 & 0x0F) << 12;
|
||||
code_point |= (c2 & 0x3F) << 6;
|
||||
code_point |= (c3 & 0x3F);
|
||||
|
||||
// Error Check 2: Overlong check (3-byte must be > 0x7FF)
|
||||
// Also check for surrogates (0xD800-0xDFFF)
|
||||
if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
// --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx ---
|
||||
else if ((c1 & 0xF8) == 0xF0) {
|
||||
uint8_t c2 = *current++;
|
||||
uint8_t c3 = *current++;
|
||||
uint8_t c4 = *current++;
|
||||
|
||||
// Error Check 1: Check continuation bytes
|
||||
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
code_point = (c1 & 0x07) << 18;
|
||||
code_point |= (c2 & 0x3F) << 12;
|
||||
code_point |= (c3 & 0x3F) << 6;
|
||||
code_point |= (c4 & 0x3F);
|
||||
|
||||
// Error Check 2: Overlong check (4-byte must be > 0xFFFF)
|
||||
// Also check for valid Unicode range (must be <= 0x10FFFF)
|
||||
if (code_point <= 0xFFFF || code_point > 0x10FFFF) {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
// --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) ---
|
||||
else {
|
||||
*length = 0;
|
||||
return 0;
|
||||
}
|
||||
*length = current - reinterpret_cast<const uint8_t *>(utf8_str);
|
||||
return code_point;
|
||||
}
|
||||
|
||||
Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
|
||||
uint8_t bpp)
|
||||
: glyphs_(ConstVector(data, data_nr)),
|
||||
@@ -271,7 +170,7 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in
|
||||
int x = 0;
|
||||
for (;;) {
|
||||
size_t length;
|
||||
auto code_point = extract_unicode_codepoint(str, &length);
|
||||
auto code_point = unicode::extract_unicode_codepoint(str, &length);
|
||||
if (length == 0)
|
||||
break;
|
||||
str += length;
|
||||
@@ -300,7 +199,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
|
||||
int x_at = x_start;
|
||||
for (;;) {
|
||||
size_t length;
|
||||
auto code_point = extract_unicode_codepoint(text, &length);
|
||||
auto code_point = unicode::extract_unicode_codepoint(text, &length);
|
||||
if (length == 0)
|
||||
break;
|
||||
text += length;
|
||||
|
||||
@@ -37,7 +37,6 @@ void GreeClimate::transmit_state() {
|
||||
remote_state[1] = this->temperature_();
|
||||
|
||||
if (model == GREE_YAN) {
|
||||
remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN
|
||||
remote_state[3] = 0x50; // bits 4..7 always 0101
|
||||
remote_state[4] = this->vertical_swing_();
|
||||
}
|
||||
@@ -61,7 +60,6 @@ void GreeClimate::transmit_state() {
|
||||
}
|
||||
|
||||
if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
|
||||
remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN
|
||||
remote_state[3] = 0x50; // bits 4..7 always 0101
|
||||
remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010
|
||||
|
||||
|
||||
@@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00;
|
||||
static constexpr uint8_t GREE_PRESET_SLEEP = 0x01;
|
||||
static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80;
|
||||
|
||||
// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9.
|
||||
static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10;
|
||||
static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
|
||||
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
|
||||
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
|
||||
|
||||
// Model codes
|
||||
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG };
|
||||
|
||||
@@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR {
|
||||
uint8_t preset_();
|
||||
|
||||
Model model_{};
|
||||
uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2]
|
||||
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
|
||||
};
|
||||
|
||||
} // namespace esphome::gree
|
||||
|
||||
@@ -17,12 +17,22 @@ CONF_HEALTH = "health"
|
||||
CONF_XFAN = "xfan"
|
||||
CONF_GREE_ID = "gree_id"
|
||||
|
||||
# Switch configurations: (config_key, display_name, bit_mask, icon)
|
||||
# Switch configurations: (config_key, display_name, mode_bit, icon)
|
||||
SWITCH_CONFIGS = (
|
||||
(CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"),
|
||||
(CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"),
|
||||
(CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"),
|
||||
(CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"),
|
||||
(
|
||||
CONF_TURBO,
|
||||
"Gree Turbo Switch",
|
||||
gree_ns.GREE_MODE_BIT_TURBO,
|
||||
"mdi:car-turbocharger",
|
||||
),
|
||||
(CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"),
|
||||
(CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"),
|
||||
(
|
||||
CONF_XFAN,
|
||||
"Gree X-FAN Switch",
|
||||
gree_ns.GREE_MODE_BIT_XFAN,
|
||||
"mdi:wall-sconce-flat",
|
||||
),
|
||||
)
|
||||
|
||||
SUPPORTED_MODELS = {
|
||||
@@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
cv.Optional(key): switch.switch_schema(
|
||||
GreeModeBitSwitch,
|
||||
icon=icon,
|
||||
default_restore_mode="RESTORE_DEFAULT_OFF",
|
||||
# The display light was on before the switches existed, so it stays on by default
|
||||
default_restore_mode="RESTORE_DEFAULT_ON"
|
||||
if key == CONF_LIGHT
|
||||
else "RESTORE_DEFAULT_OFF",
|
||||
device_class=DEVICE_CLASS_SWITCH,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
)
|
||||
|
||||
@@ -187,15 +187,18 @@ void HeatpumpIRClimate::transmit_state() {
|
||||
swing_h_cmd = HDIR_SWING;
|
||||
}
|
||||
|
||||
// MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto,
|
||||
// so low/medium/high have to start at FAN_1 there instead of FAN_2.
|
||||
const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC;
|
||||
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
|
||||
case climate::CLIMATE_FAN_LOW:
|
||||
fan_speed_cmd = FAN_2;
|
||||
fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2;
|
||||
break;
|
||||
case climate::CLIMATE_FAN_MEDIUM:
|
||||
fan_speed_cmd = FAN_3;
|
||||
fan_speed_cmd = three_speed_fan ? FAN_2 : FAN_3;
|
||||
break;
|
||||
case climate::CLIMATE_FAN_HIGH:
|
||||
fan_speed_cmd = FAN_4;
|
||||
fan_speed_cmd = three_speed_fan ? FAN_3 : FAN_4;
|
||||
break;
|
||||
case climate::CLIMATE_FAN_AUTO:
|
||||
default:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user