Merge remote-tracking branch 'origin/dev' into light-effect-color-tables

# Conflicts:
#	esphome/cpp_generator.py
This commit is contained in:
J. Nick Koston
2026-10-07 12:26:51 -10:00
379 changed files with 17741 additions and 2698 deletions
+14 -8
View File
@@ -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
+6
View File
@@ -187,6 +187,7 @@ 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
@@ -246,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
@@ -283,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
@@ -653,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
+27 -1
View File
@@ -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
+8 -1
View File
@@ -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(
+1
View File
@@ -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,
+214 -81
View File
@@ -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
@@ -293,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(
{
@@ -320,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)),
@@ -375,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,
)
@@ -431,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(
@@ -586,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(
@@ -621,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)
@@ -661,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:
@@ -695,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),
@@ -710,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.
@@ -731,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")
@@ -812,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,
}
)
@@ -833,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(
@@ -888,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"
@@ -1007,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",
}
)
+176 -2
View File
@@ -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,6 +78,7 @@ 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) {}
}
@@ -113,6 +116,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 +236,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 +341,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 +393,11 @@ 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 DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
@@ -389,6 +407,90 @@ 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"];
}
// ==================== 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": "...",
// "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.
//
// 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";
// Not logged: the data is in flash, which ESP8266 cannot read for a dump
option (log) = false;
// The data is in flash, and the device sends it from there
bytes data = 1 [(pointer_to_buffer) = 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 or whose entity_id
// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id 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) = 255];
}
message ListEntitiesRequest {
@@ -2683,7 +2785,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 +2799,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 +2808,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 +3000,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;
+93 -6
View File
@@ -198,6 +198,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
}
@@ -1517,8 +1528,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
@@ -1528,6 +1541,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;
}
@@ -1540,13 +1554,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
@@ -1554,6 +1593,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
@@ -1645,6 +1685,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()) {
@@ -1816,7 +1872,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 = 18;
// 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());
@@ -1839,6 +1895,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);
}
@@ -1961,6 +2030,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;
@@ -2013,6 +2085,9 @@ 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
return this->send_message(resp);
}
@@ -2227,7 +2302,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);
@@ -2271,8 +2352,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
@@ -2670,6 +2750,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);
+49 -4
View File
@@ -233,9 +233,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 +246,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 +279,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 +314,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 +361,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 +394,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);
@@ -390,6 +423,9 @@ class APIConnection final : public APIServerConnectionBase {
bool send_ping_response_();
bool send_device_info_response_();
bool send_device_capabilities_response_();
#ifdef USE_API_WIZARD
bool send_device_wizard_response_();
#endif
#ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif
@@ -734,12 +770,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());
+2 -1
View File
@@ -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_();
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+90 -1
View File
@@ -143,6 +143,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::S
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232");
case enums::SERIAL_PROXY_PORT_TYPE_RS485:
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485");
case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL:
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -890,13 +892,40 @@ 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");
}
}
const char *HelloRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest"));
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 +1049,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 +1078,13 @@ 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
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
#ifdef USE_BLUETOOTH_PROXY
@@ -1069,9 +1108,29 @@ 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
return out.c_str();
}
#ifdef USE_API_WIZARD
const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse"));
dump_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 +2773,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 +2802,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 +2894,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 {
+30 -1
View File
@@ -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,35 @@ 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
default:
break;
+12 -1
View File
@@ -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,6 +245,10 @@ 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
+126 -49
View File
@@ -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;
+29 -1
View File
@@ -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};
@@ -360,6 +381,10 @@ class APIServer final : public Component
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};
@@ -375,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)
+103
View File
@@ -0,0 +1,103 @@
#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";
uint8_t *wizard_encode_response(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const DeviceWizardResponse *>(self);
if (msg.data_len == 0)
return pos;
pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited
pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len);
PROTO_ENCODE_CHECK_BOUNDS(pos, msg.data_len);
progmem_memcpy(pos, msg.data, msg.data_len);
return pos + msg.data_len;
}
#ifdef USE_API_WIZARD_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) {
if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) {
ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input");
return nullptr;
}
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;
memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size());
entry.entity_id[msg.entity_id.size()] = '\0';
return entry.entity_id;
}
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input");
return nullptr;
}
#endif // USE_API_WIZARD_INPUTS
bool APIConnection::send_device_wizard_response_() {
DeviceWizardResponse resp;
resp.data = API_WIZARD_DATA;
resp.data_len = API_WIZARD_DATA_SIZE;
// Not send_message: the data is in flash, so wizard_encode_response copies it out
return this->send_message_(DeviceWizardResponse::calc_size_msg(&resp), DeviceWizardResponse::MESSAGE_TYPE,
&wizard_encode_response, &resp);
}
void APIConnection::on_device_wizard_request() {
if (!this->send_device_wizard_response_()) {
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
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API_WIZARD
#include <cstddef>
#include <cstdint>
#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;
/// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read
/// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere.
uint8_t *wizard_encode_response(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
#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 of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity
};
/// 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.
StringRef entity_id() const { return StringRef(this->entity_id_); }
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
protected:
const char *entity_id_;
};
#endif
} // namespace esphome::api
#endif // USE_API_WIZARD
+98 -169
View File
@@ -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
+28 -42
View File
@@ -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
+30 -41
View File
@@ -231,6 +231,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
@@ -263,21 +265,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; }
@@ -288,7 +275,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
@@ -313,6 +299,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.
@@ -575,22 +564,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
@@ -704,9 +707,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; }
@@ -960,18 +961,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.
+461
View File
@@ -0,0 +1,461 @@
"""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 types import ModuleType
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_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
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
)
# 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),
}
)
# 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_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),
)
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")
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 zstd_module() -> ModuleType:
"""The zstd module: the standard library one from Python 3.14, otherwise the backport."""
try:
return importlib.import_module("compression.zstd")
except ImportError:
return importlib.import_module("backports.zstd")
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 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": "...",
"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.
- 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)
cg.add_global(
cg.RawStatement(
f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";'
)
)
if CONF_ID in conf:
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}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))
+2 -3
View File
@@ -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()
@@ -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 -13
View File
@@ -13,7 +13,6 @@ from esphome.const import (
CONF_VALUE,
CONF_WAIT_TIME,
)
from esphome.core import ID
CODEOWNERS = ["@lygris", "@gabest11"]
DEPENDENCIES = ["spi"]
@@ -384,7 +383,10 @@ def validate_raw_data(value):
SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(CC1101Component),
cv.Required(CONF_DATA): cv.templatable(validate_raw_data),
# TX FIFO size
cv.Required(CONF_DATA): cv.templatable(
cv.All(validate_raw_data, cv.Length(max=64))
),
},
key=CONF_DATA,
)
@@ -399,17 +401,13 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
async def send_packet_action_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
data = config[CONF_DATA]
if isinstance(data, bytes):
data = list(data)
if cg.is_template(data):
templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8))
cg.add(var.set_data_template(templ))
else:
# Generate static array in flash to avoid RAM copy
arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data))
cg.add(var.set_data_static(arr, len(data)))
await automation.templatable_bytes(
config[CONF_DATA],
args,
var.set_data_template,
var.set_data_static,
"cc1101_data",
)
return var
+24 -4
View File
@@ -3,10 +3,16 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cmath>
#include <cstring>
#ifdef USE_ESP32
#include <esp_memory_utils.h>
#endif
namespace esphome::cc1101 {
static const char *const TAG = "cc1101";
static constexpr size_t TX_FIFO_SIZE = 64;
static void split_float(float value, int mbits, uint8_t &e, uint32_t &m) {
int e_tmp;
@@ -379,18 +385,32 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) {
this->disable();
}
CC1101Error CC1101Component::transmit_packet(const std::vector<uint8_t> &packet) {
CC1101Error CC1101Component::transmit_packet(const uint8_t *data, size_t len) {
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
return CC1101Error::PARAMS;
}
const bool variable = this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
// In variable length mode the FIFO also holds the length byte
if (len + (variable ? 1 : 0) > TX_FIFO_SIZE) {
ESP_LOGE(TAG, "Packet of %u bytes does not fit the TX FIFO", static_cast<unsigned>(len));
return CC1101Error::PARAMS;
}
// Write packet
this->enter_idle_();
this->strobe_(Command::FTX);
if (this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE)) {
this->write_(Register::FIFO, static_cast<uint8_t>(packet.size()));
if (variable) {
this->write_(Register::FIFO, static_cast<uint8_t>(len));
}
this->write_(Register::FIFO, packet.data(), packet.size());
#ifdef USE_ESP32
// SPI DMA cannot read flash or unaligned buffers; stage them here so the driver does not allocate a copy
alignas(4) uint8_t staged[TX_FIFO_SIZE];
if (len != 0 && (!esp_ptr_dma_capable(data) || (reinterpret_cast<uintptr_t>(data) & 3) != 0)) {
std::memcpy(staged, data, len);
data = staged;
}
#endif
this->write_(Register::FIFO, data, len);
// Calibrate PLL
if (!this->enter_calibrated_(State::FSTXON, Command::FSTXON)) {
+6 -19
View File
@@ -89,7 +89,10 @@ class CC1101Component final : public Component,
void set_whitening(bool value);
// Packet mode operations
CC1101Error transmit_packet(const std::vector<uint8_t> &packet);
CC1101Error transmit_packet(const std::vector<uint8_t> &packet) {
return this->transmit_packet(packet.data(), packet.size());
}
CC1101Error transmit_packet(const uint8_t *data, size_t len);
void register_listener(CC1101Listener *listener) { this->listeners_.push_back(listener); }
Trigger<std::vector<uint8_t>, float, float, uint8_t> *get_packet_trigger() { return &this->packet_trigger_; }
@@ -131,27 +134,11 @@ class CC1101Component final : public Component,
// Action Wrappers
template<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<CC1101Component> {
public:
void set_data_template(std::function<std::vector<uint8_t>(Ts...)> func) { this->data_func_ = func; }
void set_data_static(const uint8_t *data, size_t len) {
this->data_static_ = data;
this->data_static_len_ = len;
}
TEMPLATABLE_BYTES(data)
void play(const Ts &...x) override {
if (this->data_func_) {
auto data = this->data_func_(x...);
this->parent_->transmit_packet(data);
} else if (this->data_static_ != nullptr) {
std::vector<uint8_t> data(this->data_static_, this->data_static_ + this->data_static_len_);
this->parent_->transmit_packet(data);
}
this->data_.visit([this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
}
protected:
std::function<std::vector<uint8_t>(Ts...)> data_func_{};
const uint8_t *data_static_{nullptr};
size_t data_static_len_{0};
};
} // namespace esphome::cc1101
+6
View File
@@ -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_; };
+232 -94
View File
@@ -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
@@ -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:
@@ -3149,6 +3311,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
@@ -3219,38 +3385,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)
@@ -3260,12 +3394,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.
@@ -3298,6 +3434,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
+1
View File
@@ -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"
+185
View File
@@ -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
+305
View File
@@ -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
+110
View File
@@ -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
+3 -11
View File
@@ -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
+4 -4
View File
@@ -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]))
+5 -3
View File
@@ -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) {
+201 -135
View File
@@ -1,6 +1,7 @@
#include "hoermann_hcp.h"
#include <algorithm>
#include <utility>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -16,20 +17,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b
static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away.
static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the
// rest names the button - the low byte for the door commands, the second register for those that do not fit
// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each.
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
// The intermediate positions are named in the second register, so the first only carries the phase.
static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000};
static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400};
// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0110};
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0120};
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0140};
static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0100, 0x4000};
static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0100, 0x0400};
// Absolute, as a vendor gateway sends them, so a late or repeated one cannot switch the lamp the wrong way.
static constexpr HoermannHcpCommand COMMAND_LIGHT_ON{"light on", 0x0880};
static constexpr HoermannHcpCommand COMMAND_LIGHT_OFF{"light off", 0x0800, 0x0100};
// Kept as the intent, as the same impulse starts a door at rest; only a door still moving at the fetch gets it.
static constexpr HoermannHcpCommand COMMAND_STOP{"stop", 0x0140};
// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
// its low byte tells a plain stop from the vent position.
@@ -63,9 +61,24 @@ static bool is_moving(DoorState state) {
}
}
#ifdef USE_HOERMANN_HCP_IDENTITY
// The command byte of a status poll. Only its answer can carry a request.
static bool at_destination(const HoermannHcpCommand &command, DoorState state) {
if (&command == &COMMAND_OPEN)
return state == DoorState::OPEN;
if (&command == &COMMAND_CLOSE)
return state == DoorState::CLOSED;
if (&command == &COMMAND_VENT)
return state == DoorState::VENT;
if (&command == &COMMAND_HALF_OPEN)
return state == DoorState::HALF_OPEN;
return false;
}
// Only a status poll's answer carries commands.
static constexpr uint8_t STATUS_COMMAND = 0x03;
// A second stop this soon after one went out is ignored, so a double press cannot restart the door.
static constexpr uint32_t STOP_LOCK_MS = 500;
#ifdef USE_HOERMANN_HCP_IDENTITY
// A status answer with this code in the low byte of its second register asks the bus controller for a value,
// named in the high byte of the third.
static constexpr uint8_t ANSWER_REQUEST = 0x22;
@@ -125,29 +138,40 @@ void HoermannHcp::update() {
this->set_valid_(false);
// Status broadcasts alone keep the connection alive, so a command the controller never fetches would
// otherwise block every later one for as long as it keeps broadcasting.
if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
// Dropping after the press was presented leaves the door without its release value, which is worth saying
// apart from a command the controller never looked at.
if (this->command_written_at_ != 0) {
ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press",
this->next_command_->name);
} else {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
}
// A stop held for a door that has not reported its start yet is timed by the start window instead.
const bool stop_held = this->next_command_ == &COMMAND_STOP && this->starting_;
if (this->next_command_ != nullptr && !stop_held && now - this->command_queued_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
this->drop_command_();
// Children may have assumed the command would land, so let them re-derive from the door.
this->changed_ = true;
}
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) {
// A target the door never started towards would otherwise cut a later move short.
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->start_window_ms_) {
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
this->clear_target_();
}
// The door took the lamp key press but never reported the lamp changing, so stop expecting it to.
if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle");
this->forget_light_toggles_();
// A door that never answers a fetched command is at rest after all.
if (this->starting_ && now - this->start_fetched_at_ > this->start_window_ms_) {
this->starting_ = false;
if (stop_held) {
ESP_LOGW(TAG, "Door did not report moving, dropping the stop");
this->next_command_ = nullptr;
} else {
ESP_LOGD(TAG, "Door did not start after the command");
}
}
// A lamp command held while the door starts or moves waits on purpose, so its deadline starts once the door rests.
if (this->is_moving_or_starting_() && this->light_requested_ && !this->light_command_sent_)
this->light_since_ = now;
// Neither fire late nor block the next request.
if (this->light_requested_ && now - this->light_since_ > this->connection_timeout_ms_) {
if (this->light_command_sent_) {
ESP_LOGW(TAG, "Door did not report the lamp changing, giving up");
} else {
ESP_LOGW(TAG, "Bus controller did not fetch the lamp command, dropping it");
}
this->clear_light_request_();
}
#ifdef USE_HOERMANN_HCP_IDENTITY
this->publish_identity_();
@@ -179,6 +203,7 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add
}
this->record_response_();
const bool status_poll = std::exchange(this->status_poll_pending_, false);
#ifdef USE_HOERMANN_HCP_IDENTITY
// Acknowledge the transfer taken by the write half of this frame.
@@ -198,7 +223,11 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add
// Command request: return the internal state, injecting any pending command.
registers.push_back(counter);
registers.push_back(static_cast<uint16_t>(0x0001 | command));
this->push_command_registers_(registers);
if (status_poll && static_cast<uint8_t>(this->command_reg_value_) == STATUS_COMMAND) {
this->push_command_registers_(registers);
} else {
push_zeros(registers, 2);
}
push_zeros(registers, 4);
#ifdef USE_HOERMANN_HCP_IDENTITY
this->add_identity_request_(registers, command);
@@ -234,6 +263,7 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
// command byte back from STATE_REG. The hub always runs the write before the read within one request.
this->record_response_();
this->command_reg_value_ = registers[0];
this->status_poll_pending_ = true;
#ifdef USE_HOERMANN_HCP_IDENTITY
this->transfer_answer_counter_ = this->take_identity_transfer_(registers);
#endif
@@ -268,34 +298,62 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
}
void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
const HoermannHcpCommand *command = this->next_command_;
const HoermannHcpCommand *command = this->take_command_();
if (command == nullptr)
command = this->take_light_command_();
if (command == nullptr) {
push_zeros(registers, 2);
return;
}
if (this->command_written_at_ == 0) {
// First read after the command was queued: present the "key pressed" values.
this->command_written_at_ = millis();
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
registers.push_back(command->pressed_value);
registers.push_back(command->pressed_value_2);
return;
}
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
// Between the two events there is nothing to report, including in the second register.
push_zeros(registers, 2);
return;
}
// Enough time passed: present the "key released" values and clear the command.
ESP_LOGD(TAG, "Released '%s' command", command->name);
this->command_written_at_ = 0;
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
registers.push_back(command->value);
registers.push_back(command->value_2);
}
const HoermannHcpCommand *HoermannHcp::take_command_() {
const HoermannHcpCommand *command = this->next_command_;
if (command == nullptr)
return nullptr;
const bool moving = is_moving(this->door_state_);
// The door was just told to start and has not said so yet, so an impulse now could start it instead.
if (command == &COMMAND_STOP && this->starting_ && !moving)
return nullptr;
this->next_command_ = nullptr;
// A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left
// to wait for, so it must not re-arm the watchdog.
if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0)
this->light_toggle_released_at_ = millis();
registers.push_back(command->released_value);
registers.push_back(command->released_value_2);
if (moving) {
// The door may have been started from elsewhere since the command was queued.
if ((command == &COMMAND_OPEN && this->door_state_ == DoorState::OPENING) ||
(command == &COMMAND_CLOSE && this->door_state_ == DoorState::CLOSING)) {
ESP_LOGD(TAG, "Door is already moving that way, dropping '%s'", command->name);
return nullptr;
}
if (command != &COMMAND_STOP) {
ESP_LOGD(TAG, "Door is moving, stopping it instead of '%s'", command->name);
}
this->last_stop_at_ = millis();
this->stop_sent_ = true;
return &COMMAND_STOP;
}
if (command == &COMMAND_STOP) {
ESP_LOGD(TAG, "Door came to rest before the stop was fetched, dropping it");
return nullptr;
}
// Until the door answers, it still reads as at rest.
if (!this->door_state_seen_ || !at_destination(*command, this->door_state_)) {
this->starting_ = true;
this->start_command_ = command;
this->start_fetched_at_ = millis();
}
return command;
}
const HoermannHcpCommand *HoermannHcp::take_light_command_() {
// The motor ignores the lamp while its door moves and may switch it itself as it starts, so the lamp waits for rest.
if (!this->light_requested_ || this->light_command_sent_ || this->is_moving_or_starting_() ||
this->light_target_ == this->light_on_)
return nullptr;
this->light_command_sent_ = true;
this->light_since_ = millis();
return this->light_target_ ? &COMMAND_LIGHT_ON : &COMMAND_LIGHT_OFF;
}
#ifdef USE_HOERMANN_HCP_IDENTITY
@@ -308,8 +366,8 @@ void HoermannHcp::add_identity_request_(modbus::RegisterValues &registers, uint1
this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_SERIAL);
return;
}
// Uses the registers of a key press, so it waits while one is pending.
if (this->next_command_ != nullptr || registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_())
// Uses the command registers, so it waits while a command is pending.
if (registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_())
return;
registers[1] = static_cast<uint16_t>(ANSWER_REQUEST | command);
registers[2] = encode_uint16(this->identity_request_(), 0);
@@ -505,56 +563,57 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name);
return false;
}
// A stop still waiting for a door that has come to rest would be dropped at the fetch anyway.
if (this->next_command_ == &COMMAND_STOP)
this->next_command_ = nullptr;
if (this->next_command_ != nullptr) {
ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller");
return false;
}
// A new command supersedes any half-open target the door was still travelling to.
if (command.clears_target)
this->clear_target_();
this->clear_target_();
this->next_command_ = &command;
this->command_queued_at_ = millis();
return true;
}
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); }
bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); }
bool HoermannHcp::toggle_light() {
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
bool HoermannHcp::is_moving_or_starting_() const { return this->starting_ || is_moving(this->door_state_); }
bool HoermannHcp::command_door_(const HoermannHcpCommand &command) {
// Only stopped, so it is never reversed at speed. take_command_() drops one queued before the door started the same
// way.
if (this->is_moving_or_starting_())
return this->stop_door();
return this->queue_command_(command);
}
bool HoermannHcp::open_door() { return this->command_door_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->command_door_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->command_door_(COMMAND_IMPULSE); }
bool HoermannHcp::vent_door() { return this->command_door_(COMMAND_VENT); }
bool HoermannHcp::half_open_door() { return this->command_door_(COMMAND_HALF_OPEN); }
bool HoermannHcp::stop_door() {
this->clear_target_();
// A stop outranks whatever is still waiting, so a door at rest does not start after the user pressed stop.
if (this->next_command_ != nullptr && this->next_command_ != &COMMAND_STOP) {
ESP_LOGD(TAG, "Stop cancels the unfetched '%s' command", this->next_command_->name);
this->next_command_ = nullptr;
}
if (!this->is_moving_or_starting_())
return true;
if (!this->valid_) {
ESP_LOGW(TAG, "Not connected to the bus controller, dropping 'stop' command");
return false;
}
if (!this->queue_command_(COMMAND_TOGGLE_LAMP))
return false;
this->light_toggles_in_flight_++;
return true;
}
bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; }
uint8_t HoermannHcp::unsent_light_toggles_() const {
return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0;
}
bool HoermannHcp::cancel_light_toggle() {
// Once the pressed value has been presented the key press is already on the wire, so only an untouched
// command can be withdrawn.
if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0)
return false;
ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name);
this->drop_command_();
return true;
}
bool HoermannHcp::stop_door() {
if (!is_moving(this->door_state_)) {
this->clear_target_();
if (this->next_command_ == &COMMAND_STOP)
return true;
if (this->stop_sent_ && millis() - this->last_stop_at_ < STOP_LOCK_MS) {
ESP_LOGD(TAG, "Door already stopping, ignoring stop");
return true;
}
// On success queue_command_() clears the target; on refusal it stays armed so the next position retries.
return this->queue_command_(COMMAND_IMPULSE);
this->next_command_ = &COMMAND_STOP;
this->command_queued_at_ = millis();
return true;
}
bool HoermannHcp::set_position(float position) {
@@ -563,8 +622,8 @@ bool HoermannHcp::set_position(float position) {
return this->close_door();
if (position >= OPEN_POSITION_THRESHOLD)
return this->open_door();
// Asking the door to travel to where it already is means stopping it.
if (position == this->current_position_)
// Asking the door to travel to where it already is, or anywhere while it moves, means stopping it.
if (position == this->current_position_ || this->is_moving_or_starting_())
return this->stop_door();
// The door itself has no notion of a target, so it is started in the right direction and stopped on the way.
@@ -574,8 +633,6 @@ bool HoermannHcp::set_position(float position) {
this->target_position_ = position;
this->target_queued_at_ = millis();
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
// A door already travelling that way is on its way; one moving the other way has to turn around first.
this->target_started_ = this->door_state_ == this->target_direction_;
return true;
}
@@ -596,9 +653,8 @@ void HoermannHcp::set_valid_(bool valid) {
ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
// Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
this->drop_command_();
// The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards.
this->clear_target_();
this->forget_light_toggles_();
this->starting_ = false;
this->clear_light_request_();
// The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
this->set_light_seen_(false);
// The same holds for the door. The next broadcast is decoded even if it repeats the last one.
@@ -608,38 +664,8 @@ void HoermannHcp::set_valid_(bool valid) {
}
void HoermannHcp::drop_command_() {
const bool was_light_toggle = this->is_light_toggle_pending_();
// Cleared first so the settling below no longer counts this command among the toggles still to be sent.
this->next_command_ = nullptr;
this->command_written_at_ = 0;
if (was_light_toggle) {
// A lamp toggle says nothing about where the door was going, so it leaves the target alone.
this->light_toggle_settled_();
} else {
this->clear_target_();
}
}
void HoermannHcp::light_toggle_settled_() {
if (this->light_toggles_in_flight_ == 0)
return;
this->light_toggles_in_flight_--;
// Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for.
if (this->light_toggles_in_flight_ == this->unsent_light_toggles_())
this->light_toggle_released_at_ = 0;
// The light was showing where the lamp was heading, so it has to be told to look again.
this->changed_ = true;
}
void HoermannHcp::forget_light_toggles_() {
// Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever.
this->light_toggle_released_at_ = 0;
// A toggle the door has not been shown yet is still going to fire, so it keeps counting.
const uint8_t unsent = this->unsent_light_toggles_();
if (this->light_toggles_in_flight_ == unsent)
return;
this->light_toggles_in_flight_ = unsent;
this->changed_ = true;
this->clear_target_();
}
void HoermannHcp::set_door_state_(DoorState state) {
@@ -648,10 +674,21 @@ void HoermannHcp::set_door_state_(DoorState state) {
this->door_state_seen_ = true;
this->changed_ = true;
}
// Only moving or the command's destination answers it, even as a first report that changes nothing.
if (this->starting_ && (is_moving(state) || at_destination(*this->start_command_, state))) {
// A stop held for the start is due now, so its fetch deadline starts here.
if (this->next_command_ == &COMMAND_STOP)
this->command_queued_at_ = millis();
this->starting_ = false;
}
if (this->door_state_ == state)
return;
this->door_state_ = state;
this->changed_ = true;
if (!is_moving(state)) {
// A door at rest cannot be restarted by a second stop, as stop_door() sends nothing then.
this->stop_sent_ = false;
}
this->update_current_position_();
if (!this->has_target_())
return;
@@ -688,19 +725,48 @@ void HoermannHcp::set_light_on_(bool on) {
return;
this->light_on_ = on;
this->changed_ = true;
if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) {
// The door has not been shown a toggle that could explain this, so the lamp was switched at the door.
if (!this->light_requested_) {
ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on));
return;
}
// The door acted, so one of the toggles it has seen has arrived. Any others still count.
this->light_toggle_settled_();
if (on == this->light_target_) {
this->clear_light_request_();
return;
}
// Switched away from the target while the command was out: send again.
this->light_command_sent_ = false;
this->light_since_ = millis();
}
bool HoermannHcp::set_light(bool on) {
// A known lamp implies a live connection.
if (!this->light_seen_)
return false;
this->light_target_ = on;
const bool was_requested = this->light_requested_;
// A sent command may still switch it away.
this->light_requested_ = on != this->light_on_ || this->light_command_sent_;
// The deadline belongs to the request, so more taps cannot keep it alive.
if (!was_requested)
this->light_since_ = millis();
this->changed_ = true;
return true;
}
void HoermannHcp::clear_light_request_() {
if (!this->light_requested_)
return;
this->light_requested_ = false;
this->light_command_sent_ = false;
this->changed_ = true;
}
void HoermannHcp::set_light_seen_(bool seen) {
if (this->light_seen_ == seen)
return;
this->light_seen_ = seen;
if (!seen)
this->clear_light_request_();
// A resting door changes nothing else, so without this the light would never hear about it.
this->changed_ = true;
}
+29 -36
View File
@@ -47,17 +47,11 @@ enum class IdentityPhase : uint8_t {
};
#endif
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
// short delay the released value. Each half also carries a second register, which names the buttons that do
// not fit into the first.
// Sent once, in a single status answer, as Hoermann's own bus accessory does.
struct HoermannHcpCommand {
const char *name;
uint16_t pressed_value;
uint16_t released_value;
uint16_t pressed_value_2{0x0000};
uint16_t released_value_2{0x0000};
// A door command supersedes a half-open target; the lamp has no bearing on where the door is going.
bool clears_target{true};
uint16_t value;
uint16_t value_2{0x0000};
};
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
@@ -92,7 +86,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
bool half_open_door();
bool stop_door();
bool set_position(float position);
bool toggle_light();
// False while the door has not reported the lamp.
bool set_light(bool on);
DoorState get_door_state() const { return this->door_state_; }
// False until a broadcast has carried a state the door is known to report. Bus traffic alone makes the
@@ -104,29 +99,20 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
// False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection
// valid without saying anything about the lamp, so is_light_on() would still be its default.
bool is_light_known() const { return this->light_seen_; }
// Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the
// lamp still reads as its old self, so this is what a request has to be judged against.
bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); }
// Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright
// instead of fighting it. Returns false if there is nothing to cancel.
bool cancel_light_toggle();
// The requested state while switching, else the reported one.
bool is_light_heading_on() const { return this->light_requested_ ? this->light_target_ : this->light_on_; }
protected:
// True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert.
bool is_light_toggle_pending_() const;
// Toggles the door has not been shown yet, which is at most the one still waiting in the command slot.
uint8_t unsent_light_toggles_() const;
void record_response_();
bool command_door_(const HoermannHcpCommand &command);
// Returns false when the bus controller has not fetched the previous command yet.
bool queue_command_(const HoermannHcpCommand &command);
// Throws away the pending command, taking any armed target with it unless the command was the lamp toggle.
void drop_command_();
// One outstanding toggle reached the lamp, was withdrawn, or was thrown away.
void light_toggle_settled_();
// Stops expecting the toggles the door has already been shown to reach the lamp.
void forget_light_toggles_();
// Appends the two key-press registers and advances the pending command's press/release state.
void clear_light_request_();
void push_command_registers_(modbus::RegisterValues &registers);
// Decide at the fetch what goes into a status answer, against the door as it stands then.
const HoermannHcpCommand *take_command_();
const HoermannHcpCommand *take_light_command_();
void on_position_reg_(uint16_t value);
void on_state_reg_(uint16_t value);
void on_light_reg_(uint16_t value);
@@ -151,6 +137,7 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
// Recomputes the reported position from position_raw_ and the current door state.
void update_current_position_();
bool has_target_() const { return this->target_position_ != 0.0f; }
bool is_moving_or_starting_() const;
void clear_target_();
void set_light_on_(bool on);
void set_light_seen_(bool seen);
@@ -161,21 +148,24 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
// Position the door was told to travel to; 0.0 means no target is armed.
float target_position_{0.0f};
// Pending command / key-press state machine.
const HoermannHcpCommand *next_command_{nullptr};
const HoermannHcpCommand *start_command_{nullptr};
uint32_t command_queued_at_{0};
// Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline.
uint32_t target_queued_at_{0};
uint32_t command_written_at_{0};
uint32_t last_response_{0};
// When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the
// wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline.
uint32_t light_toggle_released_at_{0};
// Start of the wait for the fetch, then for the report.
uint32_t light_since_{0};
uint32_t last_stop_at_{0};
uint32_t start_fetched_at_{0};
bool stop_sent_{false};
// A door command was fetched and the door has not answered it by moving or reaching its destination yet.
bool starting_{false};
// A command is "pressed" for this long before its end value is sent.
uint16_t key_press_delay_ms_{100};
// Drop the "connected" flag if the bus controller has not polled us for this long.
uint16_t connection_timeout_ms_{2000};
// A chosen margin for a door to report moving after a fetched command.
uint16_t start_window_ms_{5000};
// The state starts on a value the bus controller never reports, so the first broadcast is decoded even when
// it reads 0x0000.
uint16_t prev_state_reg_{0xFFFF};
@@ -184,19 +174,22 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
uint16_t command_reg_value_{0};
DoorState door_state_{DoorState::CLOSED};
// Direction the door was started in for the current target. A target armed while the door is still travelling
// the other way must not be judged by the reported direction until the door has turned around.
// Direction the door was started in for the current target, judged only once the door reports moving that way.
DoorState target_direction_{DoorState::STOPPED};
// Position as reported by the bus controller, 0..200 across the full travel.
uint8_t position_raw_{0};
uint8_t light_toggles_in_flight_{0};
bool target_started_{false};
bool valid_{false};
bool changed_{false};
bool light_on_{false};
bool light_seen_{false};
bool light_requested_{false};
bool light_command_sent_{false};
bool light_target_{false};
bool door_state_seen_{false};
bool short_broadcast_logged_{false};
// Only the read half right after a 0x17 write carries a command, so a second read without a new write does not.
bool status_poll_pending_{false};
#ifdef USE_HOERMANN_HCP_IDENTITY
uint32_t identity_asked_at_{0};
@@ -37,15 +37,10 @@ void HoermannHcpLight::write_state(light::LightState *state) {
if (restored) {
ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing");
} else if (published != binary) {
if (!this->parent_->is_light_known()) {
// Commanding a lamp that has not been read could switch off one that is already on.
ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state");
} else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) {
// A toggle the controller has not fetched is withdrawn outright rather than fought with a second one.
// Refused until the door has reported the lamp, as commanding an unread one could switch off a lit lamp.
if (this->parent_->set_light(binary))
return;
} else {
ESP_LOGW(TAG, "Light command was not accepted by the door");
}
ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state");
}
// Nothing was sent, so the entity has to go back to showing the lamp rather than the request.
this->publish_lamp_state_(heading_on);
+31 -7
View File
@@ -2,8 +2,9 @@ from collections.abc import Callable, Iterable
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL
from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@OttoWinter", "@esphome/core"]
@@ -14,10 +15,12 @@ def validate_entity_domain(
platform: str, supported_domains: Iterable[str]
) -> Callable[[ConfigType], ConfigType]:
def validator(config: ConfigType) -> ConfigType:
domain = config[CONF_ENTITY_ID].split(".", 1)[0]
if domain not in supported_domains:
# A wizard input supplies the entity ID later; the wizard checks its domains
if (entity_id := config.get(CONF_ENTITY_ID)) is None:
return config
if entity_id.split(".", 1)[0] not in supported_domains:
raise cv.Invalid(
f"Entity ID {config[CONF_ENTITY_ID]} is not supported by the {platform} platform."
f"Entity ID {entity_id} is not supported by the {platform} platform."
)
return config
@@ -26,7 +29,7 @@ def validate_entity_domain(
HOME_ASSISTANT_IMPORT_SCHEMA = cv.Schema(
{
cv.Required(CONF_ENTITY_ID): cv.entity_id,
cv.Optional(CONF_ENTITY_ID): cv.entity_id,
cv.Optional(CONF_ATTRIBUTE): cv.string,
cv.Optional(CONF_INTERNAL, default=True): cv.boolean,
}
@@ -34,14 +37,35 @@ HOME_ASSISTANT_IMPORT_SCHEMA = cv.Schema(
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema(
{
cv.Required(CONF_ENTITY_ID): cv.entity_id,
cv.Optional(CONF_ENTITY_ID): cv.entity_id,
cv.Optional(CONF_INTERNAL, default=True): cv.boolean,
}
)
def final_validate_entity_id(config: ConfigType) -> ConfigType:
"""Without an entity_id, the entity must be a wizard input, as Home Assistant then supplies it."""
if CONF_ENTITY_ID in config:
return config
from esphome.components.api import wizard
if config[CONF_ID].id not in wizard.wizard_input_ids(
fv.full_config.get()[wizard.API_DOMAIN]
):
raise cv.Invalid(
f"{CONF_ENTITY_ID} is required unless this entity is a wizard input"
)
return config
def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None:
cg.add(var.set_entity_id(config[CONF_ENTITY_ID]))
from esphome.components.api import wizard
if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None:
# The entity ID is chosen in the wizard and lives in a buffer the API owns
cg.add(var.set_entity_id(cg.RawExpression(buffer)))
else:
cg.add(var.set_entity_id(config[CONF_ENTITY_ID]))
if CONF_ATTRIBUTE in config:
cg.add(var.set_attribute(config[CONF_ATTRIBUTE]))
cg.add_define("USE_API_HOMEASSISTANT_STATES")
@@ -4,6 +4,7 @@ from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
)
@@ -18,6 +19,8 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex
HOME_ASSISTANT_IMPORT_SCHEMA
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
@@ -0,0 +1,40 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
validate_entity_domain,
)
CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["api"]
SUPPORTED_DOMAINS = [
"button",
"input_button",
]
HomeassistantButton = homeassistant_ns.class_(
"HomeassistantButton", button.Button, cg.Component
)
CONFIG_SCHEMA = cv.All(
button.button_schema(HomeassistantButton)
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
validate_entity_domain("button", SUPPORTED_DOMAINS),
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
var = await button.new_button(config)
await cg.register_component(var, config)
setup_home_assistant_entity(var, config)
@@ -0,0 +1,54 @@
#include "homeassistant_button.h"
#include <cstring>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
static const char *const TAG = "homeassistant.button";
void HomeassistantButton::dump_config() {
LOG_BUTTON("", "Homeassistant Button", this);
ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_);
}
float HomeassistantButton::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
void HomeassistantButton::press_action() {
if (!api::global_api_server->is_connected()) {
ESP_LOGE(TAG, "No clients connected to API server");
return;
}
#ifdef USE_API_WIZARD_LINKED_INPUTS
if (this->entity_id_[0] == '\0') {
ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str());
return;
}
#endif
static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press");
static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press");
static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id");
static constexpr char INPUT_PREFIX[] = "input_";
api::HomeassistantActionRequest resp;
if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) {
resp.service = SERVICE_INPUT_BUTTON;
} else {
resp.service = SERVICE_BUTTON;
}
resp.data.init(1);
auto &entity_id_kv = resp.data.emplace_back();
entity_id_kv.key = ENTITY_ID_KEY;
entity_id_kv.value = StringRef(this->entity_id_);
api::global_api_server->send_homeassistant_action(resp);
}
} // namespace esphome::homeassistant
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/components/button/button.h"
#include "esphome/core/component.h"
namespace esphome::homeassistant {
class HomeassistantButton final : public button::Button, public Component {
public:
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
void dump_config() override;
float get_setup_priority() const override;
protected:
void press_action() override;
const char *entity_id_{nullptr};
};
} // namespace esphome::homeassistant
@@ -5,6 +5,7 @@ from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
validate_entity_domain,
@@ -26,6 +27,8 @@ CONFIG_SCHEMA = cv.All(
validate_entity_domain("number", SUPPORTED_DOMAINS),
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
@@ -80,6 +80,13 @@ void HomeassistantNumber::control(float value) {
return;
}
#ifdef USE_API_WIZARD_LINKED_INPUTS
if (this->entity_id_[0] == '\0') {
ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str());
return;
}
#endif
this->publish_state(value);
static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value");
@@ -0,0 +1,57 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
validate_entity_domain,
)
CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["api"]
CONF_MAX_OPTIONS = "max_options"
CONF_OPTIONS_BUFFER_SIZE = "options_buffer_size"
SUPPORTED_DOMAINS = [
"input_select",
"select",
]
HomeassistantSelect = homeassistant_ns.class_(
"HomeassistantSelect", select.Select, cg.Component
)
CONFIG_SCHEMA = cv.All(
select.select_schema(HomeassistantSelect)
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
.extend(
{
cv.Optional(CONF_MAX_OPTIONS, default=16): cv.int_range(min=1, max=255),
cv.Optional(CONF_OPTIONS_BUFFER_SIZE, default=256): cv.int_range(
min=2, max=4096
),
}
)
.extend(cv.COMPONENT_SCHEMA),
validate_entity_domain("select", SUPPORTED_DOMAINS),
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
# Options come from Home Assistant at runtime, into storage reserved during setup
var = await select.new_select(
config,
config[CONF_MAX_OPTIONS],
config[CONF_OPTIONS_BUFFER_SIZE],
options=[],
)
await cg.register_component(var, config)
setup_home_assistant_entity(var, config)
@@ -0,0 +1,265 @@
#include "homeassistant_select.h"
#include <cstring>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
static const char *const TAG = "homeassistant.select";
namespace {
/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early.
template<typename C> bool emit_utf8(uint32_t code_point, C &on_char) {
if (code_point == 0 || code_point > 0x10FFFF)
return false;
if (code_point < 0x80) {
on_char(static_cast<char>(code_point));
} else if (code_point < 0x800) {
on_char(static_cast<char>(0xC0 | (code_point >> 6)));
on_char(static_cast<char>(0x80 | (code_point & 0x3F)));
} else if (code_point < 0x10000) {
on_char(static_cast<char>(0xE0 | (code_point >> 12)));
on_char(static_cast<char>(0x80 | ((code_point >> 6) & 0x3F)));
on_char(static_cast<char>(0x80 | (code_point & 0x3F)));
} else {
on_char(static_cast<char>(0xF0 | (code_point >> 18)));
on_char(static_cast<char>(0x80 | ((code_point >> 12) & 0x3F)));
on_char(static_cast<char>(0x80 | ((code_point >> 6) & 0x3F)));
on_char(static_cast<char>(0x80 | (code_point & 0x3F)));
}
return true;
}
/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as
* `['Low', "It's high", 'caf\xe9']`.
*
* Calls on_char for each decoded byte of an option and on_option at the end of each option. Returns false
* on malformed input; the callbacks may already have run by then.
*/
template<typename C, typename O> bool parse_option_list(StringRef src, C &&on_char, O &&on_option) {
const char *pos = src.c_str();
const char *const end = pos + src.size();
auto skip_spaces = [&]() {
while (pos < end && *pos == ' ')
pos++;
};
skip_spaces();
if (pos == end || *pos != '[')
return false;
pos++;
skip_spaces();
if (pos < end && *pos == ']') {
pos++;
} else {
while (true) {
skip_spaces();
if (pos == end || (*pos != '\'' && *pos != '"'))
return false;
const char quote = *pos++;
while (true) {
if (pos == end)
return false;
const char c = *pos++;
if (c == quote)
break;
if (c != '\\') {
on_char(c);
continue;
}
if (pos == end)
return false;
const char escape = *pos++;
switch (escape) {
case '\\':
case '\'':
case '"':
on_char(escape);
break;
case 'n':
on_char('\n');
break;
case 'r':
on_char('\r');
break;
case 't':
on_char('\t');
break;
case 'x':
case 'u':
case 'U': {
const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8);
if (static_cast<size_t>(end - pos) < digits)
return false;
auto code_point = parse_hex<uint32_t>(pos, digits);
if (!code_point.has_value() || !emit_utf8(*code_point, on_char))
return false;
pos += digits;
break;
}
default:
return false;
}
}
on_option();
skip_spaces();
if (pos == end)
return false;
const char separator = *pos++;
if (separator == ']')
break;
if (separator != ',')
return false;
}
}
skip_spaces();
return pos == end;
}
} // namespace
void HomeassistantSelect::setup() {
this->options_buffer_ = std::make_unique<char[]>(this->options_buffer_size_);
this->option_list_ = std::make_unique<const char *[]>(this->max_options_);
// Subscribe to the options first: Home Assistant answers subscriptions in order, so the options are
// known by the time the first state arrives.
api::global_api_server->subscribe_home_assistant_state(
this->entity_id_, "options", [this](StringRef options) { this->options_changed_(options); });
api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr,
[this](StringRef state) { this->state_changed_(state); });
}
void HomeassistantSelect::options_changed_(StringRef options) {
// First pass: validate and measure the new options, and look for the active option among them, before
// anything is overwritten. A list that does not fit leaves the current options in place.
const char *active = this->has_state() ? this->option_at(this->active_index_) : nullptr;
size_t count = 0;
size_t bytes = 0;
size_t match_len = 0;
bool matching = active != nullptr;
optional<size_t> new_active;
bool valid = parse_option_list(
options,
[&](char c) {
bytes++;
if (matching && active[match_len] == c) {
match_len++;
} else {
matching = false;
}
},
[&]() {
bytes++; // NUL terminator
if (matching && active[match_len] == '\0' && !new_active.has_value())
new_active = count;
count++;
match_len = 0;
matching = active != nullptr;
});
if (!valid) {
ESP_LOGW(TAG, "'%s': Can't parse options %s", this->entity_id_, options.c_str());
return;
}
if (count > this->max_options_) {
ESP_LOGE(TAG, "'%s': %zu options exceed max_options (%u)", this->entity_id_, count, this->max_options_);
return;
}
if (bytes > this->options_buffer_size_) {
ESP_LOGE(TAG, "'%s': Options need %zu bytes, more than options_buffer_size (%u)", this->entity_id_, bytes,
this->options_buffer_size_);
return;
}
// Second pass: the input is known to be valid and to fit, so write it out
char *buffer = this->options_buffer_.get();
size_t offset = 0;
size_t option_start = 0;
size_t index = 0;
parse_option_list(
options, [&](char c) { buffer[offset++] = c; },
[&]() {
buffer[offset++] = '\0';
this->option_list_[index++] = buffer + option_start;
option_start = offset;
});
// The traits only point at the list, which this select owns; set_options() would copy it to the heap
this->traits.set_options_static(this->option_list_.get(), count);
// The active option may have moved to another index, or may be gone
if (new_active.has_value()) {
this->active_index_ = *new_active;
} else {
this->set_has_state(false);
}
ESP_LOGD(TAG, "'%s': Got %zu options", this->entity_id_, count);
}
void HomeassistantSelect::state_changed_(StringRef state) {
auto index = this->index_of(state.c_str(), state.size());
if (!index.has_value()) {
ESP_LOGW(TAG, "'%s': State '%s' is not one of the options", this->entity_id_, state.c_str());
return;
}
if (this->has_state() && this->active_index_ == *index)
return;
ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_, state.c_str());
this->publish_state(*index);
}
void HomeassistantSelect::dump_config() {
LOG_SELECT("", "Homeassistant Select", this);
ESP_LOGCONFIG(TAG,
" Entity ID: '%s'\n"
" Max options: %u\n"
" Options buffer size: %u",
this->entity_id_, this->max_options_, this->options_buffer_size_);
}
float HomeassistantSelect::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
void HomeassistantSelect::control(size_t index) {
if (!api::global_api_server->is_connected()) {
ESP_LOGE(TAG, "No clients connected to API server");
return;
}
#ifdef USE_API_WIZARD_LINKED_INPUTS
if (this->entity_id_[0] == '\0') {
ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str());
return;
}
#endif
static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option");
static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option");
static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id");
static constexpr auto OPTION_KEY = StringRef::from_lit("option");
static constexpr char INPUT_PREFIX[] = "input_";
api::HomeassistantActionRequest resp;
if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) {
resp.service = SERVICE_INPUT_SELECT;
} else {
resp.service = SERVICE_SELECT;
}
resp.data.init(2);
auto &entity_id = resp.data.emplace_back();
entity_id.key = ENTITY_ID_KEY;
entity_id.value = StringRef(this->entity_id_);
auto &option = resp.data.emplace_back();
option.key = OPTION_KEY;
option.value = StringRef(this->option_at(index));
api::global_api_server->send_homeassistant_action(resp);
}
} // namespace esphome::homeassistant
@@ -0,0 +1,47 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include "esphome/components/select/select.h"
#include "esphome/core/component.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
/** Mirrors a Home Assistant `select` or `input_select` entity.
*
* The options are read from the entity's `options` attribute at runtime. They are stored in a buffer of
* `options_buffer_size` bytes and a list of at most `max_options` entries, both reserved in setup(), so a
* change of options never allocates.
*/
class HomeassistantSelect final : public select::Select, public Component {
public:
HomeassistantSelect(uint8_t max_options, uint16_t options_buffer_size)
: options_buffer_size_(options_buffer_size), max_options_(max_options) {}
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
protected:
void options_changed_(StringRef options);
void state_changed_(StringRef state);
void control(size_t index) override;
const char *entity_id_{nullptr};
private:
// The traits point at option_list_, whose entries point into options_buffer_; both are sized once in
// setup() and must match options_buffer_size_ and max_options_
std::unique_ptr<char[]> options_buffer_;
std::unique_ptr<const char *[]> option_list_;
uint16_t options_buffer_size_;
uint8_t max_options_;
};
} // namespace esphome::homeassistant
@@ -4,6 +4,7 @@ from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
)
@@ -18,6 +19,8 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e
HOME_ASSISTANT_IMPORT_SCHEMA
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
@@ -6,6 +6,7 @@ from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
validate_entity_domain,
@@ -36,6 +37,8 @@ CONFIG_SCHEMA = cv.All(
validate_entity_domain("switch", SUPPORTED_DOMAINS),
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
@@ -40,6 +40,13 @@ void HomeassistantSwitch::write_state(bool state) {
return;
}
#ifdef USE_API_WIZARD_LINKED_INPUTS
if (this->entity_id_[0] == '\0') {
ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str());
return;
}
#endif
static constexpr auto SERVICE_ON = StringRef::from_lit("homeassistant.turn_on");
static constexpr auto SERVICE_OFF = StringRef::from_lit("homeassistant.turn_off");
static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id");
@@ -0,0 +1,40 @@
import esphome.codegen as cg
from esphome.components import text
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
validate_entity_domain,
)
CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["api"]
SUPPORTED_DOMAINS = [
"input_text",
"text",
]
HomeassistantText = homeassistant_ns.class_(
"HomeassistantText", text.Text, cg.Component
)
CONFIG_SCHEMA = cv.All(
text.text_schema(HomeassistantText, mode="TEXT")
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
validate_entity_domain("text", SUPPORTED_DOMAINS),
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
var = await text.new_text(config)
await cg.register_component(var, config)
setup_home_assistant_entity(var, config)
@@ -0,0 +1,114 @@
#include "homeassistant_text.h"
#include <cstring>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
static const char *const TAG = "homeassistant.text";
void HomeassistantText::state_changed_(StringRef state) {
if (state == this->state) {
return;
}
ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, state.c_str());
this->publish_state(state.c_str(), state.size());
}
void HomeassistantText::min_retrieved_(StringRef min) {
auto min_value = parse_number<int>(min.c_str());
if (!min_value.has_value()) {
ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str());
return;
}
ESP_LOGD(TAG, "'%s': Min retrieved: %s", this->entity_id_, min.c_str());
this->traits.set_min_length(min_value.value());
}
void HomeassistantText::max_retrieved_(StringRef max) {
auto max_value = parse_number<int>(max.c_str());
if (!max_value.has_value()) {
ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str());
return;
}
ESP_LOGD(TAG, "'%s': Max retrieved: %s", this->entity_id_, max.c_str());
this->traits.set_max_length(max_value.value());
}
void HomeassistantText::mode_retrieved_(StringRef mode) {
if (mode == "text") {
this->traits.set_mode(text::TEXT_MODE_TEXT);
} else if (mode == "password") {
this->traits.set_mode(text::TEXT_MODE_PASSWORD);
} else {
ESP_LOGW(TAG, "'%s': Unknown 'mode' value '%s'", this->entity_id_, mode.c_str());
return;
}
ESP_LOGD(TAG, "'%s': Mode retrieved: %s", this->entity_id_, mode.c_str());
}
void HomeassistantText::setup() {
api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr,
[this](StringRef state) { this->state_changed_(state); });
api::global_api_server->get_home_assistant_state(this->entity_id_, "min",
[this](StringRef min) { this->min_retrieved_(min); });
api::global_api_server->get_home_assistant_state(this->entity_id_, "max",
[this](StringRef max) { this->max_retrieved_(max); });
api::global_api_server->get_home_assistant_state(this->entity_id_, "mode",
[this](StringRef mode) { this->mode_retrieved_(mode); });
// The "pattern" attribute is not fetched: Home Assistant sends "None" when it is unset,
// and a runtime pattern would need its own buffer.
}
void HomeassistantText::dump_config() {
LOG_TEXT("", "Homeassistant Text", this);
ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_);
}
float HomeassistantText::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
void HomeassistantText::control(const std::string &value) {
if (!api::global_api_server->is_connected()) {
ESP_LOGE(TAG, "No clients connected to API server");
return;
}
#ifdef USE_API_WIZARD_LINKED_INPUTS
if (this->entity_id_[0] == '\0') {
ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str());
return;
}
#endif
static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value");
static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value");
static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id");
static constexpr auto VALUE_KEY = StringRef::from_lit("value");
static constexpr char INPUT_PREFIX[] = "input_";
api::HomeassistantActionRequest resp;
if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) {
resp.service = SERVICE_INPUT_TEXT;
} else {
resp.service = SERVICE_TEXT;
}
resp.data.init(2);
auto &entity_id_kv = resp.data.emplace_back();
entity_id_kv.key = ENTITY_ID_KEY;
entity_id_kv.value = StringRef(this->entity_id_);
auto &value_kv = resp.data.emplace_back();
value_kv.key = VALUE_KEY;
value_kv.value = StringRef(value.data(), value.size());
api::global_api_server->send_homeassistant_action(resp);
}
} // namespace esphome::homeassistant
@@ -0,0 +1,28 @@
#pragma once
#include "esphome/components/text/text.h"
#include "esphome/core/component.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
class HomeassistantText final : public text::Text, public Component {
public:
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
protected:
void state_changed_(StringRef state);
void min_retrieved_(StringRef min);
void max_retrieved_(StringRef max);
void mode_retrieved_(StringRef mode);
void control(const std::string &value) override;
const char *entity_id_{nullptr};
};
} // namespace esphome::homeassistant
@@ -4,6 +4,7 @@ from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_SCHEMA,
final_validate_entity_id,
homeassistant_ns,
setup_home_assistant_entity,
)
@@ -18,6 +19,8 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend(
HOME_ASSISTANT_IMPORT_SCHEMA
)
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
+1 -5
View File
@@ -202,11 +202,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_watchdog_timeout(timeout_ms))
if CORE.is_esp32:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time).
# esp-tls is re-enabled too because http_request includes <esp_tls.h>
# directly and esp_http_client only pulls it in as a private dependency.
esp32.include_builtin_idf_component("esp_http_client")
esp32.include_builtin_idf_component("esp-tls")
esp32.request_http_client()
cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX]))
cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX]))
@@ -40,6 +40,16 @@ void I2SAudioSpeakerBase::setup() {
// When no audio_dac is configured, this initializes software volume control.
this->set_volume(this->volume_);
this->set_mute_state(this->mute_state_);
// Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG
// function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now.
this->park_dout_pin_();
}
void I2SAudioSpeakerBase::park_dout_pin_() {
gpio_reset_pin(this->dout_pin_);
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
gpio_set_level(this->dout_pin_, 0);
}
void I2SAudioSpeakerBase::dump_config() {
@@ -303,9 +313,7 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() {
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
gpio_reset_pin(this->dout_pin_);
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
gpio_set_level(this->dout_pin_, 0);
this->park_dout_pin_();
}
this->parent_->unlock();
}
@@ -129,6 +129,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
/// @brief Stops the I2S driver and unlocks the I2S port
void stop_i2s_driver_();
/// @brief Detaches dout from the I2S signal and drives it low
void park_dout_pin_();
/// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup.
virtual void on_task_stopped() {}
+6 -8
View File
@@ -9,20 +9,21 @@ Once the API is considered stable, this warning will be removed.
"""
import esphome.codegen as cg
from esphome.components import ir_rf_base
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE, coroutine_with_priority
from esphome.core.entity_helpers import queue_entity_register, setup_entity
from esphome.core import coroutine_with_priority
from esphome.core.entity_helpers import setup_entity
from esphome.coroutine import CoroPriority
from esphome.types import ConfigType, SafeExpType
CODEOWNERS = ["@kbx81"]
AUTO_LOAD = ["remote_base"]
AUTO_LOAD = ["ir_rf_base"]
IS_PLATFORM_COMPONENT = True
infrared_ns = cg.esphome_ns.namespace("infrared")
Infrared = infrared_ns.class_("Infrared", cg.EntityBase, cg.Component)
Infrared = infrared_ns.class_("Infrared", ir_rf_base.IrRfEntity)
InfraredCall = infrared_ns.class_("InfraredCall")
InfraredTraits = infrared_ns.class_("InfraredTraits")
@@ -52,11 +53,8 @@ async def setup_infrared_core_(var: cg.MockObj, config: ConfigType) -> None:
async def register_infrared(var: cg.MockObj, config: ConfigType) -> None:
"""Register an infrared device with the core."""
cg.add_define("USE_IR_RF")
await cg.register_component(var, config)
queue_entity_register("infrared", config)
await ir_rf_base.register_ir_rf_entity(var, config, "infrared")
await setup_infrared_core_(var, config)
CORE.register_platform_component("infrared", var)
async def new_infrared(config: ConfigType, *args: SafeExpType) -> cg.MockObj:
+2 -145
View File
@@ -1,161 +1,18 @@
#include "infrared.h"
#include <cinttypes>
#include "esphome/core/log.h"
#ifdef USE_API
#include "esphome/components/api/api_server.h"
#endif
namespace esphome::infrared {
static const char *const TAG = "infrared";
// ========== InfraredCall ==========
InfraredCall &InfraredCall::set_carrier_frequency(uint32_t frequency) {
this->carrier_frequency_ = frequency;
return *this;
}
InfraredCall &InfraredCall::set_raw_timings(const std::vector<int32_t> &timings) {
this->raw_timings_ = &timings;
this->packed_data_ = nullptr;
this->base64url_ptr_ = nullptr;
return *this;
}
InfraredCall &InfraredCall::set_raw_timings_base64url(const std::string &base64url) {
this->base64url_ptr_ = &base64url;
this->raw_timings_ = nullptr;
this->packed_data_ = nullptr;
return *this;
}
InfraredCall &InfraredCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) {
this->packed_data_ = data;
this->packed_length_ = length;
this->packed_count_ = count;
this->raw_timings_ = nullptr;
this->base64url_ptr_ = nullptr;
return *this;
}
InfraredCall &InfraredCall::set_repeat_count(uint32_t count) {
this->repeat_count_ = count;
return *this;
}
void InfraredCall::perform() {
if (this->parent_ != nullptr) {
this->parent_->control(*this);
}
}
// ========== Infrared ==========
void Infrared::setup() {
// Set up traits based on configuration
this->traits_.set_supports_transmitter(this->has_transmitter());
this->traits_.set_supports_receiver(this->has_receiver());
}
void Infrared::dump_config() {
ESP_LOGCONFIG(TAG,
"Infrared '%s'\n"
" Supports Transmitter: %s\n"
" Supports Receiver: %s",
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
YESNO(this->traits_.get_supports_receiver()));
}
void Infrared::control(const InfraredCall &call) {
if (this->transmitter_ == nullptr) {
ESP_LOGW(TAG, "No transmitter configured");
return;
}
if (!call.has_raw_timings()) {
ESP_LOGE(TAG, "No raw timings provided");
return;
}
// Create transmit data object
auto transmit_call = this->transmitter_->transmit();
auto *transmit_data = transmit_call.get_data();
// Set carrier frequency
auto freq = call.get_carrier_frequency();
if (freq.has_value()) {
transmit_data->set_carrier_frequency(*freq);
}
// Set timings based on format
if (call.is_packed()) {
// Zero-copy from packed protobuf data
transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(),
call.get_packed_count());
ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(),
call.get_repeat_count());
} else if (call.is_base64url()) {
// Decode base64url (URL-safe) into transmit buffer
if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
ESP_LOGE(TAG, "Invalid base64url data");
return;
}
// Sanity check: validate timing values are within reasonable bounds
constexpr int32_t max_timing_us = 500000; // 500ms absolute max
for (int32_t timing : transmit_data->get_data()) {
int32_t abs_timing = timing < 0 ? -timing : timing;
if (abs_timing > max_timing_us) {
ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us);
return;
}
}
ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(),
call.get_repeat_count());
} else {
// From vector (lambdas/automations)
transmit_data->set_data(call.get_raw_timings());
ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(),
call.get_repeat_count());
}
// Set repeat count
if (call.get_repeat_count() > 0) {
transmit_call.set_send_times(call.get_repeat_count());
}
// Perform transmission
transmit_call.perform();
}
uint32_t Infrared::get_capability_flags() const {
uint32_t flags = 0;
// Add transmit/receive capability based on traits
if (this->traits_.get_supports_transmitter())
flags |= InfraredCapability::CAPABILITY_TRANSMITTER;
if (this->traits_.get_supports_receiver())
flags |= InfraredCapability::CAPABILITY_RECEIVER;
return flags;
}
bool Infrared::on_receive(remote_base::RemoteReceiveData data) {
// Forward received IR data to API server
#if defined(USE_API) && defined(USE_IR_RF)
if (api::global_api_server != nullptr) {
#ifdef USE_DEVICES
uint32_t device_id = this->get_device_id();
#else
uint32_t device_id = 0;
#endif
api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data());
}
#endif
return false; // Don't consume the event, allow other listeners to process it
this->get_name().c_str(), YESNO(this->get_supports_transmitter()),
YESNO(this->get_supports_receiver()));
}
} // namespace esphome::infrared
+18 -106
View File
@@ -4,131 +4,48 @@
// without following the normal breaking changes policy. Use at your own risk.
// Once the API is considered stable, this warning will be removed.
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
#include "esphome/components/remote_base/remote_base.h"
#include <vector>
#include "esphome/components/ir_rf_base/ir_rf_base.h"
namespace esphome::infrared {
/// Capability flags for individual infrared instances
enum InfraredCapability : uint32_t {
CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals
CAPABILITY_RECEIVER = 1 << 1, // Can receive signals
};
using ir_rf_base::CAPABILITY_RECEIVER;
using ir_rf_base::CAPABILITY_TRANSMITTER;
/// Forward declarations
class Infrared;
/// InfraredCall - Builder pattern for transmitting infrared signals
class InfraredCall {
class InfraredCall : public ir_rf_base::IrRfCall<InfraredCall, Infrared> {
public:
explicit InfraredCall(Infrared *parent) : parent_(parent) {}
explicit InfraredCall(Infrared *parent) : IrRfCall(parent) {}
/// Set the carrier frequency in Hz
InfraredCall &set_carrier_frequency(uint32_t frequency);
// ===== Raw Timings Methods =====
// All set_raw_timings_* methods store pointers/references to external data.
// The referenced data must remain valid until perform() completes.
// Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous
// Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone!
/// Set the raw timings from a vector (positive = mark, negative = space)
/// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform().
/// @note Usage: Primarily for lambdas/automations where the vector is in scope.
InfraredCall &set_raw_timings(const std::vector<int32_t> &timings);
/// Set the raw timings from base64url-encoded little-endian int32 data
/// @note Lifetime: Stores a pointer to the string. The string must outlive perform().
/// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_').
/// @note Decoding happens at perform() time, directly into the transmit buffer.
InfraredCall &set_raw_timings_base64url(const std::string &base64url);
/// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded)
/// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform().
/// @note Usage: For API component where data comes directly from the protobuf message.
InfraredCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count);
/// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.)
InfraredCall &set_repeat_count(uint32_t count);
/// Perform the transmission
void perform();
InfraredCall &set_carrier_frequency(uint32_t frequency) {
this->carrier_frequency_ = frequency;
return *this;
}
/// Get the carrier frequency
const optional<uint32_t> &get_carrier_frequency() const { return this->carrier_frequency_; }
/// Get the raw timings (only valid if set via set_raw_timings)
const std::vector<int32_t> &get_raw_timings() const { return *this->raw_timings_; }
/// Check if raw timings have been set (any format)
bool has_raw_timings() const {
return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr;
}
/// Check if using packed data format
bool is_packed() const { return this->packed_data_ != nullptr; }
/// Check if using base64url data format
bool is_base64url() const { return this->base64url_ptr_ != nullptr; }
/// Get the base64url data string
const std::string &get_base64url_data() const { return *this->base64url_ptr_; }
/// Get packed data (only valid if set via set_raw_timings_packed)
const uint8_t *get_packed_data() const { return this->packed_data_; }
uint16_t get_packed_length() const { return this->packed_length_; }
uint16_t get_packed_count() const { return this->packed_count_; }
/// Get the repeat count
uint32_t get_repeat_count() const { return this->repeat_count_; }
protected:
uint32_t repeat_count_{1};
Infrared *parent_;
optional<uint32_t> carrier_frequency_;
// Pointer to vector-based timings (caller-owned, must outlive perform())
const std::vector<int32_t> *raw_timings_{nullptr};
// Pointer to base64url-encoded string (caller-owned, must outlive perform())
const std::string *base64url_ptr_{nullptr};
// Pointer to packed protobuf buffer (caller-owned, must outlive perform())
const uint8_t *packed_data_{nullptr};
uint16_t packed_length_{0};
uint16_t packed_count_{0};
};
/// InfraredTraits - Describes the capabilities of an infrared implementation
class InfraredTraits {
public:
bool get_supports_transmitter() const { return this->supports_transmitter_; }
void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; }
bool get_supports_receiver() const { return this->supports_receiver_; }
void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; }
uint32_t get_receiver_frequency_hz() const { return this->receiver_frequency_hz_; }
void set_receiver_frequency_hz(uint32_t freq) { this->receiver_frequency_hz_ = freq; }
protected:
bool supports_transmitter_{false};
bool supports_receiver_{false};
uint32_t receiver_frequency_hz_{0}; // Demodulation frequency of the IR receiver in Hz (0 = unspecified)
};
/// Infrared - Base class for infrared remote control implementations
class Infrared : public Component, public EntityBase, public remote_base::RemoteReceiverListener {
class Infrared : public ir_rf_base::IrRfEntity {
public:
Infrared() = default;
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; }
/// Set the remote receiver component; the listener registration happens from codegen, see
/// remote_base.attach_receiver
void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; }
/// Set the remote transmitter component
void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; }
/// Check if this infrared has a transmitter configured
bool has_transmitter() const { return this->transmitter_ != nullptr; }
/// Check if this infrared has a receiver configured
bool has_receiver() const { return this->receiver_ != nullptr; }
/// Get the traits for this infrared implementation
InfraredTraits &get_traits() { return this->traits_; }
@@ -137,21 +54,16 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote
/// Create a call object for transmitting
InfraredCall make_call() { return InfraredCall(this); }
/// Get capability flags for this infrared instance
uint32_t get_capability_flags() const;
/// Called when IR data is received (from RemoteReceiverListener)
bool on_receive(remote_base::RemoteReceiveData data) override;
protected:
friend class InfraredCall;
friend class ir_rf_base::IrRfCall<InfraredCall, Infrared>;
/// Perform the actual transmission (called by InfraredCall)
virtual void control(const InfraredCall &call);
// Underlying hardware components
remote_base::RemoteReceiverBase *receiver_{nullptr};
remote_base::RemoteTransmitterBase *transmitter_{nullptr};
void on_call_(const InfraredCall &) {}
/// Perform the actual transmission (called by InfraredCall); false only when no frame was handed
/// to the transmitter, in which case no completion follows
/// Without a remote_base transmitter, call api_transmit_done_() once the frame is out
virtual bool control(const InfraredCall &call) {
return this->transmit_raw_(call, call.get_carrier_frequency().value_or(0));
}
// Traits describing capabilities
InfraredTraits traits_;
+38
View File
@@ -0,0 +1,38 @@
"""Shared base for the infrared and radio_frequency entity components."""
import esphome.codegen as cg
from esphome.components import remote_base
from esphome.const import CONF_API
from esphome.core import CORE
from esphome.core.entity_helpers import queue_entity_register
from esphome.types import ConfigType
CODEOWNERS = ["@kbx81", "@bdraco"]
AUTO_LOAD = ["remote_base"]
ir_rf_base_ns = cg.esphome_ns.namespace("ir_rf_base")
IrRfEntity = ir_rf_base_ns.class_("IrRfEntity", cg.EntityBase, cg.Component)
async def attach_transmitter(var: cg.MockObj, config: ConfigType, key: str) -> None:
"""Link the configured transmitter to an entity.
With the API configured this also compiles in the transmit completion
tracking that answers API transmit requests; the transmitter platform has
to report completion through notify_complete_(), as remote_transmitter
does. Without this call anywhere in the build a request is answered as
soon as the frame is handed over.
"""
await remote_base.register_transmittable(var, config, key)
if CONF_API in CORE.config:
cg.add_define("USE_IR_RF_TRANSMIT_COMPLETE")
async def register_ir_rf_entity(
var: cg.MockObj, config: ConfigType, domain: str
) -> None:
"""Register an infrared or radio_frequency entity; USE_IR_RF covers both kinds."""
cg.add_define("USE_IR_RF")
await cg.register_component(var, config)
queue_entity_register(domain, config)
CORE.register_platform_component(domain, var)
@@ -0,0 +1,237 @@
#include "ir_rf_base.h"
#include <algorithm>
#include <cinttypes>
#include "esphome/core/log.h"
#ifdef USE_API
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/application.h"
#endif
namespace esphome::ir_rf_base {
static const char *const TAG = "ir_rf";
#if defined(USE_API) && defined(USE_IR_RF)
// A missing completion is answered as failed this long after the frame should have left the
// wire, and an owed reply the client never reads is dropped this long after the first refusal
static constexpr uint32_t API_REPLY_TIMEOUT_MS = 30000;
#endif
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
// Longest air time added to the deadline, in 16 ms ticks (8 min); with the 30 s above the
// deadline stays within the signed 16 bit tick window loop() compares against
static constexpr uint16_t API_REPLY_MAX_AIR_TICKS = 30000;
#endif
#if defined(USE_API) && defined(USE_IR_RF)
static uint16_t api_reply_ticks_from_now() {
return static_cast<uint16_t>((App.get_loop_component_start_time() >> 4) + (API_REPLY_TIMEOUT_MS >> 4));
}
#endif
void IrRfEntity::setup() {
// merged, not assigned: a platform may have set a flag for its own hardware before setup()
if (this->has_transmitter())
this->supports_transmitter_ = true;
if (this->has_receiver())
this->supports_receiver_ = true;
}
bool IrRfEntity::transmit_raw_(const IrRfCallData &call, uint32_t carrier_frequency_hz) {
if (this->transmitter_ == nullptr) {
ESP_LOGW(TAG, "No transmitter configured");
return false;
}
if (!call.has_raw_timings()) {
ESP_LOGE(TAG, "No raw timings provided");
return false;
}
auto transmit_call = this->transmitter_->transmit();
auto *transmit_data = transmit_call.get_data();
transmit_data->set_carrier_frequency(carrier_frequency_hz);
if (call.is_packed()) {
// Zero-copy from packed protobuf data
transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(),
call.get_packed_count());
ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(),
call.get_repeat_count());
} else if (call.is_base64url()) {
// Decode base64url (URL-safe) into transmit buffer
if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
ESP_LOGE(TAG, "Invalid base64url data");
return false;
}
constexpr int32_t max_timing_us = 500000; // 500ms absolute max
for (int32_t timing : transmit_data->get_data()) {
int32_t abs_timing = timing < 0 ? -timing : timing;
if (abs_timing > max_timing_us) {
ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us);
return false;
}
}
ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(),
call.get_repeat_count());
} else {
// From vector (lambdas/automations)
transmit_data->set_data(call.get_raw_timings());
ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(),
call.get_repeat_count());
}
// one answer for every backend: a frame that decoded to nothing is refused here
if (transmit_data->get_data().empty()) {
ESP_LOGE(TAG, "No raw timings provided");
return false;
}
if (call.get_repeat_count() > 0) {
transmit_call.set_send_times(call.get_repeat_count());
}
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
// only the API frame expect_api_reply_() armed claims the seq and extends its deadline
if (call.wants_api_reply() && this->api_reply_ == ApiReply::API_REPLY_WAITING) {
this->inflight_seq_ = transmit_call.get_seq();
// a long frame must not be answered as failed while still on the wire: the 30 s safety net
// starts after this frame's own air time (capped so the tick comparison cannot wrap)
uint64_t frame_us = 0;
for (const int64_t timing : transmit_data->get_data())
frame_us += timing < 0 ? -timing : timing;
const uint64_t air_ticks = (frame_us * std::max<uint32_t>(call.get_repeat_count(), 1) / 1000) >> 4;
this->api_reply_deadline_ += static_cast<uint16_t>(std::min<uint64_t>(air_ticks, API_REPLY_MAX_AIR_TICKS));
}
#endif
transmit_call.perform();
return true;
}
bool IrRfEntity::on_receive(remote_base::RemoteReceiveData data) {
#if defined(USE_API) && defined(USE_IR_RF)
if (api::global_api_server != nullptr) {
#ifdef USE_DEVICES
uint32_t device_id = this->get_device_id();
#else
uint32_t device_id = 0;
#endif
api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data());
}
#endif
return false; // Don't consume the event, allow other listeners to process it
}
#if defined(USE_API) && defined(USE_IR_RF)
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
void IrRfEntity::on_transmit_complete(remote_base::RemoteTransmitterBase *transmitter, uint16_t seq, bool sent) {
// only the frame this entity submitted; YAML automations and other entities share the transmitter
if (transmitter == this->transmitter_ && seq == this->inflight_seq_ &&
this->api_reply_ == ApiReply::API_REPLY_WAITING)
this->finish_api_reply_(sent);
}
#endif
bool IrRfEntity::expect_api_reply_(api::APIConnection *conn) {
// only an unpaced client gets here: its earlier request is answered as not started, and a reply
// the buffer still owes gets one last try; if that fails too the slot stays with it and the
// new request is refused rather than answered to nobody
if (this->api_reply_ == ApiReply::API_REPLY_WAITING)
this->finish_api_reply_(false);
if (this->api_reply_ != ApiReply::API_REPLY_NONE && !this->send_api_reply_()) {
ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("refused, reply still owed"));
this->refuse_api_call_(conn);
return false;
}
this->api_reply_connection_ = conn;
this->api_reply_deadline_ = api_reply_ticks_from_now();
this->api_reply_ = ApiReply::API_REPLY_WAITING;
return true;
}
void IrRfEntity::refuse_api_call_(api::APIConnection *conn) {
uint32_t device_id = 0;
#ifdef USE_DEVICES
device_id = this->get_device_id();
#endif
// no slot to retry from; a drop here is covered by the client's own timeout
if (!conn->send_infrared_rf_transmit_complete(device_id, this->get_object_id_hash(), false)) {
API_LOG_MSG_DROPPED(TAG, "IR/RF reply");
}
}
void IrRfEntity::finish_api_reply_(bool success) {
this->api_reply_ = success ? ApiReply::API_REPLY_OWED_OK : ApiReply::API_REPLY_OWED_FAILED;
if (!this->send_api_reply_()) {
// the retry gets its own window
this->api_reply_deadline_ = api_reply_ticks_from_now();
}
}
bool IrRfEntity::send_api_reply_() {
uint32_t device_id = 0;
#ifdef USE_DEVICES
device_id = this->get_device_id();
#endif
// Refused by a full TCP buffer: the reply stays owed and loop() retries it, since a lost
// reply would stall the client's pacing for good (same shape as bluetooth_proxy)
if (!this->api_reply_connection_->send_infrared_rf_transmit_complete(device_id, this->get_object_id_hash(),
this->api_reply_ == ApiReply::API_REPLY_OWED_OK))
return false;
this->clear_api_reply_();
return true;
}
// Only runs while an API reply is pending: retries an owed one until it goes out or the client is
// gone, and expires a transmit that never reported (a platform overriding control() without wiring
// its transmitter's completion)
void IrRfEntity::loop() {
if (this->api_reply_ == ApiReply::API_REPLY_NONE) {
this->disable_loop();
return;
}
const auto remaining = static_cast<int16_t>(this->api_reply_deadline_ - (App.get_loop_component_start_time() >> 4));
if (this->api_reply_ != ApiReply::API_REPLY_WAITING) {
if (this->send_api_reply_() || remaining > 0)
return;
ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("reply dropped, client not reading"));
this->clear_api_reply_();
return;
}
if (remaining > 0)
return;
ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("never reported completion"));
this->finish_api_reply_(false);
}
void IrRfEntity::on_api_connection_closed(api::APIConnection *conn) {
if (this->api_reply_connection_ == conn) {
this->clear_api_reply_();
}
}
#endif
} // namespace esphome::ir_rf_base
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
namespace esphome::remote_base {
void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent) {
#ifdef USE_INFRARED
for (auto *entity : App.get_infrareds()) {
entity->on_transmit_complete(transmitter, seq, sent);
}
#endif
#ifdef USE_RADIO_FREQUENCY
for (auto *entity : App.get_radio_frequencies()) {
entity->on_transmit_complete(transmitter, seq, sent);
}
#endif
}
} // namespace esphome::remote_base
#endif
+273
View File
@@ -0,0 +1,273 @@
#pragma once
// WARNING: This component is EXPERIMENTAL. The API may change at any time
// without following the normal breaking changes policy. Use at your own risk.
// Once the API is considered stable, this warning will be removed.
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#include "esphome/components/remote_base/remote_base.h"
#include <string>
#include <vector>
#if defined(USE_API) && defined(USE_IR_RF)
namespace esphome::api {
class APIConnection;
} // namespace esphome::api
#endif
namespace esphome::ir_rf_base {
/// Capability flags reported by infrared and radio frequency entities
enum IrRfCapability : uint32_t {
CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals
CAPABILITY_RECEIVER = 1 << 1, // Can receive signals
};
/// Raw timings of a transmit call, in one of three caller-owned forms
class IrRfCallData {
public:
/// Get the raw timings (only valid if set via set_raw_timings)
const std::vector<int32_t> &get_raw_timings() const { return *this->raw_timings_; }
/// Check if raw timings have been set (any format)
bool has_raw_timings() const {
return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr;
}
/// Check if using packed data format
bool is_packed() const { return this->packed_data_ != nullptr; }
/// Check if using base64url data format
bool is_base64url() const { return this->base64url_ptr_ != nullptr; }
/// Get the base64url data string
const std::string &get_base64url_data() const { return *this->base64url_ptr_; }
/// Get packed data (only valid if set via set_raw_timings_packed)
const uint8_t *get_packed_data() const { return this->packed_data_; }
uint16_t get_packed_length() const { return this->packed_length_; }
uint16_t get_packed_count() const { return this->packed_count_; }
/// Get the repeat count
uint32_t get_repeat_count() const { return this->repeat_count_; }
#if defined(USE_API) && defined(USE_IR_RF)
/// True for a frame an API client is waiting on
bool wants_api_reply() const { return this->api_connection_ != nullptr; }
#endif
protected:
uint32_t repeat_count_{1};
#if defined(USE_API) && defined(USE_IR_RF)
api::APIConnection *api_connection_{nullptr};
#endif
// Pointer to vector-based timings (caller-owned, must outlive perform())
const std::vector<int32_t> *raw_timings_{nullptr};
// Pointer to base64url-encoded string (caller-owned, must outlive perform())
const std::string *base64url_ptr_{nullptr};
// Pointer to packed protobuf buffer (caller-owned, must outlive perform())
const uint8_t *packed_data_{nullptr};
uint16_t packed_length_{0};
uint16_t packed_count_{0};
};
template<typename Call, typename Entity> class IrRfCall;
/// Everything an infrared or radio frequency entity does that does not depend on the medium:
/// the remote_base transport, forwarding received frames to the API, and answering the API
/// once a transmit it started has left the transmitter.
class IrRfEntity : public Component, public EntityBase, public remote_base::RemoteReceiverListener {
public:
/// Reports the configured transports, listens on the receiver and hooks the transmitter's
/// completion; a platform with its own setup() calls it first
void setup() override;
float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; }
/// Set the remote receiver component; the listener registration happens from codegen, see
/// remote_base.attach_receiver
void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; }
/// Set the remote transmitter component
void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; }
bool has_transmitter() const { return this->transmitter_ != nullptr; }
bool has_receiver() const { return this->receiver_ != nullptr; }
/// What the entity can do; platforms with their own hardware set these from their own
/// setup(), remote_base ones get them from IrRfEntity::setup()
bool get_supports_transmitter() const { return this->supports_transmitter_; }
void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; }
bool get_supports_receiver() const { return this->supports_receiver_; }
void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; }
/// Capability flags as reported to the API and web server
uint32_t get_capability_flags() const {
uint32_t flags = 0;
if (this->supports_transmitter_)
flags |= CAPABILITY_TRANSMITTER;
if (this->supports_receiver_)
flags |= CAPABILITY_RECEIVER;
return flags;
}
/// Forwards a received frame to the API; never consumes it, so other listeners still run
bool on_receive(remote_base::RemoteReceiveData data) override;
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
/// Called for every finished frame on every transmitter; answers the API request when the
/// frame is the one this entity submitted for it, with sent as the outcome
void on_transmit_complete(remote_base::RemoteTransmitterBase *transmitter, uint16_t seq, bool sent);
#endif
#if defined(USE_API) && defined(USE_IR_RF)
void loop() override;
/// The API server calls this when a client disconnects, so no reply goes to a stale pointer
void on_api_connection_closed(api::APIConnection *conn);
#endif
protected:
template<typename, typename> friend class IrRfCall;
/// Hands the call's timings to the transmitter; the default transmit path of both entity types
bool transmit_raw_(const IrRfCallData &call, uint32_t carrier_frequency_hz);
#if defined(USE_API) && defined(USE_IR_RF)
// One reply slot: a pacing client has at most one transmit outstanding, and a second request
// from an unpaced client displaces the first. Retried and expired from loop(), which only
// runs while a reply is pending; polling the expiry there costs nothing while idle, where a
// scheduler timeout would allocate per frame.
enum class ApiReply : uint8_t {
API_REPLY_NONE,
API_REPLY_WAITING, // frame handed to the transmitter, completion not reported yet
API_REPLY_OWED_OK, // reply refused by a full TCP buffer; loop() retries it
API_REPLY_OWED_FAILED // same, for a transmit that did not start
};
/// Claims the reply slot for conn; false when it still owes a reply that cannot be sent
bool expect_api_reply_(api::APIConnection *conn);
void refuse_api_call_(api::APIConnection *conn);
/// After control(): a false start is answered now, and the loop only runs once something is
/// pending, since a blocking transmitter has already answered inside control()
void settle_api_reply_(bool started) {
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
if (!started)
this->finish_api_reply_(false);
#else
// no transmitter in this build reports completion, so the hand-over is the answer
this->finish_api_reply_(started);
#endif
if (this->api_reply_ != ApiReply::API_REPLY_NONE)
this->enable_loop();
}
void finish_api_reply_(bool success);
/// Answers the API request for a control() override that transmits without a remote_base transmitter
void api_transmit_done_(bool sent) {
if (this->api_reply_ == ApiReply::API_REPLY_WAITING)
this->finish_api_reply_(sent);
}
bool send_api_reply_();
void clear_api_reply_() {
this->api_reply_ = ApiReply::API_REPLY_NONE;
this->api_reply_connection_ = nullptr;
this->disable_loop();
}
api::APIConnection *api_reply_connection_{nullptr};
#endif
remote_base::RemoteReceiverBase *receiver_{nullptr};
remote_base::RemoteTransmitterBase *transmitter_{nullptr};
#if defined(USE_API) && defined(USE_IR_RF)
// 16 ms ticks: 30 s for completion (plus capped air time) or for delivering an owed reply
uint16_t api_reply_deadline_{0};
#endif
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
uint16_t inflight_seq_{0}; // seq of the API frame this entity submitted last
#endif
// short members last, so the derived traits start on the next word without a gap
bool supports_transmitter_{false};
bool supports_receiver_{false};
#if defined(USE_API) && defined(USE_IR_RF)
ApiReply api_reply_{ApiReply::API_REPLY_NONE};
#endif
};
/// Builder for a transmit; Call is the concrete call type and Entity its entity, so the fluent
/// setters return the concrete type and control() sees the medium specific fields
template<typename Call, typename Entity> class IrRfCall : public IrRfCallData {
// only the concrete call may construct the CRTP base
friend Call;
explicit IrRfCall(Entity *parent) : parent_(parent) {}
public:
// ===== Raw Timings Methods =====
// All set_raw_timings_* methods store pointers/references to external data.
// The referenced data must remain valid until perform() completes.
// Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous
// Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone!
/// Set the raw timings from a vector (positive = mark, negative = space)
/// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform().
/// @note Usage: Primarily for lambdas/automations where the vector is in scope.
Call &set_raw_timings(const std::vector<int32_t> &timings) {
this->raw_timings_ = &timings;
this->packed_data_ = nullptr;
this->base64url_ptr_ = nullptr;
return this->self_();
}
/// Set the raw timings from base64url-encoded little-endian int32 data
/// @note Lifetime: Stores a pointer to the string. The string must outlive perform().
/// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_').
/// @note Decoding happens at perform() time, directly into the transmit buffer.
Call &set_raw_timings_base64url(const std::string &base64url) {
this->base64url_ptr_ = &base64url;
this->raw_timings_ = nullptr;
this->packed_data_ = nullptr;
return this->self_();
}
/// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded)
/// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform().
/// @note Usage: For API component where data comes directly from the protobuf message.
Call &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) {
this->packed_data_ = data;
this->packed_length_ = length;
this->packed_count_ = count;
this->raw_timings_ = nullptr;
this->base64url_ptr_ = nullptr;
return this->self_();
}
/// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.)
Call &set_repeat_count(uint32_t count) {
this->repeat_count_ = count;
return this->self_();
}
#if defined(USE_API) && defined(USE_IR_RF)
/// Reply to this API client once the frame has left the transmitter (API 1.18+)
Call &set_api_connection(api::APIConnection *conn) {
this->api_connection_ = conn;
return this->self_();
}
#endif
/// Perform the transmission; returns true if a frame was handed to the transmitter
bool perform() {
// make_call() always sets the parent
Entity *parent = this->parent_;
if (parent == nullptr)
return false;
parent->on_call_(this->self_());
#if defined(USE_API) && defined(USE_IR_RF)
// Before control(): blocking transmitters report completion from inside it, and the
// non-blocking RMT path flushes the previous frame's completion there
if (this->api_connection_ != nullptr && !parent->expect_api_reply_(this->api_connection_))
return false;
#endif
const bool started = parent->control(this->self_());
#if defined(USE_API) && defined(USE_IR_RF)
if (this->api_connection_ != nullptr)
parent->settle_api_reply_(started);
#endif
return started;
}
protected:
Call &self_() { return static_cast<Call &>(*this); }
Entity *parent_;
};
} // namespace esphome::ir_rf_base
+3 -9
View File
@@ -3,12 +3,7 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import (
infrared,
remote_base,
remote_receiver,
remote_transmitter,
)
from esphome.components import infrared, ir_rf_base, remote_base, remote_receiver
from esphome.components.const import CONF_RECEIVER_FREQUENCY
import esphome.config_validation as cv
from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY
@@ -30,7 +25,7 @@ CONFIG_SCHEMA = cv.All(
remote_receiver.RemoteReceiverComponent
),
cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id(
remote_transmitter.RemoteTransmitterComponent
remote_base.RemoteTransmitterBase
),
}
),
@@ -82,8 +77,7 @@ async def to_code(config: dict[str, Any]) -> None:
# Link transmitter if specified
if CONF_REMOTE_TRANSMITTER_ID in config:
transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID])
cg.add(var.set_transmitter(transmitter))
await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID)
# Link receiver if specified
if CONF_REMOTE_RECEIVER_ID in config:
+9 -69
View File
@@ -8,70 +8,17 @@ namespace esphome::ir_rf_proxy {
static const char *const TAG = "ir_rf_proxy";
// ========== Shared transmit helper ==========
// Static template: all instantiations occur in this translation unit.
template<typename CallT>
static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency,
const CallT &call) {
if (transmitter == nullptr) {
ESP_LOGW(TAG, "No transmitter configured");
return;
}
if (!call.has_raw_timings()) {
ESP_LOGE(TAG, "No raw timings provided");
return;
}
auto transmit_call = transmitter->transmit();
auto *transmit_data = transmit_call.get_data();
transmit_data->set_carrier_frequency(carrier_frequency);
if (call.is_packed()) {
transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(),
call.get_packed_count());
ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(),
call.get_repeat_count());
} else if (call.is_base64url()) {
if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
ESP_LOGE(TAG, "Invalid base64url data");
return;
}
constexpr int32_t max_timing_us = 500000;
for (int32_t timing : transmit_data->get_data()) {
int32_t abs_timing = timing < 0 ? -timing : timing;
if (abs_timing > max_timing_us) {
ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us);
return;
}
}
ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(),
call.get_repeat_count());
} else {
transmit_data->set_data(call.get_raw_timings());
ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(),
call.get_repeat_count());
}
if (call.get_repeat_count() > 0) {
transmit_call.set_send_times(call.get_repeat_count());
}
transmit_call.perform();
}
// ========== IrRfProxy (Infrared platform) ==========
#ifdef USE_IR_RF
#ifdef USE_INFRARED
void IrRfProxy::dump_config() {
ESP_LOGCONFIG(TAG,
"IR Proxy '%s'\n"
" Supports Transmitter: %s\n"
" Supports Receiver: %s",
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
YESNO(this->traits_.get_supports_receiver()));
this->get_name().c_str(), YESNO(this->get_supports_transmitter()),
YESNO(this->get_supports_receiver()));
if (this->is_rf()) {
ESP_LOGCONFIG(TAG, " Hardware Type: RF (%.3f MHz)", this->frequency_khz_ / 1e3f);
@@ -80,21 +27,14 @@ void IrRfProxy::dump_config() {
}
}
void IrRfProxy::control(const infrared::InfraredCall &call) {
uint32_t carrier = call.get_carrier_frequency().value_or(0);
transmit_raw_timings(this->transmitter_, carrier, call);
}
#endif // USE_IR_RF
#endif // USE_INFRARED
// ========== RfProxy (Radio Frequency platform) ==========
#ifdef USE_RADIO_FREQUENCY
void RfProxy::setup() {
this->traits_.set_supports_transmitter(this->transmitter_ != nullptr);
this->traits_.set_supports_receiver(this->receiver_ != nullptr);
ir_rf_base::IrRfEntity::setup();
// remote_transmitter/receiver always uses OOK (on-off keying)
this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK);
}
@@ -104,8 +44,8 @@ void RfProxy::dump_config() {
"RF Proxy '%s'\n"
" Supports Transmitter: %s\n"
" Supports Receiver: %s",
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
YESNO(this->traits_.get_supports_receiver()));
this->get_name().c_str(), YESNO(this->get_supports_transmitter()),
YESNO(this->get_supports_receiver()));
const auto &traits = this->traits_;
if (traits.get_frequency_min_hz() > 0) {
@@ -118,11 +58,11 @@ void RfProxy::dump_config() {
}
}
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
bool RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
// RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning)
// happens via the radio_frequency entity's on_control trigger and remote_transmitter's
// on_transmit/on_complete triggers — wired up in user YAML.
transmit_raw_timings(this->transmitter_, 0, call);
return this->transmit_raw_(call, 0);
}
#endif // USE_RADIO_FREQUENCY
+4 -15
View File
@@ -6,7 +6,7 @@
#include "esphome/components/remote_base/remote_base.h"
#ifdef USE_IR_RF
#ifdef USE_INFRARED
#include "esphome/components/infrared/infrared.h"
#endif
@@ -16,7 +16,7 @@
namespace esphome::ir_rf_proxy {
#ifdef USE_IR_RF
#ifdef USE_INFRARED
/// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend
class IrRfProxy final : public infrared::Infrared {
public:
@@ -35,12 +35,10 @@ class IrRfProxy final : public infrared::Infrared {
void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); }
protected:
void control(const infrared::InfraredCall &call) override;
// RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF
uint32_t frequency_khz_{0};
};
#endif // USE_IR_RF
#endif // USE_INFRARED
#ifdef USE_RADIO_FREQUENCY
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend.
@@ -54,20 +52,11 @@ class RfProxy final : public radio_frequency::RadioFrequency {
void setup() override;
void dump_config() override;
/// Set the remote transmitter component
void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; }
/// Set the remote receiver component; the listener registration happens from codegen, see
/// remote_base.attach_receiver
void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; }
/// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware)
void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); }
protected:
void control(const radio_frequency::RadioFrequencyCall &call) override;
remote_base::RemoteTransmitterBase *transmitter_{nullptr};
remote_base::RemoteReceiverBase *receiver_{nullptr};
bool control(const radio_frequency::RadioFrequencyCall &call) override;
};
#endif // USE_RADIO_FREQUENCY
@@ -1,12 +1,7 @@
"""Radio Frequency platform implementation using remote_base (remote_transmitter/receiver)."""
import esphome.codegen as cg
from esphome.components import (
radio_frequency,
remote_base,
remote_receiver,
remote_transmitter,
)
from esphome.components import ir_rf_base, radio_frequency, remote_base, remote_receiver
import esphome.config_validation as cv
from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY
import esphome.final_validate as fv
@@ -27,7 +22,7 @@ CONFIG_SCHEMA = cv.All(
remote_receiver.RemoteReceiverComponent
),
cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id(
remote_transmitter.RemoteTransmitterComponent
remote_base.RemoteTransmitterBase
),
}
),
@@ -67,8 +62,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY])))
if CONF_REMOTE_TRANSMITTER_ID in config:
transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID])
cg.add(var.set_transmitter(transmitter))
await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID)
if CONF_REMOTE_RECEIVER_ID in config:
await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID)
+14 -1
View File
@@ -3,11 +3,14 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::logger {
// Maximum header size: 35 bytes fixed + 32 bytes tag + 16 bytes thread name = 83 bytes (45 byte safety margin)
static constexpr uint16_t MAX_HEADER_SIZE = 128;
// Longest tag the header budget allows
static constexpr size_t MAX_TAG_LENGTH = 32;
// ANSI color code last digit (30-38 range, store only last digit to save RAM on ESP8266)
static const char LOG_LEVEL_COLOR_DIGIT[] PROGMEM = {
@@ -189,13 +192,23 @@ struct LogBuffer {
*p++ = 'm';
}
// Copy string without null terminator, updates pointer in place
// Caller is responsible for ensuring buffer has sufficient space
// Caller is responsible for ensuring buffer has sufficient space. Tags may be in PROGMEM on ESP8266.
void copy_string_(char *&p, const char *str) {
#ifdef USE_ESP8266
// Only ESP8266 caps the length; tags may be in PROGMEM, so read each byte once
for (size_t i = 0; i < MAX_TAG_LENGTH; i++) {
const char c = static_cast<char>(progmem_read_byte(reinterpret_cast<const uint8_t *>(str + i)));
if (c == '\0')
break;
*p++ = c;
}
#else
const size_t len = strlen(str);
// NOLINTNEXTLINE(bugprone-not-null-terminated-result) - intentionally no null terminator, building string piece by
// piece
memcpy(p, str, len);
p += len;
#endif
}
};
+4 -1
View File
@@ -144,7 +144,10 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas
inline uint8_t Logger::level_for(const char *tag) {
#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS
auto it = this->log_levels_.find(tag);
// Skip the lookup when no per-tag levels are set
if (this->log_levels_.empty())
return this->current_level_;
auto it = this->log_levels_.find(FlashTag{tag});
if (it != this->log_levels_.end())
return it->second;
#endif
+17 -3
View File
@@ -12,6 +12,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "log_buffer.h"
#include "task_log_buffer_host.h"
@@ -85,12 +86,16 @@ class LoggerLevelListener {
};
#endif
#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS
// Comparison function for const char* keys in log_levels_ map
// RAM map keys compared with a tag that may be in PROGMEM, without copying it
struct FlashTag {
const char *tag;
};
struct CStrCompare {
using is_transparent = void;
bool operator()(const char *a, const char *b) const { return strcmp(a, b) < 0; }
bool operator()(const char *key, FlashTag t) const { return ESPHOME_strcmp_P(key, t.tag) < 0; }
bool operator()(FlashTag t, const char *key) const { return ESPHOME_strcmp_P(key, t.tag) > 0; }
};
#endif
// Stack buffer size for retrieving thread/task names from the OS
// macOS allows up to 64 bytes, Linux up to 16
@@ -488,6 +493,12 @@ class LoggerMessageTrigger final : public Trigger<uint8_t, const char *, const c
[](void *self, uint8_t level, const char *tag, const char *message, size_t message_len) {
auto *trigger = static_cast<LoggerMessageTrigger *>(self);
if (level <= trigger->level_) {
#ifdef USE_ESP8266
// User lambdas may strcmp the tag, which may be in PROGMEM. The copy lives in
// the trigger so an automation that suspends (delay) still sees a valid tag.
ESPHOME_strncpy_P(trigger->ram_tag_, tag, MAX_TAG_LENGTH);
tag = trigger->ram_tag_;
#endif
trigger->trigger(level, tag, message);
}
});
@@ -495,6 +506,9 @@ class LoggerMessageTrigger final : public Trigger<uint8_t, const char *, const c
protected:
uint8_t level_;
#ifdef USE_ESP8266
char ram_tag_[MAX_TAG_LENGTH + 1]{};
#endif
};
} // namespace esphome::logger
+1 -1
View File
@@ -79,7 +79,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema(
mode_validator=validate_mode,
).extend(
{
cv.Required(CONF_MAX6956): cv.use_id(MAX6956),
cv.Required(CONF_MAX6956): pins.use_id_or_address(MAX6956),
}
)
+1 -1
View File
@@ -60,7 +60,7 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema(
invertible=True,
).extend(
{
cv.Required(CONF_MCP23016): cv.use_id(MCP23016),
cv.Required(CONF_MCP23016): pins.use_id_or_address(MCP23016),
}
)
+1 -1
View File
@@ -74,7 +74,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema(
invertible=True,
).extend(
{
cv.Required(CONF_MCP23XXX): cv.use_id(MCP23XXXBase),
cv.Required(CONF_MCP23XXX): pins.use_id_or_address(MCP23XXXBase),
cv.Optional(CONF_INTERRUPT, default="NO_INTERRUPT"): cv.enum(
MCP23XXX_INTERRUPT_MODES, upper=True
),
+4 -1
View File
@@ -648,7 +648,10 @@ class DriverChip:
sequence.append((INVON,))
else:
sequence.append((INVOFF,))
if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)):
brightness = config.get(
CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS, None)
)
if brightness is not None:
sequence.append((BRIGHTNESS, brightness))
# Add a SLPOUT command if required.
if not self.skip_command("SLPOUT"):
@@ -14,6 +14,8 @@ DsiDriverChip(
pclk_frequency="60MHz",
lane_bit_rate="730Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
(0xB7, 0x03), # Pad control - 2 lane
@@ -67,6 +69,8 @@ TAB5_ST7123 = DsiDriverChip(
pclk_frequency="80MHz",
lane_bit_rate="960Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0x01,),
(0x60, 0x71, 0x23, 0xa2),
@@ -118,6 +122,8 @@ DsiDriverChip(
pclk_frequency="70MHz",
lane_bit_rate="965Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0x01,),
(0x60, 0x71, 0x21, 0xA2),
+2 -2
View File
@@ -225,10 +225,10 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
)
.extend({model.option(x): cv.boolean for x in other_options})
)
if brightness := model.get_default(CONF_BRIGHTNESS):
if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM":
schema = schema.extend(
{
cv.Optional(CONF_BRIGHTNESS, default=brightness): cv.int_range(
model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range(
0, 0xFF, min_included=True, max_included=True
),
}
@@ -0,0 +1,68 @@
import esphome.codegen as cg
from esphome.components import light
import esphome.config_validation as cv
from esphome.const import (
CONF_BRIGHTNESS,
CONF_DISPLAY_ID,
CONF_GAMMA_CORRECT,
CONF_MAX_BRIGHTNESS,
CONF_MIN_BRIGHTNESS,
CONF_MODEL,
CONF_OUTPUT_ID,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from ..display import MipiSpi, mipi_spi_ns
MipiSpiLight = mipi_spi_ns.class_("MipiSpiLight", light.LightOutput)
def _validate_brightness_range(config: ConfigType) -> ConfigType:
if config[CONF_MIN_BRIGHTNESS] >= config[CONF_MAX_BRIGHTNESS]:
raise cv.Invalid(
f"'{CONF_MIN_BRIGHTNESS}' must be less than '{CONF_MAX_BRIGHTNESS}'"
)
return config
CONFIG_SCHEMA = cv.All(
light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight),
cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi),
cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float,
cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255),
cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255),
}
),
_validate_brightness_range,
)
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
display_path = full_config.get_path_for_id(config[CONF_DISPLAY_ID])[:-1]
display_config = full_config.get_config_for_path(display_path)
if CONF_BRIGHTNESS not in display_config:
model = display_config[CONF_MODEL]
if model == "CUSTOM":
raise cv.Invalid(
f"The '{CONF_BRIGHTNESS}' option must be set in the display config"
)
raise cv.Invalid(
f"Display model {model} does not support setting brightness with an SPI command"
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(
config[CONF_OUTPUT_ID],
config[CONF_MIN_BRIGHTNESS],
config[CONF_MAX_BRIGHTNESS],
)
await light.register_light(var, config)
await cg.register_parented(var, config[CONF_DISPLAY_ID])
@@ -0,0 +1,36 @@
#pragma once
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
#include "esphome/components/mipi_spi/mipi_spi.h"
#include "esphome/core/helpers.h"
namespace esphome::mipi_spi {
// Brightness-only light that sets the display brightness with an SPI command.
// Brightness is scaled into [min_brightness, max_brightness]; off writes min_brightness.
class MipiSpiLight final : public light::LightOutput, public Parented<MipiSpiBrightness> {
public:
MipiSpiLight(uint8_t min_brightness, uint8_t max_brightness)
: min_brightness_(min_brightness), max_brightness_(max_brightness) {}
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS});
return traits;
}
void write_state(light::LightState *state) override {
float brightness;
state->current_values_as_brightness(&brightness);
this->parent_->set_brightness(
this->min_brightness_ +
static_cast<uint8_t>(roundf(brightness * (this->max_brightness_ - this->min_brightness_))));
}
protected:
uint8_t min_brightness_;
uint8_t max_brightness_;
};
} // namespace esphome::mipi_spi
+11 -2
View File
@@ -72,6 +72,12 @@ void internal_dump_config(const char *model, int width, int height, int offset_w
GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width,
bool has_hardware_rotation);
// Lets a light set the display brightness without knowing the display's template parameters.
class MipiSpiBrightness {
public:
virtual void set_brightness(uint8_t brightness) = 0;
};
/**
* Base class for MIPI SPI displays.
* All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file.
@@ -94,6 +100,7 @@ template<typename BUFFERTYPE, PixelMode BUFFERPIXEL, bool IS_BIG_ENDIAN, PixelMo
int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL,
bool HAS_HARDWARE_ROTATION>
class MipiSpi : public display::Display,
public MipiSpiBrightness,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_1MHZ> {
public:
@@ -108,9 +115,11 @@ class MipiSpi : public display::Display,
this->invert_colors_ = invert_colors;
this->reset_params_();
}
void set_brightness(uint8_t brightness) {
void set_brightness(uint8_t brightness) override {
this->brightness_ = brightness;
this->reset_params_();
// Before setup the stored value is applied by reset_params_()
if (this->is_ready())
this->write_command_(BRIGHTNESS, brightness);
}
void set_rotation(display::DisplayRotation rotation) override {
this->rotation_ = rotation;

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