mirror of
https://github.com/esphome/esphome.git
synced 2026-10-16 11:12:52 +00:00
Merge remote-tracking branch 'origin/dev' into sscanf_stub
This commit is contained in:
@@ -287,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
|
||||
@@ -657,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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -51,12 +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.cpp_generator import Expression, MockObj, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import fnv1_hash
|
||||
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
|
||||
@@ -91,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
|
||||
@@ -389,6 +392,7 @@ 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,
|
||||
@@ -520,9 +524,15 @@ def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -678,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(
|
||||
@@ -760,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:
|
||||
@@ -794,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),
|
||||
@@ -809,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.
|
||||
@@ -830,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")
|
||||
@@ -911,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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -932,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(
|
||||
@@ -987,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"
|
||||
@@ -1106,6 +1138,7 @@ _define_filter = filter_source_files_from_defines(
|
||||
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
|
||||
"api_frame_helper_noise.cpp": "USE_API_NOISE",
|
||||
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
|
||||
"api_wizard.cpp": "USE_API_WIZARD",
|
||||
"api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -20,6 +20,8 @@ service APIConnection {
|
||||
option (needs_authentication) = false;
|
||||
}
|
||||
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
|
||||
rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {}
|
||||
rpc wizard_input_set (WizardInputSetRequest) returns (void) {}
|
||||
rpc list_entities (ListEntitiesRequest) returns (void) {}
|
||||
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
|
||||
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
|
||||
@@ -76,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) {}
|
||||
}
|
||||
@@ -233,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 {
|
||||
@@ -389,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;
|
||||
@@ -398,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 {
|
||||
@@ -2692,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
|
||||
@@ -2706,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;
|
||||
|
||||
@@ -2715,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
|
||||
@@ -2885,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;
|
||||
|
||||
@@ -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());
|
||||
@@ -2029,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);
|
||||
}
|
||||
@@ -2243,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);
|
||||
@@ -2287,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
|
||||
@@ -2686,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);
|
||||
|
||||
|
||||
@@ -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 *);
|
||||
|
||||
@@ -407,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
|
||||
@@ -751,15 +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());
|
||||
|
||||
+232
-218
File diff suppressed because it is too large
Load Diff
+343
-200
File diff suppressed because it is too large
Load Diff
@@ -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,7 +892,31 @@ 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"));
|
||||
@@ -1052,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
|
||||
@@ -1075,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();
|
||||
@@ -2720,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
|
||||
@@ -2749,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 {
|
||||
@@ -2832,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -504,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{};
|
||||
@@ -517,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
|
||||
|
||||
@@ -203,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -4,64 +4,40 @@
|
||||
#ifdef USE_API
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include "api_pb2.h"
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
#include "esphome/components/json/json_util.h"
|
||||
#endif
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> {
|
||||
// Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation)
|
||||
// rather than being wrapped in a lambda. The base class constructor for const char* is more
|
||||
// specialized than the templated constructor here, so it should be selected.
|
||||
static_assert(std::is_constructible_v<TemplatableValue<std::string, X...>, const char *>,
|
||||
"Base class must have const char* constructor for STATIC_STRING optimization");
|
||||
// Converts a lambda result to the string sent to Home Assistant
|
||||
template<typename T>
|
||||
requires(!std::is_pointer_v<std::remove_cvref_t<T>>) std::string field_to_string(T &&val) {
|
||||
return to_string(std::forward<T>(val)); // NOLINT
|
||||
}
|
||||
inline std::string field_to_string(const char *val) { return val ? std::string(val) : std::string(); }
|
||||
inline std::string field_to_string(std::string val) { return val; }
|
||||
inline std::string field_to_string(StringRef val) { return val.str(); }
|
||||
|
||||
private:
|
||||
// Helper to convert value to string - handles the case where value is already a string
|
||||
template<typename T> static std::string value_to_string(T &&val) {
|
||||
return to_string(std::forward<T>(val)); // NOLINT
|
||||
/// A key and value from codegen; on ESP8266 the table and its strings are in flash.
|
||||
/// The value is the constant `value`, or the result of `fn` when it is set.
|
||||
template<typename... Ts> struct HomeAssistantField {
|
||||
const char *key;
|
||||
const char *value;
|
||||
std::string (*fn)(const Ts &...);
|
||||
|
||||
template<typename F> static constexpr HomeAssistantField from_lambda(const char *key, F /*lambda*/) {
|
||||
return {key, nullptr, &call_lambda<F>};
|
||||
}
|
||||
|
||||
// Overloads for string types - needed because std::to_string doesn't support them
|
||||
static std::string value_to_string(char *val) {
|
||||
return val ? std::string(val) : std::string();
|
||||
} // For lambdas returning char* (e.g., itoa)
|
||||
static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str()
|
||||
static std::string value_to_string(const std::string &val) { return val; }
|
||||
static std::string value_to_string(std::string &&val) { return std::move(val); }
|
||||
static std::string value_to_string(const StringRef &val) { return val.str(); }
|
||||
static std::string value_to_string(StringRef &&val) { return val.str(); }
|
||||
|
||||
public:
|
||||
TemplatableStringValue() : TemplatableValue<std::string, X...>() {}
|
||||
|
||||
template<typename F, enable_if_t<!is_invocable<F, X...>::value, int> = 0>
|
||||
TemplatableStringValue(F value) : TemplatableValue<std::string, X...>(value) {}
|
||||
|
||||
template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0>
|
||||
TemplatableStringValue(F f)
|
||||
: TemplatableValue<std::string, X...>([f](X... x) -> std::string { return value_to_string(f(x...)); }) {}
|
||||
};
|
||||
|
||||
template<typename... Ts> class TemplatableKeyValuePair {
|
||||
public:
|
||||
// Default constructor needed for FixedVector::emplace_back()
|
||||
TemplatableKeyValuePair() = default;
|
||||
|
||||
// Keys are always string literals from YAML dictionary keys (e.g., "code", "event")
|
||||
// and never templatable values or lambdas. Only the value parameter can be a lambda/template.
|
||||
// Using const char* avoids std::string heap allocation - keys remain in flash.
|
||||
template<typename T> TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {}
|
||||
|
||||
const char *key{nullptr};
|
||||
TemplatableStringValue<Ts...> value;
|
||||
template<typename F> static std::string call_lambda(const Ts &...x) { return field_to_string(F{}(x...)); }
|
||||
};
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
@@ -106,45 +82,20 @@ template<typename... Ts> using ActionResponseCallback = std::function<void(const
|
||||
|
||||
template<typename... Ts> class HomeAssistantServiceCallAction final : public Action<Ts...> {
|
||||
public:
|
||||
explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) {
|
||||
using Field = HomeAssistantField<Ts...>;
|
||||
|
||||
/// `fields` is a codegen table: the action or event name (no key), then the data, data_template
|
||||
/// and variables entries.
|
||||
HomeAssistantServiceCallAction(APIServer *parent, bool is_event, const Field *fields, uint8_t data_count,
|
||||
uint8_t data_template_count, uint8_t variables_count)
|
||||
: parent_(parent),
|
||||
fields_(fields),
|
||||
data_count_(data_count),
|
||||
data_template_count_(data_template_count),
|
||||
variables_count_(variables_count) {
|
||||
this->flags_.is_event = is_event;
|
||||
}
|
||||
|
||||
template<typename T> void set_service(T service) { this->service_ = service; }
|
||||
|
||||
// Initialize FixedVector members - called from Python codegen with compile-time known sizes.
|
||||
// Must be called before any add_* methods; capacity must match the number of subsequent add_* calls.
|
||||
void init_data(size_t count) { this->data_.init(count); }
|
||||
void init_data_template(size_t count) { this->data_template_.init(count); }
|
||||
void init_variables(size_t count) { this->variables_.init(count); }
|
||||
|
||||
// Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))).
|
||||
// The value parameter can be a lambda/template, but keys are never templatable.
|
||||
// Using const char* for keys avoids std::string heap allocation - keys remain in flash.
|
||||
template<typename V> void add_data(const char *key, V &&value) {
|
||||
this->add_kv_(this->data_, key, std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_data_template(const char *key, V &&value) {
|
||||
this->add_kv_(this->data_template_, key, std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_variable(const char *key, V &&value) {
|
||||
this->add_kv_(this->variables_, key, std::forward<V>(value));
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, ESPHOME_F() returns __FlashStringHelper* (PROGMEM pointer).
|
||||
// Store as const char* — populate_service_map copies from PROGMEM at play() time.
|
||||
template<typename V> void add_data(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->data_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_data_template(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->data_template_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_variable(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->variables_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
template<typename T> void set_response_template(T response_template) {
|
||||
this->response_template_ = response_template;
|
||||
@@ -162,19 +113,61 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
const Field *fields = this->fields_;
|
||||
const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_;
|
||||
|
||||
// Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send
|
||||
size_t lambda_count = 0;
|
||||
#ifdef USE_ESP8266
|
||||
size_t flash_len = 0;
|
||||
#endif
|
||||
for (size_t i = 0; i < total; i++) {
|
||||
lambda_count += fields[i].fn != nullptr;
|
||||
#ifdef USE_ESP8266
|
||||
if (fields[i].fn == nullptr)
|
||||
flash_len += ESPHOME_strlen_P(fields[i].value);
|
||||
if (fields[i].key != nullptr)
|
||||
flash_len += ESPHOME_strlen_P(fields[i].key);
|
||||
#endif
|
||||
}
|
||||
FixedVector<std::string> results;
|
||||
results.init(lambda_count);
|
||||
#ifdef USE_ESP8266
|
||||
SmallBufferWithHeapFallback<128, char> flash_copy(flash_len);
|
||||
char *cursor = flash_copy.get();
|
||||
#endif
|
||||
auto string_ref = [&](const char *str) {
|
||||
#ifdef USE_ESP8266
|
||||
size_t len = ESPHOME_strlen_P(str);
|
||||
memcpy_P(cursor, str, len);
|
||||
StringRef ref(cursor, len);
|
||||
cursor += len;
|
||||
return ref;
|
||||
#else
|
||||
return StringRef(str);
|
||||
#endif
|
||||
};
|
||||
auto value_ref = [&](const Field &field) {
|
||||
if (field.fn == nullptr)
|
||||
return string_ref(field.value);
|
||||
results.push_back(field.fn(x...));
|
||||
return StringRef(results.back());
|
||||
};
|
||||
auto fill = [&](FixedVector<HomeassistantServiceMap> &dest, uint8_t count) {
|
||||
dest.init(count);
|
||||
for (uint8_t i = 0; i < count; i++, fields++) {
|
||||
auto &kv = dest.emplace_back();
|
||||
kv.key = string_ref(fields->key);
|
||||
kv.value = value_ref(*fields);
|
||||
}
|
||||
};
|
||||
|
||||
HomeassistantActionRequest resp;
|
||||
std::string service_value = this->service_.value(x...);
|
||||
resp.service = StringRef(service_value);
|
||||
resp.service = value_ref(*fields++);
|
||||
resp.is_event = this->flags_.is_event;
|
||||
|
||||
// Local storage for lambda-evaluated strings - lives until after send
|
||||
FixedVector<std::string> data_storage;
|
||||
FixedVector<std::string> data_template_storage;
|
||||
FixedVector<std::string> variables_storage;
|
||||
|
||||
this->populate_service_map(resp.data, this->data_, data_storage, x...);
|
||||
this->populate_service_map(resp.data_template, this->data_template_, data_template_storage, x...);
|
||||
this->populate_service_map(resp.variables, this->variables_, variables_storage, x...);
|
||||
fill(resp.data, this->data_count_);
|
||||
fill(resp.data_template, this->data_template_count_);
|
||||
fill(resp.variables, this->variables_count_);
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
@@ -223,90 +216,26 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
|
||||
}
|
||||
|
||||
protected:
|
||||
// Helper to add key-value pairs to FixedVectors
|
||||
// Keys are always string literals (const char*), values can be lambdas/templates
|
||||
template<typename V> void add_kv_(FixedVector<TemplatableKeyValuePair<Ts...>> &vec, const char *key, V &&value) {
|
||||
auto &kv = vec.emplace_back();
|
||||
kv.key = key;
|
||||
kv.value = std::forward<V>(value);
|
||||
}
|
||||
|
||||
template<typename VectorType, typename SourceType>
|
||||
static void populate_service_map(VectorType &dest, SourceType &source, FixedVector<std::string> &value_storage,
|
||||
Ts... x) {
|
||||
dest.init(source.size());
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, all static strings from codegen are FLASH_STRING (PROGMEM),
|
||||
// so is_static_string() is always false — the zero-copy STATIC_STRING fast
|
||||
// path from the non-ESP8266 branch cannot trigger. We copy all keys and
|
||||
// values unconditionally: keys via _P functions (may be in PROGMEM), values
|
||||
// via value() which handles FLASH_STRING internally.
|
||||
value_storage.init(source.size() * 2);
|
||||
|
||||
for (auto &it : source) {
|
||||
auto &kv = dest.emplace_back();
|
||||
|
||||
// Key: copy from possible PROGMEM
|
||||
{
|
||||
size_t key_len = strlen_P(it.key);
|
||||
value_storage.push_back(std::string(key_len, '\0'));
|
||||
memcpy_P(value_storage.back().data(), it.key, key_len);
|
||||
kv.key = StringRef(value_storage.back());
|
||||
}
|
||||
|
||||
// Value: value() handles FLASH_STRING via _P functions internally
|
||||
value_storage.push_back(it.value.value(x...));
|
||||
kv.value = StringRef(value_storage.back());
|
||||
}
|
||||
#else
|
||||
// On non-ESP8266, strings are directly readable from flash-mapped memory.
|
||||
// Count non-static strings to allocate exact storage needed.
|
||||
size_t lambda_count = 0;
|
||||
for (const auto &it : source) {
|
||||
if (!it.value.is_static_string()) {
|
||||
lambda_count++;
|
||||
}
|
||||
}
|
||||
value_storage.init(lambda_count);
|
||||
|
||||
for (auto &it : source) {
|
||||
auto &kv = dest.emplace_back();
|
||||
kv.key = StringRef(it.key);
|
||||
|
||||
if (it.value.is_static_string()) {
|
||||
// Static string — pointer directly readable, zero allocation
|
||||
kv.value = StringRef(it.value.get_static_string());
|
||||
} else {
|
||||
// Lambda — evaluate and store result
|
||||
value_storage.push_back(it.value.value(x...));
|
||||
kv.value = StringRef(value_storage.back());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
APIServer *parent_;
|
||||
TemplatableStringValue<Ts...> service_{};
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> data_;
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> data_template_;
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> variables_;
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
TemplatableStringValue<Ts...> response_template_{""};
|
||||
Trigger<JsonObjectConst, Ts...> success_trigger_with_response_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
Trigger<Ts...> success_trigger_;
|
||||
Trigger<std::string, Ts...> error_trigger_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
|
||||
const Field *fields_;
|
||||
uint8_t data_count_;
|
||||
uint8_t data_template_count_;
|
||||
uint8_t variables_count_;
|
||||
struct Flags {
|
||||
uint8_t is_event : 1;
|
||||
uint8_t wants_status : 1;
|
||||
uint8_t wants_response : 1;
|
||||
uint8_t has_response_template : 1;
|
||||
uint8_t reserved : 5;
|
||||
uint8_t reserved : 4;
|
||||
} flags_{0};
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
TemplatableValue<std::string, Ts...> response_template_{};
|
||||
Trigger<JsonObjectConst, Ts...> success_trigger_with_response_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
Trigger<Ts...> success_trigger_;
|
||||
Trigger<std::string, Ts...> error_trigger_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -119,7 +119,7 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
|
||||
}
|
||||
|
||||
// Single-pass encode for repeated submessage elements (non-template core).
|
||||
// Writes field tag, reserves 1 byte for length varint, encodes the submessage body,
|
||||
// Reserves 1 byte for length varint, encodes the submessage body,
|
||||
// then backpatches the actual length. For the common case (body < 128 bytes), this is
|
||||
// just a single byte write with no memmove — all current repeated submessage types
|
||||
// (BLE advertisements at ~47B, GATT descriptors at ~24B, service args, etc.) take
|
||||
@@ -143,51 +143,34 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
|
||||
//
|
||||
// After writing 2-byte varint at len_pos:
|
||||
// [tag][v1][v2][body ..... body]
|
||||
// ^-- pos_ = element end, within buffer
|
||||
void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *,
|
||||
ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) {
|
||||
this->encode_field_raw(field_id, 2);
|
||||
// Reserve 1 byte for length varint (optimistic: submessage < 128 bytes)
|
||||
uint8_t *len_pos = this->pos_;
|
||||
this->debug_check_bounds_(1);
|
||||
this->pos_++;
|
||||
uint8_t *body_start = this->pos_;
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
uint32_t body_size = static_cast<uint32_t>(this->pos_ - body_start);
|
||||
if (body_size < 128) [[likely]] {
|
||||
// ^-- returned cursor = element end, within buffer
|
||||
uint8_t *ProtoEncode::encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *value,
|
||||
ProtoEncodeFn encode_fn) {
|
||||
// Reserve 1 byte for the length varint (optimistic: submessage < 128 bytes)
|
||||
uint8_t *len_pos = pos;
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
uint8_t *body_start = pos + 1;
|
||||
uint8_t *after_body = encode_fn(value, body_start PROTO_ENCODE_DEBUG_ARG);
|
||||
uint32_t body_size = static_cast<uint32_t>(after_body - body_start);
|
||||
if (body_size < VARINT_MAX_1_BYTE) [[likely]] {
|
||||
// Common case: 1-byte varint, just backpatch
|
||||
*len_pos = static_cast<uint8_t>(body_size);
|
||||
return;
|
||||
return after_body;
|
||||
}
|
||||
// Compute extra bytes needed for varint beyond the 1 already reserved
|
||||
// Shift the body forward to make room for the extra length varint bytes
|
||||
uint8_t extra = ProtoSize::varint(body_size) - 1;
|
||||
// Shift body forward to make room for the extra varint bytes
|
||||
this->debug_check_bounds_(extra);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(after_body, extra);
|
||||
std::memmove(body_start + extra, body_start, body_size);
|
||||
uint8_t *end = this->pos_ + extra;
|
||||
// Write the full varint at len_pos
|
||||
this->pos_ = len_pos;
|
||||
this->encode_varint_raw(body_size);
|
||||
this->pos_ = end;
|
||||
(void) encode_varint_raw_loop(len_pos PROTO_ENCODE_DEBUG_ARG, body_size);
|
||||
return after_body + extra;
|
||||
}
|
||||
|
||||
// Non-template core for encode_optional_sub_message.
|
||||
void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *,
|
||||
ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) {
|
||||
if (nested_size == 0)
|
||||
return;
|
||||
this->encode_field_raw(field_id, 2);
|
||||
this->encode_varint_raw(nested_size);
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *start = this->pos_;
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
if (static_cast<uint32_t>(this->pos_ - start) != nested_size)
|
||||
this->debug_check_encode_size_(field_id, nested_size, this->pos_ - start);
|
||||
#else
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
#endif
|
||||
uint8_t *ProtoEncode::encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t nested_size, const void *value, ProtoEncodeFn encode_fn) {
|
||||
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, nested_size);
|
||||
return encode_fn(value, pos PROTO_ENCODE_DEBUG_ARG);
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
@@ -201,6 +184,14 @@ void proto_check_encode_end(const uint8_t *end, const uint8_t *expected) {
|
||||
ESP_LOGE(TAG, "Proto encode ended %td bytes off the calculated size", end - expected);
|
||||
abort();
|
||||
}
|
||||
void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end) {
|
||||
ptrdiff_t actual = end - (len_pos + ProtoSize::varint(expected));
|
||||
if (actual == static_cast<ptrdiff_t>(expected))
|
||||
return;
|
||||
ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id,
|
||||
expected, actual);
|
||||
abort();
|
||||
}
|
||||
void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) {
|
||||
if (this->pos_ + bytes > this->buffer_->data() + this->buffer_->size()) {
|
||||
ESP_LOGE(TAG, "ProtoWriteBuffer bounds check failed in %s: bytes=%zu offset=%td buf_size=%zu", caller, bytes,
|
||||
@@ -208,11 +199,6 @@ void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) {
|
||||
abort();
|
||||
}
|
||||
}
|
||||
void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual) {
|
||||
ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id,
|
||||
expected, actual);
|
||||
abort();
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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_; };
|
||||
|
||||
@@ -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
|
||||
@@ -77,6 +77,7 @@ from .const import (
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_FULL_CERT_BUNDLE,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_MBEDTLS_SDKCONFIG,
|
||||
KEY_NETWORK_SDKCONFIG,
|
||||
KEY_PATH,
|
||||
KEY_REF,
|
||||
@@ -242,7 +243,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
|
||||
@@ -817,6 +818,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,
|
||||
@@ -1853,6 +1945,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"
|
||||
@@ -1868,12 +1961,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"
|
||||
|
||||
@@ -1912,6 +2000,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
|
||||
|
||||
|
||||
@@ -1937,33 +2028,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:
|
||||
@@ -1976,18 +2071,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:
|
||||
@@ -2142,6 +2233,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,
|
||||
@@ -2533,34 +2625,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:
|
||||
@@ -3182,6 +3343,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
|
||||
@@ -3252,38 +3417,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)
|
||||
|
||||
@@ -3293,12 +3426,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.
|
||||
@@ -3331,6 +3466,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -24,7 +24,6 @@ from esphome.types import ConfigType
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
AUTO_LOAD = ["network"]
|
||||
|
||||
byte_vector = cg.std_vector.template(cg.uint8)
|
||||
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
|
||||
|
||||
espnow_ns = cg.esphome_ns.namespace("espnow")
|
||||
@@ -304,11 +303,12 @@ async def send_action(
|
||||
|
||||
await register_peer(var, config, args)
|
||||
|
||||
data = config.get(CONF_DATA, [])
|
||||
data = config[CONF_DATA]
|
||||
if isinstance(data, str):
|
||||
data = list(data.encode())
|
||||
templ = await cg.templatable(data, args, byte_vector, byte_vector)
|
||||
cg.add(var.set_data(templ))
|
||||
await automation.templatable_bytes(
|
||||
data, args, var.set_data_template, var.set_data_static, "espnow_data"
|
||||
)
|
||||
|
||||
cg.add(var.set_wait_for_sent(config[CONF_WAIT_FOR_SENT]))
|
||||
cg.add(var.set_continue_on_error(config[CONF_CONTINUE_ON_ERROR]))
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace esphome::espnow {
|
||||
|
||||
template<typename... Ts> class SendAction final : public Action<Ts...>, public Parented<ESPNowComponent> {
|
||||
TEMPLATABLE_VALUE(peer_address_t, address);
|
||||
TEMPLATABLE_VALUE(std::vector<uint8_t>, data);
|
||||
TEMPLATABLE_BYTES(data)
|
||||
|
||||
public:
|
||||
void add_on_sent(const std::initializer_list<Action<Ts...> *> &actions) {
|
||||
@@ -58,8 +58,10 @@ template<typename... Ts> class SendAction final : public Action<Ts...>, public P
|
||||
}
|
||||
};
|
||||
peer_address_t address = this->address_.value(x...);
|
||||
std::vector<uint8_t> data = this->data_.value(x...);
|
||||
esp_err_t err = this->parent_->send(address.data(), data, send_callback);
|
||||
esp_err_t err = ESP_OK;
|
||||
this->data_.visit(
|
||||
[&](const uint8_t *data, size_t len) { err = this->parent_->send(address.data(), data, len, send_callback); },
|
||||
x...);
|
||||
if (err != ESP_OK) {
|
||||
send_callback(err);
|
||||
} else if (!this->flags_.wait_for_sent) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -29,6 +30,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
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")
|
||||
|
||||
@@ -24,6 +24,13 @@ void HomeassistantButton::press_action() {
|
||||
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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
@@ -40,6 +41,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
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")
|
||||
|
||||
@@ -230,6 +230,13 @@ void HomeassistantSelect::control(size_t index) {
|
||||
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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
@@ -29,6 +30,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
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")
|
||||
|
||||
@@ -79,6 +79,13 @@ void HomeassistantText::control(const std::string &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
|
||||
|
||||
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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome.components.esp32 import (
|
||||
add_idf_component,
|
||||
idf_version,
|
||||
include_builtin_idf_component,
|
||||
request_tls,
|
||||
)
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_defines,
|
||||
@@ -358,8 +359,8 @@ async def to_code(config):
|
||||
add_idf_component(name="espressif/mqtt", ref="1.0.0")
|
||||
else:
|
||||
include_builtin_idf_component("mqtt")
|
||||
# mqtt_client.h drags in esp_tls types; esp-tls is excluded by default
|
||||
include_builtin_idf_component("esp-tls")
|
||||
# esp-mqtt links transport_ssl.c (esp_tls) even for plain MQTT
|
||||
request_tls()
|
||||
|
||||
cg.add_define("USE_MQTT")
|
||||
cg.add_global(mqtt_ns.using)
|
||||
|
||||
@@ -271,10 +271,7 @@ async def to_code(config):
|
||||
)
|
||||
|
||||
if CORE.is_esp32:
|
||||
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
|
||||
# and esp-tls, whose sdkconfig options below need the component present
|
||||
esp32.include_builtin_idf_component("esp_http_client")
|
||||
esp32.include_builtin_idf_component("esp-tls")
|
||||
esp32.request_http_client()
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True)
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True
|
||||
|
||||
@@ -13,6 +13,7 @@ from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
include_builtin_idf_component,
|
||||
only_on_variant,
|
||||
require_mbedtls_ecp,
|
||||
require_mbedtls_tls_extras,
|
||||
require_mbedtls_tls_server,
|
||||
require_vfs_select,
|
||||
@@ -112,9 +113,9 @@ def set_sdkconfig_options(config: ConfigType) -> None:
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True)
|
||||
|
||||
# OpenThread's DTLS commissioner is a TLS server, and its crypto platform
|
||||
# uses AES-CCM and deterministic ECDSA directly. Keep the esp32 component
|
||||
# from trimming them out of mbedTLS.
|
||||
# Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked;
|
||||
# setting one under sdkconfig_options keeps TLS in the build automatically.
|
||||
# The crypto platform uses AES-CCM and deterministic ECDSA directly.
|
||||
require_mbedtls_tls_server()
|
||||
require_mbedtls_tls_extras(
|
||||
("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC")
|
||||
@@ -293,6 +294,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Re-enable openthread IDF component (excluded by default)
|
||||
if CORE.is_esp32:
|
||||
include_builtin_idf_component("openthread")
|
||||
# OPENTHREAD_CONFIG_ECDSA_ENABLE: the SRP client host key uses mbedtls_ecdsa_*
|
||||
require_mbedtls_ecp()
|
||||
|
||||
cg.add_define("USE_OPENTHREAD")
|
||||
if config.get(CONF_FORCE_DATASET):
|
||||
|
||||
@@ -60,7 +60,7 @@ def validate_mode(value: ConfigType) -> ConfigType:
|
||||
PCA6416A_PIN_SCHEMA = cv.All(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin),
|
||||
cv.Required(CONF_PCA6416A): cv.use_id(PCA6416AComponent),
|
||||
cv.Required(CONF_PCA6416A): pins.use_id_or_address(PCA6416AComponent),
|
||||
cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15),
|
||||
cv.Optional(CONF_MODE, default={}): cv.All(
|
||||
{
|
||||
|
||||
@@ -66,7 +66,7 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema(
|
||||
mode_validator=validate_mode,
|
||||
).extend(
|
||||
{
|
||||
cv.Required(CONF_PCA9554): cv.use_id(PCA9554Component),
|
||||
cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ PCF8574_PIN_SCHEMA = pins.gpio_base_schema(
|
||||
invertible=True,
|
||||
).extend(
|
||||
{
|
||||
cv.Required(CONF_PCF8574): cv.use_id(PCF8574Component),
|
||||
cv.Required(CONF_PCF8574): pins.use_id_or_address(PCF8574Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema(
|
||||
mode_validator=validate_mode,
|
||||
).extend(
|
||||
{
|
||||
cv.Required(CONF_PI4IOE5V6408): cv.use_id(PI4IOE5V6408Component),
|
||||
cv.Required(CONF_PI4IOE5V6408): pins.use_id_or_address(PI4IOE5V6408Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -10,22 +10,21 @@ Once the API is considered stable, this warning will be removed.
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ir_rf_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_CONTROL
|
||||
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
|
||||
|
||||
radio_frequency_ns = cg.esphome_ns.namespace("radio_frequency")
|
||||
RadioFrequency = radio_frequency_ns.class_(
|
||||
"RadioFrequency", cg.EntityBase, cg.Component
|
||||
)
|
||||
RadioFrequency = radio_frequency_ns.class_("RadioFrequency", ir_rf_base.IrRfEntity)
|
||||
RadioFrequencyCall = radio_frequency_ns.class_("RadioFrequencyCall")
|
||||
RadioFrequencyTraits = radio_frequency_ns.class_("RadioFrequencyTraits")
|
||||
RadioFrequencyModulation = radio_frequency_ns.enum("RadioFrequencyModulation")
|
||||
@@ -55,11 +54,8 @@ async def setup_radio_frequency_core_(var: cg.MockObj, config: ConfigType) -> No
|
||||
|
||||
async def register_radio_frequency(var: cg.MockObj, config: ConfigType) -> None:
|
||||
"""Register a radio frequency device with the core."""
|
||||
cg.add_define("USE_RADIO_FREQUENCY")
|
||||
await cg.register_component(var, config)
|
||||
queue_entity_register("radio_frequency", config)
|
||||
await ir_rf_base.register_ir_rf_entity(var, config, "radio_frequency")
|
||||
await setup_radio_frequency_core_(var, config)
|
||||
CORE.register_platform_component("radio_frequency", var)
|
||||
|
||||
for conf in config.get(CONF_ON_CONTROL, []):
|
||||
await automation.build_callback_automation(
|
||||
|
||||
@@ -4,73 +4,17 @@
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::radio_frequency {
|
||||
|
||||
static const char *const TAG = "radio_frequency";
|
||||
|
||||
// ========== RadioFrequencyCall ==========
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_frequency(uint32_t frequency_hz) {
|
||||
this->frequency_hz_ = frequency_hz;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_modulation(RadioFrequencyModulation modulation) {
|
||||
this->modulation_ = modulation;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_raw_timings(const std::vector<int32_t> &timings) {
|
||||
this->raw_timings_ = &timings;
|
||||
this->packed_data_ = nullptr;
|
||||
this->base64url_ptr_ = nullptr;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_base64url(const std::string &base64url) {
|
||||
this->base64url_ptr_ = &base64url;
|
||||
this->raw_timings_ = nullptr;
|
||||
this->packed_data_ = nullptr;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::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;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) {
|
||||
this->repeat_count_ = count;
|
||||
return *this;
|
||||
}
|
||||
|
||||
void RadioFrequencyCall::perform() {
|
||||
if (this->parent_ != nullptr) {
|
||||
// Fire any on_control hooks (user-wired automations) before handing off to
|
||||
// the platform-specific control() — gives users a chance to react to call
|
||||
// parameters (e.g. retune an external RF front-end based on call.get_frequency()).
|
||||
this->parent_->control_callback_.call(*this);
|
||||
this->parent_->control(*this);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== RadioFrequency ==========
|
||||
|
||||
void RadioFrequency::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Radio Frequency '%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->traits_.get_frequency_min_hz() > 0) {
|
||||
if (this->traits_.get_frequency_min_hz() == this->traits_.get_frequency_max_hz()) {
|
||||
ESP_LOGCONFIG(TAG, " Frequency: %" PRIu32 " Hz (fixed)", this->traits_.get_frequency_min_hz());
|
||||
@@ -81,31 +25,9 @@ void RadioFrequency::dump_config() {
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t RadioFrequency::get_capability_flags() const {
|
||||
uint32_t flags = 0;
|
||||
if (this->traits_.get_supports_transmitter())
|
||||
flags |= RadioFrequencyCapability::CAPABILITY_TRANSMITTER;
|
||||
if (this->traits_.get_supports_receiver())
|
||||
flags |= RadioFrequencyCapability::CAPABILITY_RECEIVER;
|
||||
return flags;
|
||||
}
|
||||
|
||||
bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) {
|
||||
// Invoke local callbacks
|
||||
this->receive_callback_.call(data);
|
||||
|
||||
// Forward received RF data to API server
|
||||
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
|
||||
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
|
||||
return IrRfEntity::on_receive(data);
|
||||
}
|
||||
|
||||
} // namespace esphome::radio_frequency
|
||||
|
||||
@@ -4,20 +4,12 @@
|
||||
// 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 <vector>
|
||||
#include "esphome/components/ir_rf_base/ir_rf_base.h"
|
||||
|
||||
namespace esphome::radio_frequency {
|
||||
|
||||
/// Capability flags for individual radio frequency instances
|
||||
enum RadioFrequencyCapability : 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;
|
||||
|
||||
/// Modulation types supported by radio frequency implementations
|
||||
enum RadioFrequencyModulation : uint8_t {
|
||||
@@ -25,95 +17,36 @@ enum RadioFrequencyModulation : uint8_t {
|
||||
// Future: RADIO_FREQUENCY_MODULATION_FSK, RADIO_FREQUENCY_MODULATION_GFSK, etc.
|
||||
};
|
||||
|
||||
/// Forward declarations
|
||||
class RadioFrequency;
|
||||
|
||||
/// RadioFrequencyCall - Builder pattern for transmitting radio frequency signals
|
||||
class RadioFrequencyCall {
|
||||
class RadioFrequencyCall : public ir_rf_base::IrRfCall<RadioFrequencyCall, RadioFrequency> {
|
||||
public:
|
||||
explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {}
|
||||
explicit RadioFrequencyCall(RadioFrequency *parent) : IrRfCall(parent) {}
|
||||
|
||||
/// Set the carrier frequency in Hz (e.g. 433920000 for 433.92 MHz)
|
||||
RadioFrequencyCall &set_frequency(uint32_t frequency_hz);
|
||||
|
||||
RadioFrequencyCall &set_frequency(uint32_t frequency_hz) {
|
||||
this->frequency_hz_ = frequency_hz;
|
||||
return *this;
|
||||
}
|
||||
/// Set the modulation type (defaults to OOK)
|
||||
RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation);
|
||||
|
||||
// ===== 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.
|
||||
RadioFrequencyCall &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.
|
||||
RadioFrequencyCall &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.
|
||||
RadioFrequencyCall &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.)
|
||||
RadioFrequencyCall &set_repeat_count(uint32_t count);
|
||||
|
||||
/// Perform the transmission
|
||||
void perform();
|
||||
|
||||
RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation) {
|
||||
this->modulation_ = modulation;
|
||||
return *this;
|
||||
}
|
||||
/// Get the frequency in Hz
|
||||
const optional<uint32_t> &get_frequency() const { return this->frequency_hz_; }
|
||||
/// Get the modulation type
|
||||
RadioFrequencyModulation get_modulation() const { return this->modulation_; }
|
||||
/// 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:
|
||||
optional<uint32_t> frequency_hz_{};
|
||||
uint32_t repeat_count_{1};
|
||||
RadioFrequency *parent_;
|
||||
// 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};
|
||||
RadioFrequencyModulation modulation_{RADIO_FREQUENCY_MODULATION_OOK};
|
||||
};
|
||||
|
||||
/// RadioFrequencyTraits - Describes the capabilities of a radio frequency implementation
|
||||
class RadioFrequencyTraits {
|
||||
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; }
|
||||
|
||||
/// Hardware-supported tunable frequency range in Hz.
|
||||
/// If min == max (and both non-zero): fixed-frequency hardware.
|
||||
/// If both 0: range unspecified.
|
||||
@@ -140,17 +73,14 @@ class RadioFrequencyTraits {
|
||||
uint32_t frequency_min_hz_{0}; // Minimum tunable frequency in Hz (0 = unspecified)
|
||||
uint32_t frequency_max_hz_{0}; // Maximum tunable frequency in Hz (0 = unspecified)
|
||||
uint32_t supported_modulations_{0}; // Bitmask of supported RadioFrequencyModulation values
|
||||
bool supports_transmitter_{false};
|
||||
bool supports_receiver_{false};
|
||||
};
|
||||
|
||||
/// RadioFrequency - Base class for radio frequency implementations
|
||||
class RadioFrequency : public Component, public EntityBase, public remote_base::RemoteReceiverListener {
|
||||
class RadioFrequency : public ir_rf_base::IrRfEntity {
|
||||
public:
|
||||
RadioFrequency() = default;
|
||||
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; }
|
||||
|
||||
/// Get the traits for this radio frequency implementation
|
||||
RadioFrequencyTraits &get_traits() { return this->traits_; }
|
||||
@@ -159,9 +89,6 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
|
||||
/// Create a call object for transmitting
|
||||
RadioFrequencyCall make_call() { return RadioFrequencyCall(this); }
|
||||
|
||||
/// Get capability flags for this radio frequency instance
|
||||
uint32_t get_capability_flags() const;
|
||||
|
||||
/// Called when RF data is received (from RemoteReceiverListener)
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||
|
||||
@@ -180,11 +107,16 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
|
||||
}
|
||||
|
||||
protected:
|
||||
friend class RadioFrequencyCall;
|
||||
friend class ir_rf_base::IrRfCall<RadioFrequencyCall, RadioFrequency>;
|
||||
|
||||
/// Fires the on_control hooks before the platform-specific control() runs
|
||||
void on_call_(const RadioFrequencyCall &call) { this->control_callback_.call(call); }
|
||||
/// Perform the actual transmission (called by RadioFrequencyCall::perform())
|
||||
/// Platforms must override this to implement hardware-specific transmission.
|
||||
virtual void control(const RadioFrequencyCall &call) = 0;
|
||||
/// Returns 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 RadioFrequencyCall &call) = 0;
|
||||
|
||||
// Traits describing capabilities
|
||||
RadioFrequencyTraits traits_;
|
||||
|
||||
@@ -136,8 +136,8 @@ async def attach_receiver(
|
||||
add_listener(receiver, var)
|
||||
|
||||
|
||||
async def register_transmittable(var, config):
|
||||
transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID])
|
||||
async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID):
|
||||
transmitter_ = await cg.get_variable(config[key])
|
||||
cg.add(var.set_transmitter(transmitter_))
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ void DishProtocol::encode(RemoteTransmitData *dst, const DishData &data) {
|
||||
|
||||
// Typically a DISH device needs to get a command a total of
|
||||
// at least 4 times to accept it.
|
||||
for (uint i = 0; i < 4; i++) {
|
||||
for (uint32_t i = 0; i < 4; i++) {
|
||||
// COMMAND (function, in MSB)
|
||||
for (uint8_t mask = 1UL << 5; mask; mask >>= 1) {
|
||||
if (data.command & mask) {
|
||||
@@ -39,7 +39,7 @@ void DishProtocol::encode(RemoteTransmitData *dst, const DishData &data) {
|
||||
}
|
||||
}
|
||||
// PADDING
|
||||
for (uint j = 0; j < 6; j++)
|
||||
for (uint32_t j = 0; j < 6; j++)
|
||||
dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US);
|
||||
|
||||
// FOOTER
|
||||
@@ -73,7 +73,7 @@ optional<DishData> DishProtocol::decode(RemoteReceiveData src) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
for (uint j = 0; j < 6; j++) {
|
||||
for (uint32_t j = 0; j < 6; j++) {
|
||||
if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) {
|
||||
|
||||
/* RemoteTransmitterBase */
|
||||
|
||||
void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) {
|
||||
void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, [[maybe_unused]] uint16_t seq) {
|
||||
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
|
||||
const auto &vec = this->temp_.get_data();
|
||||
char buffer[256];
|
||||
@@ -213,6 +213,10 @@ void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) {
|
||||
if (pos != 0) {
|
||||
ESP_LOGVV(TAG, "%s", buffer);
|
||||
}
|
||||
#endif
|
||||
this->flush_pending_completion();
|
||||
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
|
||||
this->current_seq_ = seq;
|
||||
#endif
|
||||
this->send_internal(send_times, send_wait);
|
||||
}
|
||||
|
||||
@@ -143,6 +143,14 @@ class RemoteRMTChannel {
|
||||
#endif // SOC_RMT_SUPPORTED
|
||||
#endif // USE_ESP32
|
||||
|
||||
class RemoteTransmitterBase;
|
||||
|
||||
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
|
||||
/// Defined by ir_rf_base: answers the API request waiting on the entity that submitted seq;
|
||||
/// sent is false when the platform never put the frame on the wire.
|
||||
/// One function for the whole build instead of a callback list on every transmitter.
|
||||
void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent);
|
||||
#endif
|
||||
// Protocol shapes, checked where a protocol is used so a missing method fails at the use site
|
||||
// instead of deep inside a template body. Receive-only protocols such as RCSwitchBase decode
|
||||
// without encoding.
|
||||
@@ -164,21 +172,24 @@ class RemoteTransmitterBase : public RemoteComponentBase {
|
||||
RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {}
|
||||
class TransmitCall {
|
||||
public:
|
||||
explicit TransmitCall(RemoteTransmitterBase *parent) : parent_(parent) {}
|
||||
TransmitCall(RemoteTransmitterBase *parent, uint16_t seq) : parent_(parent), seq_(seq) {}
|
||||
RemoteTransmitData *get_data() { return &this->parent_->temp_; }
|
||||
void set_send_times(uint32_t send_times) { send_times_ = send_times; }
|
||||
void set_send_wait(uint32_t send_wait) { send_wait_ = send_wait; }
|
||||
void perform() { this->parent_->send_(this->send_times_, this->send_wait_); }
|
||||
/// Identifies this transmission in the completion hook
|
||||
uint16_t get_seq() const { return this->seq_; }
|
||||
void perform() { this->parent_->send_(this->send_times_, this->send_wait_, this->seq_); }
|
||||
|
||||
protected:
|
||||
RemoteTransmitterBase *parent_;
|
||||
uint32_t send_times_{1};
|
||||
uint32_t send_wait_{0};
|
||||
uint16_t seq_;
|
||||
};
|
||||
|
||||
TransmitCall transmit() {
|
||||
this->temp_.reset();
|
||||
return TransmitCall(this);
|
||||
return TransmitCall(this, this->take_seq_());
|
||||
}
|
||||
template<RemoteProtocolEncoder Protocol>
|
||||
void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) {
|
||||
@@ -190,9 +201,25 @@ class RemoteTransmitterBase : public RemoteComponentBase {
|
||||
}
|
||||
|
||||
protected:
|
||||
void send_(uint32_t send_times, uint32_t send_wait);
|
||||
void send_(uint32_t send_times, uint32_t send_wait, uint16_t seq);
|
||||
virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0;
|
||||
void send_single_() { this->send_(1, 0); }
|
||||
/// Platforms that report completion later wait out the previous frame here, before send_()
|
||||
/// assigns the next seq, so that completion carries the seq it belongs to
|
||||
virtual void flush_pending_completion() {}
|
||||
void send_single_() { this->send_(1, 0, this->take_seq_()); }
|
||||
#ifdef USE_IR_RF_TRANSMIT_COMPLETE
|
||||
/// Reports the frame handed to the platform last, after its final repeat and before the
|
||||
/// on_complete trigger; a seq only has to be unique within the 30 s reply window
|
||||
void notify_complete_(bool sent) { ir_rf_transmit_complete(this, this->current_seq_, sent); }
|
||||
uint16_t take_seq_() { return ++this->next_seq_; }
|
||||
|
||||
uint16_t next_seq_{0};
|
||||
uint16_t current_seq_{0};
|
||||
#else
|
||||
// seq tracking only exists for the API completion reply
|
||||
void notify_complete_(bool /*sent*/) {}
|
||||
static uint16_t take_seq_() { return 0; }
|
||||
#endif
|
||||
|
||||
/// Use same vector for all transmits, avoids many allocations
|
||||
RemoteTransmitData temp_;
|
||||
|
||||
@@ -114,7 +114,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
}
|
||||
}
|
||||
}
|
||||
this->complete_trigger_.trigger();
|
||||
this->fire_complete_(send_times != 0); // same answer as the ISR backend for a frame sent zero times
|
||||
}
|
||||
|
||||
} // namespace esphome::remote_transmitter
|
||||
|
||||
@@ -82,6 +82,16 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
|
||||
|
||||
protected:
|
||||
void send_internal(uint32_t send_times, uint32_t send_wait) override;
|
||||
// the API reply is answered before the user's on_complete automation; sent is false on a
|
||||
// bail-out that never put the frame on the wire, on_complete fires either way
|
||||
void fire_complete_(bool sent = true) {
|
||||
this->notify_complete_(sent);
|
||||
this->complete_trigger_.trigger();
|
||||
}
|
||||
#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \
|
||||
defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
void flush_pending_completion() override;
|
||||
#endif
|
||||
#if defined(USE_ESP8266) || \
|
||||
(defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
|
||||
defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
|
||||
@@ -110,7 +110,7 @@ void RemoteTransmitterComponent::deliver_completion_() {
|
||||
if (!this->stall_aborted_)
|
||||
this->status_clear_warning();
|
||||
this->complete_pending_ = false;
|
||||
this->complete_trigger_.trigger();
|
||||
this->fire_complete_(!this->stall_aborted_);
|
||||
}
|
||||
|
||||
// Waits until no chain is in flight, delivering any deferred completions; a completion
|
||||
@@ -152,20 +152,21 @@ void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_w
|
||||
this->start_isr_item_(0);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::flush_pending_completion() { this->wait_until_idle_(); }
|
||||
|
||||
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
|
||||
if (!this->envelope_ready_()) {
|
||||
// both triggers still fire, so an on_complete-sequenced automation does not stall
|
||||
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
this->wait_until_idle_();
|
||||
if (send_times == 0) {
|
||||
// parity with the loop-based implementations: transmit nothing, but both triggers
|
||||
// still fire so an on_complete-sequenced automation does not stall
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Sending remote code");
|
||||
@@ -175,7 +176,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
if (this->isr_data_.empty()) {
|
||||
ESP_LOGW(TAG, "Empty data");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
// trigger first: the deadline computed in arm_chain_ must not be charged for user code
|
||||
|
||||
@@ -200,25 +200,35 @@ void RemoteTransmitterComponent::wait_for_rmt_() {
|
||||
this->status_set_warning();
|
||||
}
|
||||
|
||||
this->complete_trigger_.trigger();
|
||||
this->fire_complete_(error == ESP_OK);
|
||||
}
|
||||
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1)
|
||||
void RemoteTransmitterComponent::flush_pending_completion() {
|
||||
// a frame still on the wire is waited out, and its completion reported, before the next one
|
||||
if (this->non_blocking_ && this->cancel_timeout("complete")) {
|
||||
this->wait_for_rmt_();
|
||||
}
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
|
||||
uint64_t total_duration = 0;
|
||||
|
||||
if (this->is_failed()) {
|
||||
// both triggers still fire, so a paced API client or on_complete automation is not left waiting
|
||||
this->transmit_trigger_.trigger();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the timeout was cancelled, block until the tx is complete
|
||||
if (this->non_blocking_ && this->cancel_timeout("complete")) {
|
||||
this->wait_for_rmt_();
|
||||
}
|
||||
|
||||
if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) {
|
||||
this->current_carrier_frequency_ = this->temp_.get_carrier_frequency();
|
||||
this->configure_rmt_();
|
||||
if (this->is_failed()) { // the carrier change failed, there is no channel to send on
|
||||
this->transmit_trigger_.trigger();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this->rmt_temp_.clear();
|
||||
@@ -258,6 +268,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
|
||||
if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.size() <= offset) {
|
||||
ESP_LOGE(TAG, "Empty data");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -273,9 +285,11 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
if (error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error));
|
||||
this->status_set_warning();
|
||||
} else {
|
||||
this->status_clear_warning();
|
||||
// nothing was queued, so there is no frame to wait for
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
this->status_clear_warning();
|
||||
|
||||
if (this->non_blocking_) {
|
||||
this->set_timeout("complete", total_duration / 1000, [this]() { this->wait_for_rmt_(); });
|
||||
@@ -284,13 +298,23 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
}
|
||||
}
|
||||
#else
|
||||
void RemoteTransmitterComponent::flush_pending_completion() {}
|
||||
|
||||
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
|
||||
if (this->is_failed())
|
||||
if (this->is_failed()) {
|
||||
this->transmit_trigger_.trigger();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) {
|
||||
this->current_carrier_frequency_ = this->temp_.get_carrier_frequency();
|
||||
this->configure_rmt_();
|
||||
if (this->is_failed()) { // the carrier change failed, there is no channel to send on
|
||||
this->transmit_trigger_.trigger();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this->rmt_temp_.clear();
|
||||
@@ -328,9 +352,12 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
|
||||
if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.empty()) {
|
||||
ESP_LOGE(TAG, "Empty data");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
this->transmit_trigger_.trigger();
|
||||
bool sent = send_times != 0; // same answer as the ISR backend for a frame sent zero times
|
||||
for (uint32_t i = 0; i < send_times; i++) {
|
||||
rmt_transmit_config_t config;
|
||||
memset(&config, 0, sizeof(config));
|
||||
@@ -340,18 +367,21 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
if (error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error));
|
||||
this->status_set_warning();
|
||||
} else {
|
||||
this->status_clear_warning();
|
||||
sent = false;
|
||||
}
|
||||
error = rmt_tx_wait_all_done(this->channel_, -1);
|
||||
if (error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "rmt_tx_wait_all_done failed: %s", esp_err_to_name(error));
|
||||
this->status_set_warning();
|
||||
sent = false;
|
||||
}
|
||||
if (i + 1 < send_times)
|
||||
delayMicroseconds(send_wait);
|
||||
}
|
||||
this->complete_trigger_.trigger();
|
||||
// a later repeat must not clear the warning a failed one raised
|
||||
if (sent)
|
||||
this->status_clear_warning();
|
||||
this->fire_complete_(sent);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ void RemoteTransmitterComponent::await_target_time_() {
|
||||
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
|
||||
if (this->pwm_ == nullptr) {
|
||||
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->fire_complete_(false);
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Sending remote code");
|
||||
@@ -194,7 +196,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
}
|
||||
}
|
||||
}
|
||||
this->complete_trigger_.trigger();
|
||||
this->fire_complete_();
|
||||
}
|
||||
|
||||
#endif // USE_LIBRETINY_VARIANT_RTL8720C
|
||||
|
||||
@@ -17,10 +17,12 @@ from dataclasses import dataclass
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
from esphome.components.usb_uart import is_usb_uart_channel
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_NAME
|
||||
from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
@@ -38,6 +40,7 @@ SERIAL_PROXY_PORT_TYPES = {
|
||||
"TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL,
|
||||
"RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232,
|
||||
"RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485,
|
||||
"USB_SERIAL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL,
|
||||
}
|
||||
|
||||
CONF_DTR_PIN = "dtr_pin"
|
||||
@@ -73,6 +76,18 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
is_usb = is_usb_uart_channel(config[CONF_UART_ID], fv.full_config.get())
|
||||
if config[CONF_PORT_TYPE] == "USB_SERIAL" and not is_usb:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_PORT_TYPE} USB_SERIAL requires {CONF_UART_ID} to be a usb_uart channel"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_serial_proxy_count_define() -> None:
|
||||
"""Emit the SERIAL_PROXY_COUNT define once with the final instance count."""
|
||||
@@ -88,6 +103,12 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(cg.App.register_serial_proxy(var))
|
||||
cg.add(var.set_name(config[CONF_NAME]))
|
||||
cg.add(var.set_port_type(config[CONF_PORT_TYPE]))
|
||||
# port_type names the electrical interface (a USB RS485 adapter is RS485), so every
|
||||
# usb_uart channel reports USB identity whatever port type it declares
|
||||
if is_usb_uart_channel(config[CONF_UART_ID], CORE.config):
|
||||
channel = await cg.get_variable(config[CONF_UART_ID])
|
||||
cg.add(var.set_usb_channel(channel))
|
||||
cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY")
|
||||
cg.add_define("USE_SERIAL_PROXY")
|
||||
|
||||
# Track instance count for the FINAL priority define
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
#include "esphome/components/usb_uart/usb_uart.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::serial_proxy {
|
||||
|
||||
static const char *const TAG = "serial_proxy";
|
||||
@@ -35,6 +39,12 @@ void SerialProxy::setup() {
|
||||
// instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data
|
||||
this->outgoing_msg_.instance = this->instance_index_;
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
if (this->usb_channel_ != nullptr) {
|
||||
this->usb_channel_->get_parent()->add_on_connection_callback(
|
||||
[this](bool connected) { this->on_usb_connection_changed_(connected); });
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// A tap sets itself up before this runs (its setup priority is higher), so it may
|
||||
// already be waiting on the port -- a boot-time handshake with the device, say. Leaving
|
||||
@@ -161,9 +171,10 @@ void SerialProxy::dump_config() {
|
||||
" RTS Pin: %s\n"
|
||||
" DTR Pin: %s",
|
||||
this->instance_index_, this->name_ != nullptr ? this->name_ : "",
|
||||
this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485")
|
||||
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232")
|
||||
: LOG_STR_LITERAL("TTL"),
|
||||
this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485")
|
||||
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232")
|
||||
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL ? LOG_STR_LITERAL("USB_SERIAL")
|
||||
: LOG_STR_LITERAL("TTL"),
|
||||
this->rts_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"),
|
||||
this->dtr_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"));
|
||||
}
|
||||
@@ -373,6 +384,60 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
#ifdef USE_API
|
||||
void SerialProxy::send_identity(api::APIConnection *api_connection) {
|
||||
IdentityScratch scratch;
|
||||
api::SerialProxyIdentity msg{};
|
||||
this->fill_identity_(scratch, msg);
|
||||
api_connection->send_serial_proxy_identity(msg);
|
||||
}
|
||||
|
||||
void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api::SerialProxyIdentity &msg) const {
|
||||
msg.instance = this->instance_index_;
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
// The define is global, so a hardware UART port in the same config also gets here
|
||||
if (this->usb_channel_ != nullptr) {
|
||||
msg.source = api::enums::SERIAL_PROXY_IDENTITY_SOURCE_USB;
|
||||
// Covers a removed device, a channel the device has no CDC function for and a failed channel setup
|
||||
if (!this->usb_channel_->is_connected()) {
|
||||
return;
|
||||
}
|
||||
auto *client = this->usb_channel_->get_parent();
|
||||
msg.flags = api::enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED;
|
||||
if (!client->get_device_info(scratch)) {
|
||||
msg.flags |= api::enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR;
|
||||
return;
|
||||
}
|
||||
msg.usb.vendor_id = scratch.vendor_id;
|
||||
msg.usb.product_id = scratch.product_id;
|
||||
msg.usb.bcd_device = scratch.bcd_device;
|
||||
msg.usb.interface_number = this->usb_channel_->get_interface_number();
|
||||
msg.manufacturer = StringRef(scratch.manufacturer);
|
||||
msg.product = StringRef(scratch.product);
|
||||
msg.serial_number = StringRef(scratch.serial_number);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// Zero-initialized message: source NONE, no flags
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
void SerialProxy::on_usb_connection_changed_(bool connected) {
|
||||
ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]",
|
||||
connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_);
|
||||
#ifdef USE_API
|
||||
if (api::global_api_server == nullptr) {
|
||||
return;
|
||||
}
|
||||
IdentityScratch scratch;
|
||||
api::SerialProxyIdentity msg{};
|
||||
this->fill_identity_(scratch, msg);
|
||||
api::global_api_server->send_serial_proxy_identity(msg);
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
uint32_t SerialProxy::get_modem_pins() const {
|
||||
return (this->rts_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) |
|
||||
(this->dtr_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
namespace esphome::usb_uart {
|
||||
class USBUartChannel;
|
||||
} // namespace esphome::usb_uart
|
||||
namespace esphome::usb_host {
|
||||
struct UsbDeviceInfo;
|
||||
} // namespace esphome::usb_host
|
||||
#endif
|
||||
|
||||
// Forward-declare types needed outside the USE_API guard.
|
||||
namespace esphome::api {
|
||||
class APIConnection;
|
||||
@@ -160,6 +169,16 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Set the DTR GPIO pin (from YAML configuration)
|
||||
void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; }
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
/// Attach the USB UART channel behind this port (from code generation)
|
||||
void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; }
|
||||
#endif
|
||||
|
||||
#ifdef USE_API
|
||||
/// Send this port's identity to one client
|
||||
void send_identity(api::APIConnection *api_connection);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Attach a traffic observer. At most one, set once at setup time.
|
||||
void set_tap(SerialProxyTap *tap) { this->tap_ = tap; }
|
||||
@@ -226,6 +245,23 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
bool tap_observing_() const;
|
||||
#endif
|
||||
|
||||
#ifdef USE_API
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
using IdentityScratch = usb_host::UsbDeviceInfo;
|
||||
#else
|
||||
struct IdentityScratch {};
|
||||
#endif
|
||||
/// Fill an identity message for this port. The message's strings are views into scratch,
|
||||
/// so it must outlive the send.
|
||||
void fill_identity_(IdentityScratch &scratch, api::SerialProxyIdentity &msg) const;
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
/// The USB device behind this port was attached or removed; report the port's new
|
||||
/// identity to every subscribed API client
|
||||
void on_usb_connection_changed_(bool connected);
|
||||
#endif
|
||||
|
||||
/// Instance index for identifying this proxy in API messages
|
||||
uint32_t instance_index_{0};
|
||||
|
||||
@@ -268,6 +304,11 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
SerialProxyTap *tap_{nullptr};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
/// The USB UART channel behind this port; nullptr on non-USB ports
|
||||
usb_uart::USBUartChannel *usb_channel_{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::serial_proxy
|
||||
|
||||
@@ -146,6 +146,11 @@ def validate_raw_data(value: Any) -> bytes | list[int]:
|
||||
)
|
||||
|
||||
|
||||
MAX_PACKET_SIZE = 255
|
||||
# The radio sends packets of 1 to MAX_PACKET_SIZE bytes.
|
||||
validate_packet_data = cv.All(validate_raw_data, cv.Length(min=1, max=MAX_PACKET_SIZE))
|
||||
|
||||
|
||||
def validate_config(config: ConfigType) -> ConfigType:
|
||||
lora_bws = [
|
||||
"7_8kHz",
|
||||
@@ -205,7 +210,9 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_PA_POWER, default=17): cv.int_range(min=-3, max=22),
|
||||
cv.Optional(CONF_PA_RAMP, default="40us"): cv.enum(RAMP),
|
||||
cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=255),
|
||||
cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(
|
||||
min=0, max=MAX_PACKET_SIZE
|
||||
),
|
||||
cv.Optional(CONF_PREAMBLE_DETECT, default=2): cv.int_range(min=0, max=4),
|
||||
cv.Optional(CONF_PREAMBLE_SIZE, default=8): cv.int_range(min=1, max=65535),
|
||||
cv.Required(CONF_RST_PIN): pins.gpio_output_pin_schema,
|
||||
@@ -314,7 +321,7 @@ automation.register_apply_action(
|
||||
SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(SX126x),
|
||||
cv.Required(CONF_DATA): cv.templatable(validate_raw_data),
|
||||
cv.Required(CONF_DATA): cv.templatable(validate_packet_data),
|
||||
},
|
||||
key=CONF_DATA,
|
||||
)
|
||||
@@ -334,15 +341,11 @@ async def send_packet_action_to_code(
|
||||
) -> MockObj:
|
||||
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,
|
||||
"sx126x_data",
|
||||
)
|
||||
return var
|
||||
|
||||
@@ -7,35 +7,12 @@
|
||||
namespace esphome::sx126x {
|
||||
|
||||
template<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<SX126x> {
|
||||
public:
|
||||
void set_data_template(std::vector<uint8_t> (*func)(Ts...)) {
|
||||
this->data_.func = func;
|
||||
this->len_ = -1; // Sentinel value indicates template mode
|
||||
}
|
||||
|
||||
void set_data_static(const uint8_t *data, size_t len) {
|
||||
this->data_.data = data;
|
||||
this->len_ = len; // Length >= 0 indicates static mode
|
||||
}
|
||||
TEMPLATABLE_BYTES(data)
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
std::vector<uint8_t> data;
|
||||
if (this->len_ >= 0) {
|
||||
// Static mode: copy from flash to vector
|
||||
data.assign(this->data_.data, this->data_.data + this->len_);
|
||||
} else {
|
||||
// Template mode: call function
|
||||
data = this->data_.func(x...);
|
||||
}
|
||||
this->parent_->transmit_packet(data);
|
||||
this->data_.template visit<SX126X_MAX_PACKET_SIZE>(
|
||||
[this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
|
||||
}
|
||||
|
||||
protected:
|
||||
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
|
||||
union Data {
|
||||
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
|
||||
const uint8_t *data; // Pointer to static data in flash
|
||||
} data_;
|
||||
};
|
||||
|
||||
} // namespace esphome::sx126x
|
||||
|
||||
@@ -42,13 +42,13 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
|
||||
return status;
|
||||
}
|
||||
|
||||
void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
|
||||
void SX126x::write_fifo_(uint8_t offset, const uint8_t *data, size_t len) {
|
||||
this->enable();
|
||||
this->wait_busy_();
|
||||
this->transfer_byte(RADIO_WRITE_BUFFER);
|
||||
this->transfer_byte(offset);
|
||||
for (const uint8_t &byte : packet) {
|
||||
this->transfer_byte(byte);
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
this->transfer_byte(data[i]);
|
||||
}
|
||||
this->disable();
|
||||
delayMicroseconds(SWITCHING_DELAY_US);
|
||||
@@ -280,7 +280,7 @@ size_t SX126x::get_max_packet_size() {
|
||||
if (this->payload_length_ > 0) {
|
||||
return this->payload_length_;
|
||||
}
|
||||
return 255;
|
||||
return SX126X_MAX_PACKET_SIZE;
|
||||
}
|
||||
|
||||
void SX126x::set_packet_params_(uint8_t payload_length) {
|
||||
@@ -312,12 +312,12 @@ void SX126x::set_packet_params_(uint8_t payload_length) {
|
||||
}
|
||||
}
|
||||
|
||||
SX126xError SX126x::transmit_packet(const std::vector<uint8_t> &packet) {
|
||||
if (this->payload_length_ > 0 && this->payload_length_ != packet.size()) {
|
||||
SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) {
|
||||
if (this->payload_length_ > 0 && this->payload_length_ != len) {
|
||||
ESP_LOGE(TAG, "Packet size does not match config");
|
||||
return SX126xError::INVALID_PARAMS;
|
||||
}
|
||||
if (packet.empty() || packet.size() > this->get_max_packet_size()) {
|
||||
if (len == 0 || len > this->get_max_packet_size()) {
|
||||
ESP_LOGE(TAG, "Packet size out of range");
|
||||
return SX126xError::INVALID_PARAMS;
|
||||
}
|
||||
@@ -325,9 +325,9 @@ SX126xError SX126x::transmit_packet(const std::vector<uint8_t> &packet) {
|
||||
SX126xError ret = SX126xError::NONE;
|
||||
this->set_mode_standby(STDBY_XOSC);
|
||||
if (this->payload_length_ == 0) {
|
||||
this->set_packet_params_(packet.size());
|
||||
this->set_packet_params_(len);
|
||||
}
|
||||
this->write_fifo_(0x00, packet);
|
||||
this->write_fifo_(0x00, data, len);
|
||||
this->set_mode_tx();
|
||||
|
||||
// wait until transmit completes, typically the delay will be less than 100 ms
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
namespace esphome::sx126x {
|
||||
|
||||
static constexpr size_t SX126X_MAX_PACKET_SIZE = 255;
|
||||
|
||||
enum SX126xBw : uint8_t {
|
||||
// FSK
|
||||
SX126X_BW_4800,
|
||||
@@ -97,7 +99,10 @@ class SX126x final : public Component,
|
||||
void set_tcxo_delay(uint32_t tcxo_delay) { this->tcxo_delay_ = tcxo_delay; }
|
||||
void run_image_cal();
|
||||
void configure();
|
||||
SX126xError transmit_packet(const std::vector<uint8_t> &packet);
|
||||
SX126xError transmit_packet(const uint8_t *data, size_t len);
|
||||
SX126xError transmit_packet(const std::vector<uint8_t> &packet) {
|
||||
return this->transmit_packet(packet.data(), packet.size());
|
||||
}
|
||||
void register_listener(SX126xListener *listener) { this->listeners_.push_back(listener); }
|
||||
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
|
||||
|
||||
@@ -107,7 +112,7 @@ class SX126x final : public Component,
|
||||
void configure_lora_();
|
||||
void set_packet_params_(uint8_t payload_length);
|
||||
uint8_t read_fifo_(uint8_t offset, std::vector<uint8_t> &packet);
|
||||
void write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet);
|
||||
void write_fifo_(uint8_t offset, const uint8_t *data, size_t len);
|
||||
void write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size);
|
||||
uint8_t read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size);
|
||||
void write_register_(uint16_t reg, uint8_t *data, uint8_t size);
|
||||
|
||||
@@ -135,6 +135,11 @@ def validate_raw_data(value: Any) -> bytes | list[int]:
|
||||
)
|
||||
|
||||
|
||||
MAX_PACKET_SIZE = 255
|
||||
# The radio's payload length register is 8 bits, and empty packets are rejected.
|
||||
validate_packet_data = cv.All(validate_raw_data, cv.Length(min=1, max=MAX_PACKET_SIZE))
|
||||
|
||||
|
||||
def validate_config(config: ConfigType) -> ConfigType:
|
||||
if config[CONF_MODULATION] == "LORA":
|
||||
bws = [
|
||||
@@ -198,7 +203,9 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(CONF_PA_POWER, default=17): cv.int_range(min=0, max=17),
|
||||
cv.Optional(CONF_PA_RAMP, default="40us"): cv.enum(RAMP),
|
||||
cv.Optional(CONF_PACKET_MODE): cv.boolean,
|
||||
cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=256),
|
||||
cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(
|
||||
min=0, max=MAX_PACKET_SIZE
|
||||
),
|
||||
cv.Optional(CONF_PREAMBLE_DETECT, default=0): cv.int_range(min=0, max=3),
|
||||
cv.Optional(CONF_PREAMBLE_ERRORS, default=0): cv.int_range(min=0, max=31),
|
||||
cv.Optional(CONF_PREAMBLE_POLARITY, default=0xAA): cv.All(
|
||||
@@ -286,7 +293,7 @@ for _name, _call in (
|
||||
SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(SX127x),
|
||||
cv.Required(CONF_DATA): cv.templatable(validate_raw_data),
|
||||
cv.Required(CONF_DATA): cv.templatable(validate_packet_data),
|
||||
},
|
||||
key=CONF_DATA,
|
||||
)
|
||||
@@ -306,15 +313,11 @@ async def send_packet_action_to_code(
|
||||
) -> MockObj:
|
||||
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,
|
||||
"sx127x_data",
|
||||
)
|
||||
return var
|
||||
|
||||
@@ -7,35 +7,13 @@
|
||||
namespace esphome::sx127x {
|
||||
|
||||
template<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<SX127x> {
|
||||
TEMPLATABLE_BYTES(data)
|
||||
|
||||
public:
|
||||
void set_data_template(std::vector<uint8_t> (*func)(Ts...)) {
|
||||
this->data_.func = func;
|
||||
this->len_ = -1; // Sentinel value indicates template mode
|
||||
}
|
||||
|
||||
void set_data_static(const uint8_t *data, size_t len) {
|
||||
this->data_.data = data;
|
||||
this->len_ = len; // Length >= 0 indicates static mode
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
std::vector<uint8_t> data;
|
||||
if (this->len_ >= 0) {
|
||||
// Static mode: copy from flash to vector
|
||||
data.assign(this->data_.data, this->data_.data + this->len_);
|
||||
} else {
|
||||
// Template mode: call function
|
||||
data = this->data_.func(x...);
|
||||
}
|
||||
this->parent_->transmit_packet(data);
|
||||
this->data_.template visit<SX127X_MAX_PACKET_SIZE>(
|
||||
[this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
|
||||
}
|
||||
|
||||
protected:
|
||||
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
|
||||
union Data {
|
||||
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
|
||||
const uint8_t *data; // Pointer to static data in flash
|
||||
} data_;
|
||||
};
|
||||
|
||||
} // namespace esphome::sx127x
|
||||
|
||||
@@ -43,11 +43,11 @@ void SX127x::read_fifo_(std::vector<uint8_t> &packet) {
|
||||
this->disable();
|
||||
}
|
||||
|
||||
void SX127x::write_fifo_(const std::vector<uint8_t> &packet) {
|
||||
void SX127x::write_fifo_(const uint8_t *data, size_t len) {
|
||||
this->enable();
|
||||
this->write_byte(REG_FIFO | 0x80);
|
||||
for (const auto &byte : packet) {
|
||||
this->transfer_byte(byte);
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
this->transfer_byte(data[i]);
|
||||
}
|
||||
this->disable();
|
||||
}
|
||||
@@ -250,18 +250,17 @@ size_t SX127x::get_max_packet_size() {
|
||||
return this->payload_length_;
|
||||
}
|
||||
if (this->modulation_ == MOD_LORA) {
|
||||
return 256;
|
||||
} else {
|
||||
return 64;
|
||||
return SX127X_MAX_PACKET_SIZE;
|
||||
}
|
||||
return 64;
|
||||
}
|
||||
|
||||
SX127xError SX127x::transmit_packet(const std::vector<uint8_t> &packet) {
|
||||
if (this->payload_length_ > 0 && this->payload_length_ != packet.size()) {
|
||||
SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) {
|
||||
if (this->payload_length_ > 0 && this->payload_length_ != len) {
|
||||
ESP_LOGE(TAG, "Packet size does not match config");
|
||||
return SX127xError::INVALID_PARAMS;
|
||||
}
|
||||
if (packet.empty() || packet.size() > this->get_max_packet_size()) {
|
||||
if (len == 0 || len > this->get_max_packet_size()) {
|
||||
ESP_LOGE(TAG, "Packet size out of range");
|
||||
return SX127xError::INVALID_PARAMS;
|
||||
}
|
||||
@@ -275,18 +274,18 @@ SX127xError SX127x::transmit_packet(const std::vector<uint8_t> &packet) {
|
||||
if (this->modulation_ == MOD_LORA) {
|
||||
this->set_mode_standby();
|
||||
if (this->payload_length_ == 0) {
|
||||
this->write_register_(REG_PAYLOAD_LENGTH, packet.size());
|
||||
this->write_register_(REG_PAYLOAD_LENGTH, len);
|
||||
}
|
||||
this->write_register_(REG_IRQ_FLAGS, 0xFF);
|
||||
this->write_register_(REG_FIFO_ADDR_PTR, 0);
|
||||
this->write_fifo_(packet);
|
||||
this->write_fifo_(data, len);
|
||||
this->set_mode_tx();
|
||||
} else {
|
||||
this->set_mode_standby();
|
||||
if (this->payload_length_ == 0) {
|
||||
this->write_register_(REG_FIFO, packet.size());
|
||||
this->write_register_(REG_FIFO, len);
|
||||
}
|
||||
this->write_fifo_(packet);
|
||||
this->write_fifo_(data, len);
|
||||
this->set_mode_tx();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace esphome::sx127x {
|
||||
|
||||
static constexpr size_t SX127X_MAX_PACKET_SIZE = 255; // the payload length register is 8 bits
|
||||
|
||||
enum SX127xBw : uint8_t {
|
||||
SX127X_BW_2_6,
|
||||
SX127X_BW_3_1,
|
||||
@@ -81,7 +83,10 @@ class SX127x final : public Component,
|
||||
void set_sync_value(const std::vector<uint8_t> &sync_value) { this->sync_value_ = sync_value; }
|
||||
void run_image_cal();
|
||||
void configure();
|
||||
SX127xError transmit_packet(const std::vector<uint8_t> &packet);
|
||||
SX127xError transmit_packet(const uint8_t *data, size_t len);
|
||||
SX127xError transmit_packet(const std::vector<uint8_t> &packet) {
|
||||
return this->transmit_packet(packet.data(), packet.size());
|
||||
}
|
||||
void register_listener(SX127xListener *listener) { this->listeners_.push_back(listener); }
|
||||
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
|
||||
|
||||
@@ -90,7 +95,7 @@ class SX127x final : public Component,
|
||||
void configure_fsk_ook_();
|
||||
void configure_lora_();
|
||||
void set_mode_(uint8_t modulation, uint8_t mode);
|
||||
void write_fifo_(const std::vector<uint8_t> &packet);
|
||||
void write_fifo_(const uint8_t *data, size_t len);
|
||||
void read_fifo_(std::vector<uint8_t> &packet);
|
||||
void write_register_(uint8_t reg, uint8_t value);
|
||||
void call_listeners_(const std::vector<uint8_t> &packet, float rssi, float snr);
|
||||
|
||||
@@ -133,7 +133,7 @@ CONF_SX1509 = "sx1509"
|
||||
SX1509_PIN_SCHEMA = cv.All(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SX1509GPIOPin),
|
||||
cv.Required(CONF_SX1509): cv.use_id(SX1509Component),
|
||||
cv.Required(CONF_SX1509): pins.use_id_or_address(SX1509Component),
|
||||
cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15),
|
||||
cv.Optional(CONF_MODE, default={}): cv.All(
|
||||
{
|
||||
|
||||
@@ -62,7 +62,7 @@ TCA9555_PIN_SCHEMA = pins.gpio_base_schema(
|
||||
invertible=True,
|
||||
).extend(
|
||||
{
|
||||
cv.Required(CONF_TCA9555): cv.use_id(TCA9555Component),
|
||||
cv.Required(CONF_TCA9555): pins.use_id_or_address(TCA9555Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
namespace esphome::touchscreen {
|
||||
|
||||
static const char *const TAG = "touchscreen";
|
||||
static constexpr uint32_t TOUCH_TIMEOUT_ID = 0;
|
||||
|
||||
void TouchscreenInterrupt::gpio_intr(TouchscreenInterrupt *store) { store->touched = true; }
|
||||
|
||||
@@ -63,9 +64,9 @@ void Touchscreen::loop() {
|
||||
// Simulate a touch after <this->touch_timeout_> ms. This will reset any existing timeout operation.
|
||||
// This is to detect touch release.
|
||||
if (this->is_touched_) {
|
||||
this->set_timeout(TAG, this->touch_timeout_, [this]() { this->store_.touched = true; });
|
||||
this->set_timeout(TOUCH_TIMEOUT_ID, this->touch_timeout_, [this]() { this->store_.touched = true; });
|
||||
} else {
|
||||
this->cancel_timeout(TAG);
|
||||
this->cancel_timeout(TOUCH_TIMEOUT_ID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from itertools import combinations
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_MANUFACTURER
|
||||
from esphome.components.esp32 import (
|
||||
VARIANT_ESP32H4,
|
||||
VARIANT_ESP32P4,
|
||||
@@ -15,6 +18,8 @@ from esphome.const import CONF_DEVICES, CONF_ID
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import Component
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import cpp_u16string_escape
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["bytebuffer"]
|
||||
@@ -26,11 +31,21 @@ USBClient = usb_host_ns.class_("USBClient", Component)
|
||||
DOMAIN = "usb_host"
|
||||
CONF_VID = "vid"
|
||||
CONF_PID = "pid"
|
||||
CONF_PRODUCT = "product"
|
||||
CONF_ENABLE_HUBS = "enable_hubs"
|
||||
CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests"
|
||||
CONF_MAX_PACKET_SIZE = "max_packet_size"
|
||||
|
||||
|
||||
# VID/PID set to 0 or `None` product/manufacturer are wildcards
|
||||
_FILTER_WILDCARDS = {
|
||||
CONF_VID: 0,
|
||||
CONF_PID: 0,
|
||||
CONF_MANUFACTURER: None,
|
||||
CONF_PRODUCT: None,
|
||||
}
|
||||
|
||||
|
||||
def usb_device_schema(
|
||||
cls=USBClient, vid: int | None = None, pid: int | None = None
|
||||
) -> cv.Schema:
|
||||
@@ -47,7 +62,40 @@ def usb_device_schema(
|
||||
schema = schema.extend({cv.Optional(CONF_PID, default=pid): cv.hex_uint16_t})
|
||||
else:
|
||||
schema = schema.extend({cv.Required(CONF_PID): cv.hex_uint16_t})
|
||||
return schema
|
||||
|
||||
return schema.extend(
|
||||
{
|
||||
cv.Optional(CONF_MANUFACTURER): cv.string_strict,
|
||||
cv.Optional(CONF_PRODUCT): cv.string_strict,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
|
||||
# Two entries overlap when no field they both constrain tells them apart
|
||||
for first, second in combinations(configs, 2):
|
||||
for key, wildcard in _FILTER_WILDCARDS.items():
|
||||
a = first.get(key)
|
||||
b = second.get(key)
|
||||
if wildcard not in (a, b) and a != b:
|
||||
break
|
||||
else:
|
||||
raise cv.Invalid(
|
||||
f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}"
|
||||
)
|
||||
return configs
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# Every USB client on the bus, whichever component configured it: any two could
|
||||
# otherwise open the same device
|
||||
clients = list(config.get(CONF_DEVICES) or ())
|
||||
clients.extend(fv.full_config.get().get("usb_uart") or ())
|
||||
validate_usb_clients(clients)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def _set_max_packet_size(config: dict) -> dict:
|
||||
@@ -91,6 +139,15 @@ CONFIG_SCHEMA = cv.All(
|
||||
async def register_usb_client(config: ConfigType) -> MockObj:
|
||||
var = cg.new_Pvariable(config[CONF_ID], config[CONF_VID], config[CONF_PID])
|
||||
await cg.register_component(var, config)
|
||||
# UTF-16 literals, the encoding the descriptors use, so the device compares code units
|
||||
if (manufacturer := config.get(CONF_MANUFACTURER)) is not None:
|
||||
cg.add(
|
||||
var.set_manufacturer_filter(
|
||||
cg.RawExpression(cpp_u16string_escape(manufacturer))
|
||||
)
|
||||
)
|
||||
if (product := config.get(CONF_PRODUCT)) is not None:
|
||||
cg.add(var.set_product_filter(cg.RawExpression(cpp_u16string_escape(product))))
|
||||
return var
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4)
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include <vector>
|
||||
#include "usb/usb_host.h"
|
||||
#include <freertos/FreeRTOS.h>
|
||||
@@ -12,6 +13,7 @@
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
#include "esphome/core/event_pool.h"
|
||||
#include <atomic>
|
||||
#include <span>
|
||||
|
||||
namespace esphome::usb_host {
|
||||
|
||||
@@ -117,6 +119,25 @@ struct UsbEvent {
|
||||
|
||||
// callback function type.
|
||||
|
||||
// USB string descriptors hold at most 126 characters; one more for the terminator
|
||||
static constexpr size_t DESC_STRING_BUF_SIZE = 128;
|
||||
|
||||
/// Identity of a connected USB device, copied out of the descriptors the USB host
|
||||
/// stack caches for the lifetime of the connection
|
||||
struct UsbDeviceInfo {
|
||||
uint16_t vendor_id;
|
||||
uint16_t product_id;
|
||||
uint16_t bcd_device;
|
||||
char manufacturer[DESC_STRING_BUF_SIZE];
|
||||
char product[DESC_STRING_BUF_SIZE];
|
||||
char serial_number[DESC_STRING_BUF_SIZE];
|
||||
};
|
||||
|
||||
/// Copy a USB string descriptor into a NUL-terminated buffer. A missing descriptor copies as
|
||||
/// an empty string. Returns false when a descriptor contains non-ASCII characters,
|
||||
/// UTF-16 to UTF-8 conversion is not currently implemented.
|
||||
bool copy_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer);
|
||||
|
||||
enum ClientState {
|
||||
USB_CLIENT_INIT = 0,
|
||||
USB_CLIENT_OPEN,
|
||||
@@ -143,6 +164,25 @@ class USBClient : public Component {
|
||||
trq_bitmask_t get_trq_in_use() const { return trq_in_use_; }
|
||||
bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback,
|
||||
const std::vector<uint8_t> &data = {});
|
||||
void set_manufacturer_filter(const char16_t *manufacturer) { this->manufacturer_filter_ = manufacturer; }
|
||||
void set_product_filter(const char16_t *product) { this->product_filter_ = product; }
|
||||
|
||||
/// Whether a device has been opened and its setup by the subclass has finished
|
||||
bool is_connected() const { return this->connection_reported_; }
|
||||
|
||||
/// Copy the connected device's identity out of the cached USB descriptors.
|
||||
/// Returns false when no device is connected or the host stack refused the query.
|
||||
bool get_device_info(UsbDeviceInfo &info) const;
|
||||
|
||||
/// Register a callback for the device this client claims being connected (true) or
|
||||
/// removed (false). Fires only for a device that was fully opened, so a device another
|
||||
/// client claims is never reported. Called from the main loop: connected once the device
|
||||
/// has been enumerated and the subclass has finished its setup of it (whether or not that
|
||||
/// setup succeeded), removed after on_disconnected() has run. This tracks the device's
|
||||
/// presence, not whether a given channel is usable.
|
||||
template<typename F> void add_on_connection_callback(F &&callback) {
|
||||
this->connection_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
// Lock-free event queue and pool for USB task to main loop communication
|
||||
// Must be public for access from static callbacks
|
||||
@@ -161,6 +201,13 @@ class USBClient : public Component {
|
||||
TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe)
|
||||
virtual void disconnect();
|
||||
virtual void on_connected() {}
|
||||
|
||||
/// Whether the subclass reports the device as connected itself, once its own setup of
|
||||
/// the device has finished, rather than as soon as the device has been opened
|
||||
virtual bool reports_connection_itself() const { return false; }
|
||||
/// Report the claimed device to the connection callbacks. Idempotent; a subclass that
|
||||
/// reports itself calls this once the device is ready to use.
|
||||
void report_connected_();
|
||||
virtual void on_disconnected() {
|
||||
// Reset all requests to available (all bits to 0)
|
||||
this->trq_in_use_.store(0);
|
||||
@@ -177,12 +224,18 @@ class USBClient : public Component {
|
||||
usb_device_handle_t device_handle_{};
|
||||
int device_addr_{-1};
|
||||
int state_{USB_CLIENT_INIT};
|
||||
LazyCallbackManager<void(bool)> connection_callback_;
|
||||
// Lock-free pool management using atomic bitmask (no dynamic allocation)
|
||||
// Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available
|
||||
// Supports multiple concurrent consumers and producers (both threads can allocate/deallocate)
|
||||
std::atomic<trq_bitmask_t> trq_in_use_;
|
||||
const char16_t *manufacturer_filter_{nullptr};
|
||||
const char16_t *product_filter_{nullptr};
|
||||
uint16_t vid_{};
|
||||
uint16_t pid_{};
|
||||
// Whether the connection callbacks were told about the current device, so a removal is
|
||||
// only ever reported for a device that was reported connected
|
||||
bool connection_reported_{false};
|
||||
};
|
||||
class USBHost final : public Component {
|
||||
public:
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include <cstring>
|
||||
#include <atomic>
|
||||
#include <span>
|
||||
#include <string>
|
||||
|
||||
namespace esphome::usb_host {
|
||||
|
||||
#pragma GCC diagnostic ignored "-Wparentheses"
|
||||
@@ -143,25 +145,92 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc
|
||||
} while (next_desc != NULL);
|
||||
}
|
||||
#endif
|
||||
// USB string descriptors: bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType).
|
||||
// Character count = (bLength - 2) / 2, max 126 chars + null terminator.
|
||||
static constexpr size_t DESC_STRING_BUF_SIZE = 128;
|
||||
|
||||
static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
|
||||
// bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType),
|
||||
// so character count = (bLength - 2) / 2.
|
||||
bool copy_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
|
||||
buffer[0] = '\0';
|
||||
if (desc == nullptr || desc->bLength < 2)
|
||||
return "(unspecified)";
|
||||
return true;
|
||||
int char_count = (desc->bLength - 2) / 2;
|
||||
char *p = buffer.data();
|
||||
char *end = p + buffer.size() - 1;
|
||||
for (int i = 0; i != char_count && p < end; i++) {
|
||||
auto c = desc->wData[i];
|
||||
if (c < 0x100)
|
||||
*p++ = static_cast<char>(c);
|
||||
// TODO: encode non-ASCII code units as UTF-8 if a device with such descriptors turns up
|
||||
if (c >= 0x80) {
|
||||
buffer[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
*p++ = static_cast<char>(c);
|
||||
}
|
||||
*p = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Folds UTF-16 to Latin-1 for logging, dropping anything that does not fit
|
||||
template<typename T>
|
||||
static const char *utf16_to_latin1(const T *data, size_t count, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
|
||||
char *p = buffer.data();
|
||||
char *end = p + buffer.size() - 1;
|
||||
for (size_t i = 0; i != count && p < end; i++) {
|
||||
if (data[i] < 0x100)
|
||||
*p++ = static_cast<char>(data[i]);
|
||||
}
|
||||
*p = '\0';
|
||||
return buffer.data();
|
||||
}
|
||||
|
||||
static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
|
||||
if (desc == nullptr || desc->bLength < 2)
|
||||
return "(unspecified)";
|
||||
return utf16_to_latin1(desc->wData, (desc->bLength - 2) / 2, buffer);
|
||||
}
|
||||
|
||||
static const char *filter_string(const char16_t *filter, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
|
||||
return utf16_to_latin1(filter, std::char_traits<char16_t>::length(filter), buffer);
|
||||
}
|
||||
|
||||
// Both sides are UTF-16: the descriptor by specification, the filter because code
|
||||
// generation emits it as a u"" literal
|
||||
static bool descriptor_string_equals(const usb_str_desc_t *desc, const char16_t *expected) {
|
||||
const int char_count = (desc == nullptr || desc->bLength < 2) ? 0 : (desc->bLength - 2) / 2;
|
||||
for (int i = 0; i != char_count; i++) {
|
||||
if (expected[i] == u'\0' || desc->wData[i] != expected[i])
|
||||
return false;
|
||||
}
|
||||
return expected[char_count] == u'\0';
|
||||
}
|
||||
|
||||
bool USBClient::get_device_info(UsbDeviceInfo &info) const {
|
||||
if (!this->is_connected())
|
||||
return false;
|
||||
const usb_device_desc_t *desc;
|
||||
esp_err_t err = usb_host_get_device_descriptor(this->device_handle_, &desc);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Device descriptor query failed: %s", esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
info.vendor_id = desc->idVendor;
|
||||
info.product_id = desc->idProduct;
|
||||
info.bcd_device = desc->bcdDevice;
|
||||
usb_device_info_t dev_info;
|
||||
err = usb_host_device_info(this->device_handle_, &dev_info);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Device info query failed: %s", esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
if (!copy_descriptor_string(dev_info.str_desc_manufacturer, info.manufacturer)) {
|
||||
ESP_LOGW(TAG, "Manufacturer string descriptor is not ASCII");
|
||||
}
|
||||
if (!copy_descriptor_string(dev_info.str_desc_product, info.product)) {
|
||||
ESP_LOGW(TAG, "Product string descriptor is not ASCII");
|
||||
}
|
||||
if (!copy_descriptor_string(dev_info.str_desc_serial_num, info.serial_number)) {
|
||||
ESP_LOGW(TAG, "Serial number string descriptor is not ASCII");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task)
|
||||
static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) {
|
||||
auto *client = static_cast<USBClient *>(ptr);
|
||||
@@ -302,12 +371,10 @@ void USBClient::handle_open_state_() {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct);
|
||||
if (desc->idVendor != this->vid_ || desc->idProduct != this->pid_) {
|
||||
if (this->vid_ != 0 || this->pid_ != 0) {
|
||||
ESP_LOGD(TAG, "Not our device, closing");
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
if ((this->vid_ != 0 && desc->idVendor != this->vid_) || (this->pid_ != 0 && desc->idProduct != this->pid_)) {
|
||||
ESP_LOGD(TAG, "Not our device, closing");
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
usb_device_info_t dev_info;
|
||||
err = usb_host_device_info(this->device_handle_, &dev_info);
|
||||
@@ -316,9 +383,21 @@ void USBClient::handle_open_state_() {
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
this->state_ = USB_CLIENT_CONNECTED;
|
||||
char buf_manuf[DESC_STRING_BUF_SIZE];
|
||||
char buf_product[DESC_STRING_BUF_SIZE];
|
||||
const bool manufacturer_matches =
|
||||
this->manufacturer_filter_ == nullptr ||
|
||||
descriptor_string_equals(dev_info.str_desc_manufacturer, this->manufacturer_filter_);
|
||||
const bool product_matches =
|
||||
this->product_filter_ == nullptr || descriptor_string_equals(dev_info.str_desc_product, this->product_filter_);
|
||||
if (!manufacturer_matches || !product_matches) {
|
||||
ESP_LOGD(TAG, "Device does not match filter, closing. Manuf: %s; Prod: %s",
|
||||
get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf),
|
||||
get_descriptor_string(dev_info.str_desc_product, buf_product));
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
this->state_ = USB_CLIENT_CONNECTED;
|
||||
char buf_serial[DESC_STRING_BUF_SIZE];
|
||||
ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s",
|
||||
get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf),
|
||||
@@ -336,6 +415,18 @@ void USBClient::handle_open_state_() {
|
||||
usb_client_print_config_descriptor(config_desc, nullptr);
|
||||
#endif
|
||||
this->on_connected();
|
||||
// on_connected() may have rejected the device (no usable interface, say) and closed it
|
||||
if (this->state_ == USB_CLIENT_CONNECTED && !this->reports_connection_itself()) {
|
||||
this->report_connected_();
|
||||
}
|
||||
}
|
||||
|
||||
void USBClient::report_connected_() {
|
||||
if (this->state_ != USB_CLIENT_CONNECTED || this->connection_reported_) {
|
||||
return;
|
||||
}
|
||||
this->connection_reported_ = true;
|
||||
this->connection_callback_.call(true);
|
||||
}
|
||||
|
||||
void USBClient::on_opened(uint8_t addr) {
|
||||
@@ -406,6 +497,10 @@ TransferRequest *USBClient::get_trq_() {
|
||||
}
|
||||
|
||||
void USBClient::disconnect() {
|
||||
// Also reached for a device this client opened and then declined, or lost before it was
|
||||
// ready; neither was reported as connected, so neither is reported as removed
|
||||
const bool was_reported = this->connection_reported_;
|
||||
this->connection_reported_ = false;
|
||||
this->on_disconnected();
|
||||
auto err = usb_host_device_close(this->handle_, this->device_handle_);
|
||||
if (err != ESP_OK) {
|
||||
@@ -414,6 +509,9 @@ void USBClient::disconnect() {
|
||||
this->state_ = USB_CLIENT_INIT;
|
||||
this->device_handle_ = nullptr;
|
||||
this->device_addr_ = -1;
|
||||
if (was_reported) {
|
||||
this->connection_callback_.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Called from main loop thread only
|
||||
@@ -557,6 +655,13 @@ void USBClient::dump_config() {
|
||||
" Vendor id %04X\n"
|
||||
" Product id %04X",
|
||||
this->vid_, this->pid_);
|
||||
char buf[DESC_STRING_BUF_SIZE];
|
||||
if (this->manufacturer_filter_ != nullptr) {
|
||||
ESP_LOGCONFIG(TAG, " Manufacturer %s", filter_string(this->manufacturer_filter_, buf));
|
||||
}
|
||||
if (this->product_filter_ != nullptr) {
|
||||
ESP_LOGCONFIG(TAG, " Product %s", filter_string(this->product_filter_, buf));
|
||||
}
|
||||
}
|
||||
// THREAD CONTEXT: Called from both USB task and main loop threads
|
||||
// - USB task: Immediately after transfer callback completes
|
||||
|
||||
@@ -18,7 +18,7 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_types import Component
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -29,6 +29,15 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart")
|
||||
USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component)
|
||||
USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent)
|
||||
|
||||
|
||||
def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool:
|
||||
return any(
|
||||
channel[CONF_ID] == uart_id
|
||||
for device in full_config.get("usb_uart") or []
|
||||
for channel in device[CONF_CHANNELS]
|
||||
)
|
||||
|
||||
|
||||
UARTParityOptions = usb_uart_ns.enum("UARTParityOptions")
|
||||
UART_PARITY_OPTIONS = {
|
||||
"NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user