Merge branch 'neutral-ble-client' into radon-eye-single-node

This commit is contained in:
J. Nick Koston
2026-10-06 21:35:46 -10:00
535 changed files with 16156 additions and 2566 deletions
+2 -2
View File
@@ -299,7 +299,7 @@ jobs:
# Pushes any fixes the hooks made back to the pull request. This step
# must keep its default name: the GitHub App that performs the push
# locates the workflow run by that name.
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
- uses: pre-commit-ci/lite-action@062bca0919bc9d6e66755cc05074b70c77e111fc # v1.2.0
if: always()
with:
msg: apply automatic formatting fixes
@@ -626,7 +626,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0
with:
run: |
. venv/bin/activate
+14 -8
View File
@@ -125,14 +125,20 @@ jobs:
}
async function getEsphomeAndComponentChanges(github, owner, repo, prNumber) {
const changedFiles = await github.rest.pulls.listFiles({
owner: owner,
repo: repo,
pull_number: prNumber,
});
const changedFiles = await github.paginate(
github.rest.pulls.listFiles,
{
owner: owner,
repo: repo,
pull_number: prNumber,
per_page: 100,
}
);
const esphomeChanges = changedFiles.data
.filter(file => file.filename !== "esphome/core/defines.h" && file.filename.startsWith('esphome/'))
// Files used only for development and CI, which do not affect use as an external component
const ignoredFiles = ["esphome/core/defines.h", "esphome/idf_component.yml"];
const esphomeChanges = changedFiles
.filter(file => !ignoredFiles.includes(file.filename) && file.filename.startsWith('esphome/'))
.map(file => {
const match = file.filename.match(/esphome\/([^/]+)/);
return match ? match[1] : null;
@@ -144,7 +150,7 @@ jobs:
}
const uniqueEsphomeChanges = [...new Set(esphomeChanges)];
const componentChanges = changedFiles.data
const componentChanges = changedFiles
.filter(file => file.filename.startsWith('esphome/components/'))
.map(file => {
const match = file.filename.match(/esphome\/components\/([^/]+)\//);
@@ -7,10 +7,6 @@ on:
permissions:
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check blocking labels
+1 -1
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.9
rev: v0.16.10
hooks:
# Run the linter.
- id: ruff
+7
View File
@@ -187,10 +187,12 @@ esphome/components/esp32_rmt/* @jesserockz
esphome/components/esp32_rmt_led_strip/* @jesserockz
esphome/components/esp8266/* @esphome/core
esphome/components/esp_ldo/* @clydebarrow
esphome/components/espectre/* @francescopace
esphome/components/espnow/* @jesserockz
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
esphome/components/ethernet_info/* @gtjadsonsantos
esphome/components/event/* @nohat
esphome/components/exponential_moving_average/* @clydebarrow
esphome/components/exposure_notifications/* @OttoWinter
esphome/components/ezo/* @ssieb
esphome/components/ezo_pmp/* @carlos-sarmiento
@@ -245,8 +247,11 @@ esphome/components/hmac_md5/* @dwmw2
esphome/components/hmac_sha256/* @dwmw2
esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/button/* @jesserockz
esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/select/* @jesserockz
esphome/components/homeassistant/switch/* @Links2004
esphome/components/homeassistant/text/* @jesserockz
esphome/components/honeywell_hih_i2c/* @Benichou34
esphome/components/honeywellabp/* @RubyBailey
esphome/components/honeywellabp2_i2c/* @jpfaff
@@ -602,11 +607,13 @@ esphome/components/uart/button/* @ssieb
esphome/components/uart/event/* @eoasmxd
esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/uart_mux/* @kbx81
esphome/components/uart_tcp/* @Bascht74
esphome/components/udp/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli
esphome/components/ufm01/* @ljungqvist
esphome/components/ultrasonic/* @ssieb @swoboda1337
esphome/components/unicode/* @esphome/core
esphome/components/update/* @jesserockz
esphome/components/uponor_smatrix/* @kroimon
esphome/components/usb_cdc_acm/* @kbx81
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.19.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0
RUN \
platformio settings set enable_telemetry No \
+3 -3
View File
@@ -829,9 +829,9 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf):
# Info, not a warning: an orchestrator cannot see YAML toolchain
# overrides, this is its expected no-op, and a full build is safe.
_LOGGER.info(
# Debug only: an orchestrator cannot see YAML toolchain overrides,
# so this is its expected no-op, and a full build is safe.
_LOGGER.debug(
"--skip-bootloader ignored: only supported on ESP32 with the "
"esp-idf toolchain"
)
+8 -1
View File
@@ -160,7 +160,7 @@ def _cmake_quote(value: str) -> str:
# CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be
# cleared at the CMake level (the same state IDF's RAM-app build type uses).
# The macro is IDF's __build_process_project_includes plus two added lines;
# The macro is IDF's __build_process_project_includes plus a few added lines;
# the flag is ignored and the bootloader builds as usual if IDF changes it.
IDF_BOOTLOADER_OVERRIDE = """\
# ESPHome bootloader skip switch; see esphome/espidf/toolchain.py.
@@ -173,6 +173,9 @@ if(ESPHOME_SKIP_BOOTLOADER)
# skipped) bootloader project_include leaks; keep it defined, or
# its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /.
idf_build_get_property(idf_target IDF_TARGET)
# partition_table's V1 ECDSA signing reads this key, which the
# skipped bootloader project_include also sets.
get_filename_component(SECURE_BOOT_SIGNING_KEY "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")
idf_build_get_property(build_properties __BUILD_PROPERTIES)
foreach(build_property ${build_properties})
idf_build_get_property(val ${build_property})
@@ -198,6 +201,10 @@ endif()
BOOTLOADER_OVERRIDE_ADDED_LINES = (
'set(CONFIG_APP_BUILD_BOOTLOADER "")',
"idf_build_get_property(idf_target IDF_TARGET)",
(
"get_filename_component(SECURE_BOOT_SIGNING_KEY"
' "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")'
),
)
_MACRO = re.compile(
+1
View File
@@ -40,6 +40,7 @@ from esphome.cpp_generator import ( # noqa: F401
progmem_array,
safe_exp,
set_cpp_standard,
shared_progmem_array,
statement,
static_const_array,
static_function,
+4 -10
View File
@@ -62,16 +62,10 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
sens = await sensor.new_sensor(config[CONF_TVOC])
cg.add(var.set_tvoc(sens))
if version_config := config.get(CONF_VERSION):
sens = await sensor.new_sensor(version_config)
cg.add(var.set_version(sens))
if resistance_config := config.get(CONF_RESISTANCE):
sens = await sensor.new_sensor(resistance_config)
cg.add(var.set_resistance(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TVOC, var.set_tvoc)
await sensors(CONF_VERSION, var.set_version)
await sensors(CONF_RESISTANCE, var.set_resistance)
AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
+3 -7
View File
@@ -57,10 +57,6 @@ async def to_code(config: ConfigType) -> None:
await i2c.register_i2c_device(var, config)
cg.add(var.set_variant(config[CONF_VARIANT]))
if temperature := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature)
cg.add(var.set_temperature_sensor(sens))
if humidity := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity)
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
+3 -7
View File
@@ -46,10 +46,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
+3 -7
View File
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
+3 -7
View File
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
if battery_level_config := config.get(CONF_BATTERY_LEVEL):
sens = await sensor.new_sensor(battery_level_config)
cg.add(var.set_battery(sens))
if illuminance_config := config.get(CONF_ILLUMINANCE):
sens = await sensor.new_sensor(illuminance_config)
cg.add(var.set_illuminance(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_BATTERY_LEVEL, var.set_battery)
await sensors(CONF_ILLUMINANCE, var.set_illuminance)
+108 -5
View File
@@ -1,3 +1,4 @@
from ipaddress import IPv4Address, IPv6Address
import logging
import re
from typing import Any
@@ -5,7 +6,7 @@ from typing import Any
from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.const import CONF_DESCRIPTION
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
from esphome.components.logger import request_log_listener
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
@@ -26,6 +27,8 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DELAY,
CONF_ENABLE_IPV6,
CONF_ENCRYPTION,
CONF_EVENT,
CONF_ID,
@@ -49,7 +52,9 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import fnv1_hash
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigFragmentType, ConfigType
# Compat alias: downstream consumers (e.g. device-builder) referenced the
@@ -135,6 +140,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_OUTGOING_CONNECTION = "outgoing_connection"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
# Schema defaults that also match the C++ initializers in api_server.h; codegen
@@ -142,6 +148,7 @@ CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
DEFAULT_PORT = 6053
DEFAULT_REBOOT_TIMEOUT = "15min"
DEFAULT_BATCH_DELAY = "100ms"
DEFAULT_LISTEN_BACKLOG = 4
def _register_provisioning_source(config: ConfigType) -> ConfigType:
@@ -292,9 +299,72 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
# (not max_connections, which is the upper limit rarely reached)
socket.consume_sockets(3, "api")(config)
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
if CONF_OUTGOING_CONNECTION in config:
socket.consume_sockets(1, "api_outgoing_connection")(config)
return config
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None:
return config
if CONF_ENCRYPTION not in config:
raise cv.Invalid(
"outgoing_connection requires 'encryption' so the peer is verified by key",
path=[CONF_OUTGOING_CONNECTION],
)
# A device with no client reboots once reboot_timeout passes, so a delay that
# reaches it would reboot the device before it ever dials
reboot_timeout = config[CONF_REBOOT_TIMEOUT]
delay = outgoing[CONF_DELAY]
if reboot_timeout.total_milliseconds and delay >= reboot_timeout:
raise cv.Invalid(
f"delay must be shorter than reboot_timeout ({reboot_timeout}), "
"otherwise the device reboots before it dials",
path=[CONF_OUTGOING_CONNECTION, CONF_DELAY],
)
return config
def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address:
"""Only accept an address the device itself can parse.
Python accepts a scope id, which neither `inet_pton` nor lwIP's `inet6_aton`
takes, and a v4-mapped address is dialed as plain IPv4, needing no IPv6 build.
"""
address = cv.ipaddress(value)
if isinstance(address, IPv6Address):
if address.scope_id is not None:
raise cv.Invalid(
f"{value} carries a scope id, which the device cannot parse; "
"give the address without the '%' part"
)
if (mapped := address.ipv4_mapped) is not None:
return mapped
return address
_OUTGOING_CONNECTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_HOST): _validate_outgoing_host,
cv.Optional(CONF_PORT, default=6054): cv.port,
# Bounded against reboot_timeout in _validate_outgoing_connection
cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds,
}
)
@schema_extractor("schema")
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
# A bare `outgoing_connection:` block is valid; without a host the device
# dials the remembered last dial-back client
if config is SCHEMA_EXTRACT:
# Let the language-schema dumper walk host, port and delay
return _OUTGOING_CONNECTION_SCHEMA
if config is None:
config = {}
return _OUTGOING_CONNECTION_SCHEMA(config)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -319,6 +389,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(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema,
cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
@@ -374,6 +445,7 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_validate_outgoing_connection,
_consume_api_sockets,
_register_provisioning_source,
)
@@ -430,7 +502,28 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
if (
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
or (host := outgoing.get(CONF_HOST)) is None
or host.version != 6
):
return config
network_conf = fv.full_config.get().get("network") or {}
if not network_conf.get(CONF_ENABLE_IPV6):
raise cv.Invalid(
"outgoing_connection host is an IPv6 address but IPv6 is not "
"enabled; set 'network: enable_ipv6: true'",
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
)
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_esp8266_action_strings,
_validate_outgoing_host_ipv6,
)
def _add_action_strings(
@@ -477,8 +570,10 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_reboot_timeout(reboot_timeout))
if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY):
cg.add(var.set_batch_delay(batch_delay))
if CONF_LISTEN_BACKLOG in config:
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
if (
listen_backlog := config.get(CONF_LISTEN_BACKLOG)
) is not None and listen_backlog != DEFAULT_LISTEN_BACKLOG:
cg.add(var.set_listen_backlog(listen_backlog))
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
@@ -601,7 +696,7 @@ async def to_code(config: ConfigType) -> None:
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
cg.add(var.set_noise_psk(new_psk_progmem(key)))
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
# No key provided, but encryption desired
@@ -618,6 +713,13 @@ async def to_code(config: ConfigType) -> None:
else:
cg.add_define("USE_API_PLAINTEXT")
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None:
cg.add_define("USE_API_OUTGOING_CONNECTION")
if (host := outgoing.get(CONF_HOST)) is not None:
cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host))
cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT])
cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY])
cg.add_define("USE_API")
cg.add_global(api_ns.using)
@@ -1004,6 +1106,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_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
}
)
+10 -1
View File
@@ -113,6 +113,11 @@ message HelloRequest {
string client_info = 1;
uint32 api_version_major = 2;
uint32 api_version_minor = 3;
// Set by clients that can accept connections the device opens to them
// (see api: outgoing_connection:). The device remembers this client's
// address as the target to dial when no such client is connected.
bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
}
// Confirmation of successful connection request.
@@ -332,6 +337,10 @@ message DeviceInfoResponse {
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
// Device is built with the api outgoing_connection option and can open
// the TCP connection to a dial-back target itself
bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
}
// ==================== DEVICE CAPABILITIES ====================
@@ -1439,7 +1448,7 @@ message ListEntitiesSelectResponse {
reserved 4; // Deprecated: was string unique_id
string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
repeated string options = 6 [(container_pointer_no_template) = "FixedVector<const char *>"];
repeated string options = 6 [(container_pointer_no_template) = "std::span<const char *const>"];
bool disabled_by_default = 7;
EntityCategory entity_category = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
+19 -1
View File
@@ -990,7 +990,9 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection
uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *select = static_cast<select::Select *>(entity);
ListEntitiesSelectResponse msg;
msg.options = &select->traits.get_options();
const auto &opts = select->traits.get_options();
const std::span<const char *const> options(opts.data(), opts.size());
msg.options = &options;
return fill_and_encode_entity_info(select, msg, conn, remaining_size);
}
void APIConnection::on_select_command_request(const SelectCommandRequest &msg) {
@@ -1837,6 +1839,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
#ifdef USE_API_OUTGOING_CONNECTION
// With a PSK set only key-verified transports reach hello: plaintext and
// zero-PSK are rejected, and pre-activation sessions are force-closed
if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) {
if (this->parent_->get_noise_ctx().has_psk()) {
this->flags_.outgoing_connection_target = true;
this->parent_->on_outgoing_target_client(this);
} else {
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active"));
}
}
#endif
return this->send_message(resp);
}
@@ -1959,6 +1974,9 @@ bool APIConnection::send_device_info_response_() {
// one) so this advertisement survives the plaintext removal in 2027.2.0.
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
#endif
#ifdef USE_API_OUTGOING_CONNECTION
resp.api_outgoing_connection_supported = true;
#endif
#endif
#ifdef USE_DEVICES
size_t device_index = 0;
+20
View File
@@ -378,6 +378,23 @@ class APIConnection final : public APIServerConnectionBase {
return this->helper_->get_peername_to(buf);
}
#ifdef USE_API_OUTGOING_CONNECTION
/// Get the peer address itself, for remembering a dial-back target
int getpeername(struct sockaddr *addr, socklen_t *addrlen) const { return this->helper_->getpeername(addr, addrlen); }
/// Outgoing connection: send our server hello immediately so the peer can
/// pick the matching key. Outgoing connections are only dialed when a PSK
/// is set, so the helper is always the noise helper. Call after start().
void mark_outgoing() {
if (this->flags_.remove) {
return; // start() failed; the connection is already being torn down
}
APIError err = static_cast<APINoiseFrameHelper *>(this->helper_.get())->send_server_hello_first();
if (err != APIError::OK) {
this->fatal_error_with_log_(LOG_STR("Server hello failed"), err);
}
}
#endif
protected:
bool try_to_clear_buffer_slow_(bool log_out_of_space);
@@ -734,6 +751,9 @@ 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_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
+2 -1
View File
@@ -285,7 +285,8 @@ class APIFrameHelper {
DATA = 5,
CLOSED = 6,
FAILED = 7,
EXPLICIT_REJECT = 8, // Noise only
EXPLICIT_REJECT = 8, // Noise only
CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn)
};
// Fast inline state check for read_packet/write_protobuf_messages hot path.
@@ -70,6 +70,13 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
return APIError::OK;
}
#ifdef USE_API_OUTGOING_CONNECTION
APIError APINoiseFrameHelper::send_server_hello_first() {
// The peer needs our name and MAC to pick the key before its first message
this->state_ = State::CLIENT_HELLO_OUTGOING;
return this->send_server_hello_frame_();
}
#endif
#ifdef USE_API_PLAINTEXT
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
APIError err = this->init();
@@ -242,6 +249,9 @@ APIError APINoiseFrameHelper::state_action_() {
HELPER_LOG("Bad state for method: %d", (int) this->state_);
return APIError::BAD_STATE;
case State::CLIENT_HELLO:
#ifdef USE_API_OUTGOING_CONNECTION
case State::CLIENT_HELLO_OUTGOING:
#endif
return this->state_action_client_hello_();
case State::SERVER_HELLO:
return this->state_action_server_hello_();
@@ -274,11 +284,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
std::memcpy(dst + 2, this->rx_buf_.data(), rx_size);
}
#ifdef USE_API_OUTGOING_CONNECTION
if (this->state_ == State::CLIENT_HELLO_OUTGOING) {
// Server hello already went out at handoff
return this->start_handshake_();
}
#endif
state_ = State::SERVER_HELLO;
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_server_hello_() {
// send server hello
APIError APINoiseFrameHelper::send_server_hello_frame_() {
const auto &name = App.get_name();
char mac[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac);
@@ -302,15 +317,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
// node mac, terminated by null byte
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
APIError aerr = write_frame_(msg, total_size);
return write_frame_(msg, total_size);
}
APIError APINoiseFrameHelper::state_action_server_hello_() {
APIError aerr = this->send_server_hello_frame_();
if (aerr != APIError::OK)
return aerr;
// start handshake
aerr = init_handshake_();
return this->start_handshake_();
}
APIError APINoiseFrameHelper::start_handshake_() {
APIError aerr = init_handshake_();
if (aerr != APIError::OK)
return aerr;
state_ = State::HANDSHAKE;
return APIError::OK;
}
@@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Seeds the already-read header bytes and pumps the handshake state machine
// until it would block.
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
// Send the server hello immediately so the peer can pick the key before
// its PSK-mixed message. Call after init(); the mode is tracked in state_
// so the helper does not grow.
APIError send_server_hello_first();
#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
@@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
APIError send_server_hello_frame_();
APIError start_handshake_();
APIError state_action_handshake_();
APIError state_action_handshake_read_();
APIError state_action_handshake_write_();
@@ -0,0 +1,318 @@
#include "api_outgoing_connection.h"
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
#include "api_connection.h"
#include "api_server.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cerrno>
#include <cinttypes>
#include <cstdint>
#include <cstring>
namespace esphome::api {
static const char *const TAG = "api.outgoing";
#ifndef API_OUTGOING_CONNECTION_HOST
static constexpr uint32_t OUTGOING_TARGET_PREF_HASH = 629847102UL;
#endif
#ifndef API_OUTGOING_CONNECTION_HOST
// Read the connection's peer address into target; false when unavailable or
// of a family this build cannot dial
static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) {
// Zeroed because the raw lwIP getpeername() leaves sin6_scope_id untouched
struct sockaddr_storage peer = {};
socklen_t peer_len = sizeof(peer);
if (conn->getpeername((struct sockaddr *) &peer, &peer_len) != 0) {
return false;
}
const sa_family_t family = ((struct sockaddr *) &peer)->sa_family;
#if USE_NETWORK_IPV6
if (family == AF_INET6) {
const auto *addr6 = reinterpret_cast<const struct sockaddr_in6 *>(&peer);
const auto *bytes = reinterpret_cast<const uint8_t *>(&addr6->sin6_addr);
uint32_t prefix[3];
memcpy(prefix, bytes, sizeof(prefix));
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d
if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == htonl(0xFFFFUL)) {
target.family = AF_INET;
memcpy(target.addr, bytes + sizeof(prefix), sizeof(struct in_addr));
return true;
}
// A link-local target is only reachable through the interface it came in
// on. Device platforms number interfaces from one; a host build can hand
// out an index too large to store, and a truncated one dials the wrong
// interface, so that target is not remembered at all.
if (addr6->sin6_scope_id > UINT8_MAX) {
return false;
}
target.family = AF_INET6;
memcpy(target.addr, bytes, sizeof(target.addr));
target.scope_id = static_cast<uint8_t>(addr6->sin6_scope_id);
return true;
}
#endif
if (family != AF_INET) {
return false;
}
const auto *addr4 = reinterpret_cast<const struct sockaddr_in *>(&peer);
target.family = AF_INET;
memcpy(target.addr, &addr4->sin_addr, sizeof(addr4->sin_addr));
return true;
}
#endif
socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *addr) const {
#ifdef API_OUTGOING_CONNECTION_HOST
// Validation only lets through a literal both inet_pton and inet6_aton
// accept, so this cannot fail
return socket::set_sockaddr((struct sockaddr *) addr, sizeof(*addr), API_OUTGOING_CONNECTION_HOST,
API_OUTGOING_CONNECTION_PORT);
#else
#if USE_NETWORK_IPV6
if (this->saved_.family == AF_INET6) {
auto *addr6 = reinterpret_cast<struct sockaddr_in6 *>(addr);
memset(addr6, 0, sizeof(*addr6));
addr6->sin6_family = AF_INET6;
addr6->sin6_port = htons(API_OUTGOING_CONNECTION_PORT);
memcpy(&addr6->sin6_addr, this->saved_.addr, sizeof(this->saved_.addr));
addr6->sin6_scope_id = this->saved_.scope_id;
return sizeof(*addr6);
}
#endif
if (this->saved_.family != AF_INET) {
return 0;
}
auto *addr4 = reinterpret_cast<struct sockaddr_in *>(addr);
memset(addr4, 0, sizeof(*addr4));
addr4->sin_family = AF_INET;
addr4->sin_port = htons(API_OUTGOING_CONNECTION_PORT);
memcpy(&addr4->sin_addr, this->saved_.addr, sizeof(addr4->sin_addr));
return sizeof(*addr4);
#endif
}
#ifndef API_OUTGOING_CONNECTION_HOST
void OutgoingConnectionManager::format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const {
struct sockaddr_storage addr;
socklen_t addr_len = this->target_sockaddr_(&addr);
if (addr_len == 0) {
buf[0] = '\0';
return;
}
// Clears buf itself if it cannot format the address
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, buf);
}
#endif
void OutgoingConnectionManager::setup() {
#ifndef API_OUTGOING_CONNECTION_HOST
this->target_pref_ = global_preferences->make_preference<SavedOutgoingTarget>(OUTGOING_TARGET_PREF_HASH, true);
struct sockaddr_storage addr;
// dump_config() prints whichever target this leaves in place
if (this->target_pref_.load(&this->saved_) && this->target_sockaddr_(&addr) != 0) {
this->host_persisted_ = true;
} else {
// Never saved, failed its size or CRC check, or holds an unknown family
this->saved_ = {};
}
#endif
}
void OutgoingConnectionManager::loop(APIServer *server) {
if (server->has_outgoing_target_client_()) {
return; // on_target_client() already reset the dial state
}
if (this->dialed_conn_ != nullptr) {
// A live dialed session (flagged or not, e.g. a host: peer) is the
// target; a silent one dies on the handshake timeout
return;
}
const uint32_t now = App.get_loop_component_start_time();
switch (this->state_) {
case DialState::DIAL_STATE_IDLE:
// Target went away; give it the configured delay to reconnect first
this->schedule_wait_(now, IDLE_WAIT_MS);
break;
case DialState::DIAL_STATE_WAITING:
if (now - this->state_ts_ >= this->wait_) {
this->try_dial_(server, now);
}
break;
case DialState::DIAL_STATE_CONNECTING:
this->poll_connect_(server, now);
break;
}
}
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
if (!network::is_connected()) {
// Flips within seconds of boot; recheck fast so a deep sleep wake
// window is not spent waiting
this->schedule_wait_(now, NETWORK_RETRY_MS);
return;
}
struct sockaddr_storage addr;
socklen_t addr_len = this->target_sockaddr_(&addr);
const bool at_limit = server->at_client_limit_();
// No target is the steady state until a dial-back client has ever connected
if (addr_len == 0 || at_limit || !server->noise_ctx_.has_psk()) {
// Repeats for as long as the reason holds, so keep it out of debug logs
ESP_LOGV(TAG, "Not dialing: %s",
addr_len == 0 ? LOG_STR_LITERAL("no target")
: (at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key")));
// Not a dial failure; retry without escalating the backoff
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
return;
}
this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP);
if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) {
ESP_LOGW(TAG, "Socket %s failed: errno %d",
this->dial_socket_ ? LOG_STR_LITERAL("setblocking") : LOG_STR_LITERAL("create"), errno);
this->schedule_retry_(now);
return;
}
#ifdef API_OUTGOING_CONNECTION_HOST
ESP_LOGD(TAG, "Dialing " API_OUTGOING_CONNECTION_HOST ":%u", API_OUTGOING_CONNECTION_PORT);
#else
char host[socket::SOCKADDR_STR_LEN];
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, host);
ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT);
#endif
int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len);
if (err == 0) {
// Immediate success (possible for localhost)
this->handoff_(server, now);
return;
}
if (errno != EINPROGRESS) {
ESP_LOGW(TAG, "Connect failed: %d", errno);
this->schedule_retry_(now);
return;
}
this->state_ = DialState::DIAL_STATE_CONNECTING;
this->state_ts_ = now;
this->last_poll_ = now;
}
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Connect timeout");
this->schedule_retry_(now);
return;
}
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
return;
}
this->last_poll_ = now;
int err = 0;
switch (socket::poll_connect(*this->dial_socket_, err)) {
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
break;
case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED:
this->handoff_(server, now);
break;
case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR:
ESP_LOGW(TAG, "Connect failed: %d", err);
this->schedule_retry_(now);
break;
}
}
void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) {
this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_));
if (this->dialed_conn_ == nullptr) {
// Only preconditions (slot limit, key cleared) refuse the handoff; the
// peer is reachable, so do not escalate the backoff
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
return;
}
// Connected; dialed_conn_ gates further dialing until the session settles
this->state_ = DialState::DIAL_STATE_IDLE;
}
void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) {
this->dial_socket_.reset(); // no-op when the socket was handed off
this->state_ = DialState::DIAL_STATE_WAITING;
this->state_ts_ = now;
this->wait_ = wait;
}
void OutgoingConnectionManager::schedule_retry_(uint32_t now) {
// +/-20% jitter so a fleet of devices does not retry one server in lockstep
const uint32_t jitter_span = this->backoff_ / 5;
this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)));
this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS);
}
void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) {
if (conn != this->dialed_conn_) {
return;
}
this->dialed_conn_ = nullptr;
if (was_authenticated) {
// A working peer (e.g. a host: target that never sends the flag)
// disconnected normally; state is IDLE, so loop() applies the delay
this->backoff_ = BACKOFF_MIN_MS;
} else {
this->schedule_retry_(App.get_loop_component_start_time());
}
}
void OutgoingConnectionManager::on_target_client(APIConnection *conn) {
// The target is connected; stop any dial in flight and reset the backoff.
// A dialed connection stays tracked unless it is this one: an inbound
// target must not orphan a still-open dial.
this->dial_socket_.reset();
if (conn == this->dialed_conn_) {
this->dialed_conn_ = nullptr;
}
this->state_ = DialState::DIAL_STATE_IDLE;
this->backoff_ = BACKOFF_MIN_MS;
#ifndef API_OUTGOING_CONNECTION_HOST
SavedOutgoingTarget target{};
if (!peer_to_target(conn, target)) {
ESP_LOGW(TAG, "Not remembering this target; its address cannot be dialed");
return;
}
if (this->host_persisted_ && memcmp(&target, &this->saved_, sizeof(target)) == 0) {
return; // unchanged and already on flash; avoid flash wear
}
// Use the fresh address this boot even if the flash write fails; a failed
// write is retried on the next flagged hello via host_persisted_
this->saved_ = target;
if (!this->persist_target_()) {
ESP_LOGW(TAG, "Failed to save target");
return;
}
char host[socket::SOCKADDR_STR_LEN];
this->format_target_(host);
ESP_LOGD(TAG, "Remembered %s as the dial target", host);
#endif
}
void OutgoingConnectionManager::dump_config() const {
// The boot delay differs from delay: on deep sleep builds, so print the
// value that actually applies
ESP_LOGCONFIG(TAG,
" Outgoing connection port: %u\n"
" Outgoing connection boot delay: %" PRIu32 "ms",
API_OUTGOING_CONNECTION_PORT, BOOT_WAIT_MS);
// Both forms keep their text out of RAM on ESP8266: in the format string,
// or through LOG_STR_LITERAL
#ifdef API_OUTGOING_CONNECTION_HOST
ESP_LOGCONFIG(TAG, " Outgoing connection host: " API_OUTGOING_CONNECTION_HOST);
#else
char buf[socket::SOCKADDR_STR_LEN];
this->format_target_(buf);
ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", buf[0] == '\0' ? LOG_STR_LITERAL("none remembered yet") : buf);
#endif
}
} // namespace esphome::api
#endif // USE_API && USE_API_OUTGOING_CONNECTION
@@ -0,0 +1,120 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
#ifndef USE_API_NOISE
#error "api outgoing_connection needs noise encryption so the peer is verified by key"
#endif
#include "esphome/components/socket/socket.h"
#include "esphome/core/preferences.h"
#include <memory>
namespace esphome::api {
class APIServer;
class APIConnection;
// Room for an IPv6 address in every build, so a remembered IPv4 target is
// still dialed after enable_ipv6 is turned on. A size that followed the build
// would also shift every preference registered after this one on ESP8266,
// where slots are positional. An IPv6 target on a build without IPv6 is
// dropped by target_sockaddr_() and relearned.
// Bytes in an IPv6 address
static constexpr size_t TARGET_ADDR_LEN = 16;
struct SavedOutgoingTarget {
// 0 when none is remembered, else AF_INET or AF_INET6
uint8_t family;
// Network order, IPv4 in the first four bytes and the rest zero
uint8_t addr[TARGET_ADDR_LEN];
// Interface a link-local IPv6 target is reachable on, 0 when it needs none.
// Free in flash: the record still rounds up to the same five words.
uint8_t scope_id;
} PACKED; // NOLINT
/// Dials out when no dial-back target client is connected. Only the TCP
/// direction flips: the device stays the Noise responder, so both sides
/// still verify by key. Targets the YAML host or the last remembered client.
class OutgoingConnectionManager {
public:
void setup();
void loop(APIServer *server);
/// A key-verified client declared itself a dial-back target; last one wins
void on_target_client(APIConnection *conn);
/// Clears the dialed-connection gate; dying unauthenticated escalates the backoff
void on_client_removed(APIConnection *conn, bool was_authenticated);
void on_shutdown() { this->dial_socket_.reset(); }
void dump_config() const;
protected:
enum class DialState : uint8_t {
DIAL_STATE_IDLE,
DIAL_STATE_WAITING,
DIAL_STATE_CONNECTING,
};
static constexpr uint32_t BACKOFF_MIN_MS = 5000;
static constexpr uint32_t BACKOFF_MAX_MS = 300000;
static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000;
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
static constexpr uint32_t NETWORK_RETRY_MS = 500;
static constexpr uint32_t PRECONDITION_RETRY_MS = 5000;
// A deep sleep wake window is too short to spend on the delay, so those
// builds dial out as soon as the target is gone
#ifdef USE_DEEP_SLEEP
static constexpr uint32_t BOOT_WAIT_MS = 0;
static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS;
#else
static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
#endif
void try_dial_(APIServer *server, uint32_t now);
void poll_connect_(APIServer *server, uint32_t now);
// Hand the connected socket to the server and gate on the new connection
void handoff_(APIServer *server, uint32_t now);
// Close any half-open dial and wait a jittered backoff before retrying
void schedule_retry_(uint32_t now);
// Wait without escalating the backoff (used for unmet preconditions)
void schedule_wait_(uint32_t now, uint32_t wait);
/// Fill addr with the target and return its length, or 0 when there is none
socklen_t target_sockaddr_(struct sockaddr_storage *addr) const;
#ifndef API_OUTGOING_CONNECTION_HOST
// Write saved_ to flash, tracking success in host_persisted_
bool persist_target_() {
this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync();
return this->host_persisted_;
}
/// Format the remembered target for a log line; empty when there is none
void format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const;
#endif
// Pointers first (4 bytes each on 32-bit)
std::unique_ptr<socket::Socket> dial_socket_;
// Compared only, never dereferenced
APIConnection *dialed_conn_{nullptr};
#ifndef API_OUTGOING_CONNECTION_HOST
ESPPreferenceObject target_pref_;
#endif
// 4-byte types
uint32_t backoff_{BACKOFF_MIN_MS};
uint32_t wait_{BOOT_WAIT_MS};
uint32_t state_ts_{0};
uint32_t last_poll_{0};
// Byte-aligned types last
#ifndef API_OUTGOING_CONNECTION_HOST
SavedOutgoingTarget saved_{};
// False while saved_ holds a value the flash write failed for; retried on
// the next flagged hello
bool host_persisted_{false};
#endif
DialState state_{DialState::DIAL_STATE_WAITING};
};
} // namespace esphome::api
#endif // USE_API && USE_API_OUTGOING_CONNECTION
+11
View File
@@ -20,6 +20,11 @@ void HelloRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, p
case proto_tag(3, WIRE_TYPE_VARINT):
msg.api_version_minor = value.as_varint();
break;
#ifdef USE_API_OUTGOING_CONNECTION
case proto_tag(4, WIRE_TYPE_VARINT):
msg.outgoing_connection_target = value.as_bool();
break;
#endif
}
}
uint8_t *HelloResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {
@@ -176,6 +181,9 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, ProtoWriteBuffer &buff
#endif
#ifdef USE_API_NOISE
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.api_encryption_provisionable);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, msg.api_outgoing_connection_supported);
#endif
return pos;
}
@@ -242,6 +250,9 @@ uint32_t DeviceInfoResponse::calc_size_msg(const void *self) {
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, msg.api_encryption_provisionable);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
size += ProtoSize::calc_bool(2, msg.api_outgoing_connection_supported);
#endif
return size;
}
+9 -3
View File
@@ -417,13 +417,16 @@ class CommandProtoMessage : public ProtoDecodableMessage {
class HelloRequest final : public ProtoDecodableMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 1;
static constexpr uint8_t ESTIMATED_SIZE = 17;
static constexpr uint8_t ESTIMATED_SIZE = 19;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("hello_request"); }
#endif
StringRef client_info{};
uint32_t api_version_major{0};
uint32_t api_version_minor{0};
#ifdef USE_API_OUTGOING_CONNECTION
bool outgoing_connection_target{false};
#endif
void decode(const uint8_t *buffer, size_t length) {
ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);
}
@@ -579,7 +582,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 312;
static constexpr uint16_t ESTIMATED_SIZE = 315;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -637,6 +640,9 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif
#ifdef USE_API_OUTGOING_CONNECTION
bool api_outgoing_connection_supported{false};
#endif
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -1909,7 +1915,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage {
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); }
#endif
const FixedVector<const char *> *options{};
const std::span<const char *const> *options{};
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG);
+6
View File
@@ -897,6 +897,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("client_info"), this->client_info);
dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major);
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
#ifdef USE_API_OUTGOING_CONNECTION
dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target);
#endif
return out.c_str();
}
const char *HelloResponse::dump_to(DumpBuffer &out) const {
@@ -1020,6 +1023,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported);
#endif
return out.c_str();
}
@@ -28,6 +28,7 @@
// Standard library includes that might be needed
#include <set>
#include <span>
#include <vector>
#include <string>
+115 -48
View File
@@ -56,7 +56,12 @@ APIServer::APIServer() { global_api_server = this; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
#ifdef USE_API_OUTGOING_CONNECTION
// Dial-out needs no listener; degrade instead of stopping the component
this->status_set_error(LOG_STR("listen socket failed"));
#else
this->mark_failed();
#endif
}
void APIServer::setup() {
@@ -72,43 +77,6 @@ void APIServer::setup() {
#endif
#endif
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->socket_ == nullptr) {
this->socket_failed_(LOG_STR("creation"));
return;
}
int enable = 1;
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (err != 0) {
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
// we can still continue
}
err = this->socket_->setblocking(false);
if (err != 0) {
this->socket_failed_(LOG_STR("nonblocking"));
return;
}
struct sockaddr_storage server;
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
if (sl == 0) {
this->socket_failed_(LOG_STR("set sockaddr"));
return;
}
err = this->socket_->bind((struct sockaddr *) &server, sl);
if (err != 0) {
this->socket_failed_(LOG_STR("bind"));
return;
}
err = this->socket_->listen(this->listen_backlog_);
if (err != 0) {
this->socket_failed_(LOG_STR("listen"));
return;
}
#ifdef USE_LOGGER
if (logger::global_logger != nullptr) {
logger::global_logger->add_log_callback(
@@ -154,6 +122,47 @@ void APIServer::setup() {
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
}
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.setup();
#endif
// Listener last: on failure socket_failed_() returns early, and an
// outgoing_connection build keeps dialing out without one
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->socket_ == nullptr) {
this->socket_failed_(LOG_STR("creation"));
return;
}
int enable = 1;
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (err != 0) {
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
// we can still continue
}
err = this->socket_->setblocking(false);
if (err != 0) {
this->socket_failed_(LOG_STR("nonblocking"));
return;
}
struct sockaddr_storage server;
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
if (sl == 0) {
this->socket_failed_(LOG_STR("set sockaddr"));
return;
}
err = this->socket_->bind((struct sockaddr *) &server, sl);
if (err != 0) {
this->socket_failed_(LOG_STR("bind"));
return;
}
err = this->socket_->listen(this->listen_backlog_);
if (err != 0) {
this->socket_failed_(LOG_STR("listen"));
}
}
void APIServer::loop() {
@@ -169,6 +178,12 @@ void APIServer::loop() {
}
#endif
#ifdef USE_API_OUTGOING_CONNECTION
if (!this->shutting_down_) {
this->outgoing_conn_.loop(this);
}
#endif
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time).
@@ -241,6 +256,15 @@ void APIServer::remove_client_(uint8_t client_index) {
std::string client_peername(client->get_peername_to(peername_buf));
#endif
// Read before the swap-and-reset below destroys the connection
const bool was_authenticated = client->is_authenticated();
#ifdef USE_API_OUTGOING_CONNECTION
if (client->flags_.outgoing_connection_target) {
this->outgoing_target_count_--;
}
this->outgoing_conn_.on_client_removed(client.get(), was_authenticated);
#endif
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
@@ -259,9 +283,15 @@ void APIServer::remove_client_(uint8_t client_index) {
// Last client disconnected - set warning and start tracking for reboot timeout
// (suppressed while provisioning is pending - see loop()).
// Refresh on every authenticated removal, not just the last one, so an
// unauthenticated straggler removed later (e.g. a port scan, or a dial to
// a host that accepts TCP but never speaks the API) cannot discard a
// healthy session's timestamp and trigger a spurious reboot
if (was_authenticated) {
this->last_connected_ = App.get_loop_component_start_time();
}
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
@@ -283,7 +313,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
sock->getpeername_to(peername);
// Check if we're at the connection limit
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
if (this->at_client_limit_()) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
@@ -292,18 +322,47 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
ESP_LOGD(TAG, "Accept %s", peername);
auto *conn = new APIConnection(std::move(sock), this);
this->clients_[this->api_connection_count_++].reset(conn);
conn->start();
// First client connected - clear warning and update timestamp
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
this->add_client_(std::move(sock));
}
}
APIConnection *APIServer::add_client_(std::unique_ptr<socket::Socket> sock) {
auto *conn = new APIConnection(std::move(sock), this); // NOLINT(cppcoreguidelines-owning-memory)
this->clients_[this->api_connection_count_++].reset(conn);
conn->start();
// First client connected - clear warning. The reboot watchdog timestamp is
// refreshed when an authenticated client is removed (see remove_client_),
// never on bare TCP connects.
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
}
return conn;
}
#ifdef USE_API_OUTGOING_CONNECTION
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> sock) {
// Re-check at the handoff: inbound clients may have taken the last slot and
// the PSK may have been cleared since the dial started (mark_outgoing()
// needs the noise helper)
const bool at_limit = this->at_client_limit_();
if (at_limit || !this->noise_ctx_.has_psk()) {
ESP_LOGW(TAG, "Dropping outgoing connection (%s)",
at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key"));
return nullptr;
}
auto *conn = this->add_client_(std::move(sock));
// After start(): sends our server hello first so the peer can pick the key
conn->mark_outgoing();
return conn;
}
void APIServer::on_outgoing_target_client(APIConnection *conn) {
this->outgoing_target_count_++;
this->outgoing_conn_.on_target_client(conn);
}
#endif
void APIServer::dump_config() {
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
@@ -320,6 +379,9 @@ void APIServer::dump_config() {
#else
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
#endif
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.dump_config();
#endif
}
void APIServer::handle_disconnect(APIConnection *conn) {}
@@ -616,6 +678,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
if (!c->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
}
// Force it: a session from before the key was active must not survive
c->flags_.next_close = true;
}
});
}
@@ -717,6 +781,9 @@ void APIServer::on_shutdown() {
// Close the listening socket to prevent new connections
this->destroy_socket_();
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.on_shutdown();
#endif
// Change batch delay to 5ms for quick flushing during shutdown
this->batch_delay_ = 5;
+25 -3
View File
@@ -11,6 +11,7 @@
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "api_outgoing_connection.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
@@ -90,6 +91,10 @@ class APIServer final : public Component
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
#endif // USE_API_NOISE
#ifdef USE_API_OUTGOING_CONNECTION
// Called by APIConnection when a client declares itself a dial-back target in its hello
void on_outgoing_target_client(APIConnection *conn);
#endif
void handle_disconnect(APIConnection *conn);
#ifdef USE_BINARY_SENSOR
@@ -267,6 +272,16 @@ class APIServer final : public Component
protected:
// Accept incoming socket connections. Only called when socket has pending connections.
void __attribute__((noinline)) accept_new_connections_();
/// Takes the socket into a new connection and starts it; callers must have
/// checked at_client_limit_() first
APIConnection *add_client_(std::unique_ptr<socket::Socket> sock);
bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; }
#ifdef USE_API_OUTGOING_CONNECTION
// Returns the new connection, or nullptr (socket dropped) when at the limit
APIConnection *add_outgoing_client_(std::unique_ptr<socket::Socket> sock);
bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; }
friend class OutgoingConnectionManager;
#endif
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
void __attribute__((noinline)) remove_client_(uint8_t client_index);
@@ -307,6 +322,8 @@ class APIServer final : public Component
delete this->socket_;
this->socket_ = nullptr;
}
/// Log the failure, drop the listen socket, and mark the component failed
/// unless this build can still dial out
void socket_failed_(const LogString *msg);
// Pointers and pointer-like types first (4 bytes each)
socket::ListenSocket *socket_{nullptr};
@@ -357,11 +374,13 @@ class APIServer final : public Component
// Group smaller types together
uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py
uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py
// Connection limits - these defaults will be overridden by config values
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
uint8_t listen_backlog_{4};
uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py
bool shutting_down_ = false;
uint8_t api_connection_count_{0};
#ifdef USE_API_OUTGOING_CONNECTION
// Connected clients whose hello declared them a dial-back target
uint8_t outgoing_target_count_{0};
#endif
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Index assigned by the provisioning manager for reporting this transport's state.
uint8_t provisioning_source_{0};
@@ -377,6 +396,9 @@ class APIServer final : public Component
#endif
ESPPreferenceObject noise_pref_;
#endif // USE_API_NOISE
#ifdef USE_API_OUTGOING_CONNECTION
OutgoingConnectionManager outgoing_conn_;
#endif
};
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+17 -14
View File
@@ -56,22 +56,25 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) {
return (value & 1) ? static_cast<int64_t>(~(value >> 1)) : static_cast<int64_t>(value >> 1);
}
/// Count number of varints in a packed buffer
inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
uint16_t count = 0;
while (len > 0) {
// Skip varint bytes until we find one without continuation bit
while (len > 0 && (*data & 0x80)) {
data++;
len--;
}
if (len > 0) {
data++;
len--;
count++;
/// Count varints in a packed buffer: len minus bytes with the continuation bit, summed a word at a time.
/// Word is a template parameter so tests can cover the 32-bit path on a 64-bit host.
template<typename Word = size_t> inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
constexpr size_t word_size = sizeof(Word);
constexpr Word lane_ones = ~Word{0} / 0xFF; // 0x01..01
const uint8_t *end = data + len;
size_t continuations = 0;
while (data != end) {
// Unaligned word loads fault on Xtensa
if ((reinterpret_cast<uintptr_t>(data) & (word_size - 1)) == 0 && static_cast<size_t>(end - data) >= word_size) {
Word word;
memcpy(&word, __builtin_assume_aligned(data, word_size), word_size);
continuations += (((word >> 7) & lane_ones) * lane_ones) >> (word_size * 8 - 8);
data += word_size;
} else {
continuations += *data++ >> 7;
}
}
return count;
return static_cast<uint16_t>(len - continuations);
}
/// Encode a varint directly into a pre-allocated buffer.
+3 -7
View File
@@ -35,10 +35,6 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_AS3935_ID])
if distance_config := config.get(CONF_DISTANCE):
sens = await sensor.new_sensor(distance_config)
cg.add(hub.set_distance_sensor(sens))
if lightning_energy_config := config.get(CONF_LIGHTNING_ENERGY):
sens = await sensor.new_sensor(lightning_energy_config)
cg.add(hub.set_energy_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISTANCE, hub.set_distance_sensor)
await sensors(CONF_LIGHTNING_ENERGY, hub.set_energy_sensor)
+3 -6
View File
@@ -241,12 +241,9 @@ async def to_code(config: ConfigType) -> None:
if peak_current_config := conf.get(CONF_PEAK_CURRENT):
sens = await sensor.new_sensor(peak_current_config)
cg.add(var.set_peak_current_sensor(i, sens))
if frequency_config := config.get(CONF_FREQUENCY):
sens = await sensor.new_sensor(frequency_config)
cg.add(var.set_freq_sensor(sens))
if chip_temperature_config := config.get(CONF_CHIP_TEMPERATURE):
sens = await sensor.new_sensor(chip_temperature_config)
cg.add(var.set_chip_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_FREQUENCY, var.set_freq_sensor)
await sensors(CONF_CHIP_TEMPERATURE, var.set_chip_temperature_sensor)
cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY]))
cg.add(var.set_current_phases(config[CONF_CURRENT_PHASES]))
cg.add(var.set_pga_gain(config[CONF_GAIN_PGA]))
@@ -36,14 +36,13 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ID])
hub = await cg.get_variable(config[CONF_ID])
if phase_cfg := config.get(CONF_PHASE_STATUS):
for i, key in enumerate(PHASE_KEYS):
if sub_phase_cfg := phase_cfg.get(key):
sens = await text_sensor.new_text_sensor(sub_phase_cfg)
cg.add(parent.set_phase_status_text_sensor(i, sens))
cg.add(hub.set_phase_status_text_sensor(i, sens))
if freq_status_config := config.get(CONF_FREQUENCY_STATUS):
sens = await text_sensor.new_text_sensor(freq_status_config)
cg.add(parent.set_freq_status_text_sensor(sens))
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_FREQUENCY_STATUS, hub.set_freq_status_text_sensor)
+1 -1
View File
@@ -72,7 +72,7 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
return ESP_ERR_INVALID_STATE;
}
esp_err_t AudioReader::start(AudioFile *audio_file, AudioFileType &file_type) {
esp_err_t AudioReader::start(const AudioFile *audio_file, AudioFileType &file_type) {
file_type = AudioFileType::NONE;
this->current_audio_file_ = audio_file;
+2 -2
View File
@@ -47,7 +47,7 @@ class AudioReader {
/// @param audio_file AudioFile struct containing the file.
/// @param file_type AudioFileType variable passed-by-reference indicating the type of file being read.
/// @return ESP_OK
esp_err_t start(AudioFile *audio_file, AudioFileType &file_type);
esp_err_t start(const AudioFile *audio_file, AudioFileType &file_type);
/// @brief Reads new file data from the source and sends to the ring buffer sink.
/// @return AudioReaderState
@@ -69,7 +69,7 @@ class AudioReader {
esp_http_client_handle_t client_{nullptr};
AudioFile *current_audio_file_{nullptr};
const AudioFile *current_audio_file_{nullptr};
AudioFileType audio_file_type_{AudioFileType::NONE};
const uint8_t *file_current_{nullptr};
};
+30 -11
View File
@@ -19,7 +19,7 @@ from esphome.const import (
CONF_URL,
)
from esphome.core import CORE, ID, HexInt
from esphome.cpp_generator import MockObj
from esphome.cpp_generator import MockObj, ProgmemAssignmentExpression
from esphome.external_files import download_web_files_in_config
from esphome.types import ConfigType
@@ -151,11 +151,14 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
)
CONF_FILE_DATA_ID = "file_data_id"
MEDIA_FILE_TYPE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(audio.AudioFile),
cv.Required(CONF_FILE): _file_schema,
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
cv.GenerateID(CONF_FILE_DATA_ID): cv.declare_id(audio.AudioFile),
}
)
@@ -217,9 +220,9 @@ def audio_files_schema() -> cv.All:
def generate_audio_file_code(file_config: ConfigType) -> MockObj:
"""Generate the progmem data, AudioFile struct, and Pvariable for one file.
"""Generate the progmem data and a flash AudioFile for one file.
Returns the created Pvariable. Caller is responsible for any further
Returns a const pointer to the AudioFile. Caller is responsible for any further
registration (the audio_file component additionally registers each file in
its named C++ registry; other consumers may skip that).
"""
@@ -230,17 +233,33 @@ def generate_audio_file_code(file_config: ConfigType) -> MockObj:
else:
data, media_file_type = read_audio_file_and_type(file_config)
rhs = [HexInt(x) for x in data]
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
media_files_struct = cg.StructInitializer(
# Global constants so the AudioFile lives in flash; the id stays a plain pointer
# because actions render id arguments as ``::<id>``.
data_id = file_config[CONF_RAW_DATA_ID]
cg.add_global(
ProgmemAssignmentExpression(
data_id.type, data_id, cg.safe_exp([HexInt(x) for x in data])
)
)
media_file = cg.StructInitializer(
audio.AudioFile,
("data", prog_arr),
("length", len(rhs)),
("data", MockObj(data_id, ".")),
("length", len(data)),
("file_type", media_file_type),
)
return cg.new_Pvariable(file_config[CONF_ID], media_files_struct)
file_var_id = file_config[CONF_ID]
storage = file_config[CONF_FILE_DATA_ID]
cg.add_global(
cg.RawStatement(f"static constexpr {storage.type} {storage} = {media_file};")
)
cg.add_global(
cg.RawStatement(
f"static const {storage.type} *const {file_var_id} = &{storage};"
)
)
var = MockObj(file_var_id, "->")
CORE.register_variable(file_var_id, var)
return var
CONFIG_SCHEMA = cv.All(
+2 -2
View File
@@ -10,14 +10,14 @@
namespace esphome::audio_file {
struct NamedAudioFile {
audio::AudioFile *file;
const audio::AudioFile *file;
const char *file_id;
};
inline StaticVector<NamedAudioFile, AUDIO_FILE_MAX_FILES>
named_audio_files; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
inline void add_named_audio_file(audio::AudioFile *file, const char *file_id) {
inline void add_named_audio_file(const audio::AudioFile *file, const char *file_id) {
named_audio_files.push_back({file, file_id});
}
@@ -46,7 +46,7 @@ class AudioFileMediaSource final : public Component,
protected:
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
audio::AudioStreamInfo stream_info_;
audio::AudioFile *current_file_{nullptr};
const audio::AudioFile *current_file_{nullptr};
// Written from the main loop in handle_command(), read from the decoder task in
// on_audio_write(). Must be atomic to avoid a data race.
+3 -7
View File
@@ -44,10 +44,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await register_bedjet_child(var, config)
if outlet_temperature_sensor := config.get(CONF_OUTLET_TEMPERATURE):
sensor_var = await sensor.new_sensor(outlet_temperature_sensor)
cg.add(var.set_outlet_temperature_sensor(sensor_var))
if ambient_temperature_sensor := config.get(CONF_AMBIENT_TEMPERATURE):
sensor_var = await sensor.new_sensor(ambient_temperature_sensor)
cg.add(var.set_ambient_temperature_sensor(sensor_var))
sensors = sensor.sub_sensors(config)
await sensors(CONF_OUTLET_TEMPERATURE, var.set_outlet_temperature_sensor)
await sensors(CONF_AMBIENT_TEMPERATURE, var.set_ambient_temperature_sensor)
@@ -11,29 +11,34 @@
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <initializer_list>
namespace esphome::ble_device_base {
/// True if `address` is in `table`, a list of MACs ended by 0.
inline bool mac_in_table(const uint64_t *table, uint64_t address) {
for (; *table != 0; table++) {
if (*table == address)
return true;
}
return false;
}
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
/// Table of MACs ended by 0; must outlive the trigger.
void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; }
bool parse_device(const ESPBTDevice &device) override {
if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(),
device.address_uint64()) == this->addresses_.end()) {
if (this->addresses_ != nullptr && !mac_in_table(this->addresses_, device.address_uint64()))
return false;
}
this->trigger(device);
return true;
}
protected:
FixedVector<uint64_t> addresses_;
const uint64_t *addresses_{nullptr};
};
// on_ble_service_data_advertise: fires when an advertisement contains service
@@ -69,13 +69,24 @@ def uuid_trigger_schema(
)
def _filter_mac(value: Any) -> Any:
mac = cv.mac_address(value)
if not any(mac.parts):
# 0 ends the flash MAC table, and it is never a real device address.
raise cv.Invalid("00:00:00:00:00:00 cannot be used as a MAC address filter")
return mac
MAC_FILTER_LIST = cv.ensure_list(_filter_mac)
def advertise_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]:
"""on_ble_advertise schema: multi-mac list filter, unlike the single-mac
uuid_trigger_schema() — pairs with advertise_trigger_to_code()."""
return automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST,
}
)
@@ -94,11 +105,18 @@ def scan_end_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]
_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE)
def mac_filter_table(macs: list) -> cg.MockObj:
"""Shared flash table of MACs ended by 0 (never a valid address), so triggers store a pointer."""
return cg.shared_progmem_array(
"ble_mac_filter", cg.uint64, [*(mac.as_hex for mac in macs), 0]
)
async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
"""Build an on_ble_advertise trigger (optional multi-mac filter)."""
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if (macs := conf.get(CONF_MAC_ADDRESS)) is not None:
cg.add(trigger.set_addresses([it.as_hex for it in macs]))
if macs := conf.get(CONF_MAC_ADDRESS):
cg.add(trigger.set_addresses(mac_filter_table(macs)))
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
_count_listener()
+8 -13
View File
@@ -88,16 +88,11 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
for d in ["x", "y", "z"]:
accel_key = f"acceleration_{d}"
if accel_key in config:
sens = await sensor.new_sensor(config[accel_key])
cg.add(getattr(var, f"set_accel_{d}_sensor")(sens))
accel_key = f"gyroscope_{d}"
if accel_key in config:
sens = await sensor.new_sensor(config[accel_key])
cg.add(getattr(var, f"set_gyro_{d}_sensor")(sens))
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ACCELERATION_X, var.set_accel_x_sensor)
await sensors(CONF_GYROSCOPE_X, var.set_gyro_x_sensor)
await sensors(CONF_ACCELERATION_Y, var.set_accel_y_sensor)
await sensors(CONF_GYROSCOPE_Y, var.set_gyro_y_sensor)
await sensors(CONF_ACCELERATION_Z, var.set_accel_z_sensor)
await sensors(CONF_GYROSCOPE_Z, var.set_gyro_z_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
+3 -7
View File
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature(sens))
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature)
await sensors(CONF_PRESSURE, var.set_pressure)
+5 -6
View File
@@ -75,14 +75,13 @@ async def to_code_base(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
temperature_config = config[CONF_TEMPERATURE]
cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING]))
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure_sensor(sens))
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
pressure_config = config[CONF_PRESSURE]
cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING]))
cg.add(var.set_iir_filter(config[CONF_IIR_FILTER]))
+5 -6
View File
@@ -80,18 +80,17 @@ async def to_code_base(config: ConfigType) -> MockObj:
await cg.register_component(var, config)
cg.add(var.set_iir_filter_config(config[CONF_IIR_FILTER]))
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
temperature_config = config[CONF_TEMPERATURE]
cg.add(
var.set_temperature_oversampling_config(
temperature_config[CONF_OVERSAMPLING]
)
)
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure_sensor(sens))
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
pressure_config = config[CONF_PRESSURE]
cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING]))
return var
+5 -6
View File
@@ -137,9 +137,9 @@ CONFIG_SCHEMA_BASE = cv.Schema(
async def to_code_base(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
temperature_config = config[CONF_TEMPERATURE]
cg.add(
var.set_temperature_oversampling_config(
temperature_config[CONF_OVERSAMPLING]
@@ -149,9 +149,8 @@ async def to_code_base(config: ConfigType) -> MockObj:
var.set_temperature_iir_filter_config(temperature_config[CONF_IIR_FILTER])
)
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure_sensor(sens))
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
pressure_config = config[CONF_PRESSURE]
cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING]))
cg.add(var.set_pressure_iir_filter_config(pressure_config[CONF_IIR_FILTER]))
+2 -3
View File
@@ -54,9 +54,8 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if co2_config := config.get(CONF_CO2):
sens = await sensor.new_sensor(co2_config)
cg.add(var.set_co2_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_CO2, var.set_co2_sensor)
CALIBRATION_ACTION_SCHEMA = maybe_simple_id(
+2 -3
View File
@@ -203,6 +203,5 @@ async def to_code(config: ConfigType) -> None:
else:
cg.add(var.add_source(source))
if CONF_STD_DEV in config:
sens = await sensor.new_sensor(config[CONF_STD_DEV])
cg.add(var.set_std_dev_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_STD_DEV, var.set_std_dev_sensor)
+4
View File
@@ -7,6 +7,7 @@ BYTE_ORDER_BIG = "big_endian"
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_ALLOWED_IPS = "allowed_ips"
CONF_B_CONSTANT = "b_constant"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BYTE_ORDER = "byte_order"
@@ -15,6 +16,7 @@ CONF_CLIMATE_ID = "climate_id"
CONF_CO2_EQUIVALENT = "co2_equivalent"
CONF_COLOR_DEPTH = "color_depth"
CONF_COLUMNS = "columns"
CONF_CONNECTED = "connected"
CONF_CONVERSION_RATE = "conversion_rate"
CONF_CRC_ENABLE = "crc_enable"
CONF_DATA_BITS = "data_bits"
@@ -41,7 +43,9 @@ CONF_ON_SCAN_END = "on_scan_end"
CONF_ON_STATE_CHANGE = "on_state_change"
CONF_PARITY = "parity"
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
CONF_RECONNECT_INTERVAL = "reconnect_interval"
CONF_REQUEST_HEADERS = "request_headers"
CONF_ROLE = "role"
CONF_ROWS = "rows"
CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SHA256 = "sha256"
+4 -9
View File
@@ -117,15 +117,10 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_hpf_enable(config[CONF_CURRENT_HPF], config[CONF_VOLTAGE_HPF]))
cg.add(var.set_pulse_energy_wh(config[CONF_PULSE_ENERGY]))
if voltage_config := config.get(CONF_VOLTAGE):
sens = await sensor.new_sensor(voltage_config)
cg.add(var.set_voltage_sensor(sens))
if current_config := config.get(CONF_CURRENT):
sens = await sensor.new_sensor(current_config)
cg.add(var.set_current_sensor(sens))
if power_config := config.get(CONF_POWER):
sens = await sensor.new_sensor(power_config)
cg.add(var.set_power_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT, var.set_current_sensor)
await sensors(CONF_POWER, var.set_power_sensor)
automation.register_apply_action(
+3 -6
View File
@@ -61,11 +61,8 @@ async def to_code(config: ConfigType) -> None:
pin = await gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
cg.add(var.set_dht_model(config[CONF_MODEL]))
+3 -7
View File
@@ -46,10 +46,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
+3 -7
View File
@@ -54,10 +54,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature)
cg.add(var.set_temperature_sensor(sens))
if pressure := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure)
cg.add(var.set_pressure_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
+2 -3
View File
@@ -63,9 +63,8 @@ async def to_code(config: ConfigType) -> None:
if CONF_LAMBDA in config:
lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [], return_type=cg.bool_)
cg.add(var.set_lambda(lambda_))
if CONF_LAST_TIME in config:
sens = await sensor.new_sensor(config[CONF_LAST_TIME])
cg.add(var.set_last_duty_time_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_LAST_TIME, var.set_last_duty_time_sensor)
# AUTOMATIONS
+4 -11
View File
@@ -57,14 +57,7 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature)
cg.add(var.set_temperature_sensor(sens))
if co2 := config.get(CONF_CO2):
sens = await sensor.new_sensor(co2)
cg.add(var.set_co2_sensor(sens))
if pressure := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure)
cg.add(var.set_pressure_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_CO2, var.set_co2_sensor)
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
+2 -1
View File
@@ -16,6 +16,7 @@ from esphome.const import (
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
@@ -70,7 +71,7 @@ SENSOR_CONFIGS = {
"E": {
CONF_UNIT_OF_MEASUREMENT: UNIT_WATT_HOURS,
CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
CONF_STATE_CLASS: STATE_CLASS_TOTAL,
CONF_ACCURACY_DECIMALS: 0,
},
"V": {
+3 -7
View File
@@ -50,10 +50,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
@@ -26,3 +26,15 @@ goodisplay_gdey042t81 = ssd1683.extend(
width=400,
height=300,
)
# The SSD1681 shares the SSD1683 command set for everything this driver uses:
# the same 0x01/0x11/0x18 initialisation, the same 0x44/0x45 window and
# 0x4E/0x4F cursor registers with a byte-addressed X and a 16-bit Y, and the
# same 0x22/0x20 update. It only differs in the panel sizes it drives.
ssd1681 = SSD1683("ssd1681")
waveshare_1_54in_v2 = ssd1681.extend(
"waveshare-1.54in-v2",
width=200,
height=200,
)
+16 -1
View File
@@ -92,6 +92,21 @@ uint8_t ES8311::calculate_resolution_value(ES8311Resolution resolution) {
}
}
/// Encodes the MCLK pre-multiplier for REG02 bits 3-4. The coefficient table stores the multiplier as the factor
/// itself (1, 2, 4 or 8), while the register takes 0, 1, 2 or 3 (ES8311 datasheet, register 0x02 MULT_PRE).
static uint8_t encode_pre_mult(uint8_t pre_mult) {
switch (pre_mult) {
case 2:
return 1;
case 4:
return 2;
case 8:
return 3;
default:
return 0;
}
}
const ES8311Coefficient *ES8311::get_coefficient(uint32_t mclk, uint32_t rate) {
for (const auto &coefficient : ES8311_COEFFICIENTS) {
if (coefficient.mclk == mclk && coefficient.rate == rate)
@@ -127,7 +142,7 @@ bool ES8311::configure_clock_() {
ES8311_ERROR_CHECK(this->read_byte(ES8311_REG02_CLK_MANAGER, &reg02));
reg02 &= 0x07;
reg02 |= (coefficient->pre_div - 1) << 5;
reg02 |= coefficient->pre_mult << 3;
reg02 |= encode_pre_mult(coefficient->pre_mult) << 3;
ES8311_ERROR_CHECK(this->write_byte(ES8311_REG02_CLK_MANAGER, reg02));
// Register 0x03
+10 -16
View File
@@ -30,19 +30,13 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ES8388_ID])
if dac_output_config := config.get(CONF_DAC_OUTPUT):
s = await select.new_select(
dac_output_config,
options=["LINE1", "LINE2", "BOTH"],
)
await cg.register_parented(s, parent)
cg.add(parent.set_dac_output_select(s))
if adc_input_mic_config := config.get(CONF_ADC_INPUT_MIC):
s = await select.new_select(
adc_input_mic_config,
options=["LINE1", "LINE2", "DIFFERENCE"],
)
await cg.register_parented(s, parent)
cg.add(parent.set_adc_input_mic_select(s))
hub = await cg.get_variable(config[CONF_ES8388_ID])
selects = select.sub_selects(config, parent=hub)
await selects(
CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"]
)
await selects(
CONF_ADC_INPUT_MIC,
hub.set_adc_input_mic_select,
options=["LINE1", "LINE2", "DIFFERENCE"],
)
+45 -25
View File
@@ -57,7 +57,7 @@ from esphome.const import (
from esphome.core import CORE, EsphomeError, HexInt
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_components
from esphome.espidf.component import IDFComponent, generate_idf_components
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
@@ -742,9 +742,10 @@ def is_idf_sdkconfig_option_enabled(name: str) -> bool:
def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None:
"""Set an sdkconfig option unless it is already set.
For the FINAL priority reconcile jobs: they run after every to_code,
including the user's sdkconfig_options, and must not override an
existing value.
User sdkconfig_options take precedence regardless of to_code order:
esp32.to_code applies them unconditionally, and this helper preserves
values that are already set. FINAL priority reconcile jobs use the same
guard because they run after every to_code, including the user's options.
"""
if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]:
add_idf_sdkconfig_option(name, value)
@@ -2873,6 +2874,8 @@ async def to_code(config):
"CONFIG_ESP32P4_SELECTS_REV_LESS_V3",
config.get(CONF_ENGINEERING_SAMPLE, False),
)
# Work around ESP-IDF bug: see https://github.com/espressif/esp-idf/issues/19020
add_idf_sdkconfig_option("CONFIG_ESP_MAIN_TASK_STACK_SIZE", 8192)
# ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not
# understand %zu or %lld and crashes on any %s that follows one.
@@ -3537,6 +3540,30 @@ def _write_idf_component_yml():
yml_path = CORE.relative_build_path("src/idf_component.yml")
dependencies: dict[str, dict] = {}
converted: list[IDFComponent] = []
if CORE.using_toolchain_esp_idf:
# Convert the PlatformIO libraries to ESP-IDF components as a batch so
# PlatformIO resolves the whole dependency tree at once -- deduplicating
# shared transitive deps (e.g. esphome/libsodium pulled by both noise-c
# and esp_wireguard) to a single version instead of clashing
# override_path entries.
libraries = [
library
for name, library in CORE.platformio_libraries.items()
# Don't process arduino libraries
if name not in ARDUINO_DISABLED_LIBRARIES
]
# A library also declared as a managed component is not converted too, or
# IDF sees the same requirement twice; converted components reach it through
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
converted = generate_idf_components(libraries, managed=managed)
# IDF names a component after its directory and a later registration of the
# same name replaces the earlier one, so a stub beside a converted library of
# the same name (espressif/libsodium vs esphome/libsodium) would win or lose
# on path order. Such a stub points at the converted library instead.
converted_by_name = {component.path.name: component for component in converted}
# For Arduino builds, override unused managed components from the Arduino framework
# by pointing them to empty stub directories using override_path
# This prevents the IDF component manager from downloading the real components
@@ -3560,8 +3587,17 @@ def _write_idf_component_yml():
# always writes, and ninja keeps triggering CMake re-runs on
# otherwise-cached rebuilds.
for component_name in sorted(components_to_stub):
stub_name = _idf_component_stub_name(component_name)
stub_path = stubs_dir / stub_name
if (component := converted_by_name.get(stub_name)) is not None:
if stub_path.exists():
rmtree(stub_path)
dependencies[_idf_component_dep_name(component_name)] = {
"version": "*",
"override_path": str(component.path),
}
continue
# Create stub directory with minimal CMakeLists.txt
stub_path = stubs_dir / _idf_component_stub_name(component_name)
stub_path.mkdir(exist_ok=True)
stub_cmake = stub_path / "CMakeLists.txt"
if not stub_cmake.exists():
@@ -3603,26 +3639,10 @@ def _write_idf_component_yml():
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
)
if CORE.using_toolchain_esp_idf:
# Convert the PlatformIO libraries to ESP-IDF components as a batch so
# PlatformIO resolves the whole dependency tree at once -- deduplicating
# shared transitive deps (e.g. esphome/libsodium pulled by both noise-c
# and esp_wireguard) to a single version instead of clashing
# override_path entries.
libraries = [
library
for name, library in CORE.platformio_libraries.items()
# Don't process arduino libraries
if name not in ARDUINO_DISABLED_LIBRARIES
]
# A library also declared as a managed component is not converted too, or
# IDF sees the same requirement twice; converted components reach it through
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
for component in generate_idf_components(libraries, managed=managed):
dependencies[component.get_sanitized_name()] = {
"override_path": str(component.path)
}
for component in converted:
dependencies[component.get_sanitized_name()] = {
"override_path": str(component.path)
}
if CORE.data[KEY_ESP32][KEY_COMPONENTS]:
components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS]
+4 -6
View File
@@ -17,8 +17,7 @@ extern "C" __attribute__((weak)) void initArduino() {}
namespace esphome {
// HAL functions live in hal.cpp. This file keeps only the loop task setup.
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StackType_t
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -40,11 +39,10 @@ extern "C" void app_main() {
initArduino();
esp32::setup_preferences();
#if CONFIG_FREERTOS_UNICORE
loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
&loop_task_tcb);
xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, &loop_task_tcb);
#else
loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1,
loop_task_stack, &loop_task_tcb, 1);
xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
&loop_task_tcb, 1);
#endif
}
+3 -3
View File
@@ -235,14 +235,14 @@ class ESP32BLE final : public Component {
#ifdef USE_ESP32_BLE_ADVERTISING
BLEAdvertising *advertising_{}; // 4 bytes (pointer)
#endif
const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash)
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum)
uint32_t advertising_cycle_time_{}; // 4 bytes
const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash)
uint32_t advertising_cycle_time_{}; // 4 bytes
// 2-byte aligned members
uint16_t appearance_{0}; // 2 bytes
// 1-byte aligned members (grouped together to minimize padding)
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef)
BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum)
bool enable_on_boot_{}; // 1 byte
#ifdef USE_ESP32_BLE_ADVERTISING
@@ -8,6 +8,10 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base, esp32_ble, ota
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW
from esphome.components.ble_device_base.automation import (
MAC_FILTER_LIST,
mac_filter_table,
)
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
@@ -277,7 +281,7 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ESPBTAdvertiseTrigger
),
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST,
}
),
cv.Optional(
@@ -381,9 +385,8 @@ async def to_code(config: ConfigType) -> None:
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
_request_listener_slot()
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if CONF_MAC_ADDRESS in conf:
addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]]
cg.add(trigger.set_addresses(addr_list))
if macs := conf.get(CONF_MAC_ADDRESS):
cg.add(trigger.set_addresses(mac_filter_table(macs)))
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []):
_request_listener_slot()
@@ -1,10 +1,9 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/components/ble_device_base/automation.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include <vector>
#ifdef USE_ESP32
namespace esphome::esp32_ble_tracker {
@@ -12,22 +11,18 @@ namespace esphome::esp32_ble_tracker {
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
public:
explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); }
void set_addresses(std::initializer_list<uint64_t> addresses) { this->address_vec_ = addresses; }
/// Table of MACs ended by 0; must outlive the trigger.
void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; }
bool parse_device(const ESPBTDevice &device) override {
uint64_t u64_addr = device.address_uint64();
if (!address_vec_.empty()) {
if (std::find(address_vec_.begin(), address_vec_.end(), u64_addr) == address_vec_.end()) {
return false;
}
}
if (this->addresses_ != nullptr && !ble_device_base::mac_in_table(this->addresses_, device.address_uint64()))
return false;
this->trigger(device);
return true;
}
protected:
std::vector<uint64_t> address_vec_;
const uint64_t *addresses_{nullptr};
};
class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
+185
View File
@@ -0,0 +1,185 @@
from ipaddress import IPv4Address
import esphome.codegen as cg
from esphome.components import esp32, wifi
from esphome.components.esp32.const import (
VARIANT_ESP32,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
VARIANT_ESP32C6,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_WIFI
from esphome.core import CORE
from esphome.types import ConfigType
DOMAIN = "espectre"
CODEOWNERS = ["@francescopace"]
DEPENDENCIES = ["esp32", "wifi"]
CONF_ESPECTRE_ID = "espectre_id"
CONF_DETECTION_ALGORITHM = "detection_algorithm"
CONF_CSI_CAPTURE_PROFILE = "csi_capture_profile"
CONF_TRAFFIC_GENERATOR_MODE = "traffic_generator_mode"
CONF_TRAFFIC_GENERATOR_TARGET_IP = "traffic_generator_target_ip"
CONF_CSI_TRAFFIC_MULTICAST_GROUP = "csi_traffic_multicast_group"
CONF_MOTION_ON_HITS = "motion_on_hits"
CONF_MOTION_OFF_HITS = "motion_off_hits"
# Fully qualified: a bare espectre:: in main.cpp would clash with the SDK namespace.
espectre_ns = cg.global_ns.namespace("esphome").namespace("espectre")
ESPectreComponent = espectre_ns.class_("ESPectreComponent", cg.Component)
sdk_ns = cg.global_ns.namespace("::espectre")
DetectionAlgorithm = sdk_ns.enum("DetectionAlgorithm", is_class=True)
CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True)
TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True)
WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True)
DETECTION_ALGORITHMS = {
"lightweight": DetectionAlgorithm.LIGHTWEIGHT,
"high_accuracy": DetectionAlgorithm.HIGH_ACCURACY,
}
CSI_CAPTURE_PROFILES = {
"auto": CsiCapturePolicy.AUTO,
"lltf": CsiCapturePolicy.LLTF,
"ht_vht": CsiCapturePolicy.HT_VHT,
}
TRAFFIC_GENERATOR_MODES = {
"ping": TrafficGeneratorMode.PING,
"dns": TrafficGeneratorMode.DNS,
"dns_tcp": TrafficGeneratorMode.DNS_TCP,
"wifi_raw": TrafficGeneratorMode.WIFI_RAW,
"external": TrafficGeneratorMode.EXTERNAL_HOST,
}
def validate_target_ip(value: str) -> str:
value = str(cv.ipv4address(value))
first_octet = int(IPv4Address(value)) >> 24
if first_octet in (0, 127) or first_octet >= 224 or value == "255.255.255.255":
raise cv.Invalid("ESPectre traffic target must be a unicast IPv4 address")
return value
def validate_multicast_group(value: str) -> str:
"""An IPv4 multicast group, or an empty string to skip joining one."""
if not (value := cv.string_strict(value).strip()):
return value
value = str(cv.ipv4address(value))
if not IPv4Address(value).is_multicast:
raise cv.Invalid("ESPectre multicast group must be an IPv4 multicast address")
return value
def supported_traffic_generator_modes(config: ConfigType) -> list[str]:
"""Traffic generator modes available with this chip and CSI capture profile."""
wifi_raw = (
esp32.get_esp32_variant() != VARIANT_ESP32C6
and config[CONF_CSI_CAPTURE_PROFILE] != "ht_vht"
)
return [mode for mode in TRAFFIC_GENERATOR_MODES if wifi_raw or mode != "wifi_raw"]
def validate_config(config: ConfigType) -> ConfigType:
mode = config[CONF_TRAFFIC_GENERATOR_MODE]
if mode not in supported_traffic_generator_modes(config):
raise cv.Invalid(
"wifi_raw traffic is not supported on ESP32-C6 "
"or with the ht_vht CSI capture profile"
)
if mode in ("wifi_raw", "external") and CONF_TRAFFIC_GENERATOR_TARGET_IP in config:
raise cv.Invalid(f"{mode} traffic does not use a target IP address")
if mode != "external" and CONF_CSI_TRAFFIC_MULTICAST_GROUP in config:
raise cv.Invalid(
f"{CONF_CSI_TRAFFIC_MULTICAST_GROUP} requires "
f"{CONF_TRAFFIC_GENERATOR_MODE}: external"
)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ESPectreComponent),
cv.Optional(CONF_DETECTION_ALGORITHM, default="lightweight"): cv.enum(
DETECTION_ALGORITHMS, lower=True
),
cv.Optional(CONF_CSI_CAPTURE_PROFILE, default="auto"): cv.enum(
CSI_CAPTURE_PROFILES, lower=True
),
cv.Optional(CONF_TRAFFIC_GENERATOR_MODE, default="ping"): cv.enum(
TRAFFIC_GENERATOR_MODES, lower=True
),
cv.Optional(CONF_TRAFFIC_GENERATOR_TARGET_IP): validate_target_ip,
cv.Optional(CONF_CSI_TRAFFIC_MULTICAST_GROUP): validate_multicast_group,
cv.Optional(CONF_MOTION_ON_HITS): cv.int_range(min=1, max=20),
cv.Optional(CONF_MOTION_OFF_HITS): cv.int_range(min=1, max=20),
}
).extend(cv.COMPONENT_SCHEMA),
esp32.only_on_variant(
supported=[
VARIANT_ESP32,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
VARIANT_ESP32C6,
],
msg_prefix="ESPectre",
),
# Arduino 3.3.7 is the first release built on ESP-IDF 5.5.3.
cv.require_framework_version(
esp_idf=cv.Version(5, 5, 3), esp32_arduino=cv.Version(3, 3, 7)
),
validate_config,
)
def final_validate(config: ConfigType) -> None:
wifi.force_power_save_off(
"ESPectre needs the radio awake to receive a steady flow of CSI packets"
)
FINAL_VALIDATE_SCHEMA = final_validate
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add_define("USE_ESPECTRE")
cg.add(var.set_detection_algorithm(config[CONF_DETECTION_ALGORITHM]))
cg.add(var.set_csi_capture_profile(config[CONF_CSI_CAPTURE_PROFILE]))
cg.add(var.set_traffic_generator_mode(config[CONF_TRAFFIC_GENERATOR_MODE]))
if (target_ip := config.get(CONF_TRAFFIC_GENERATOR_TARGET_IP)) is not None:
cg.add(var.set_traffic_generator_target_ip(target_ip))
if (group := config.get(CONF_CSI_TRAFFIC_MULTICAST_GROUP)) is not None:
cg.add(var.set_csi_traffic_multicast_group(group))
if (on_hits := config.get(CONF_MOTION_ON_HITS)) is not None:
cg.add(var.set_motion_on_hits(on_hits))
if (off_hits := config.get(CONF_MOTION_OFF_HITS)) is not None:
cg.add(var.set_motion_off_hits(off_hits))
if esp32.get_esp32_variant() == VARIANT_ESP32C5:
band = CORE.config[CONF_WIFI].get(wifi.CONF_BAND_MODE, "AUTO")
cg.add(
var.set_wifi_band_policy(
{
"2.4GHZ": WifiBandPolicy.BAND_2G,
"5GHZ": WifiBandPolicy.BAND_5G,
"AUTO": WifiBandPolicy.AUTO,
}[band]
)
)
wifi.enable_runtime_roaming_suppression()
esp32.add_idf_component(name="francescopace/espectre", ref="3.0.0")
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_CSI_ENABLED", True)
# CSI is reported once per received transmission, so aggregation hides frames from sensing.
# Disabling TX aggregation also lets the SDK fix the station TX rate (6.5 Mbps on ESP32).
# Both may lower Wi-Fi throughput for the whole firmware.
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", False)
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", False)
# Keep the radio awake while disconnected too, matching force_power_save_off().
esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE", False)
@@ -0,0 +1,32 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_MOTION, DEVICE_CLASS_MOTION, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from . import CONF_ESPECTRE_ID, ESPectreComponent
DEPENDENCIES = ["espectre"]
CONF_CALIBRATING = "calibrating"
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent),
cv.Optional(CONF_MOTION): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_MOTION
),
cv.Optional(CONF_CALIBRATING): binary_sensor.binary_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
}
),
cv.has_at_least_one_key(CONF_MOTION, CONF_CALIBRATING),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_ESPECTRE_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_MOTION, hub.set_motion_binary_sensor)
await binary_sensors(CONF_CALIBRATING, hub.set_calibrating_binary_sensor)
@@ -0,0 +1,20 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns
DEPENDENCIES = ["espectre"]
RecalibrateButton = espectre_ns.class_("RecalibrateButton", button.Button)
CONFIG_SCHEMA = button.button_schema(
RecalibrateButton, entity_category=ENTITY_CATEGORY_CONFIG
).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)})
async def to_code(config: ConfigType) -> None:
var = await button.new_button(config)
await cg.register_parented(var, config[CONF_ESPECTRE_ID])
@@ -0,0 +1,19 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESPECTRE
#include "esphome/components/button/button.h"
#include "../espectre.h"
namespace esphome::espectre {
class RecalibrateButton final : public button::Button, public Parented<ESPectreComponent> {
protected:
void press_action() override { this->parent_->recalibrate(); }
};
} // namespace esphome::espectre
#endif // USE_ESPECTRE
+305
View File
@@ -0,0 +1,305 @@
#include "espectre.h"
#ifdef USE_ESPECTRE
#include <cinttypes>
#include <cmath>
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
#include "esphome/components/wifi/wifi_component.h"
#endif
namespace esphome::espectre {
static const char *const TAG = "espectre";
struct TrafficModeOption {
const char *name;
::espectre::TrafficGeneratorMode mode;
};
// Option names match the traffic_generator_mode YAML values.
static constexpr TrafficModeOption TRAFFIC_MODE_OPTIONS[] = {
{"ping", ::espectre::TrafficGeneratorMode::PING},
{"dns", ::espectre::TrafficGeneratorMode::DNS},
{"dns_tcp", ::espectre::TrafficGeneratorMode::DNS_TCP},
{"wifi_raw", ::espectre::TrafficGeneratorMode::WIFI_RAW},
{"external", ::espectre::TrafficGeneratorMode::EXTERNAL_HOST},
};
static const char *traffic_mode_name(::espectre::TrafficGeneratorMode mode) {
for (const auto &option : TRAFFIC_MODE_OPTIONS) {
if (option.mode == mode)
return option.name;
}
return nullptr;
}
static const char *csi_capture_profile_name(::espectre::CsiCapturePolicy profile) {
switch (profile) {
case ::espectre::CsiCapturePolicy::LLTF:
return LOG_STR_LITERAL("lltf");
case ::espectre::CsiCapturePolicy::HT_VHT:
return LOG_STR_LITERAL("ht_vht");
default:
return LOG_STR_LITERAL("auto");
}
}
#ifdef USE_SELECT
void ESPectreComponent::request_traffic_generator_mode(const char *name) {
for (const auto &option : TRAFFIC_MODE_OPTIONS) {
if (strcmp(option.name, name) == 0) {
this->pending_traffic_mode_ = option.mode;
return;
}
}
}
#endif
static int log_level(::espectre::LogLevel level) {
switch (level) {
case ::espectre::LogLevel::ERROR:
return ESPHOME_LOG_LEVEL_ERROR;
case ::espectre::LogLevel::WARNING:
return ESPHOME_LOG_LEVEL_WARN;
case ::espectre::LogLevel::INFO:
return ESPHOME_LOG_LEVEL_INFO;
case ::espectre::LogLevel::DEBUG:
return ESPHOME_LOG_LEVEL_DEBUG;
default:
return ESPHOME_LOG_LEVEL_VERBOSE;
}
}
void ESPectreComponent::setup() {
// ESPHome owns scan results, including scans requested by the SDK's CSI recovery.
this->runtime_.config().wifi_scan_results_managed_externally = true;
#ifdef USE_SELECT
this->restore_traffic_mode_();
#endif
this->start_runtime_();
}
void ESPectreComponent::start_runtime_() {
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
// A sensor stays in one place, and each roaming scan takes the radio off-channel for
// seconds, emptying the CSI window. Losing the access point still reconnects normally.
if (wifi::global_wifi_component != nullptr) {
wifi::global_wifi_component->request_roaming_suppression();
this->roaming_suppressed_ = true;
}
#endif
::espectre::set_log_sink({
.context = nullptr,
.enabled = [](void *, ::espectre::LogLevel level, const char *) { return log_level(level) <= ESPHOME_LOG_LEVEL; },
.write = [](void *, ::espectre::LogLevel level, const char *tag, int line, const char *format,
va_list args) { esp_log_vprintf_(log_level(level), tag, line, format, args); },
});
if (!this->runtime_.setup(this)) {
ESP_LOGE(TAG, "Runtime setup failed");
this->stop_();
this->schedule_restart_();
return;
}
this->running_ = true;
this->status_clear_error();
#ifdef USE_SELECT
this->publish_traffic_mode_();
#endif
}
void ESPectreComponent::schedule_restart_() {
// A fault can be transient, such as a Wi-Fi stall, so retry instead of failing for good.
this->runtime_fault_ = false;
this->status_set_error(LOG_STR("Runtime stopped"));
ESP_LOGW(TAG, "Restarting the runtime in %" PRIu32 " s", RESTART_DELAY_MS / 1000);
this->restart_pending_ = true;
this->restart_requested_ms_ = App.get_loop_component_start_time();
}
#ifdef USE_SELECT
void ESPectreComponent::restore_traffic_mode_() {
if (this->traffic_mode_select_ == nullptr)
return;
// Keyed by the YAML mode, so changing it in YAML discards a mode saved from the select.
const auto yaml_mode = this->runtime_.config().traffic_generator_mode;
this->traffic_mode_pref_ =
this->traffic_mode_select_->make_entity_preference<uint8_t>(static_cast<uint32_t>(yaml_mode) + 1);
uint8_t saved;
if (!this->traffic_mode_pref_.load(&saved))
return;
const auto mode = static_cast<::espectre::TrafficGeneratorMode>(saved);
const char *name = traffic_mode_name(mode);
if (name != nullptr && this->traffic_mode_select_->has_option(name))
this->runtime_.config().traffic_generator_mode = mode;
}
void ESPectreComponent::apply_pending_traffic_mode_() {
if (!this->pending_traffic_mode_.has_value())
return;
const auto mode = *this->pending_traffic_mode_;
this->pending_traffic_mode_.reset();
if (this->runtime_.set_traffic_generator_mode(mode)) {
const auto saved = static_cast<uint8_t>(mode);
this->traffic_mode_pref_.save(&saved);
} else {
ESP_LOGW(TAG, "Traffic generator mode %s was rejected", traffic_mode_name(mode));
}
this->publish_traffic_mode_();
}
void ESPectreComponent::publish_traffic_mode_() {
if (this->traffic_mode_select_ == nullptr)
return;
const char *name = traffic_mode_name(this->runtime_.config().traffic_generator_mode);
if (name != nullptr)
this->traffic_mode_select_->publish_state(name);
}
#endif
void ESPectreComponent::loop() {
if (this->runtime_fault_) {
this->stop_();
this->schedule_restart_();
return;
}
if (!this->running_) {
// Without a backend this only reaps a traffic worker that outlived the last runtime.
this->runtime_.loop();
if (this->restart_pending_ &&
App.get_loop_component_start_time() - this->restart_requested_ms_ >= RESTART_DELAY_MS) {
this->restart_pending_ = false;
this->start_runtime_();
}
return;
}
if (this->recalibrate_pending_) {
this->recalibrate_pending_ = false;
if (!this->runtime_.trigger_recalibration()) {
ESP_LOGW(TAG, "Recalibration is not available");
}
}
#ifdef USE_SELECT
this->apply_pending_traffic_mode_();
#endif
this->runtime_.loop();
if (this->runtime_fault_)
return;
// Read once after the SDK finishes dispatching callbacks, including readiness changes.
const auto &snapshot = this->runtime_.snapshot();
#ifdef USE_BINARY_SENSOR
if (this->calibrating_binary_sensor_ != nullptr &&
(!this->calibrating_published_ || this->calibrating_state_ != snapshot.calibrating)) {
this->calibrating_state_ = snapshot.calibrating;
this->calibrating_published_ = true;
this->calibrating_binary_sensor_->publish_state(snapshot.calibrating);
}
#endif
if (!snapshot.ready_to_publish) {
if (this->ready_)
this->invalidate_sensing_();
this->movement_pending_ = false;
return;
}
#ifdef USE_BINARY_SENSOR
const bool motion = snapshot.motion_state == ::espectre::MotionState::MOTION;
if (this->motion_binary_sensor_ != nullptr && (!this->ready_ || this->motion_state_ != motion)) {
this->motion_state_ = motion;
this->motion_binary_sensor_->publish_state(motion);
}
#endif
#ifdef USE_SENSOR
if (this->movement_sensor_ != nullptr && (this->movement_pending_ || !this->ready_))
this->movement_sensor_->publish_state(snapshot.movement_metric);
#endif
this->movement_pending_ = false;
this->ready_ = true;
}
void ESPectreComponent::invalidate_sensing_() {
#ifdef USE_BINARY_SENSOR
if (this->motion_binary_sensor_ != nullptr)
this->motion_binary_sensor_->invalidate_state();
#endif
#ifdef USE_SENSOR
if (this->movement_sensor_ != nullptr)
this->movement_sensor_->publish_state(NAN);
#endif
this->ready_ = false;
}
void ESPectreComponent::on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) {
if (success) {
this->calibrated_ = true;
this->status_clear_warning();
ESP_LOGI(TAG, "Calibration complete");
return;
}
// After a successful calibration, a failed one keeps that calibrated threshold.
if (!this->calibrated_)
this->status_set_warning(LOG_STR("Calibration failed"));
ESP_LOGW(TAG, "Calibration failed; retaining the previous threshold");
}
void ESPectreComponent::on_runtime_fault(const char *message) {
ESP_LOGE(TAG, "Runtime fault: %s", message);
this->runtime_fault_ = true;
}
void ESPectreComponent::stop_() {
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION)
if (this->roaming_suppressed_ && wifi::global_wifi_component != nullptr) {
wifi::global_wifi_component->release_roaming_suppression();
}
#endif
this->roaming_suppressed_ = false;
this->running_ = false;
// The next runtime starts from the default threshold and publishes its calibration again.
this->calibrated_ = false;
this->calibrating_published_ = false;
this->runtime_.shutdown();
::espectre::clear_log_sink();
this->invalidate_sensing_();
#ifdef USE_BINARY_SENSOR
if (this->calibrating_binary_sensor_ != nullptr)
this->calibrating_binary_sensor_->invalidate_state();
#endif
}
void ESPectreComponent::on_shutdown() {
this->restart_pending_ = false;
this->stop_();
}
void ESPectreComponent::dump_config() {
const auto &config = this->runtime_.config();
ESP_LOGCONFIG(TAG,
"ESPectre:\n"
" Detection algorithm: %s\n"
" CSI capture profile: %s\n"
" Traffic generator mode: %s\n"
" Motion on/off hits: %u/%u",
config.detection_algorithm == ::espectre::DetectionAlgorithm::LIGHTWEIGHT
? LOG_STR_LITERAL("lightweight")
: LOG_STR_LITERAL("high_accuracy"),
csi_capture_profile_name(config.csi_capture_policy), traffic_mode_name(config.traffic_generator_mode),
config.motion_on_hits, config.motion_off_hits);
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Motion", this->motion_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Calibrating", this->calibrating_binary_sensor_);
#endif
#ifdef USE_SENSOR
LOG_SENSOR(" ", "Movement score", this->movement_sensor_);
#endif
#ifdef USE_SELECT
LOG_SELECT(" ", "Traffic generator mode", this->traffic_mode_select_);
#endif
}
} // namespace esphome::espectre
#endif // USE_ESPECTRE
+110
View File
@@ -0,0 +1,110 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESPECTRE
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/optional.h"
#include <espectre_sdk.h>
#include <string>
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_SELECT
#include "esphome/components/select/select.h"
#include "esphome/core/preferences.h"
#endif
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
namespace esphome::espectre {
class ESPectreComponent final : public Component, public ::espectre::IRuntimeListener {
public:
void setup() override;
void loop() override;
void dump_config() override;
void on_shutdown() override;
// Register the SDK's Wi-Fi event handlers after network setup, before station startup.
float get_setup_priority() const override { return setup_priority::WIFI + 1.0f; }
void set_detection_algorithm(::espectre::DetectionAlgorithm value) {
this->runtime_.config().detection_algorithm = value;
}
void set_csi_capture_profile(::espectre::CsiCapturePolicy value) {
this->runtime_.config().csi_capture_policy = value;
}
void set_traffic_generator_mode(::espectre::TrafficGeneratorMode value) {
this->runtime_.config().traffic_generator_mode = value;
}
void set_traffic_generator_target_ip(const std::string &value) {
this->runtime_.config().traffic_generator_target_ip = value;
}
void set_csi_traffic_multicast_group(const std::string &value) {
this->runtime_.config().csi_traffic_multicast_group = value;
}
void set_motion_on_hits(uint8_t value) { this->runtime_.config().motion_on_hits = value; }
void set_motion_off_hits(uint8_t value) { this->runtime_.config().motion_off_hits = value; }
void set_wifi_band_policy(::espectre::WifiBandPolicy value) { this->runtime_.config().wifi_band_policy = value; }
// Queue controls so entity automations cannot re-enter the SDK from a listener callback.
void recalibrate() { this->recalibrate_pending_ = true; }
#ifdef USE_SELECT
void set_traffic_mode_select(select::Select *value) { this->traffic_mode_select_ = value; }
void request_traffic_generator_mode(::espectre::TrafficGeneratorMode mode) { this->pending_traffic_mode_ = mode; }
/// Request a mode by its traffic_generator_mode YAML name; unknown names are ignored.
void request_traffic_generator_mode(const char *name);
#endif
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(motion)
SUB_BINARY_SENSOR(calibrating)
#endif
#ifdef USE_SENSOR
SUB_SENSOR(movement)
#endif
/// Latest one-second runtime diagnostics sample, or nullptr before the runtime starts.
const ::espectre::RuntimeDiagnosticsSample *diagnostics_sample() const { return this->runtime_.diagnostics_sample(); }
protected:
// Called once per SDK detector evaluation (every 250 ms), which bounds the movement publish rate.
void on_live_telemetry(const ::espectre::RuntimeSnapshot &snapshot) override { this->movement_pending_ = true; }
void on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) override;
void on_runtime_fault(const char *message) override;
void start_runtime_();
void schedule_restart_();
void invalidate_sensing_();
void stop_();
#ifdef USE_SELECT
void restore_traffic_mode_();
void apply_pending_traffic_mode_();
void publish_traffic_mode_();
#endif
static constexpr uint32_t RESTART_DELAY_MS = 30000;
::espectre::RuntimeFrontendController runtime_;
uint32_t restart_requested_ms_{0};
#ifdef USE_SELECT
select::Select *traffic_mode_select_{nullptr};
ESPPreferenceObject traffic_mode_pref_;
optional<::espectre::TrafficGeneratorMode> pending_traffic_mode_;
#endif
bool recalibrate_pending_{false};
bool movement_pending_{false};
bool ready_{false};
bool motion_state_{false};
bool calibrating_state_{false};
bool calibrating_published_{false};
bool runtime_fault_{false};
bool running_{false};
bool restart_pending_{false};
bool calibrated_{false};
bool roaming_suppressed_{false};
};
} // namespace esphome::espectre
#endif // USE_ESPECTRE
@@ -0,0 +1,32 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.core import CORE
from esphome.types import ConfigType
from .. import (
CONF_ESPECTRE_ID,
DOMAIN,
ESPectreComponent,
espectre_ns,
supported_traffic_generator_modes,
)
DEPENDENCIES = ["espectre"]
TrafficModeSelect = espectre_ns.class_(
"TrafficModeSelect", select.Select, cg.Parented.template(ESPectreComponent)
)
CONFIG_SCHEMA = select.select_schema(
TrafficModeSelect, entity_category=ENTITY_CATEGORY_CONFIG
).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)})
async def to_code(config: ConfigType) -> None:
options = supported_traffic_generator_modes(CORE.config[DOMAIN])
var = await select.new_select(config, options=options)
await cg.register_parented(var, config[CONF_ESPECTRE_ID])
parent = await cg.get_variable(config[CONF_ESPECTRE_ID])
cg.add(parent.set_traffic_mode_select(var))
@@ -0,0 +1,11 @@
#include "espectre_select.h"
#ifdef USE_ESPECTRE
namespace esphome::espectre {
void TrafficModeSelect::control(size_t index) { this->parent_->request_traffic_generator_mode(this->option_at(index)); }
} // namespace esphome::espectre
#endif // USE_ESPECTRE
@@ -0,0 +1,19 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESPECTRE
#include "esphome/components/select/select.h"
#include "../espectre.h"
namespace esphome::espectre {
class TrafficModeSelect final : public select::Select, public Parented<ESPectreComponent> {
protected:
void control(size_t index) override;
};
} // namespace esphome::espectre
#endif // USE_ESPECTRE
@@ -0,0 +1,78 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
)
from esphome.types import ConfigType
from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns
DEPENDENCIES = ["espectre"]
CONF_MOVEMENT = "movement"
CONF_DIAGNOSTICS = "diagnostics"
CONF_GENERATOR_RATE = "generator_rate"
CONF_TRAFFIC_TX_RATE = "traffic_tx_rate"
CONF_TRAFFIC_RX_RATE = "traffic_rx_rate"
CONF_CSI_ACCEPTED_RATE = "csi_accepted_rate"
CONF_CSI_OCCUPANCY = "csi_occupancy"
UNIT_PACKETS_PER_SECOND = "pps"
DiagnosticsUpdater = espectre_ns.class_("DiagnosticsUpdater", cg.PollingComponent)
def _diagnostic_schema(unit: str, accuracy_decimals: int) -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=unit,
accuracy_decimals=accuracy_decimals,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
DIAGNOSTIC_SENSORS = {
CONF_GENERATOR_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
CONF_TRAFFIC_TX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
CONF_TRAFFIC_RX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
CONF_CSI_ACCEPTED_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1),
CONF_CSI_OCCUPANCY: _diagnostic_schema(UNIT_PERCENT, 0),
}
# Diagnostics are rarely watched, so they publish only on request unless an interval is set.
DIAGNOSTICS_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(DiagnosticsUpdater),
**{cv.Optional(key): schema for key, schema in DIAGNOSTIC_SENSORS.items()},
}
).extend(cv.polling_component_schema("never")),
cv.has_at_least_one_key(*DIAGNOSTIC_SENSORS),
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent),
cv.Optional(CONF_MOVEMENT): sensor.sensor_schema(
accuracy_decimals=3, state_class=STATE_CLASS_MEASUREMENT
),
cv.Optional(CONF_DIAGNOSTICS): DIAGNOSTICS_SCHEMA,
}
),
cv.has_at_least_one_key(CONF_MOVEMENT, CONF_DIAGNOSTICS),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_ESPECTRE_ID])
await sensor.sub_sensors(config)(CONF_MOVEMENT, hub.set_movement_sensor)
if (diagnostics_config := config.get(CONF_DIAGNOSTICS)) is not None:
updater = cg.new_Pvariable(diagnostics_config[CONF_ID], hub)
await cg.register_component(updater, diagnostics_config)
diagnostic_sensors = sensor.sub_sensors(diagnostics_config)
for key in DIAGNOSTIC_SENSORS:
await diagnostic_sensors(key, getattr(updater, f"set_{key}_sensor"))
@@ -0,0 +1,40 @@
#include "espectre_diagnostics.h"
#ifdef USE_ESPECTRE
#include <cmath>
#include "esphome/core/log.h"
namespace esphome::espectre {
static const char *const TAG = "espectre.sensor";
static void publish_diagnostic(sensor::Sensor *sensor, const ::espectre::RuntimeDiagnosticsSample *sample,
float ::espectre::RuntimeDiagnosticsSample::*field, float scale = 1.0f) {
if (sensor != nullptr)
sensor->publish_state(sample != nullptr ? sample->*field * scale : NAN);
}
void DiagnosticsUpdater::update() {
using Sample = ::espectre::RuntimeDiagnosticsSample;
const auto *sample = this->parent_->diagnostics_sample();
publish_diagnostic(this->generator_rate_sensor_, sample, &Sample::generator_pps);
publish_diagnostic(this->traffic_tx_rate_sensor_, sample, &Sample::traffic_tx_pps);
publish_diagnostic(this->traffic_rx_rate_sensor_, sample, &Sample::traffic_rx_pps);
publish_diagnostic(this->csi_accepted_rate_sensor_, sample, &Sample::csi_accepted_pps);
publish_diagnostic(this->csi_occupancy_sensor_, sample, &Sample::csi_occupancy_ratio, 100.0f);
}
void DiagnosticsUpdater::dump_config() {
ESP_LOGCONFIG(TAG, "ESPectre diagnostics:");
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Generator rate", this->generator_rate_sensor_);
LOG_SENSOR(" ", "Traffic TX rate", this->traffic_tx_rate_sensor_);
LOG_SENSOR(" ", "Traffic RX rate", this->traffic_rx_rate_sensor_);
LOG_SENSOR(" ", "CSI accepted rate", this->csi_accepted_rate_sensor_);
LOG_SENSOR(" ", "CSI occupancy", this->csi_occupancy_sensor_);
}
} // namespace esphome::espectre
#endif // USE_ESPECTRE
@@ -0,0 +1,32 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESPECTRE
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
#include "../espectre.h"
namespace esphome::espectre {
/// Publishes all diagnostic sensors together from the same runtime sample.
class DiagnosticsUpdater final : public PollingComponent {
public:
explicit DiagnosticsUpdater(ESPectreComponent *parent) : parent_(parent) {}
void update() override;
void dump_config() override;
SUB_SENSOR(generator_rate)
SUB_SENSOR(traffic_tx_rate)
SUB_SENSOR(traffic_rx_rate)
SUB_SENSOR(csi_accepted_rate)
SUB_SENSOR(csi_occupancy)
protected:
ESPectreComponent *parent_;
};
} // namespace esphome::espectre
#endif // USE_ESPECTRE
+4 -12
View File
@@ -133,20 +133,12 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
_resolve_encryption_key(encryption_conf, api_conf)
elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None:
_LOGGER.warning(
"'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 "
"bytes plus the password on the heap): the device already offers "
"encryption with the '%s' %s %s, which authenticates any uploader "
"that takes it, and a password only matters for uploaders without "
"encryption support; remove '%s' and add '%s' under '%s' so "
"uploads use the key and encryption is required",
"'%s' %s wastes significant flash and RAM; "
"using '%s' instead is recommended - "
"see https://esphome.io/components/ota/esphome/#configuration-variables",
CONF_OTA,
CONF_PASSWORD,
CONF_API,
CONF_ENCRYPTION,
CONF_KEY,
CONF_PASSWORD,
CONF_ENCRYPTION,
CONF_OTA,
)
elif (
CONF_PASSWORD in ota_conf
@@ -360,7 +352,7 @@ async def to_code(config: ConfigType) -> None:
# Build time key: the ota keeps its own pointer so safe mode, which
# has no api server, still has it
cg.add_define("USE_OTA_ENCRYPTION")
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
cg.add(var.set_noise_psk(new_psk_progmem(key)))
elif CONF_ENCRYPTION in api_conf:
# Runtime key: found in the api server, or in preferences in safe mode
cg.add_define("USE_OTA_ENCRYPTION")
+15 -14
View File
@@ -636,6 +636,19 @@ def phy_register(address: int, value: int, page: int) -> cg.StructInitializer:
)
def _add_phy_registers(var: cg.MockObj, config: ConfigType) -> None:
if not (registers := config.get(CONF_PHY_REGISTERS)):
return
cg.add_define("ESPHOME_ETHERNET_PHY_REGISTER_COUNT", len(registers))
for register_value in registers:
reg = phy_register(
register_value.get(CONF_ADDRESS),
register_value.get(CONF_VALUE),
register_value.get(CONF_PAGE_ID),
)
cg.add(var.add_phy_register(reg))
@coroutine_with_priority(CoroPriority.COMMUNICATION)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -741,13 +754,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN]))
if CONF_POWER_PIN in config:
cg.add(var.set_power_pin(config[CONF_POWER_PIN]))
for register_value in config.get(CONF_PHY_REGISTERS, []):
reg = phy_register(
register_value.get(CONF_ADDRESS),
register_value.get(CONF_VALUE),
register_value.get(CONF_PAGE_ID),
)
cg.add(var.add_phy_register(reg))
_add_phy_registers(var, config)
else:
cg.add(var.set_phy_addr(config[CONF_PHY_ADDR]))
cg.add(var.set_mdc_pin(config[CONF_MDC_PIN]))
@@ -756,13 +763,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None:
cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN]))
if CONF_POWER_PIN in config:
cg.add(var.set_power_pin(config[CONF_POWER_PIN]))
for register_value in config.get(CONF_PHY_REGISTERS, []):
reg = phy_register(
register_value.get(CONF_ADDRESS),
register_value.get(CONF_VALUE),
register_value.get(CONF_PAGE_ID),
)
cg.add(var.add_phy_register(reg))
_add_phy_registers(var, config)
# Register Ethernet with the esp32 sdkconfig reconciler. It disables the
# WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi,
@@ -193,7 +193,9 @@ class EthernetComponent final : public Component {
void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; }
void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; }
void add_phy_register(PHYRegister register_value);
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
void add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); }
#endif
#endif // USE_ETHERNET_SPI
#endif // USE_ESP32
@@ -220,6 +222,9 @@ class EthernetComponent final : public Component {
protected:
void start_connect_();
void finish_connect_();
#if LWIP_IPV6
esp_err_t ensure_ip6_linklocal_();
#endif
void dump_connect_params_();
#ifdef USE_ESP32
@@ -252,8 +257,10 @@ class EthernetComponent final : public Component {
/// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling.
void yt8531_phy_init_();
#endif
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
/// @brief Set arbitratry PHY registers from config.
void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data);
#endif
#ifdef USE_ETHERNET_SPI
uint8_t clk_pin_;
@@ -277,7 +284,9 @@ class EthernetComponent final : public Component {
// Group all 32-bit members first
int power_pin_{-1};
emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN};
std::vector<PHYRegister> phy_registers_{};
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
StaticVector<PHYRegister, ESPHOME_ETHERNET_PHY_REGISTER_COUNT> phy_registers_{};
#endif
// Group all 8-bit members together
uint8_t clk_pin_{0};
@@ -10,6 +10,10 @@
#include <lwip/dns.h>
#include <cinttypes>
#include "esp_event.h"
#if USE_NETWORK_IPV6
#include <esp_netif_net_stack.h>
#include <lwip/netif.h>
#endif
#ifdef USE_PSRAM
#include <esp_psram.h>
#endif
@@ -458,9 +462,11 @@ void EthernetComponent::ethernet_lazy_init_() {
}
#endif // USE_ETHERNET_KSZ8081
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
for (const auto &phy_register : this->phy_registers_) {
this->write_phy_register_(mac, phy_register);
}
#endif
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#ifdef USE_ETHERNET_GENERIC
@@ -509,6 +515,18 @@ void EthernetComponent::ethernet_lazy_init_() {
}
}
#endif
#if USE_NETWORK_IPV6 && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
// Since IDF 5.5 the internal EMAC drops multicast groups that were never added (before,
// it passed all multicast), and lwIP never adds all-nodes, so router advertisements
// were lost and SLAAC never ran.
{
uint8_t all_nodes[6] = {0x33, 0x33, 0x00, 0x00, 0x00, 0x01};
if (esp_err_t filter_err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_ADD_MAC_FILTER, all_nodes);
filter_err != ESP_OK) {
ESP_LOGD(TAG, "IPv6 all-nodes multicast filter not added: %s", esp_err_to_name(filter_err));
}
}
#endif
// Register user defined event handers
err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr);
@@ -755,6 +773,13 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
global_eth_component->notify_ip_state_listeners_();
}
#endif
#if USE_NETWORK_IPV6
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
// link-local after a link flap, which clears the IPv6 addresses.
if (esp_err_t ll_err = esp_netif_create_ip6_linklocal(global_eth_component->eth_netif_); ll_err != ESP_OK) {
ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed on link-up: %s", esp_err_to_name(ll_err));
}
#endif /* USE_NETWORK_IPV6 */
break;
case ETHERNET_EVENT_DISCONNECTED:
event_name = "ETH disconnected";
@@ -791,7 +816,10 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
void *event_data) {
ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data;
ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip));
global_eth_component->ipv6_count_ += 1;
// Count the addresses on the interface, not the events: recreating the link-local
// after a link flap fires another event for the same address.
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s);
#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ =
global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT);
@@ -806,6 +834,29 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
}
#endif /* USE_NETWORK_IPV6 */
#if USE_NETWORK_IPV6
// Create the link-local address unless the interface already has one, including one still in
// duplicate address detection: recreating it would restart DAD. esp_netif_get_ip6_linklocal()
// only reports a preferred address, so ask lwIP for the slot state instead.
esp_err_t EthernetComponent::ensure_ip6_linklocal_() {
if (auto *netif = static_cast<struct netif *>(esp_netif_get_netif_impl(this->eth_netif_)); netif != nullptr) {
u8_t state;
{
LwIPLock lock;
state = netif_ip6_addr_state(netif, 0);
}
if (ip6_addr_istentative(state) || ip6_addr_isvalid(state)) {
return ESP_OK;
}
}
esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_);
if (err == ESP_OK) {
ESP_LOGD(TAG, "IPv6 link-local address created");
}
return err;
}
#endif /* USE_NETWORK_IPV6 */
void EthernetComponent::finish_connect_() {
#if USE_NETWORK_IPV6
// Retry IPv6 link-local setup if it failed during initial connect
@@ -816,10 +867,7 @@ void EthernetComponent::finish_connect_() {
// - Cable unplugged/network interruption (#10705)
// We can now retry since we're in CONNECTED state and the interface is definitely up.
if (!this->ipv6_setup_done_) {
esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_);
if (err == ESP_OK) {
ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)");
}
this->ensure_ip6_linklocal_();
// Always set the flag to prevent continuous retries
// If IPv6 setup fails here with the interface up and stable, it's
// likely a persistent issue (IPv6 disabled at router, hardware
@@ -833,7 +881,9 @@ void EthernetComponent::finish_connect_() {
void EthernetComponent::start_connect_() {
global_eth_component->got_ipv4_address_ = false;
#if USE_NETWORK_IPV6
global_eth_component->ipv6_count_ = 0;
// Recount rather than zero: addresses that survive a reconnect are not announced again.
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s);
this->ipv6_setup_done_ = false;
#endif /* USE_NETWORK_IPV6 */
this->connect_begin_ = millis();
@@ -912,7 +962,7 @@ void EthernetComponent::start_connect_() {
// - At bootup when link isn't ready (#10281)
// - After disconnection/cable unplugged (#10705)
// We'll retry in finish_connect_() if it fails here.
err = esp_netif_create_ip6_linklocal(this->eth_netif_);
err = this->ensure_ip6_linklocal_();
if (err != ESP_OK) {
if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) {
// This is a programming error, not a transient failure
@@ -995,10 +1045,6 @@ void EthernetComponent::dump_connect_params_() {
#endif /* USE_NETWORK_IPV6 */
}
#ifndef USE_ETHERNET_SPI
void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); }
#endif
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
if (!this->ethernet_initialized_) {
// External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless
@@ -1096,6 +1142,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) {
}
#endif // USE_ETHERNET_KSZ8081
#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT
void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) {
esp_err_t err;
@@ -1120,6 +1167,7 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi
}
#endif
}
#endif // ESPHOME_ETHERNET_PHY_REGISTER_COUNT
#ifdef USE_ETHERNET_YT8531
void EthernetComponent::yt8531_phy_init_() {
@@ -0,0 +1 @@
CODEOWNERS = ["@clydebarrow"]
@@ -0,0 +1,116 @@
#include "exponential_moving_average_sensor.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::exponential_moving_average {
static const char *const TAG = "exponential_moving_average";
const LogString *time_weighting_to_string(TimeWeighting weighting) {
switch (weighting) {
case TIME_WEIGHTING_PREVIOUS:
return LOG_STR("previous");
case TIME_WEIGHTING_LINEAR:
return LOG_STR("linear");
default:
return LOG_STR("new");
}
}
ScaledDuration scale_duration(uint32_t ms) {
if (ms < 1000)
return {static_cast<float>(ms), LOG_STR("ms"), 0};
if (ms < 60 * 1000)
return {ms / 1000.0f, LOG_STR("s"), 1};
if (ms < 60 * 60 * 1000)
return {ms / (60 * 1000.0f), LOG_STR("min"), 1};
return {ms / (60 * 60 * 1000.0f), LOG_STR("h"), 1};
}
void ExponentialMovingAverageSensor::setup() {
if (this->restore_) {
this->pref_ = this->make_entity_preference<float>();
float restored;
if (this->pref_.load(&restored) && std::isfinite(restored)) {
this->accumulator_ = restored;
this->publish_state(restored);
}
}
const uint32_t now = App.get_loop_component_start_time();
this->last_update_ = now;
this->source_->add_on_state_callback(
[this](float value) { this->process_(value, App.get_loop_component_start_time()); });
// The source may have published during its own setup(), before the callback was added.
if (this->source_->has_state())
this->process_(this->source_->state, now);
}
void ExponentialMovingAverageSensor::dump_config() {
LOG_SENSOR("", "Exponential Moving Average Sensor", this);
if (this->time_constant_ms_ != 0) {
const ScaledDuration time_constant = scale_duration(this->time_constant_ms_);
ESP_LOGCONFIG(TAG,
" Time Constant: %.*f %s\n"
" Time Weighting: %s",
time_constant.decimals, time_constant.value, LOG_STR_ARG(time_constant.unit),
LOG_STR_ARG(time_weighting_to_string(this->time_weighting_)));
} else {
ESP_LOGCONFIG(TAG, " Alpha: %.3f", this->alpha_);
}
ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_));
}
void ExponentialMovingAverageSensor::reset() { this->publish_and_save_(NAN); }
void ExponentialMovingAverageSensor::process_(float value, uint32_t now) {
if (std::isnan(value))
return;
// After a reboot the downtime is unknown, so the first interval is measured from setup().
const uint32_t dt = now - this->last_update_;
this->last_update_ = now;
const float previous = this->previous_value_;
this->previous_value_ = value;
if (std::isnan(this->accumulator_)) {
this->publish_and_save_(value);
return;
}
if (this->time_constant_ms_ == 0) {
this->publish_and_save_(this->alpha_ * value + (1.0f - this->alpha_) * this->accumulator_);
return;
}
// Computed in double with expm1(): when the interval is short compared to the time constant, the weights are
// tiny and float rounding of exp() would swamp them.
const double x = static_cast<double>(dt) / this->time_constant_ms_;
// The share of the old average replaced during this interval.
const double gain = -std::expm1(-x);
const double average = this->accumulator_;
// After a reboot there is no previous reading, so only the new value can be used.
const TimeWeighting weighting = std::isnan(previous) ? TIME_WEIGHTING_NEW : this->time_weighting_;
double result;
switch (weighting) {
case TIME_WEIGHTING_PREVIOUS:
result = average + gain * (previous - average);
break;
case TIME_WEIGHTING_LINEAR: {
// Exact result for a value moving in a straight line from the previous reading to the new one.
const double weight_new = x > 0.0 ? (x + std::expm1(-x)) / x : 0.0;
result = average + (gain - weight_new) * (previous - average) + weight_new * (value - average);
break;
}
default:
result = average + gain * (value - average);
break;
}
this->publish_and_save_(static_cast<float>(result));
}
void ExponentialMovingAverageSensor::publish_and_save_(float value) {
this->accumulator_ = value;
this->publish_state(value);
if (this->restore_)
this->pref_.save(&value);
}
} // namespace esphome::exponential_moving_average
@@ -0,0 +1,61 @@
#pragma once
#include <cmath>
#include <cstdint>
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::exponential_moving_average {
/// Which value is assumed to apply during the time between two readings, when a time constant is used.
enum TimeWeighting : uint8_t {
TIME_WEIGHTING_NEW = 0,
TIME_WEIGHTING_PREVIOUS,
TIME_WEIGHTING_LINEAR,
};
const LogString *time_weighting_to_string(TimeWeighting weighting);
/// A duration in the largest of ms, s, min or h that keeps the value at 1 or more.
struct ScaledDuration {
float value;
const LogString *unit;
uint8_t decimals;
};
ScaledDuration scale_duration(uint32_t ms);
class ExponentialMovingAverageSensor : public sensor::Sensor, public Component {
public:
explicit ExponentialMovingAverageSensor(sensor::Sensor *source) : source_(source) {}
void setup() override;
void dump_config() override;
void set_alpha(float alpha) { this->alpha_ = alpha; }
/// When non-zero, each sample is weighted by the time since the previous one instead of by a fixed alpha.
void set_time_constant(uint32_t time_constant_ms) { this->time_constant_ms_ = time_constant_ms; }
void set_time_weighting(TimeWeighting weighting) { this->time_weighting_ = weighting; }
void set_restore(bool restore) { this->restore_ = restore; }
/// Clear the average; the next sample starts it again.
void reset();
protected:
void process_(float value, uint32_t now);
void publish_and_save_(float value);
sensor::Sensor *source_;
ESPPreferenceObject pref_;
float alpha_{0.1f};
float accumulator_{NAN};
float previous_value_{NAN};
uint32_t time_constant_ms_{0};
uint32_t last_update_{0};
TimeWeighting time_weighting_{TIME_WEIGHTING_NEW};
bool restore_{true};
};
} // namespace esphome::exponential_moving_average
@@ -0,0 +1,103 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_ALPHA,
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_ID,
CONF_RESTORE,
CONF_SENSOR,
CONF_STATE_CLASS,
CONF_TIME_CONSTANT,
CONF_UNIT_OF_MEASUREMENT,
)
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
exponential_moving_average_ns = cg.esphome_ns.namespace("exponential_moving_average")
ExponentialMovingAverageSensor = exponential_moving_average_ns.class_(
"ExponentialMovingAverageSensor", sensor.Sensor, cg.Component
)
TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting")
TIME_WEIGHTINGS: dict[str, cg.MockObj] = {
"new": TimeWeighting.TIME_WEIGHTING_NEW,
"previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS,
"linear": TimeWeighting.TIME_WEIGHTING_LINEAR,
}
CONF_TIME_WEIGHTING: str = "time_weighting"
DEFAULT_ALPHA: float = 0.1
def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int:
# An average carries more precision than the individual readings.
return decimals + 1
def validate_time_weighting(config: ConfigType) -> ConfigType:
if CONF_TIME_WEIGHTING in config and CONF_TIME_CONSTANT not in config:
raise cv.Invalid(
f"'{CONF_TIME_WEIGHTING}' can only be used with '{CONF_TIME_CONSTANT}'",
path=[CONF_TIME_WEIGHTING],
)
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(ExponentialMovingAverageSensor)
.extend(
{
cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor),
cv.Optional(CONF_ALPHA): cv.All(
cv.float_, cv.Range(min=0, min_included=False, max=1)
),
cv.Optional(CONF_TIME_CONSTANT): cv.positive_time_period_milliseconds,
cv.Optional(CONF_TIME_WEIGHTING): cv.enum(TIME_WEIGHTINGS, lower=True),
cv.Optional(CONF_RESTORE, default=True): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.has_at_most_one_key(CONF_ALPHA, CONF_TIME_CONSTANT),
validate_time_weighting,
)
FINAL_VALIDATE_SCHEMA = cv.All(
inherit_property_from(CONF_ICON, CONF_SENSOR),
inherit_property_from(CONF_UNIT_OF_MEASUREMENT, CONF_SENSOR),
inherit_property_from(
CONF_ACCURACY_DECIMALS, CONF_SENSOR, transform=inherit_accuracy_decimals
),
inherit_property_from(CONF_DEVICE_CLASS, CONF_SENSOR),
inherit_property_from(CONF_STATE_CLASS, CONF_SENSOR),
)
async def to_code(config: ConfigType) -> None:
source = await cg.get_variable(config[CONF_SENSOR])
var = cg.new_Pvariable(config[CONF_ID], source)
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
if (time_constant := config.get(CONF_TIME_CONSTANT)) is not None:
cg.add(var.set_time_constant(time_constant))
if (weighting := config.get(CONF_TIME_WEIGHTING)) is not None:
cg.add(var.set_time_weighting(weighting))
else:
cg.add(var.set_alpha(config.get(CONF_ALPHA, DEFAULT_ALPHA)))
cg.add(var.set_restore(config[CONF_RESTORE]))
automation.register_apply_action(
"sensor.exponential_moving_average.reset",
automation.maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(ExponentialMovingAverageSensor),
}
),
automation.ApplyCall("reset()"),
)
+3 -1
View File
@@ -41,6 +41,7 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = "font"
MULTI_CONF = True
AUTO_LOAD = ["unicode"]
CODEOWNERS = ["@esphome/core", "@clydebarrow"]
@@ -726,7 +727,8 @@ async def to_code(config):
)
]
glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer)
# constexpr Glyph table in flash; on ESP8266 static_const_array would place it in RAM.
glyphs = cg.progmem_array(config[CONF_RAW_GLYPH_ID], glyph_initializer)
font_height = pt_to_px(base_font.size.height)
ascender = pt_to_px(base_font.size.ascender)
+5 -105
View File
@@ -3,6 +3,7 @@
#include "esphome/core/color.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/components/unicode/unicode.h"
namespace esphome::font {
static const char *const TAG = "font";
@@ -121,108 +122,6 @@ const Glyph *Font::get_glyph_data_(uint32_t unicode_letter) {
}
#endif
/**
* Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string.
* If successful, return the codepoint and set the length to the number of bytes read.
* If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to
* 0.
*
* @param utf8_str The input string
* @param length Pointer to length storage
* @return The extracted code point
*/
static uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) {
// Safely cast to uint8_t* for correct bitwise operations on bytes
const uint8_t *current = reinterpret_cast<const uint8_t *>(utf8_str);
uint32_t code_point = 0;
uint8_t c1 = *current++;
// check for end of string
if (c1 == 0) {
*length = 0;
return 0;
}
// --- 1-Byte Sequence: 0xxxxxxx (ASCII) ---
if (c1 < 0x80) {
// Valid ASCII byte.
code_point = c1;
// Optimization: No need to check for continuation bytes.
}
// --- 2-Byte Sequence: 110xxxxx 10xxxxxx ---
else if ((c1 & 0xE0) == 0xC0) {
uint8_t c2 = *current++;
// Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx)
if ((c2 & 0xC0) != 0x80) {
*length = 0;
return 0;
}
code_point = (c1 & 0x1F) << 6;
code_point |= (c2 & 0x3F);
// Error Check 2: Overlong check (2-byte must be > 0x7F)
if (code_point <= 0x7F) {
*length = 0;
return 0;
}
}
// --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx ---
else if ((c1 & 0xF0) == 0xE0) {
uint8_t c2 = *current++;
uint8_t c3 = *current++;
// Error Check 1: Check continuation bytes
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) {
*length = 0;
return 0;
}
code_point = (c1 & 0x0F) << 12;
code_point |= (c2 & 0x3F) << 6;
code_point |= (c3 & 0x3F);
// Error Check 2: Overlong check (3-byte must be > 0x7FF)
// Also check for surrogates (0xD800-0xDFFF)
if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) {
*length = 0;
return 0;
}
}
// --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx ---
else if ((c1 & 0xF8) == 0xF0) {
uint8_t c2 = *current++;
uint8_t c3 = *current++;
uint8_t c4 = *current++;
// Error Check 1: Check continuation bytes
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) {
*length = 0;
return 0;
}
code_point = (c1 & 0x07) << 18;
code_point |= (c2 & 0x3F) << 12;
code_point |= (c3 & 0x3F) << 6;
code_point |= (c4 & 0x3F);
// Error Check 2: Overlong check (4-byte must be > 0xFFFF)
// Also check for valid Unicode range (must be <= 0x10FFFF)
if (code_point <= 0xFFFF || code_point > 0x10FFFF) {
*length = 0;
return 0;
}
}
// --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) ---
else {
*length = 0;
return 0;
}
*length = current - reinterpret_cast<const uint8_t *>(utf8_str);
return code_point;
}
Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
uint8_t bpp)
: glyphs_(ConstVector(data, data_nr)),
@@ -271,7 +170,7 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in
int x = 0;
for (;;) {
size_t length;
auto code_point = extract_unicode_codepoint(str, &length);
auto code_point = unicode::extract_unicode_codepoint(str, &length);
if (length == 0)
break;
str += length;
@@ -300,7 +199,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
int x_at = x_start;
for (;;) {
size_t length;
auto code_point = extract_unicode_codepoint(text, &length);
auto code_point = unicode::extract_unicode_codepoint(text, &length);
if (length == 0)
break;
text += length;
@@ -309,7 +208,8 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
// Unknown char, skip
ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point);
if (!this->glyphs_.empty()) {
uint8_t glyph_width = this->glyphs_[0].advance;
// Full-width read: a narrowing byte load would fault on a PROGMEM table on ESP8266.
int glyph_width = this->glyphs_[0].advance;
display->rectangle(x_at, y_start, glyph_width, this->height_, color);
x_at += glyph_width;
}
+14
View File
@@ -1,5 +1,7 @@
#pragma once
#include <type_traits>
#include "esphome/core/color.h"
#include "esphome/core/datatypes.h"
#include "esphome/core/defines.h"
@@ -36,6 +38,18 @@ class Glyph final {
int width;
int height;
};
// The glyph table lives in flash, read with plain loads, which ESP8266 only allows for whole words.
static_assert(std::is_same_v<decltype(Glyph::code_point), const uint32_t>, "Glyph fields must stay word sized");
static_assert(std::is_same_v<decltype(Glyph::data), const uint8_t *>, "Glyph fields must stay word sized");
static_assert(std::is_same_v<decltype(Glyph::advance), int>, "Glyph fields must stay word sized");
static_assert(std::is_same_v<decltype(Glyph::offset_x), int>, "Glyph fields must stay word sized");
static_assert(std::is_same_v<decltype(Glyph::offset_y), int>, "Glyph fields must stay word sized");
static_assert(std::is_same_v<decltype(Glyph::width), int>, "Glyph fields must stay word sized");
static_assert(std::is_same_v<decltype(Glyph::height), int>, "Glyph fields must stay word sized");
#ifdef USE_ESP8266
static_assert(alignof(Glyph) == sizeof(uint32_t), "Glyph fields must stay word sized");
static_assert(sizeof(Glyph) == 7 * sizeof(uint32_t), "Glyph is read from flash with word loads");
#endif
class Font final
#ifdef USE_DISPLAY
+2 -3
View File
@@ -55,9 +55,8 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if co2 := config.get(CONF_CO2):
sens = await sensor.new_sensor(co2)
cg.add(var.set_co2_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_CO2, var.set_co2_sensor)
cg.add(var.set_warmup_seconds(config[CONF_WARMUP_TIME]))
+3 -7
View File
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
cg.add(var.set_humidity(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature)
await sensors(CONF_HUMIDITY, var.set_humidity)
+3 -7
View File
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
+3 -7
View File
@@ -49,10 +49,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature(sens))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature)
await sensors(CONF_HUMIDITY, var.set_humidity)
+3 -7
View File
@@ -66,13 +66,9 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temp_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temp_config)
cg.add(var.set_temp_sensor(sens))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temp_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
cg.add(var.set_power_mode(config[CONF_POWER_MODE]))
+201 -135
View File
@@ -1,6 +1,7 @@
#include "hoermann_hcp.h"
#include <algorithm>
#include <utility>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -16,20 +17,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b
static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away.
static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the
// rest names the button - the low byte for the door commands, the second register for those that do not fit
// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each.
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
// The intermediate positions are named in the second register, so the first only carries the phase.
static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000};
static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400};
// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0110};
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0120};
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0140};
static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0100, 0x4000};
static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0100, 0x0400};
// Absolute, as a vendor gateway sends them, so a late or repeated one cannot switch the lamp the wrong way.
static constexpr HoermannHcpCommand COMMAND_LIGHT_ON{"light on", 0x0880};
static constexpr HoermannHcpCommand COMMAND_LIGHT_OFF{"light off", 0x0800, 0x0100};
// Kept as the intent, as the same impulse starts a door at rest; only a door still moving at the fetch gets it.
static constexpr HoermannHcpCommand COMMAND_STOP{"stop", 0x0140};
// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
// its low byte tells a plain stop from the vent position.
@@ -63,9 +61,24 @@ static bool is_moving(DoorState state) {
}
}
#ifdef USE_HOERMANN_HCP_IDENTITY
// The command byte of a status poll. Only its answer can carry a request.
static bool at_destination(const HoermannHcpCommand &command, DoorState state) {
if (&command == &COMMAND_OPEN)
return state == DoorState::OPEN;
if (&command == &COMMAND_CLOSE)
return state == DoorState::CLOSED;
if (&command == &COMMAND_VENT)
return state == DoorState::VENT;
if (&command == &COMMAND_HALF_OPEN)
return state == DoorState::HALF_OPEN;
return false;
}
// Only a status poll's answer carries commands.
static constexpr uint8_t STATUS_COMMAND = 0x03;
// A second stop this soon after one went out is ignored, so a double press cannot restart the door.
static constexpr uint32_t STOP_LOCK_MS = 500;
#ifdef USE_HOERMANN_HCP_IDENTITY
// A status answer with this code in the low byte of its second register asks the bus controller for a value,
// named in the high byte of the third.
static constexpr uint8_t ANSWER_REQUEST = 0x22;
@@ -125,29 +138,40 @@ void HoermannHcp::update() {
this->set_valid_(false);
// Status broadcasts alone keep the connection alive, so a command the controller never fetches would
// otherwise block every later one for as long as it keeps broadcasting.
if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
// Dropping after the press was presented leaves the door without its release value, which is worth saying
// apart from a command the controller never looked at.
if (this->command_written_at_ != 0) {
ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press",
this->next_command_->name);
} else {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
}
// A stop held for a door that has not reported its start yet is timed by the start window instead.
const bool stop_held = this->next_command_ == &COMMAND_STOP && this->starting_;
if (this->next_command_ != nullptr && !stop_held && now - this->command_queued_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
this->drop_command_();
// Children may have assumed the command would land, so let them re-derive from the door.
this->changed_ = true;
}
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) {
// A target the door never started towards would otherwise cut a later move short.
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->start_window_ms_) {
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
this->clear_target_();
}
// The door took the lamp key press but never reported the lamp changing, so stop expecting it to.
if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle");
this->forget_light_toggles_();
// A door that never answers a fetched command is at rest after all.
if (this->starting_ && now - this->start_fetched_at_ > this->start_window_ms_) {
this->starting_ = false;
if (stop_held) {
ESP_LOGW(TAG, "Door did not report moving, dropping the stop");
this->next_command_ = nullptr;
} else {
ESP_LOGD(TAG, "Door did not start after the command");
}
}
// A lamp command held while the door starts or moves waits on purpose, so its deadline starts once the door rests.
if (this->is_moving_or_starting_() && this->light_requested_ && !this->light_command_sent_)
this->light_since_ = now;
// Neither fire late nor block the next request.
if (this->light_requested_ && now - this->light_since_ > this->connection_timeout_ms_) {
if (this->light_command_sent_) {
ESP_LOGW(TAG, "Door did not report the lamp changing, giving up");
} else {
ESP_LOGW(TAG, "Bus controller did not fetch the lamp command, dropping it");
}
this->clear_light_request_();
}
#ifdef USE_HOERMANN_HCP_IDENTITY
this->publish_identity_();
@@ -179,6 +203,7 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add
}
this->record_response_();
const bool status_poll = std::exchange(this->status_poll_pending_, false);
#ifdef USE_HOERMANN_HCP_IDENTITY
// Acknowledge the transfer taken by the write half of this frame.
@@ -198,7 +223,11 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add
// Command request: return the internal state, injecting any pending command.
registers.push_back(counter);
registers.push_back(static_cast<uint16_t>(0x0001 | command));
this->push_command_registers_(registers);
if (status_poll && static_cast<uint8_t>(this->command_reg_value_) == STATUS_COMMAND) {
this->push_command_registers_(registers);
} else {
push_zeros(registers, 2);
}
push_zeros(registers, 4);
#ifdef USE_HOERMANN_HCP_IDENTITY
this->add_identity_request_(registers, command);
@@ -234,6 +263,7 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
// command byte back from STATE_REG. The hub always runs the write before the read within one request.
this->record_response_();
this->command_reg_value_ = registers[0];
this->status_poll_pending_ = true;
#ifdef USE_HOERMANN_HCP_IDENTITY
this->transfer_answer_counter_ = this->take_identity_transfer_(registers);
#endif
@@ -268,34 +298,62 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
}
void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
const HoermannHcpCommand *command = this->next_command_;
const HoermannHcpCommand *command = this->take_command_();
if (command == nullptr)
command = this->take_light_command_();
if (command == nullptr) {
push_zeros(registers, 2);
return;
}
if (this->command_written_at_ == 0) {
// First read after the command was queued: present the "key pressed" values.
this->command_written_at_ = millis();
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
registers.push_back(command->pressed_value);
registers.push_back(command->pressed_value_2);
return;
}
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
// Between the two events there is nothing to report, including in the second register.
push_zeros(registers, 2);
return;
}
// Enough time passed: present the "key released" values and clear the command.
ESP_LOGD(TAG, "Released '%s' command", command->name);
this->command_written_at_ = 0;
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
registers.push_back(command->value);
registers.push_back(command->value_2);
}
const HoermannHcpCommand *HoermannHcp::take_command_() {
const HoermannHcpCommand *command = this->next_command_;
if (command == nullptr)
return nullptr;
const bool moving = is_moving(this->door_state_);
// The door was just told to start and has not said so yet, so an impulse now could start it instead.
if (command == &COMMAND_STOP && this->starting_ && !moving)
return nullptr;
this->next_command_ = nullptr;
// A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left
// to wait for, so it must not re-arm the watchdog.
if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0)
this->light_toggle_released_at_ = millis();
registers.push_back(command->released_value);
registers.push_back(command->released_value_2);
if (moving) {
// The door may have been started from elsewhere since the command was queued.
if ((command == &COMMAND_OPEN && this->door_state_ == DoorState::OPENING) ||
(command == &COMMAND_CLOSE && this->door_state_ == DoorState::CLOSING)) {
ESP_LOGD(TAG, "Door is already moving that way, dropping '%s'", command->name);
return nullptr;
}
if (command != &COMMAND_STOP) {
ESP_LOGD(TAG, "Door is moving, stopping it instead of '%s'", command->name);
}
this->last_stop_at_ = millis();
this->stop_sent_ = true;
return &COMMAND_STOP;
}
if (command == &COMMAND_STOP) {
ESP_LOGD(TAG, "Door came to rest before the stop was fetched, dropping it");
return nullptr;
}
// Until the door answers, it still reads as at rest.
if (!this->door_state_seen_ || !at_destination(*command, this->door_state_)) {
this->starting_ = true;
this->start_command_ = command;
this->start_fetched_at_ = millis();
}
return command;
}
const HoermannHcpCommand *HoermannHcp::take_light_command_() {
// The motor ignores the lamp while its door moves and may switch it itself as it starts, so the lamp waits for rest.
if (!this->light_requested_ || this->light_command_sent_ || this->is_moving_or_starting_() ||
this->light_target_ == this->light_on_)
return nullptr;
this->light_command_sent_ = true;
this->light_since_ = millis();
return this->light_target_ ? &COMMAND_LIGHT_ON : &COMMAND_LIGHT_OFF;
}
#ifdef USE_HOERMANN_HCP_IDENTITY
@@ -308,8 +366,8 @@ void HoermannHcp::add_identity_request_(modbus::RegisterValues &registers, uint1
this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_SERIAL);
return;
}
// Uses the registers of a key press, so it waits while one is pending.
if (this->next_command_ != nullptr || registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_())
// Uses the command registers, so it waits while a command is pending.
if (registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_())
return;
registers[1] = static_cast<uint16_t>(ANSWER_REQUEST | command);
registers[2] = encode_uint16(this->identity_request_(), 0);
@@ -505,56 +563,57 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name);
return false;
}
// A stop still waiting for a door that has come to rest would be dropped at the fetch anyway.
if (this->next_command_ == &COMMAND_STOP)
this->next_command_ = nullptr;
if (this->next_command_ != nullptr) {
ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller");
return false;
}
// A new command supersedes any half-open target the door was still travelling to.
if (command.clears_target)
this->clear_target_();
this->clear_target_();
this->next_command_ = &command;
this->command_queued_at_ = millis();
return true;
}
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); }
bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); }
bool HoermannHcp::toggle_light() {
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
bool HoermannHcp::is_moving_or_starting_() const { return this->starting_ || is_moving(this->door_state_); }
bool HoermannHcp::command_door_(const HoermannHcpCommand &command) {
// Only stopped, so it is never reversed at speed. take_command_() drops one queued before the door started the same
// way.
if (this->is_moving_or_starting_())
return this->stop_door();
return this->queue_command_(command);
}
bool HoermannHcp::open_door() { return this->command_door_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->command_door_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->command_door_(COMMAND_IMPULSE); }
bool HoermannHcp::vent_door() { return this->command_door_(COMMAND_VENT); }
bool HoermannHcp::half_open_door() { return this->command_door_(COMMAND_HALF_OPEN); }
bool HoermannHcp::stop_door() {
this->clear_target_();
// A stop outranks whatever is still waiting, so a door at rest does not start after the user pressed stop.
if (this->next_command_ != nullptr && this->next_command_ != &COMMAND_STOP) {
ESP_LOGD(TAG, "Stop cancels the unfetched '%s' command", this->next_command_->name);
this->next_command_ = nullptr;
}
if (!this->is_moving_or_starting_())
return true;
if (!this->valid_) {
ESP_LOGW(TAG, "Not connected to the bus controller, dropping 'stop' command");
return false;
}
if (!this->queue_command_(COMMAND_TOGGLE_LAMP))
return false;
this->light_toggles_in_flight_++;
return true;
}
bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; }
uint8_t HoermannHcp::unsent_light_toggles_() const {
return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0;
}
bool HoermannHcp::cancel_light_toggle() {
// Once the pressed value has been presented the key press is already on the wire, so only an untouched
// command can be withdrawn.
if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0)
return false;
ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name);
this->drop_command_();
return true;
}
bool HoermannHcp::stop_door() {
if (!is_moving(this->door_state_)) {
this->clear_target_();
if (this->next_command_ == &COMMAND_STOP)
return true;
if (this->stop_sent_ && millis() - this->last_stop_at_ < STOP_LOCK_MS) {
ESP_LOGD(TAG, "Door already stopping, ignoring stop");
return true;
}
// On success queue_command_() clears the target; on refusal it stays armed so the next position retries.
return this->queue_command_(COMMAND_IMPULSE);
this->next_command_ = &COMMAND_STOP;
this->command_queued_at_ = millis();
return true;
}
bool HoermannHcp::set_position(float position) {
@@ -563,8 +622,8 @@ bool HoermannHcp::set_position(float position) {
return this->close_door();
if (position >= OPEN_POSITION_THRESHOLD)
return this->open_door();
// Asking the door to travel to where it already is means stopping it.
if (position == this->current_position_)
// Asking the door to travel to where it already is, or anywhere while it moves, means stopping it.
if (position == this->current_position_ || this->is_moving_or_starting_())
return this->stop_door();
// The door itself has no notion of a target, so it is started in the right direction and stopped on the way.
@@ -574,8 +633,6 @@ bool HoermannHcp::set_position(float position) {
this->target_position_ = position;
this->target_queued_at_ = millis();
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
// A door already travelling that way is on its way; one moving the other way has to turn around first.
this->target_started_ = this->door_state_ == this->target_direction_;
return true;
}
@@ -596,9 +653,8 @@ void HoermannHcp::set_valid_(bool valid) {
ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
// Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
this->drop_command_();
// The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards.
this->clear_target_();
this->forget_light_toggles_();
this->starting_ = false;
this->clear_light_request_();
// The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
this->set_light_seen_(false);
// The same holds for the door. The next broadcast is decoded even if it repeats the last one.
@@ -608,38 +664,8 @@ void HoermannHcp::set_valid_(bool valid) {
}
void HoermannHcp::drop_command_() {
const bool was_light_toggle = this->is_light_toggle_pending_();
// Cleared first so the settling below no longer counts this command among the toggles still to be sent.
this->next_command_ = nullptr;
this->command_written_at_ = 0;
if (was_light_toggle) {
// A lamp toggle says nothing about where the door was going, so it leaves the target alone.
this->light_toggle_settled_();
} else {
this->clear_target_();
}
}
void HoermannHcp::light_toggle_settled_() {
if (this->light_toggles_in_flight_ == 0)
return;
this->light_toggles_in_flight_--;
// Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for.
if (this->light_toggles_in_flight_ == this->unsent_light_toggles_())
this->light_toggle_released_at_ = 0;
// The light was showing where the lamp was heading, so it has to be told to look again.
this->changed_ = true;
}
void HoermannHcp::forget_light_toggles_() {
// Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever.
this->light_toggle_released_at_ = 0;
// A toggle the door has not been shown yet is still going to fire, so it keeps counting.
const uint8_t unsent = this->unsent_light_toggles_();
if (this->light_toggles_in_flight_ == unsent)
return;
this->light_toggles_in_flight_ = unsent;
this->changed_ = true;
this->clear_target_();
}
void HoermannHcp::set_door_state_(DoorState state) {
@@ -648,10 +674,21 @@ void HoermannHcp::set_door_state_(DoorState state) {
this->door_state_seen_ = true;
this->changed_ = true;
}
// Only moving or the command's destination answers it, even as a first report that changes nothing.
if (this->starting_ && (is_moving(state) || at_destination(*this->start_command_, state))) {
// A stop held for the start is due now, so its fetch deadline starts here.
if (this->next_command_ == &COMMAND_STOP)
this->command_queued_at_ = millis();
this->starting_ = false;
}
if (this->door_state_ == state)
return;
this->door_state_ = state;
this->changed_ = true;
if (!is_moving(state)) {
// A door at rest cannot be restarted by a second stop, as stop_door() sends nothing then.
this->stop_sent_ = false;
}
this->update_current_position_();
if (!this->has_target_())
return;
@@ -688,19 +725,48 @@ void HoermannHcp::set_light_on_(bool on) {
return;
this->light_on_ = on;
this->changed_ = true;
if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) {
// The door has not been shown a toggle that could explain this, so the lamp was switched at the door.
if (!this->light_requested_) {
ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on));
return;
}
// The door acted, so one of the toggles it has seen has arrived. Any others still count.
this->light_toggle_settled_();
if (on == this->light_target_) {
this->clear_light_request_();
return;
}
// Switched away from the target while the command was out: send again.
this->light_command_sent_ = false;
this->light_since_ = millis();
}
bool HoermannHcp::set_light(bool on) {
// A known lamp implies a live connection.
if (!this->light_seen_)
return false;
this->light_target_ = on;
const bool was_requested = this->light_requested_;
// A sent command may still switch it away.
this->light_requested_ = on != this->light_on_ || this->light_command_sent_;
// The deadline belongs to the request, so more taps cannot keep it alive.
if (!was_requested)
this->light_since_ = millis();
this->changed_ = true;
return true;
}
void HoermannHcp::clear_light_request_() {
if (!this->light_requested_)
return;
this->light_requested_ = false;
this->light_command_sent_ = false;
this->changed_ = true;
}
void HoermannHcp::set_light_seen_(bool seen) {
if (this->light_seen_ == seen)
return;
this->light_seen_ = seen;
if (!seen)
this->clear_light_request_();
// A resting door changes nothing else, so without this the light would never hear about it.
this->changed_ = true;
}
+29 -36
View File
@@ -47,17 +47,11 @@ enum class IdentityPhase : uint8_t {
};
#endif
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
// short delay the released value. Each half also carries a second register, which names the buttons that do
// not fit into the first.
// Sent once, in a single status answer, as Hoermann's own bus accessory does.
struct HoermannHcpCommand {
const char *name;
uint16_t pressed_value;
uint16_t released_value;
uint16_t pressed_value_2{0x0000};
uint16_t released_value_2{0x0000};
// A door command supersedes a half-open target; the lamp has no bearing on where the door is going.
bool clears_target{true};
uint16_t value;
uint16_t value_2{0x0000};
};
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
@@ -92,7 +86,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
bool half_open_door();
bool stop_door();
bool set_position(float position);
bool toggle_light();
// False while the door has not reported the lamp.
bool set_light(bool on);
DoorState get_door_state() const { return this->door_state_; }
// False until a broadcast has carried a state the door is known to report. Bus traffic alone makes the
@@ -104,29 +99,20 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
// False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection
// valid without saying anything about the lamp, so is_light_on() would still be its default.
bool is_light_known() const { return this->light_seen_; }
// Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the
// lamp still reads as its old self, so this is what a request has to be judged against.
bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); }
// Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright
// instead of fighting it. Returns false if there is nothing to cancel.
bool cancel_light_toggle();
// The requested state while switching, else the reported one.
bool is_light_heading_on() const { return this->light_requested_ ? this->light_target_ : this->light_on_; }
protected:
// True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert.
bool is_light_toggle_pending_() const;
// Toggles the door has not been shown yet, which is at most the one still waiting in the command slot.
uint8_t unsent_light_toggles_() const;
void record_response_();
bool command_door_(const HoermannHcpCommand &command);
// Returns false when the bus controller has not fetched the previous command yet.
bool queue_command_(const HoermannHcpCommand &command);
// Throws away the pending command, taking any armed target with it unless the command was the lamp toggle.
void drop_command_();
// One outstanding toggle reached the lamp, was withdrawn, or was thrown away.
void light_toggle_settled_();
// Stops expecting the toggles the door has already been shown to reach the lamp.
void forget_light_toggles_();
// Appends the two key-press registers and advances the pending command's press/release state.
void clear_light_request_();
void push_command_registers_(modbus::RegisterValues &registers);
// Decide at the fetch what goes into a status answer, against the door as it stands then.
const HoermannHcpCommand *take_command_();
const HoermannHcpCommand *take_light_command_();
void on_position_reg_(uint16_t value);
void on_state_reg_(uint16_t value);
void on_light_reg_(uint16_t value);
@@ -151,6 +137,7 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
// Recomputes the reported position from position_raw_ and the current door state.
void update_current_position_();
bool has_target_() const { return this->target_position_ != 0.0f; }
bool is_moving_or_starting_() const;
void clear_target_();
void set_light_on_(bool on);
void set_light_seen_(bool seen);
@@ -161,21 +148,24 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
// Position the door was told to travel to; 0.0 means no target is armed.
float target_position_{0.0f};
// Pending command / key-press state machine.
const HoermannHcpCommand *next_command_{nullptr};
const HoermannHcpCommand *start_command_{nullptr};
uint32_t command_queued_at_{0};
// Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline.
uint32_t target_queued_at_{0};
uint32_t command_written_at_{0};
uint32_t last_response_{0};
// When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the
// wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline.
uint32_t light_toggle_released_at_{0};
// Start of the wait for the fetch, then for the report.
uint32_t light_since_{0};
uint32_t last_stop_at_{0};
uint32_t start_fetched_at_{0};
bool stop_sent_{false};
// A door command was fetched and the door has not answered it by moving or reaching its destination yet.
bool starting_{false};
// A command is "pressed" for this long before its end value is sent.
uint16_t key_press_delay_ms_{100};
// Drop the "connected" flag if the bus controller has not polled us for this long.
uint16_t connection_timeout_ms_{2000};
// A chosen margin for a door to report moving after a fetched command.
uint16_t start_window_ms_{5000};
// The state starts on a value the bus controller never reports, so the first broadcast is decoded even when
// it reads 0x0000.
uint16_t prev_state_reg_{0xFFFF};
@@ -184,19 +174,22 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
uint16_t command_reg_value_{0};
DoorState door_state_{DoorState::CLOSED};
// Direction the door was started in for the current target. A target armed while the door is still travelling
// the other way must not be judged by the reported direction until the door has turned around.
// Direction the door was started in for the current target, judged only once the door reports moving that way.
DoorState target_direction_{DoorState::STOPPED};
// Position as reported by the bus controller, 0..200 across the full travel.
uint8_t position_raw_{0};
uint8_t light_toggles_in_flight_{0};
bool target_started_{false};
bool valid_{false};
bool changed_{false};
bool light_on_{false};
bool light_seen_{false};
bool light_requested_{false};
bool light_command_sent_{false};
bool light_target_{false};
bool door_state_seen_{false};
bool short_broadcast_logged_{false};
// Only the read half right after a 0x17 write carries a command, so a second read without a new write does not.
bool status_poll_pending_{false};
#ifdef USE_HOERMANN_HCP_IDENTITY
uint32_t identity_asked_at_{0};
@@ -37,15 +37,10 @@ void HoermannHcpLight::write_state(light::LightState *state) {
if (restored) {
ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing");
} else if (published != binary) {
if (!this->parent_->is_light_known()) {
// Commanding a lamp that has not been read could switch off one that is already on.
ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state");
} else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) {
// A toggle the controller has not fetched is withdrawn outright rather than fought with a second one.
// Refused until the door has reported the lamp, as commanding an unread one could switch off a lit lamp.
if (this->parent_->set_light(binary))
return;
} else {
ESP_LOGW(TAG, "Light command was not accepted by the door");
}
ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state");
}
// Nothing was sent, so the entity has to go back to showing the lamp rather than the request.
this->publish_lamp_state_(heading_on);
@@ -35,16 +35,13 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
hub = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
if (conf := config.get(CONF_DOOR_STATE)) is not None:
var = await text_sensor.new_text_sensor(conf, parent)
var = await text_sensor.new_text_sensor(conf, hub)
await cg.register_component(var, conf)
# Only the identity sensors need the exchange with the motor compiled in.
if CONF_SERIAL_NUMBER in config or CONF_VERSION in config:
cg.add_define("USE_HOERMANN_HCP_IDENTITY")
if (conf := config.get(CONF_SERIAL_NUMBER)) is not None:
sens = await text_sensor.new_text_sensor(conf)
cg.add(parent.set_serial_number_text_sensor(sens))
if (conf := config.get(CONF_VERSION)) is not None:
sens = await text_sensor.new_text_sensor(conf)
cg.add(parent.set_version_text_sensor(sens))
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_SERIAL_NUMBER, hub.set_serial_number_text_sensor)
await text_sensors(CONF_VERSION, hub.set_version_text_sensor)
@@ -0,0 +1,37 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
homeassistant_ns,
setup_home_assistant_entity,
validate_entity_domain,
)
CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["api"]
SUPPORTED_DOMAINS = [
"button",
"input_button",
]
HomeassistantButton = homeassistant_ns.class_(
"HomeassistantButton", button.Button, cg.Component
)
CONFIG_SCHEMA = cv.All(
button.button_schema(HomeassistantButton)
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
validate_entity_domain("button", SUPPORTED_DOMAINS),
)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
var = await button.new_button(config)
await cg.register_component(var, config)
setup_home_assistant_entity(var, config)
@@ -0,0 +1,47 @@
#include "homeassistant_button.h"
#include <cstring>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant {
static const char *const TAG = "homeassistant.button";
void HomeassistantButton::dump_config() {
LOG_BUTTON("", "Homeassistant Button", this);
ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_);
}
float HomeassistantButton::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
void HomeassistantButton::press_action() {
if (!api::global_api_server->is_connected()) {
ESP_LOGE(TAG, "No clients connected to API server");
return;
}
static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press");
static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press");
static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id");
static constexpr char INPUT_PREFIX[] = "input_";
api::HomeassistantActionRequest resp;
if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) {
resp.service = SERVICE_INPUT_BUTTON;
} else {
resp.service = SERVICE_BUTTON;
}
resp.data.init(1);
auto &entity_id_kv = resp.data.emplace_back();
entity_id_kv.key = ENTITY_ID_KEY;
entity_id_kv.value = StringRef(this->entity_id_);
api::global_api_server->send_homeassistant_action(resp);
}
} // namespace esphome::homeassistant
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/components/button/button.h"
#include "esphome/core/component.h"
namespace esphome::homeassistant {
class HomeassistantButton final : public button::Button, public Component {
public:
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
void dump_config() override;
float get_setup_priority() const override;
protected:
void press_action() override;
const char *entity_id_{nullptr};
};
} // namespace esphome::homeassistant

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