diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ed035063..5eb96af59b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 104988d7a5..bfe96f3944 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -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\/([^/]+)\//); diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 72987c25b1..5f0966c151 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 48c0d1d6fd..727e8e2f9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index 5f89315d23..930bdc08d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 8d3a5d2cc5..9f29062e09 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 \ diff --git a/esphome/__main__.py b/esphome/__main__.py index 40457dbcc2..6c984050f4 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -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" ) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 42a1de5a4b..e7a2dc1536 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -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( diff --git a/esphome/codegen.py b/esphome/codegen.py index 4de1d8d5c1..daf53db43f 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -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, diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index fbd4bf590e..d20d80b2fc 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -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( diff --git a/esphome/components/aht10/sensor.py b/esphome/components/aht10/sensor.py index ae669d0000..add86e16a6 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -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) diff --git a/esphome/components/am2315c/sensor.py b/esphome/components/am2315c/sensor.py index febb11409c..39ded956c3 100644 --- a/esphome/components/am2315c/sensor.py +++ b/esphome/components/am2315c/sensor.py @@ -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) diff --git a/esphome/components/am2320/sensor.py b/esphome/components/am2320/sensor.py index ffac0e6407..30a1b1d8a3 100644 --- a/esphome/components/am2320/sensor.py +++ b/esphome/components/am2320/sensor.py @@ -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) diff --git a/esphome/components/am43/sensor/__init__.py b/esphome/components/am43/sensor/__init__.py index 80341972a9..ef746f3e52 100644 --- a/esphome/components/am43/sensor/__init__.py +++ b/esphome/components/am43/sensor/__init__.py @@ -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) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 40f520a057..9b65f052cc 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -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", } ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 687dc1ca95..ed7b8e515c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -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"]; + repeated string options = 6 [(container_pointer_no_template) = "std::span"]; bool disabled_by_default = 7; EntityCategory entity_category = 8; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0207bc14b9..0a2fe13e6a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -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(entity); ListEntitiesSelectResponse msg; - msg.options = &select->traits.get_options(); + const auto &opts = select->traits.get_options(); + const std::span 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; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 0b8a1dc2b2..dacca57369 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -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(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 diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index a68a0ad0d8..96c302745c 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -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. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index e726a7c967..21ef085c53 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -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; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 366751738e..a807d6e5da 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -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_(); diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp new file mode 100644 index 0000000000..c86107cef0 --- /dev/null +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -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 +#include +#include +#include + +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(&peer); + const auto *bytes = reinterpret_cast(&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(addr6->sin6_scope_id); + return true; + } +#endif + if (family != AF_INET) { + return false; + } + const auto *addr4 = reinterpret_cast(&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(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(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 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(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 diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h new file mode 100644 index 0000000000..16d50c3425 --- /dev/null +++ b/esphome/components/api/api_outgoing_connection.h @@ -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 + +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 buf) const; +#endif + + // Pointers first (4 bytes each on 32-bit) + std::unique_ptr 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 diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 1d1f594aa7..fe8992337d 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -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; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index dbf0fb49a1..d94cd709d8 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -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 *options{}; + const std::span *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); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index d854a0731b..b884507618 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -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(); } diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index 70ba579fcc..833e6529e2 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -28,6 +28,7 @@ // Standard library includes that might be needed #include +#include #include #include diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index da9ca8bc79..600ff2a2fe 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -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 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 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; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 865cd17156..fc1ca7df13 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -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 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 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) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index c112b0f415..214d9f417f 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -56,22 +56,25 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) { return (value & 1) ? static_cast(~(value >> 1)) : static_cast(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 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(data) & (word_size - 1)) == 0 && static_cast(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(len - continuations); } /// Encode a varint directly into a pre-allocated buffer. diff --git a/esphome/components/as3935/sensor.py b/esphome/components/as3935/sensor.py index b727b8fdb9..f03213c8ca 100644 --- a/esphome/components/as3935/sensor.py +++ b/esphome/components/as3935/sensor.py @@ -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) diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index 38b24c7cf6..e3d5fa2de2 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -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])) diff --git a/esphome/components/atm90e32/text_sensor/__init__.py b/esphome/components/atm90e32/text_sensor/__init__.py index 30585cb873..6b959c19fa 100644 --- a/esphome/components/atm90e32/text_sensor/__init__.py +++ b/esphome/components/atm90e32/text_sensor/__init__.py @@ -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) diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index e69f33ac2d..9f2960620e 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -72,7 +72,7 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &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; diff --git a/esphome/components/audio/audio_reader.h b/esphome/components/audio/audio_reader.h index b1f76172b0..db85185ca6 100644 --- a/esphome/components/audio/audio_reader.h +++ b/esphome/components/audio/audio_reader.h @@ -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}; }; diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index d59ed7411a..7726ef5837 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -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 ``::``. + 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( diff --git a/esphome/components/audio_file/audio_file.h b/esphome/components/audio_file/audio_file.h index 537e19fb3c..177374cf91 100644 --- a/esphome/components/audio_file/audio_file.h +++ b/esphome/components/audio_file/audio_file.h @@ -10,14 +10,14 @@ namespace esphome::audio_file { struct NamedAudioFile { - audio::AudioFile *file; + const audio::AudioFile *file; const char *file_id; }; inline StaticVector 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}); } diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index d269f77c35..12735eaca1 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -46,7 +46,7 @@ class AudioFileMediaSource final : public Component, protected: std::unique_ptr 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. diff --git a/esphome/components/bedjet/sensor/__init__.py b/esphome/components/bedjet/sensor/__init__.py index 595e798e49..9319af664f 100644 --- a/esphome/components/bedjet/sensor/__init__.py +++ b/esphome/components/bedjet/sensor/__init__.py @@ -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) diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h index ba3128c0ee..a50813c585 100644 --- a/esphome/components/ble_device_base/automation.h +++ b/esphome/components/ble_device_base/automation.h @@ -11,29 +11,34 @@ #include "esphome/core/automation.h" #include "esphome/core/helpers.h" -#include -#include - 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, public ESPBTDeviceListener { public: template explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } - void set_addresses(std::initializer_list 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 addresses_; + const uint64_t *addresses_{nullptr}; }; // on_ble_service_data_advertise: fires when an advertisement contains service diff --git a/esphome/components/ble_device_base/automation.py b/esphome/components/ble_device_base/automation.py index 6acc4edb92..7cc87add5a 100644 --- a/esphome/components/ble_device_base/automation.py +++ b/esphome/components/ble_device_base/automation.py @@ -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() diff --git a/esphome/components/bmi160/sensor.py b/esphome/components/bmi160/sensor.py index 4309f0a79f..9ad1b19fa0 100644 --- a/esphome/components/bmi160/sensor.py +++ b/esphome/components/bmi160/sensor.py @@ -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) diff --git a/esphome/components/bmp085/sensor.py b/esphome/components/bmp085/sensor.py index e4e559844e..5eb8dae731 100644 --- a/esphome/components/bmp085/sensor.py +++ b/esphome/components/bmp085/sensor.py @@ -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) diff --git a/esphome/components/bmp280_base/__init__.py b/esphome/components/bmp280_base/__init__.py index c0f0ae90bf..a956ed34f6 100644 --- a/esphome/components/bmp280_base/__init__.py +++ b/esphome/components/bmp280_base/__init__.py @@ -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])) diff --git a/esphome/components/bmp3xx_base/__init__.py b/esphome/components/bmp3xx_base/__init__.py index 75e168378e..6d0618e136 100644 --- a/esphome/components/bmp3xx_base/__init__.py +++ b/esphome/components/bmp3xx_base/__init__.py @@ -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 diff --git a/esphome/components/bmp581_base/__init__.py b/esphome/components/bmp581_base/__init__.py index 1c2c5c37d4..5d031a8208 100644 --- a/esphome/components/bmp581_base/__init__.py +++ b/esphome/components/bmp581_base/__init__.py @@ -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])) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index bf12bc38d9..c3939735a4 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -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( diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index ccc5a03964..34e9b99ce8 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -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) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 97d757c9d4..6e98162baa 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -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" diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 17ea41ed04..d087c99bf1 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -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( diff --git a/esphome/components/dht/sensor.py b/esphome/components/dht/sensor.py index 7376adb287..c7ae70b834 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -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])) diff --git a/esphome/components/dht12/sensor.py b/esphome/components/dht12/sensor.py index 2bc6e94515..88877b2dc2 100644 --- a/esphome/components/dht12/sensor.py +++ b/esphome/components/dht12/sensor.py @@ -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) diff --git a/esphome/components/dps310/sensor.py b/esphome/components/dps310/sensor.py index 8b8fd8373b..e3827b484c 100644 --- a/esphome/components/dps310/sensor.py +++ b/esphome/components/dps310/sensor.py @@ -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) diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 7fd01db836..6b035d5c98 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -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 diff --git a/esphome/components/ee895/sensor.py b/esphome/components/ee895/sensor.py index fdad47fb05..a7110bd6d2 100644 --- a/esphome/components/ee895/sensor.py +++ b/esphome/components/ee895/sensor.py @@ -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) diff --git a/esphome/components/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py index 56a7fb8b55..eadba1ea3f 100644 --- a/esphome/components/emontx/sensor/__init__.py +++ b/esphome/components/emontx/sensor/__init__.py @@ -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": { diff --git a/esphome/components/ens210/sensor.py b/esphome/components/ens210/sensor.py index bfd758f92f..cb49986f7c 100644 --- a/esphome/components/ens210/sensor.py +++ b/esphome/components/ens210/sensor.py @@ -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) diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py index b43168a336..eb51ba1f2a 100644 --- a/esphome/components/epaper_spi/models/ssd1683.py +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -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, +) diff --git a/esphome/components/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index 0386d84200..9a4da3744a 100644 --- a/esphome/components/es8311/es8311.cpp +++ b/esphome/components/es8311/es8311.cpp @@ -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, ®02)); 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 diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index b81bcd13cf..62ddd6cb38 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -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"], + ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6a693eff89..ce1a66a4d2 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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] diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index a6916fe739..f718d16df4 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -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 } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index fd4fb15ff6..950ae6e0bc 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -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 diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index fc52b92ee7..59ac445cc0 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -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() diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 9b4eb2c3b3..f30f63a5c8 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -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 - #ifdef USE_ESP32 namespace esphome::esp32_ble_tracker { @@ -12,22 +11,18 @@ namespace esphome::esp32_ble_tracker { class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } - void set_addresses(std::initializer_list 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 address_vec_; + const uint64_t *addresses_{nullptr}; }; class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { diff --git a/esphome/components/espectre/__init__.py b/esphome/components/espectre/__init__.py new file mode 100644 index 0000000000..a46d6507fd --- /dev/null +++ b/esphome/components/espectre/__init__.py @@ -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) diff --git a/esphome/components/espectre/binary_sensor.py b/esphome/components/espectre/binary_sensor.py new file mode 100644 index 0000000000..c012f823cf --- /dev/null +++ b/esphome/components/espectre/binary_sensor.py @@ -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) diff --git a/esphome/components/espectre/button/__init__.py b/esphome/components/espectre/button/__init__.py new file mode 100644 index 0000000000..c6a167324e --- /dev/null +++ b/esphome/components/espectre/button/__init__.py @@ -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]) diff --git a/esphome/components/espectre/button/espectre_button.h b/esphome/components/espectre/button/espectre_button.h new file mode 100644 index 0000000000..f89c42f8fe --- /dev/null +++ b/esphome/components/espectre/button/espectre_button.h @@ -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 { + protected: + void press_action() override { this->parent_->recalibrate(); } +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/espectre.cpp b/esphome/components/espectre/espectre.cpp new file mode 100644 index 0000000000..0cce6a3b94 --- /dev/null +++ b/esphome/components/espectre/espectre.cpp @@ -0,0 +1,305 @@ +#include "espectre.h" + +#ifdef USE_ESPECTRE + +#include +#include +#include +#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(static_cast(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(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 diff --git a/esphome/components/espectre/espectre.h b/esphome/components/espectre/espectre.h new file mode 100644 index 0000000000..e526c80cb4 --- /dev/null +++ b/esphome/components/espectre/espectre.h @@ -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 +#include +#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 diff --git a/esphome/components/espectre/select/__init__.py b/esphome/components/espectre/select/__init__.py new file mode 100644 index 0000000000..8a4835e58b --- /dev/null +++ b/esphome/components/espectre/select/__init__.py @@ -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)) diff --git a/esphome/components/espectre/select/espectre_select.cpp b/esphome/components/espectre/select/espectre_select.cpp new file mode 100644 index 0000000000..9569b2955b --- /dev/null +++ b/esphome/components/espectre/select/espectre_select.cpp @@ -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 diff --git a/esphome/components/espectre/select/espectre_select.h b/esphome/components/espectre/select/espectre_select.h new file mode 100644 index 0000000000..3d27cfabf9 --- /dev/null +++ b/esphome/components/espectre/select/espectre_select.h @@ -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 { + protected: + void control(size_t index) override; +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/sensor/__init__.py b/esphome/components/espectre/sensor/__init__.py new file mode 100644 index 0000000000..c0efac6204 --- /dev/null +++ b/esphome/components/espectre/sensor/__init__.py @@ -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")) diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.cpp b/esphome/components/espectre/sensor/espectre_diagnostics.cpp new file mode 100644 index 0000000000..0edc3c0728 --- /dev/null +++ b/esphome/components/espectre/sensor/espectre_diagnostics.cpp @@ -0,0 +1,40 @@ +#include "espectre_diagnostics.h" + +#ifdef USE_ESPECTRE + +#include +#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 diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.h b/esphome/components/espectre/sensor/espectre_diagnostics.h new file mode 100644 index 0000000000..fde1c46113 --- /dev/null +++ b/esphome/components/espectre/sensor/espectre_diagnostics.h @@ -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 diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 6ecae24e29..0cb2657ea1 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -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") diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index ce7206bf57..ff05caca83 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -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, diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 75dcfb45f6..44c4d6733d 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -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 phy_registers_{}; +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT + StaticVector phy_registers_{}; +#endif // Group all 8-bit members together uint8_t clk_pin_{0}; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index d15ef5a34c..e9281453c9 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,10 @@ #include #include #include "esp_event.h" +#if USE_NETWORK_IPV6 +#include +#include +#endif #ifdef USE_PSRAM #include #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(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_() { diff --git a/esphome/components/exponential_moving_average/__init__.py b/esphome/components/exponential_moving_average/__init__.py new file mode 100644 index 0000000000..c58ce8a01e --- /dev/null +++ b/esphome/components/exponential_moving_average/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp new file mode 100644 index 0000000000..243e2cf31e --- /dev/null +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp @@ -0,0 +1,116 @@ +#include "exponential_moving_average_sensor.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include + +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(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 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(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(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 diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h new file mode 100644 index 0000000000..98bd88553b --- /dev/null +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#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 diff --git a/esphome/components/exponential_moving_average/sensor.py b/esphome/components/exponential_moving_average/sensor.py new file mode 100644 index 0000000000..20c8135636 --- /dev/null +++ b/esphome/components/exponential_moving_average/sensor.py @@ -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()"), +) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 918fde5dbd..732933ba48 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -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) diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index fda9c269e5..2592370b6a 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -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(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(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; } diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index fa24181bd0..bab7b449ba 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -1,5 +1,7 @@ #pragma once +#include + #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, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "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 diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 05d8e233ef..10074fbf10 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -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])) diff --git a/esphome/components/hdc1080/sensor.py b/esphome/components/hdc1080/sensor.py index b2b6dc533a..c35488c96c 100644 --- a/esphome/components/hdc1080/sensor.py +++ b/esphome/components/hdc1080/sensor.py @@ -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) diff --git a/esphome/components/hdc2010/sensor.py b/esphome/components/hdc2010/sensor.py index ad0311fb4f..9837b9af33 100644 --- a/esphome/components/hdc2010/sensor.py +++ b/esphome/components/hdc2010/sensor.py @@ -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) diff --git a/esphome/components/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py index b5388b4c2b..63625bbf94 100644 --- a/esphome/components/hdc2080/sensor.py +++ b/esphome/components/hdc2080/sensor.py @@ -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) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 2686ba5322..46e1a95cb1 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -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])) diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 9ab7014e0e..22f0c12c8c 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -1,6 +1,7 @@ #include "hoermann_hcp.h" #include +#include #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(0x0001 | command)); - this->push_command_registers_(registers); + if (status_poll && static_cast(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 ®isters) { - 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 ®isters, 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(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; } diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 9ebb717d0c..c16a0890ef 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -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 ®isters); + // 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}; diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp index d3d784928d..f7e40b8921 100644 --- a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -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); diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py index 2f038025b9..da460905c0 100644 --- a/esphome/components/hoermann_hcp/text_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -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) diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py new file mode 100644 index 0000000000..660c59164e --- /dev/null +++ b/esphome/components/homeassistant/button/__init__.py @@ -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) diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp new file mode 100644 index 0000000000..d8d2faa37a --- /dev/null +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -0,0 +1,47 @@ +#include "homeassistant_button.h" + +#include + +#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 diff --git a/esphome/components/homeassistant/button/homeassistant_button.h b/esphome/components/homeassistant/button/homeassistant_button.h new file mode 100644 index 0000000000..cc7bbddfef --- /dev/null +++ b/esphome/components/homeassistant/button/homeassistant_button.h @@ -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 diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index ab1389e13a..9658d3dc84 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -7,19 +7,23 @@ from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, homeassistant_ns, setup_home_assistant_entity, + validate_entity_domain, ) CODEOWNERS = ["@landonr"] DEPENDENCIES = ["api"] +SUPPORTED_DOMAINS = ["input_number", "number"] + HomeassistantNumber = homeassistant_ns.class_( "HomeassistantNumber", number.Number, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( number.number_schema(HomeassistantNumber) .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("number", SUPPORTED_DOMAINS), ) diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 965f91d202..553c2466f8 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -1,5 +1,7 @@ #include "homeassistant_number.h" +#include + #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" @@ -80,12 +82,19 @@ void HomeassistantNumber::control(float value) { this->publish_state(value); - static constexpr auto SERVICE_NAME = StringRef::from_lit("number.set_value"); + static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); + static constexpr auto INPUT_NUMBER_SERVICE_NAME = StringRef::from_lit("input_number.set_value"); + static constexpr char INPUT_PREFIX[] = "input_"; static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); static constexpr auto VALUE_KEY = StringRef::from_lit("value"); api::HomeassistantActionRequest resp; - resp.service = SERVICE_NAME; + // input_number entities have their own set_value action + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = INPUT_NUMBER_SERVICE_NAME; + } else { + resp.service = NUMBER_SERVICE_NAME; + } resp.data.init(2); auto &entity_id = resp.data.emplace_back(); diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py new file mode 100644 index 0000000000..8be0a4545c --- /dev/null +++ b/esphome/components/homeassistant/select/__init__.py @@ -0,0 +1,54 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["api"] + +CONF_MAX_OPTIONS = "max_options" +CONF_OPTIONS_BUFFER_SIZE = "options_buffer_size" + +SUPPORTED_DOMAINS = [ + "input_select", + "select", +] + +HomeassistantSelect = homeassistant_ns.class_( + "HomeassistantSelect", select.Select, cg.Component +) + +CONFIG_SCHEMA = cv.All( + select.select_schema(HomeassistantSelect) + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend( + { + cv.Optional(CONF_MAX_OPTIONS, default=16): cv.int_range(min=1, max=255), + cv.Optional(CONF_OPTIONS_BUFFER_SIZE, default=256): cv.int_range( + min=2, max=4096 + ), + } + ) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("select", SUPPORTED_DOMAINS), +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + # Options come from Home Assistant at runtime, into storage reserved during setup + var = await select.new_select( + config, + config[CONF_MAX_OPTIONS], + config[CONF_OPTIONS_BUFFER_SIZE], + options=[], + ) + await cg.register_component(var, config) + setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp new file mode 100644 index 0000000000..82571c768d --- /dev/null +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -0,0 +1,258 @@ +#include "homeassistant_select.h" + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +static const char *const TAG = "homeassistant.select"; + +namespace { + +/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early. +template bool emit_utf8(uint32_t code_point, C &on_char) { + if (code_point == 0 || code_point > 0x10FFFF) + return false; + if (code_point < 0x80) { + on_char(static_cast(code_point)); + } else if (code_point < 0x800) { + on_char(static_cast(0xC0 | (code_point >> 6))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else if (code_point < 0x10000) { + on_char(static_cast(0xE0 | (code_point >> 12))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else { + on_char(static_cast(0xF0 | (code_point >> 18))); + on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } + return true; +} + +/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as + * `['Low', "It's high", 'caf\xe9']`. + * + * Calls on_char for each decoded byte of an option and on_option at the end of each option. Returns false + * on malformed input; the callbacks may already have run by then. + */ +template bool parse_option_list(StringRef src, C &&on_char, O &&on_option) { + const char *pos = src.c_str(); + const char *const end = pos + src.size(); + auto skip_spaces = [&]() { + while (pos < end && *pos == ' ') + pos++; + }; + + skip_spaces(); + if (pos == end || *pos != '[') + return false; + pos++; + skip_spaces(); + if (pos < end && *pos == ']') { + pos++; + } else { + while (true) { + skip_spaces(); + if (pos == end || (*pos != '\'' && *pos != '"')) + return false; + const char quote = *pos++; + while (true) { + if (pos == end) + return false; + const char c = *pos++; + if (c == quote) + break; + if (c != '\\') { + on_char(c); + continue; + } + if (pos == end) + return false; + const char escape = *pos++; + switch (escape) { + case '\\': + case '\'': + case '"': + on_char(escape); + break; + case 'n': + on_char('\n'); + break; + case 'r': + on_char('\r'); + break; + case 't': + on_char('\t'); + break; + case 'x': + case 'u': + case 'U': { + const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); + if (static_cast(end - pos) < digits) + return false; + auto code_point = parse_hex(pos, digits); + if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) + return false; + pos += digits; + break; + } + default: + return false; + } + } + on_option(); + skip_spaces(); + if (pos == end) + return false; + const char separator = *pos++; + if (separator == ']') + break; + if (separator != ',') + return false; + } + } + skip_spaces(); + return pos == end; +} + +} // namespace + +void HomeassistantSelect::setup() { + this->options_buffer_ = std::make_unique(this->options_buffer_size_); + this->option_list_ = std::make_unique(this->max_options_); + + // Subscribe to the options first: Home Assistant answers subscriptions in order, so the options are + // known by the time the first state arrives. + api::global_api_server->subscribe_home_assistant_state( + this->entity_id_, "options", [this](StringRef options) { this->options_changed_(options); }); + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, + [this](StringRef state) { this->state_changed_(state); }); +} + +void HomeassistantSelect::options_changed_(StringRef options) { + // First pass: validate and measure the new options, and look for the active option among them, before + // anything is overwritten. A list that does not fit leaves the current options in place. + const char *active = this->has_state() ? this->option_at(this->active_index_) : nullptr; + size_t count = 0; + size_t bytes = 0; + size_t match_len = 0; + bool matching = active != nullptr; + optional new_active; + bool valid = parse_option_list( + options, + [&](char c) { + bytes++; + if (matching && active[match_len] == c) { + match_len++; + } else { + matching = false; + } + }, + [&]() { + bytes++; // NUL terminator + if (matching && active[match_len] == '\0' && !new_active.has_value()) + new_active = count; + count++; + match_len = 0; + matching = active != nullptr; + }); + if (!valid) { + ESP_LOGW(TAG, "'%s': Can't parse options %s", this->entity_id_, options.c_str()); + return; + } + if (count > this->max_options_) { + ESP_LOGE(TAG, "'%s': %zu options exceed max_options (%u)", this->entity_id_, count, this->max_options_); + return; + } + if (bytes > this->options_buffer_size_) { + ESP_LOGE(TAG, "'%s': Options need %zu bytes, more than options_buffer_size (%u)", this->entity_id_, bytes, + this->options_buffer_size_); + return; + } + + // Second pass: the input is known to be valid and to fit, so write it out + char *buffer = this->options_buffer_.get(); + size_t offset = 0; + size_t option_start = 0; + size_t index = 0; + parse_option_list( + options, [&](char c) { buffer[offset++] = c; }, + [&]() { + buffer[offset++] = '\0'; + this->option_list_[index++] = buffer + option_start; + option_start = offset; + }); + // The traits only point at the list, which this select owns; set_options() would copy it to the heap + this->traits.set_options_static(this->option_list_.get(), count); + + // The active option may have moved to another index, or may be gone + if (new_active.has_value()) { + this->active_index_ = *new_active; + } else { + this->set_has_state(false); + } + ESP_LOGD(TAG, "'%s': Got %zu options", this->entity_id_, count); +} + +void HomeassistantSelect::state_changed_(StringRef state) { + auto index = this->index_of(state.c_str(), state.size()); + if (!index.has_value()) { + ESP_LOGW(TAG, "'%s': State '%s' is not one of the options", this->entity_id_, state.c_str()); + return; + } + if (this->has_state() && this->active_index_ == *index) + return; + ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_, state.c_str()); + this->publish_state(*index); +} + +void HomeassistantSelect::dump_config() { + LOG_SELECT("", "Homeassistant Select", this); + ESP_LOGCONFIG(TAG, + " Entity ID: '%s'\n" + " Max options: %u\n" + " Options buffer size: %u", + this->entity_id_, this->max_options_, this->options_buffer_size_); +} + +float HomeassistantSelect::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } + +void HomeassistantSelect::control(size_t index) { + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "No clients connected to API server"); + return; + } + + static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option"); + static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option"); + static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + static constexpr auto OPTION_KEY = StringRef::from_lit("option"); + static constexpr char INPUT_PREFIX[] = "input_"; + + api::HomeassistantActionRequest resp; + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = SERVICE_INPUT_SELECT; + } else { + resp.service = SERVICE_SELECT; + } + + resp.data.init(2); + auto &entity_id = resp.data.emplace_back(); + entity_id.key = ENTITY_ID_KEY; + entity_id.value = StringRef(this->entity_id_); + + auto &option = resp.data.emplace_back(); + option.key = OPTION_KEY; + option.value = StringRef(this->option_at(index)); + + api::global_api_server->send_homeassistant_action(resp); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/select/homeassistant_select.h b/esphome/components/homeassistant/select/homeassistant_select.h new file mode 100644 index 0000000000..843e07e60e --- /dev/null +++ b/esphome/components/homeassistant/select/homeassistant_select.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/** Mirrors a Home Assistant `select` or `input_select` entity. + * + * The options are read from the entity's `options` attribute at runtime. They are stored in a buffer of + * `options_buffer_size` bytes and a list of at most `max_options` entries, both reserved in setup(), so a + * change of options never allocates. + */ +class HomeassistantSelect final : public select::Select, public Component { + public: + HomeassistantSelect(uint8_t max_options, uint16_t options_buffer_size) + : options_buffer_size_(options_buffer_size), max_options_(max_options) {} + + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + protected: + void options_changed_(StringRef options); + void state_changed_(StringRef state); + + void control(size_t index) override; + + const char *entity_id_{nullptr}; + + private: + // The traits point at option_list_, whose entries point into options_buffer_; both are sized once in + // setup() and must match options_buffer_size_ and max_options_ + std::unique_ptr options_buffer_; + std::unique_ptr option_list_; + uint16_t options_buffer_size_; + uint8_t max_options_; +}; + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py new file mode 100644 index 0000000000..6ca920bac7 --- /dev/null +++ b/esphome/components/homeassistant/text/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import text +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["api"] + +SUPPORTED_DOMAINS = [ + "input_text", + "text", +] + +HomeassistantText = homeassistant_ns.class_( + "HomeassistantText", text.Text, cg.Component +) + +CONFIG_SCHEMA = cv.All( + text.text_schema(HomeassistantText, mode="TEXT") + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("text", SUPPORTED_DOMAINS), +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + var = await text.new_text(config) + await cg.register_component(var, config) + setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp new file mode 100644 index 0000000000..a8fabd5e14 --- /dev/null +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -0,0 +1,107 @@ +#include "homeassistant_text.h" + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +static const char *const TAG = "homeassistant.text"; + +void HomeassistantText::state_changed_(StringRef state) { + if (state == this->state) { + return; + } + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, state.c_str()); + this->publish_state(state.c_str(), state.size()); +} + +void HomeassistantText::min_retrieved_(StringRef min) { + auto min_value = parse_number(min.c_str()); + if (!min_value.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Min retrieved: %s", this->entity_id_, min.c_str()); + this->traits.set_min_length(min_value.value()); +} + +void HomeassistantText::max_retrieved_(StringRef max) { + auto max_value = parse_number(max.c_str()); + if (!max_value.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Max retrieved: %s", this->entity_id_, max.c_str()); + this->traits.set_max_length(max_value.value()); +} + +void HomeassistantText::mode_retrieved_(StringRef mode) { + if (mode == "text") { + this->traits.set_mode(text::TEXT_MODE_TEXT); + } else if (mode == "password") { + this->traits.set_mode(text::TEXT_MODE_PASSWORD); + } else { + ESP_LOGW(TAG, "'%s': Unknown 'mode' value '%s'", this->entity_id_, mode.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Mode retrieved: %s", this->entity_id_, mode.c_str()); +} + +void HomeassistantText::setup() { + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, + [this](StringRef state) { this->state_changed_(state); }); + + api::global_api_server->get_home_assistant_state(this->entity_id_, "min", + [this](StringRef min) { this->min_retrieved_(min); }); + api::global_api_server->get_home_assistant_state(this->entity_id_, "max", + [this](StringRef max) { this->max_retrieved_(max); }); + api::global_api_server->get_home_assistant_state(this->entity_id_, "mode", + [this](StringRef mode) { this->mode_retrieved_(mode); }); + // The "pattern" attribute is not fetched: Home Assistant sends "None" when it is unset, + // and a runtime pattern would need its own buffer. +} + +void HomeassistantText::dump_config() { + LOG_TEXT("", "Homeassistant Text", this); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); +} + +float HomeassistantText::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } + +void HomeassistantText::control(const std::string &value) { + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "No clients connected to API server"); + return; + } + + static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value"); + static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value"); + static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + static constexpr auto VALUE_KEY = StringRef::from_lit("value"); + static constexpr char INPUT_PREFIX[] = "input_"; + + api::HomeassistantActionRequest resp; + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = SERVICE_INPUT_TEXT; + } else { + resp.service = SERVICE_TEXT; + } + + resp.data.init(2); + auto &entity_id_kv = resp.data.emplace_back(); + entity_id_kv.key = ENTITY_ID_KEY; + entity_id_kv.value = StringRef(this->entity_id_); + + auto &value_kv = resp.data.emplace_back(); + value_kv.key = VALUE_KEY; + value_kv.value = StringRef(value.data(), value.size()); + + api::global_api_server->send_homeassistant_action(resp); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/text/homeassistant_text.h b/esphome/components/homeassistant/text/homeassistant_text.h new file mode 100644 index 0000000000..978dfdae01 --- /dev/null +++ b/esphome/components/homeassistant/text/homeassistant_text.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/text/text.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +class HomeassistantText final : public text::Text, public Component { + public: + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + protected: + void state_changed_(StringRef state); + void min_retrieved_(StringRef min); + void max_retrieved_(StringRef max); + void mode_retrieved_(StringRef mode); + + void control(const std::string &value) override; + + const char *entity_id_{nullptr}; +}; + +} // namespace esphome::homeassistant diff --git a/esphome/components/honeywell_hih_i2c/sensor.py b/esphome/components/honeywell_hih_i2c/sensor.py index 5250e1c1c7..8ab2cd16b8 100644 --- a/esphome/components/honeywell_hih_i2c/sensor.py +++ b/esphome/components/honeywell_hih_i2c/sensor.py @@ -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) diff --git a/esphome/components/honeywellabp/sensor.py b/esphome/components/honeywellabp/sensor.py index 4b116f0f16..d7e7cf29a4 100644 --- a/esphome/components/honeywellabp/sensor.py +++ b/esphome/components/honeywellabp/sensor.py @@ -56,14 +56,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await spi.register_spi_device(var, config) - if CONF_PRESSURE in config: + sensors = sensor.sub_sensors(config) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): conf = config[CONF_PRESSURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_pressure_sensor(sens)) cg.add(var.set_honeywellabp_min_pressure(conf[CONF_MIN_PRESSURE])) cg.add(var.set_honeywellabp_max_pressure(conf[CONF_MAX_PRESSURE])) - - if CONF_TEMPERATURE in config: - conf = config[CONF_TEMPERATURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_temperature_sensor(sens)) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index 299acd4b52..d4c4e44a3b 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -63,13 +63,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + pressure_config = config[CONF_PRESSURE] cg.add(var.set_min_pressure(pressure_config[CONF_MIN_PRESSURE])) cg.add(var.set_max_pressure(pressure_config[CONF_MAX_PRESSURE])) cg.add(var.set_transfer_function(pressure_config[TRANSFER_FUNCTION])) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 9292cd77f6..f753534efd 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -90,6 +90,8 @@ int main(int argc, char **argv) { // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); + // A write to a closed peer must fail with EPIPE, as on the embedded stacks, not kill the process + std::signal(SIGPIPE, SIG_IGN); esphome::host::setup_preferences(); setup(); diff --git a/esphome/components/hte501/sensor.py b/esphome/components/hte501/sensor.py index bf9fe4000e..80d5986295 100644 --- a/esphome/components/hte501/sensor.py +++ b/esphome/components/hte501/sensor.py @@ -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 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) diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index d45208ed5d..37e0de5d6a 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -25,11 +25,10 @@ std::string HttpContainer::get_response_header(const std::string &header_name) { auto lower = str_lower_case(header_name); // NOLINT for (const auto &entry : this->response_headers_) { if (entry.name == lower) { - ESP_LOGD(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); + ESP_LOGV(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); return entry.value; } } - ESP_LOGW(TAG, "No header with name %s found", lower.c_str()); return ""; } diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 71668b8556..68696118ea 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -268,6 +268,11 @@ class HttpContainer : public Parented { return !this->is_chunked_ && this->bytes_read_ >= this->content_length; } + /** + * @brief Get the value of a response header by name. + * @param header_name The name of the header to retrieve. + * @return The value of the header if found, otherwise an empty string. + */ std::string get_response_header(const std::string &header_name); protected: diff --git a/esphome/components/htu31d/sensor.py b/esphome/components/htu31d/sensor.py index 8960759d9b..41cc54686a 100644 --- a/esphome/components/htu31d/sensor.py +++ b/esphome/components/htu31d/sensor.py @@ -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 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) diff --git a/esphome/components/hyt271/sensor.py b/esphome/components/hyt271/sensor.py index 3f006a65fe..1fe5d9e14a 100644 --- a/esphome/components/hyt271/sensor.py +++ b/esphome/components/hyt271/sensor.py @@ -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.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature(sens)) - - if humidity := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(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) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 37895ac4e7..1b17380ed8 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -60,11 +60,11 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon gpio_num_t din_pin_{I2S_GPIO_UNUSED}; i2s_chan_handle_t rx_handle_; - bool pdm_{false}; #if SOC_I2S_SUPPORTS_PDM_RX i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; #endif + bool pdm_{false}; bool correct_dc_offset_; bool locked_driver_{false}; int32_t dc_offset_prev_input_{0}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 7ae58b3e44..4b74b2e29d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -40,6 +40,16 @@ void I2SAudioSpeakerBase::setup() { // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); this->set_mute_state(this->mute_state_); + + // Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG + // function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now. + this->park_dout_pin_(); +} + +void I2SAudioSpeakerBase::park_dout_pin_() { + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } void I2SAudioSpeakerBase::dump_config() { @@ -303,9 +313,7 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - gpio_reset_pin(this->dout_pin_); - gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); - gpio_set_level(this->dout_pin_, 0); + this->park_dout_pin_(); } this->parent_->unlock(); } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index b443166ea1..965eede020 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -129,6 +129,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Stops the I2S driver and unlocks the I2S port void stop_i2s_driver_(); + /// @brief Detaches dout from the I2S signal and drives it low + void park_dout_pin_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} diff --git a/esphome/components/iaqcore/sensor.py b/esphome/components/iaqcore/sensor.py index 1b905e4c63..daee46f083 100644 --- a/esphome/components/iaqcore/sensor.py +++ b/esphome/components/iaqcore/sensor.py @@ -47,12 +47,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if co2_config := config.get(CONF_CO2): - sens = await sensor.new_sensor(co2_config) - cg.add(var.set_co2(sens)) - - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2) + await sensors(CONF_TVOC, var.set_tvoc) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/improv_ble/improv_ble_component.h b/esphome/components/improv_ble/improv_ble_component.h index 2552bed69b..126663ed00 100644 --- a/esphome/components/improv_ble/improv_ble_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -72,9 +72,6 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas improv::Error get_improv_error_state() const { return this->error_state_; } protected: - bool should_start_{false}; - bool setup_complete_{false}; - uint32_t identify_start_{0}; uint32_t identify_duration_; uint32_t authorized_start_{0}; @@ -84,6 +81,14 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas std::vector incoming_data_; wifi::WiFiAP connecting_sta_; + // Single byte fields here fill the alignment gap between connecting_sta_ and the next pointer + improv::State state_{improv::STATE_STOPPED}; + improv::Error error_state_{improv::ERROR_NONE}; + bool should_start_{false}; + bool setup_complete_{false}; + bool status_indicator_state_{false}; + bool advertising_device_name_{false}; + bool advertising_requested_{false}; BLEService *service_{nullptr}; BLECharacteristic *status_{nullptr}; @@ -99,16 +104,11 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas output::BinaryOutput *status_indicator_{nullptr}; #endif - improv::State state_{improv::STATE_STOPPED}; - improv::Error error_state_{improv::ERROR_NONE}; #ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif - bool status_indicator_state_{false}; uint32_t last_name_adv_time_{0}; - bool advertising_device_name_{false}; - bool advertising_requested_{false}; void set_status_indicator_state_(bool state); void update_advertising_type_(); void request_advertising_(); diff --git a/esphome/components/kmeteriso/sensor.py b/esphome/components/kmeteriso/sensor.py index 3e007d1310..5b7087362f 100644 --- a/esphome/components/kmeteriso/sensor.py +++ b/esphome/components/kmeteriso/sensor.py @@ -48,9 +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 internal_temperature_config := config.get(CONF_INTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(internal_temperature_config) - cg.add(var.set_internal_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 3319c9be4b..0281609edf 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -79,15 +79,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pack_size(config.get(CONF_SIZE))) cg.add(var.set_pack_voltage(BATTERY_VOLTAGE_OPTIONS[config[CONF_VOLTAGE]])) - if voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_remaining_sensor) - if level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(level_config) - cg.add(var.set_battery_remaining_sensor(sens)) - - if temp_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temp_config) - cg.add(var.set_temperature_sensor(sens)) - cg.add(var.set_thermistor_b_constant(temp_config[CONF_B_CONSTANT])) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + b_constant = config[CONF_TEMPERATURE][CONF_B_CONSTANT] + cg.add(var.set_thermistor_b_constant(b_constant)) diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index c4f06ad224..d6285cce8a 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -176,6 +176,10 @@ class LD2450Component : public Component, public uart::UARTDevice { bool bluetooth_on_{false}; Target target_info_[MAX_TARGETS]; Zone zone_config_[MAX_ZONES]; +#ifdef USE_TEXT_SENSOR + // Byte aligned, so it packs after zone_config_ instead of padding before data_callback_ + std::array, MAX_TARGETS> direction_dedup_{}; +#endif #ifdef USE_NUMBER ESPPreferenceObject pref_; // only used when numbers are in use @@ -194,7 +198,6 @@ class LD2450Component : public Component, public uart::UARTDevice { #endif #ifdef USE_TEXT_SENSOR std::array direction_text_sensors_{}; - std::array, MAX_TARGETS> direction_dedup_{}; #endif LazyCallbackManager data_callback_; diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index de63325455..1df0f67572 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -35,11 +35,11 @@ class LEDCOutput final : public output::FloatOutput, public Component { InternalGPIOPin *pin_; uint8_t channel_{}; uint8_t bit_depth_{}; + bool initialized_ = false; float phase_angle_{0.0f}; float frequency_{}; float duty_{0.0f}; uint32_t last_duty_{UINT32_MAX}; - bool initialized_ = false; }; } // namespace esphome::ledc diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 8cd7f3e7b6..5d630fec6c 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -32,6 +32,7 @@ from esphome.storage_json import StorageJSON from . import gpio # noqa: F401 from .const import ( COMPONENT_BK72XX, + COMPONENT_LN882X, CONF_GPIO_RECOVER, CONF_LOGLEVEL, CONF_SDK_SILENT, @@ -490,6 +491,10 @@ async def component_to_code(config): # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") + # LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c). + if config[CONF_COMPONENT_ID] == COMPONENT_LN882X: + cg.add_build_flag("-Wl,--wrap=pvPortMalloc") + # RTL8710B needs FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake # required by AsyncTCP 3.4.3+ (https://github.com/esphome/esphome/issues/10220) # RTL8720C (ambz2) requires FreeRTOS 10.x so this only applies to RTL8710B @@ -521,6 +526,10 @@ async def component_to_code(config): # RAM-executable output section and prints a post-link placement summary. if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) + # Match the 1-byte enums of the LN882H SDK's prebuilt WiFi library. + if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_LN882X: + cg.add_build_unflag("-fno-short-enums") + cg.add_build_flag("-fshort-enums") # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 8686a41e64..1f5de35d2c 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -1,6 +1,26 @@ #ifdef USE_LIBRETINY -// HAL functions live in hal.cpp. core.cpp is intentionally empty for -// libretiny — there is no extra component bootstrap to keep here. +// HAL functions live in hal.cpp; this file only holds the serial port +// workaround below. + +#include "core.h" + +#ifdef USE_LN882X +#include + +// The SDK's open port handles (components/serial/serial.c); not in its header. +extern "C" Serial_t *serial_handles[SER_PORT_NUM]; + +namespace esphome::libretiny { + +void ensure_serial_handle(uint8_t port) { + if (port >= SER_PORT_NUM || serial_handles[port] != nullptr) + return; + // serial_init() zeroes and opens it; the port stays open for good. + serial_handles[port] = new Serial_t(); +} + +} // namespace esphome::libretiny +#endif // USE_LN882X #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/core.h b/esphome/components/libretiny/core.h index f909db4f0f..8f6760c0fc 100644 --- a/esphome/components/libretiny/core.h +++ b/esphome/components/libretiny/core.h @@ -4,6 +4,21 @@ #include -namespace esphome::libretiny {} // namespace esphome::libretiny +namespace esphome::libretiny { + +#ifdef USE_LN882X +/// Give a hardware UART its SDK handle before its Serial begin()s. +/// +/// LibreTiny's LN882H Serial passes the SDK's per port handle to +/// serial_init(), which fills it in, but only the default log port (UART1) +/// has one at boot. Any other port hands it NULL and the chip faults before +/// setup, so a logger or uart on UART0 never boots. Drop this once LibreTiny +/// gives each port storage of its own. +void ensure_serial_handle(uint8_t port); +#else +inline void ensure_serial_handle(uint8_t /*port*/) {} +#endif + +} // namespace esphome::libretiny #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index 01b276005d..f97a5a19a7 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -12,6 +12,12 @@ // libretiny component's API. namespace esphome::libretiny {} // namespace esphome::libretiny +#if defined(USE_LN882X) && !defined(CLANG_TIDY) // clang-tidy runs without the PlatformIO flags +// Fail the build if the -fshort-enums swap in __init__.py stops working. +enum Ln882xEnumSizeCheck { LN882X_ENUM_SIZE_CHECK }; +static_assert(sizeof(Ln882xEnumSizeCheck) == 1, "LN882x must build with -fshort-enums to match the SDK"); +#endif + namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), diff --git a/esphome/components/libretiny/ln882x_zero_malloc.c b/esphome/components/libretiny/ln882x_zero_malloc.c new file mode 100644 index 0000000000..77e30b3a7d --- /dev/null +++ b/esphome/components/libretiny/ln882x_zero_malloc.c @@ -0,0 +1,19 @@ +/* + * LN882x: heap_5 returns NULL for a zero-size request, and the SDK's + * malloc-failed hook ends in LN_ASSERT(0), which spins until the watchdog + * resets the chip. LibreTiny's scan handler asks for zero entries when a scan + * finds no networks. Linked with -Wl,--wrap=pvPortMalloc so every allocation + * comes through here; drop it once LibreTiny no longer allocates zero entries. + */ + +#ifdef USE_LN882X + +#include + +void *__real_pvPortMalloc(size_t size); // NOLINT(readability-identifier-naming) + +void *__wrap_pvPortMalloc(size_t size) { // NOLINT(readability-identifier-naming) + return __real_pvPortMalloc(size == 0 ? 1 : size); +} + +#endif // USE_LN882X diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index 0ab064e3e1..d7d93249c5 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -2,6 +2,9 @@ #ifdef USE_LIBRETINY +#include + +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::libretiny { @@ -18,6 +21,17 @@ void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__)); #endif +#if defined(USE_LN882X) && defined(USE_WIFI) + // The SDK falls back to this MAC when the board layout's KV store holds none. + static constexpr uint8_t SDK_DEFAULT_MAC[MAC_ADDRESS_SIZE] = {0x00, 0x50, 0xC2, 0x5E, 0x10, 0x88}; + uint8_t mac[MAC_ADDRESS_SIZE]; + get_mac_address_raw(mac); + if (memcmp(mac, SDK_DEFAULT_MAC, MAC_ADDRESS_SIZE) == 0) { + ESP_LOGE(TAG, "MAC is the LN882H SDK default 00:50:C2:5E:10:88; only one device with it can be on a network. " + "On a Tuya module, set board: to its Tuya board (e.g. wl2s, wl2h-u) and flash over serial"); + } +#endif + #ifdef USE_TEXT_SENSOR if (this->version_ != nullptr) { this->version_->publish_state(<_BANNER_STR[10]); diff --git a/esphome/components/libretiny/text_sensor.py b/esphome/components/libretiny/text_sensor.py index c1012774c8..18b412c460 100644 --- a/esphome/components/libretiny/text_sensor.py +++ b/esphome/components/libretiny/text_sensor.py @@ -25,8 +25,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - lt_component = await cg.get_variable(config[CONF_LIBRETINY]) + hub = await cg.get_variable(config[CONF_LIBRETINY]) - if CONF_VERSION in config: - sens = await text_sensor.new_text_sensor(config[CONF_VERSION]) - cg.add(lt_component.set_version_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_sensor) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index d6b9bca38b..a966ba67ad 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -235,6 +235,15 @@ def warn_ram_log_strings(config: ConfigType) -> ConfigType: return config +def validate_task_log_buffer_alignment(value: int) -> int: + # ESP-IDF rejects a no-split ring buffer whose size is not a multiple of 4 + if CORE.is_esp32 and value % 4: + raise cv.Invalid( + f"{CONF_TASK_LOG_BUFFER_SIZE} must be a multiple of 4 on ESP32" + ) + return value + + def validate_wait_for_cdc(config: ConfigType) -> ConfigType: if config.get(CONF_WAIT_FOR_CDC) and config.get(CONF_HARDWARE_UART) != USB_CDC: raise cv.Invalid("wait_for_cdc requires hardware_uart: USB_CDC") @@ -282,6 +291,7 @@ CONFIG_SCHEMA = cv.All( max=32768, # Max: Depends on message sizes, typically ~300 messages with default size ), ), + validate_task_log_buffer_alignment, ), cv.SplitDefault( CONF_HARDWARE_UART, diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index bc3922c436..9a322f751d 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -1,6 +1,8 @@ #ifdef USE_LIBRETINY #include "logger.h" +#include "esphome/components/libretiny/core.h" + namespace esphome::logger { static const char *const TAG = "logger"; @@ -11,18 +13,21 @@ void Logger::pre_setup() { #if LT_HW_UART0 case UART_SELECTION_UART0: this->hw_serial_ = &Serial0; + libretiny::ensure_serial_handle(0); Serial0.begin(this->baud_rate_); break; #endif #if LT_HW_UART1 case UART_SELECTION_UART1: this->hw_serial_ = &Serial1; + libretiny::ensure_serial_handle(1); Serial1.begin(this->baud_rate_); break; #endif #if LT_HW_UART2 case UART_SELECTION_UART2: this->hw_serial_ = &Serial2; + libretiny::ensure_serial_handle(2); Serial2.begin(this->baud_rate_); break; #endif diff --git a/esphome/components/logger/task_log_buffer_esp32.cpp b/esphome/components/logger/task_log_buffer_esp32.cpp index cb97f5504f..75b567ff89 100644 --- a/esphome/components/logger/task_log_buffer_esp32.cpp +++ b/esphome/components/logger/task_log_buffer_esp32.cpp @@ -10,15 +10,7 @@ namespace esphome::logger { TaskLogBuffer::TaskLogBuffer() { // Create a static ring buffer with RINGBUF_TYPE_NOSPLIT for message integrity // Storage is a member array (embedded in Logger), no heap allocation needed - this->ring_buffer_ = - xRingbufferCreateStatic(sizeof(this->storage_), RINGBUF_TYPE_NOSPLIT, this->storage_, &this->structure_); -} - -TaskLogBuffer::~TaskLogBuffer() { - if (this->ring_buffer_ != nullptr) { - vRingbufferDelete(this->ring_buffer_); - this->ring_buffer_ = nullptr; - } + xRingbufferCreateStatic(sizeof(this->storage_), RINGBUF_TYPE_NOSPLIT, this->storage_, &this->structure_); } bool TaskLogBuffer::borrow_message_main_loop(LogMessage *&message, uint16_t &text_length) { @@ -27,7 +19,7 @@ bool TaskLogBuffer::borrow_message_main_loop(LogMessage *&message, uint16_t &tex } size_t item_size = 0; - void *received_item = xRingbufferReceive(ring_buffer_, &item_size, 0); + void *received_item = xRingbufferReceive(this->handle_(), &item_size, 0); if (received_item == nullptr) { return false; } @@ -44,7 +36,7 @@ void TaskLogBuffer::release_message_main_loop() { if (this->current_token_ == nullptr) { return; } - vRingbufferReturnItem(ring_buffer_, this->current_token_); + vRingbufferReturnItem(this->handle_(), this->current_token_); this->current_token_ = nullptr; // Update counter to mark all messages as processed last_processed_counter_ = message_counter_.load(std::memory_order_relaxed); @@ -71,7 +63,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin // Acquire memory directly from the ring buffer void *acquired_memory = nullptr; - BaseType_t result = xRingbufferSendAcquire(ring_buffer_, &acquired_memory, total_size, 0); + BaseType_t result = xRingbufferSendAcquire(this->handle_(), &acquired_memory, total_size, 0); if (result != pdTRUE || acquired_memory == nullptr) { return false; // Failed to acquire memory @@ -100,7 +92,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin // Handle unexpected formatting error if (ret <= 0) { - vRingbufferReturnItem(ring_buffer_, acquired_memory); + vRingbufferReturnItem(this->handle_(), acquired_memory); return false; } @@ -111,7 +103,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin msg->text_length = text_length; // Complete the send operation with the acquired memory - result = xRingbufferSendComplete(ring_buffer_, acquired_memory); + result = xRingbufferSendComplete(this->handle_(), acquired_memory); if (result != pdTRUE) { return false; // Failed to complete the message send diff --git a/esphome/components/logger/task_log_buffer_esp32.h b/esphome/components/logger/task_log_buffer_esp32.h index e819766795..0ffaa04493 100644 --- a/esphome/components/logger/task_log_buffer_esp32.h +++ b/esphome/components/logger/task_log_buffer_esp32.h @@ -47,7 +47,7 @@ class TaskLogBuffer { }; TaskLogBuffer(); - ~TaskLogBuffer(); + // No destructor: Logger is never destroyed // NOT thread-safe - borrow a message from the ring buffer, only call from main loop bool borrow_message_main_loop(LogMessage *&message, uint16_t &text_length); @@ -68,7 +68,11 @@ class TaskLogBuffer { static constexpr size_t size() { return ESPHOME_TASK_LOG_BUFFER_SIZE; } private: - RingbufHandle_t ring_buffer_{nullptr}; // FreeRTOS ring buffer handle + // xRingbufferCreateStatic() returns the static structure itself as the handle; it only + // returns NULL for a no-split size that is unaligned or under two item headers + static_assert(ESPHOME_TASK_LOG_BUFFER_SIZE % 4 == 0, "task_log_buffer_size must be a multiple of 4"); + RingbufHandle_t handle_() { return &this->structure_; } + StaticRingbuffer_t structure_; // Static structure for the ring buffer uint8_t storage_[ESPHOME_TASK_LOG_BUFFER_SIZE]; // Embedded in Logger (no separate heap allocation) diff --git a/esphome/components/lps22/sensor.py b/esphome/components/lps22/sensor.py index 2eec2c586c..f219639eee 100644 --- a/esphome/components/lps22/sensor.py +++ b/esphome/components/lps22/sensor.py @@ -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 temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - 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) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index ca9a317fec..66aaac5301 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -48,6 +48,7 @@ from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -641,7 +642,15 @@ LVGL_TOP_LEVEL_SCHEMA = ( ) +def _not_on_esp8266(config: ConfigType) -> ConfigType: + # ESP8266 does not have enough RAM for LVGL to be practical. + if CORE.is_esp8266: + raise cv.Invalid("LVGL is not supported on ESP8266") + return config + + LVGL_SCHEMA = cv.All( + _not_on_esp8266, container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index d6d1aaa414..feffac28b3 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -209,9 +209,6 @@ class LvContext(LambdaContext): def __init__(self): super().__init__(parameters=LVGL_COMP_ARG) - async def __aexit__(self, exc_type, exc_val, exc_tb): - await super().__aexit__(exc_type, exc_val, exc_tb) - def add(self, expression: Expression | Statement): cg.add(expression) return expression diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 90a770ac3b..466d400023 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -1037,7 +1037,7 @@ void lv_mem_init() {} void lv_mem_deinit() {} -#if defined(USE_HOST) || defined(USE_RP2) || defined(USE_ESP8266) +#ifndef USE_ESP32 void *lv_malloc_core(size_t size) { auto *ptr = malloc(size); // NOLINT if (ptr == nullptr) { @@ -1053,7 +1053,7 @@ static void *lv_alloc_draw_buf(size_t size, bool internal) { return malloc(size); // NOLINT } -#elif defined(USE_ESP32) +#else static unsigned cap_bits = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; // NOLINT static void *lv_alloc_draw_buf(size_t size, bool internal) { diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 2e43eaf7e2..f9b744daf0 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -27,6 +27,10 @@ CONF_HAS_PULLDOWNS = "has_pulldowns" def check_keys(obj: ConfigType) -> ConfigType: + for ch in obj.get(CONF_KEYS, ""): + if not ch.isascii(): + # Each key is reported as one byte, so only ASCII characters can be key codes + raise cv.Invalid(f"Key code {ch!r} is not an ASCII character") if CONF_KEYS in obj and len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len( obj[CONF_COLUMNS] ): @@ -74,8 +78,11 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(conf[CONF_PIN]) col_pins.append(pin) cg.add(var.set_columns(col_pins)) - if CONF_KEYS in config: - cg.add(var.set_keys(config[CONF_KEYS])) + if (keys := config.get(CONF_KEYS)) is not None: + table = cg.shared_progmem_array( + "matrix_keypad_keys", cg.uint8, list(keys.encode()) + ) + cg.add(var.set_keys(table)) cg.add(var.set_debounce_time(config[CONF_DEBOUNCE_TIME])) if CONF_HAS_DIODES in config: cg.add(var.set_has_diodes(config[CONF_HAS_DIODES])) diff --git a/esphome/components/matrix_keypad/matrix_keypad.cpp b/esphome/components/matrix_keypad/matrix_keypad.cpp index 3b71b50fd8..97641ac2d3 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -58,8 +58,8 @@ void MatrixKeypad::loop() { ESP_LOGD(TAG, "key @ row %d, col %d released", row, col); for (auto &listener : this->listeners_) listener->button_released(row, col); - if (this->pressed_key_ < (int) this->keys_.size()) { - uint8_t keycode = this->keys_[this->pressed_key_]; + if (this->keys_ != nullptr) { + uint8_t keycode = progmem_read_byte(&this->keys_[this->pressed_key_]); ESP_LOGD(TAG, "key '%c' released", keycode); for (auto &listener : this->listeners_) listener->key_released(keycode); @@ -81,8 +81,8 @@ void MatrixKeypad::loop() { ESP_LOGD(TAG, "key @ row %d, col %d pressed", row, col); for (auto &listener : this->listeners_) listener->button_pressed(row, col); - if (key < (int) this->keys_.size()) { - uint8_t keycode = this->keys_[key]; + if (this->keys_ != nullptr) { + uint8_t keycode = progmem_read_byte(&this->keys_[key]); ESP_LOGD(TAG, "key '%c' pressed", keycode); for (auto &trigger : this->key_triggers_) trigger->trigger(keycode); diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 8c9acc8e0c..2384691e32 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -27,7 +27,8 @@ class MatrixKeypad final : public key_provider::KeyProvider, public Component { void dump_config() override; void set_columns(std::vector pins) { columns_ = std::move(pins); }; void set_rows(std::vector pins) { rows_ = std::move(pins); }; - void set_keys(std::string keys) { keys_ = std::move(keys); }; + /// `keys` is a codegen PROGMEM table with one key code per button. + void set_keys(const uint8_t *keys) { this->keys_ = keys; } void set_debounce_time(uint32_t debounce_time) { debounce_time_ = debounce_time; }; void set_has_diodes(bool has_diodes) { has_diodes_ = has_diodes; }; void set_has_pulldowns(bool has_pulldowns) { has_pulldowns_ = has_pulldowns; }; @@ -38,7 +39,7 @@ class MatrixKeypad final : public key_provider::KeyProvider, public Component { protected: std::vector rows_; std::vector columns_; - std::string keys_; + const uint8_t *keys_{nullptr}; uint32_t debounce_time_ = 0; bool has_diodes_{false}; bool has_pulldowns_{false}; diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 0b72955c74..1b621057c6 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -53,13 +53,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_remaining_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_remaining_sensor) MAX17043_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/max31855/sensor.py b/esphome/components/max31855/sensor.py index a52f45a18f..3d2a83f8cd 100644 --- a/esphome/components/max31855/sensor.py +++ b/esphome/components/max31855/sensor.py @@ -41,6 +41,5 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await spi.register_spi_device(var, config) - if CONF_REFERENCE_TEMPERATURE in config: - tc_ref = await sensor.new_sensor(config[CONF_REFERENCE_TEMPERATURE]) - cg.add(var.set_reference_sensor(tc_ref)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_REFERENCE_TEMPERATURE, var.set_reference_sensor) diff --git a/esphome/components/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 5542ffaa6c..75f2e025c7 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -71,12 +71,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_thermocouple_type(config[CONF_THERMOCOUPLE_TYPE])) - if CONF_HOT_JUNCTION in config: - conf = config[CONF_HOT_JUNCTION] - sens = await sensor.new_sensor(conf) - cg.add(var.set_hot_junction(sens)) - - if CONF_COLD_JUNCTION in config: - conf = config[CONF_COLD_JUNCTION] - sens = await sensor.new_sensor(conf) - cg.add(var.set_cold_junction(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_HOT_JUNCTION, var.set_hot_junction) + await sensors(CONF_COLD_JUNCTION, var.set_cold_junction) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 9b0eeeb28e..053f17765e 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -77,13 +77,9 @@ 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)) - - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) if ( automatic_baseline_calibration := config.get( diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index d7a3352432..770399fef5 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -34,7 +34,7 @@ class MicrophoneSource final { */ public: MicrophoneSource(Microphone *mic, uint8_t bits_per_sample, int32_t gain_factor, bool passive) - : mic_(mic), bits_per_sample_(bits_per_sample), gain_factor_(gain_factor), passive_(passive) {} + : mic_(mic), gain_factor_(gain_factor), passive_(passive), bits_per_sample_(bits_per_sample) {} /// @brief Enables a channel to be processed through the callback. /// @@ -81,11 +81,11 @@ class MicrophoneSource final { std::shared_ptr> processed_samples_; Microphone *mic_; - uint8_t bits_per_sample_; std::bitset<8> channels_; int32_t gain_factor_; bool enabled_{false}; bool passive_; // Only pass audio if ``mic_`` is already running + uint8_t bits_per_sample_; }; } // namespace esphome::microphone diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 50b84b630d..526afa914b 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -648,7 +648,10 @@ class DriverChip: sequence.append((INVON,)) else: sequence.append((INVOFF,)) - if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): + brightness = config.get( + CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS, None) + ) + if brightness is not None: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index b23982655a..6bf94bf364 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -201,7 +201,8 @@ async def to_code(config: ConfigType) -> None: sequence = model.get_sequence(config) cg.add(var.set_model(config[CONF_MODEL])) - cg.add(var.set_init_sequence(sequence)) + table = cg.shared_progmem_array("mipi_dsi_init_sequence", cg.uint8, sequence) + cg.add(var.set_init_sequence(table, len(sequence))) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) cg.add(var.set_hsync_pulse_width(config[CONF_HSYNC_PULSE_WIDTH])) cg.add(var.set_hsync_back_porch(config[CONF_HSYNC_BACK_PORCH])) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index b6612038b6..a27be568f2 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -121,20 +121,21 @@ void MipiDsi::setup() { return; } size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = seq[index++]; + uint8_t x = seq[index++]; if (x == DELAY_FLAG) { ESP_LOGD(TAG, "Delay %dms", cmd); delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } @@ -145,7 +146,7 @@ void MipiDsi::setup() { delay(duration); } } - const auto *ptr = vec.data() + index; + const auto *ptr = seq + index; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE char hex_buf[format_hex_pretty_size(MIPI_DSI_MAX_CMD_LOG_BYTES)]; #endif diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 7bf2feb73c..4243d3116c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -55,7 +55,10 @@ class MipiDsi final : public display::Display { void set_vsync_pulse_width(uint16_t vsync_pulse_width) { this->vsync_pulse_width_ = vsync_pulse_width; } void set_vsync_back_porch(uint16_t vsync_back_porch) { this->vsync_back_porch_ = vsync_back_porch; } void set_vsync_front_porch(uint16_t vsync_front_porch) { this->vsync_front_porch_ = vsync_front_porch; } - void set_init_sequence(const std::vector &init_sequence) { this->init_sequence_ = init_sequence; } + void set_init_sequence(const uint8_t *init_sequence, size_t len) { + this->init_sequence_ = init_sequence; + this->init_sequence_len_ = len; + } void set_model(const char *model) { this->model_ = model; } void set_lane_bit_rate(float lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; } void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } @@ -91,7 +94,9 @@ class MipiDsi final : public display::Display { uint16_t vsync_back_porch_ = 10; uint16_t vsync_front_porch_ = 10; const char *model_{"Unknown"}; - std::vector init_sequence_{}; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; float pclk_frequency_ = 16; // in MHz float lane_bit_rate_{1500}; // in Mbps uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 9cba56a976..71234c2f25 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -286,7 +286,8 @@ async def to_code(config: ConfigType) -> None: if CONF_SPI_ID in config: await spi.register_spi_device(var, config, write_only=True) sequence = model.get_sequence(config, add_reset=True) - cg.add(var.set_init_sequence(sequence)) + table = cg.shared_progmem_array("mipi_rgb_init_sequence", cg.uint8, sequence) + cg.add(var.set_init_sequence(table, len(sequence))) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 034efb9c92..4928b6f667 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -78,27 +78,28 @@ void MipiRgbSpi::write_data_(uint8_t value) { void MipiRgbSpi::write_init_sequence_() { size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = seq[index++]; + uint8_t x = seq[index++]; if (x == DELAY_FLAG) { ESP_LOGD(TAG, "Delay %dms", cmd); delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } if (cmd == SLEEP_OUT) { delay(120); // NOLINT } - const auto *ptr = vec.data() + index; + const auto *ptr = seq + index; char hex_buf[format_hex_pretty_size(MIPI_RGB_MAX_CMD_LOG_BYTES)]; ESP_LOGD(TAG, "Write command %02X, length %d, byte(s) %s", cmd, num_args, format_hex_pretty_to(hex_buf, ptr, num_args, '.')); @@ -111,7 +112,6 @@ void MipiRgbSpi::write_init_sequence_() { } } // this->spi_teardown(); // SPI not needed after this - this->init_sequence_.clear(); delay(10); } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index f528943c1b..8b29e273ab 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -109,7 +109,10 @@ class MipiRgbSpi final : public MipiRgb, public: MipiRgbSpi(int width, int height) : MipiRgb(width, height) {} - void set_init_sequence(const std::vector &init_sequence) { this->init_sequence_ = init_sequence; } + void set_init_sequence(const uint8_t *init_sequence, size_t len) { + this->init_sequence_ = init_sequence; + this->init_sequence_len_ = len; + } void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } void setup() override; @@ -120,7 +123,9 @@ class MipiRgbSpi final : public MipiRgb, void dump_config() override; GPIOPin *dc_pin_{nullptr}; - std::vector init_sequence_; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; }; #endif diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index e8b54da5c7..ff49979a24 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -225,10 +225,10 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema: ) .extend({model.option(x): cv.boolean for x in other_options}) ) - if brightness := model.get_default(CONF_BRIGHTNESS): + if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM": schema = schema.extend( { - cv.Optional(CONF_BRIGHTNESS, default=brightness): cv.int_range( + model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range( 0, 0xFF, min_included=True, max_included=True ), } @@ -401,7 +401,8 @@ async def to_code(config: ConfigType) -> None: init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) - cg.add(var.set_init_sequence(init_sequence)) + table = cg.shared_progmem_array("mipi_spi_init_sequence", cg.uint8, init_sequence) + cg.add(var.set_init_sequence(table, len(init_sequence))) cg.add(var.set_model(config[CONF_MODEL])) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] diff --git a/esphome/components/mipi_spi/light/__init__.py b/esphome/components/mipi_spi/light/__init__.py new file mode 100644 index 0000000000..aec5d9b4fd --- /dev/null +++ b/esphome/components/mipi_spi/light/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_DISPLAY_ID, + CONF_GAMMA_CORRECT, + CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, + CONF_MODEL, + CONF_OUTPUT_ID, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from ..display import MipiSpi, mipi_spi_ns + +MipiSpiLight = mipi_spi_ns.class_("MipiSpiLight", light.LightOutput) + + +def _validate_brightness_range(config: ConfigType) -> ConfigType: + if config[CONF_MIN_BRIGHTNESS] >= config[CONF_MAX_BRIGHTNESS]: + raise cv.Invalid( + f"'{CONF_MIN_BRIGHTNESS}' must be less than '{CONF_MAX_BRIGHTNESS}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight), + cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi), + cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, + cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255), + cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255), + } + ), + _validate_brightness_range, +) + + +def _final_validate(config: ConfigType) -> None: + full_config = fv.full_config.get() + display_path = full_config.get_path_for_id(config[CONF_DISPLAY_ID])[:-1] + display_config = full_config.get_config_for_path(display_path) + if CONF_BRIGHTNESS not in display_config: + model = display_config[CONF_MODEL] + if model == "CUSTOM": + raise cv.Invalid( + f"The '{CONF_BRIGHTNESS}' option must be set in the display config" + ) + raise cv.Invalid( + f"Display model {model} does not support setting brightness with an SPI command" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_MIN_BRIGHTNESS], + config[CONF_MAX_BRIGHTNESS], + ) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_DISPLAY_ID]) diff --git a/esphome/components/mipi_spi/light/mipi_spi_light.h b/esphome/components/mipi_spi/light/mipi_spi_light.h new file mode 100644 index 0000000000..30bbdcd8ee --- /dev/null +++ b/esphome/components/mipi_spi/light/mipi_spi_light.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/mipi_spi/mipi_spi.h" +#include "esphome/core/helpers.h" + +namespace esphome::mipi_spi { + +// Brightness-only light that sets the display brightness with an SPI command. +// Brightness is scaled into [min_brightness, max_brightness]; off writes min_brightness. +class MipiSpiLight final : public light::LightOutput, public Parented { + public: + MipiSpiLight(uint8_t min_brightness, uint8_t max_brightness) + : min_brightness_(min_brightness), max_brightness_(max_brightness) {} + + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_brightness( + this->min_brightness_ + + static_cast(roundf(brightness * (this->max_brightness_ - this->min_brightness_)))); + } + + protected: + uint8_t min_brightness_; + uint8_t max_brightness_; +}; + +} // namespace esphome::mipi_spi diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index e97b0e3a31..48a2cafbdd 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -5,6 +5,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" namespace esphome::mipi_spi { @@ -71,6 +72,12 @@ void internal_dump_config(const char *model, int width, int height, int offset_w GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, bool has_hardware_rotation); +// Lets a light set the display brightness without knowing the display's template parameters. +class MipiSpiBrightness { + public: + virtual void set_brightness(uint8_t brightness) = 0; +}; + /** * Base class for MIPI SPI displays. * All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file. @@ -93,6 +100,7 @@ template class MipiSpi : public display::Display, + public MipiSpiBrightness, public spi::SPIDevice { public: @@ -107,9 +115,11 @@ class MipiSpi : public display::Display, this->invert_colors_ = invert_colors; this->reset_params_(); } - void set_brightness(uint8_t brightness) { + void set_brightness(uint8_t brightness) override { this->brightness_ = brightness; - this->reset_params_(); + // Before setup the stored value is applied by reset_params_() + if (this->is_ready()) + this->write_command_(BRIGHTNESS, brightness); } void set_rotation(display::DisplayRotation rotation) override { this->rotation_ = rotation; @@ -133,7 +143,10 @@ class MipiSpi : public display::Display, return HEIGHT; } - void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } + void set_init_sequence(const uint8_t *sequence, size_t len) { + this->init_sequence_ = sequence; + this->init_sequence_len_ = len; + } // reset the display, and write the init sequence void setup() override { @@ -159,15 +172,16 @@ class MipiSpi : public display::Display, // need to know when the display is ready for SLPOUT command - will be 120ms after reset auto when = millis() + 120; size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { esph_log_e(TAG, "Malformed init sequence"); this->mark_failed(); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = progmem_read_byte(seq + index++); + uint8_t x = progmem_read_byte(seq + index++); if (x == DELAY_FLAG) { if (cmd == 0) { cmd = clamp_at_least((int) (when - millis()), 0); @@ -176,19 +190,21 @@ class MipiSpi : public display::Display, delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { esph_log_e(TAG, "Malformed init sequence"); this->mark_failed(); return; } - const auto *ptr = vec.data() + index; - this->write_command_(cmd, ptr, num_args); + // The sequence is in flash, which SPI DMA (and ESP8266 byte loads) cannot read + uint8_t args[0x80]; + progmem_memcpy(args, seq + index, num_args); + this->write_command_(cmd, args, num_args); index += num_args; } } this->reset_params_(); - // init sequence no longer needed - this->init_sequence_.clear(); + // Marks init as done, so later commands log at verbose level instead of debug + this->init_sequence_len_ = 0; } // Drawing operations @@ -236,7 +252,7 @@ class MipiSpi : public display::Display, void write_command_(uint8_t cmd, const uint8_t *bytes, size_t len) { char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)]; // Don't spam the log after setup - if (this->init_sequence_.empty()) { + if (this->init_sequence_len_ == 0) { esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); } else { esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); @@ -486,7 +502,9 @@ class MipiSpi : public display::Display, bool invert_colors_{}; optional brightness_{}; const char *model_{"Unknown"}; - std::vector init_sequence_{}; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; }; /** diff --git a/esphome/components/mipi_spi/models/spd2010.py b/esphome/components/mipi_spi/models/spd2010.py new file mode 100644 index 0000000000..b5893f179a --- /dev/null +++ b/esphome/components/mipi_spi/models/spd2010.py @@ -0,0 +1,418 @@ +# SPDX-FileCopyrightText: 2023 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +# Adapted from esp_lcd_spd2010 2.0.0~1 for ESPHome. +# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_spd2010 + +from esphome.components.mipi import MODE_RGB, DriverChip, delay +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y + +# fmt: off +DriverChip( + "WAVESHARE-ESP32-S3-TOUCH-LCD-1.46", + width=412, + height=412, + bus_mode=TYPE_QUAD, + spi_mode="MODE0", + data_rate="40MHz", + cs_pin=21, + reset_pin={"pca9554": None, "number": 1}, + color_order=MODE_RGB, + draw_rounding=4, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, + use_axis_flips=True, + reset_delay=20, + requires={"pca9554"}, + initsequence=( + delay(100), + (0xFF, 0x20, 0x10, 0x10), + (0x0C, 0x11), + (0x10, 0x02), + (0x11, 0x11), + (0x15, 0x42), + (0x16, 0x11), + (0x1A, 0x02), + (0x1B, 0x11), + (0x61, 0x80), + (0x62, 0x80), + (0x54, 0x44), + (0x58, 0x88), + (0x5C, 0xCC), + (0x20, 0x80), + (0x21, 0x81), + (0x22, 0x31), + (0x23, 0x20), + (0x24, 0x11), + (0x25, 0x11), + (0x26, 0x12), + (0x27, 0x12), + (0x30, 0x80), + (0x31, 0x81), + (0x32, 0x31), + (0x33, 0x20), + (0x34, 0x11), + (0x35, 0x11), + (0x36, 0x12), + (0x37, 0x12), + (0x41, 0x11), + (0x42, 0x22), + (0x43, 0x33), + (0x49, 0x11), + (0x4A, 0x22), + (0x4B, 0x33), + (0xFF, 0x20, 0x10, 0x15), + (0x00, 0x00), + (0x01, 0x00), + (0x02, 0x00), + (0x03, 0x00), + (0x04, 0x10), + (0x05, 0x0C), + (0x06, 0x23), + (0x07, 0x22), + (0x08, 0x21), + (0x09, 0x20), + (0x0A, 0x33), + (0x0B, 0x32), + (0x0C, 0x34), + (0x0D, 0x35), + (0x0E, 0x01), + (0x0F, 0x01), + (0x20, 0x00), + (0x21, 0x00), + (0x22, 0x00), + (0x23, 0x00), + (0x24, 0x0C), + (0x25, 0x10), + (0x26, 0x20), + (0x27, 0x21), + (0x28, 0x22), + (0x29, 0x23), + (0x2A, 0x33), + (0x2B, 0x32), + (0x2C, 0x34), + (0x2D, 0x35), + (0x2E, 0x01), + (0x2F, 0x01), + (0xFF, 0x20, 0x10, 0x16), + (0x00, 0x00), + (0x01, 0x00), + (0x02, 0x00), + (0x03, 0x00), + (0x04, 0x08), + (0x05, 0x04), + (0x06, 0x19), + (0x07, 0x18), + (0x08, 0x17), + (0x09, 0x16), + (0x0A, 0x33), + (0x0B, 0x32), + (0x0C, 0x34), + (0x0D, 0x35), + (0x0E, 0x01), + (0x0F, 0x01), + (0x20, 0x00), + (0x21, 0x00), + (0x22, 0x00), + (0x23, 0x00), + (0x24, 0x04), + (0x25, 0x08), + (0x26, 0x16), + (0x27, 0x17), + (0x28, 0x18), + (0x29, 0x19), + (0x2A, 0x33), + (0x2B, 0x32), + (0x2C, 0x34), + (0x2D, 0x35), + (0x2E, 0x01), + (0x2F, 0x01), + (0xFF, 0x20, 0x10, 0x12), + (0x00, 0x99), + (0x2A, 0x28), + (0x2B, 0x0F), + (0x2C, 0x16), + (0x2D, 0x28), + (0x2E, 0x0F), + (0xFF, 0x20, 0x10, 0xA0), + (0x08, 0xDC), + (0xFF, 0x20, 0x10, 0x45), + (0x01, 0x9C), + (0x03, 0x9C), + (0xFF, 0x20, 0x10, 0x42), + (0x05, 0x2C), + (0xFF, 0x20, 0x10, 0x11), + (0x50, 0x01), + (0xFF, 0x20, 0x10, 0x40), + (0x86, 0x00), + (0xFF, 0x20, 0x10, 0x12), + (0x0D, 0x66), + (0xFF, 0x20, 0x10, 0x17), + (0x39, 0x3C), + (0xFF, 0x20, 0x10, 0x31), + (0x38, 0x03), + (0x39, 0xF0), + (0x36, 0x03), + (0x37, 0xE8), + (0x34, 0x03), + (0x35, 0xCF), + (0x32, 0x03), + (0x33, 0xBA), + (0x30, 0x03), + (0x31, 0xA2), + (0x2E, 0x03), + (0x2F, 0x95), + (0x2C, 0x03), + (0x2D, 0x7E), + (0x2A, 0x03), + (0x2B, 0x62), + (0x28, 0x03), + (0x29, 0x44), + (0x26, 0x02), + (0x27, 0xFC), + (0x24, 0x02), + (0x25, 0xD0), + (0x22, 0x02), + (0x23, 0x98), + (0x20, 0x02), + (0x21, 0x6F), + (0x1E, 0x02), + (0x1F, 0x32), + (0x1C, 0x01), + (0x1D, 0xF6), + (0x1A, 0x01), + (0x1B, 0xB8), + (0x18, 0x01), + (0x19, 0x6E), + (0x16, 0x01), + (0x17, 0x41), + (0x14, 0x00), + (0x15, 0xFD), + (0x12, 0x00), + (0x13, 0xCF), + (0x10, 0x00), + (0x11, 0x98), + (0x0E, 0x00), + (0x0F, 0x89), + (0x0C, 0x00), + (0x0D, 0x79), + (0x0A, 0x00), + (0x0B, 0x67), + (0x08, 0x00), + (0x09, 0x55), + (0x06, 0x00), + (0x07, 0x3F), + (0x04, 0x00), + (0x05, 0x28), + (0x02, 0x00), + (0x03, 0x0E), + (0xFF, 0x20, 0x10, 0x32), + (0x38, 0x03), + (0x39, 0xF0), + (0x36, 0x03), + (0x37, 0xE8), + (0x34, 0x03), + (0x35, 0xCF), + (0x32, 0x03), + (0x33, 0xBA), + (0x30, 0x03), + (0x31, 0xA2), + (0x2E, 0x03), + (0x2F, 0x95), + (0x2C, 0x03), + (0x2D, 0x7E), + (0x2A, 0x03), + (0x2B, 0x62), + (0x28, 0x03), + (0x29, 0x44), + (0x26, 0x02), + (0x27, 0xFC), + (0x24, 0x02), + (0x25, 0xD0), + (0x22, 0x02), + (0x23, 0x98), + (0x20, 0x02), + (0x21, 0x6F), + (0x1E, 0x02), + (0x1F, 0x32), + (0x1C, 0x01), + (0x1D, 0xF6), + (0x1A, 0x01), + (0x1B, 0xB8), + (0x18, 0x01), + (0x19, 0x6E), + (0x16, 0x01), + (0x17, 0x41), + (0x14, 0x00), + (0x15, 0xFD), + (0x12, 0x00), + (0x13, 0xCF), + (0x10, 0x00), + (0x11, 0x98), + (0x0E, 0x00), + (0x0F, 0x89), + (0x0C, 0x00), + (0x0D, 0x79), + (0x0A, 0x00), + (0x0B, 0x67), + (0x08, 0x00), + (0x09, 0x55), + (0x06, 0x00), + (0x07, 0x3F), + (0x04, 0x00), + (0x05, 0x28), + (0x02, 0x00), + (0x03, 0x0E), + (0xFF, 0x20, 0x10, 0x11), + (0x60, 0x01), + (0x65, 0x03), + (0x66, 0x38), + (0x67, 0x04), + (0x68, 0x34), + (0x69, 0x03), + (0x61, 0x03), + (0x62, 0x38), + (0x63, 0x04), + (0x64, 0x34), + (0x0A, 0x11), + (0x0B, 0x20), + (0x0C, 0x20), + (0x55, 0x06), + (0xFF, 0x20, 0x10, 0x42), + (0x05, 0x3D), + (0x06, 0x03), + (0xFF, 0x20, 0x10, 0x12), + (0x1F, 0xDC), + (0xFF, 0x20, 0x10, 0x17), + (0x11, 0xAA), + (0x16, 0x12), + (0x0B, 0xC3), + (0x10, 0x0E), + (0x14, 0xAA), + (0x18, 0xA0), + (0x1A, 0x80), + (0x1F, 0x80), + (0xFF, 0x20, 0x10, 0x11), + (0x30, 0xEE), + (0xFF, 0x20, 0x10, 0x12), + (0x15, 0x0F), + (0xFF, 0x20, 0x10, 0x2D), + (0x01, 0x3E), + (0xFF, 0x20, 0x10, 0x40), + (0x83, 0xC4), + (0xFF, 0x20, 0x10, 0x12), + (0x00, 0xCC), + (0x36, 0xA0), + (0x2A, 0x2D), + (0x2B, 0x1E), + (0x2C, 0x26), + (0x2D, 0x2D), + (0x2E, 0x1E), + (0x1F, 0xE6), + (0xFF, 0x20, 0x10, 0xA0), + (0x08, 0xE6), + (0xFF, 0x20, 0x10, 0x12), + (0x10, 0x0F), + (0xFF, 0x20, 0x10, 0x18), + (0x01, 0x01), + (0x00, 0x1E), + (0xFF, 0x20, 0x10, 0x43), + (0x03, 0x04), + (0xFF, 0x20, 0x10, 0x18), + (0x3A, 0x01), + (0xFF, 0x20, 0x10, 0x50), + (0x05, 0x08), + (0xFF, 0x20, 0x10, 0x50), + (0x00, 0xA6), + (0x01, 0xA6), + (0xFF, 0x20, 0x10, 0x50), + (0x08, 0x55), + (0xFF, 0x20, 0x10, 0x10), + (0x0B, 0x43), + (0x0C, 0x12), + (0x10, 0x01), + (0x11, 0x12), + (0x15, 0x00), + (0x16, 0x00), + (0x1A, 0x00), + (0x1B, 0x00), + (0x61, 0x00), + (0x62, 0x00), + (0x51, 0x11), + (0x55, 0x55), + (0x58, 0x00), + (0x5C, 0x00), + (0xFF, 0x20, 0x10, 0x10), + (0x20, 0x81), + (0x21, 0x82), + (0x22, 0x72), + (0x30, 0x00), + (0x31, 0x00), + (0x32, 0x00), + (0xFF, 0x20, 0x10, 0x10), + (0x44, 0x44), + (0x45, 0x55), + (0x46, 0x66), + (0x47, 0x77), + (0x49, 0x00), + (0x4A, 0x00), + (0x4B, 0x00), + (0xFF, 0x20, 0x10, 0x17), + (0x37, 0x00), + (0xFF, 0x20, 0x10, 0x15), + (0x04, 0x08), + (0x05, 0x04), + (0x06, 0x1C), + (0x07, 0x1A), + (0x08, 0x18), + (0x09, 0x16), + (0x24, 0x05), + (0x25, 0x09), + (0x26, 0x17), + (0x27, 0x19), + (0x28, 0x1B), + (0x29, 0x1D), + (0xFF, 0x20, 0x10, 0x16), + (0x04, 0x09), + (0x05, 0x05), + (0x06, 0x1D), + (0x07, 0x1B), + (0x08, 0x19), + (0x09, 0x17), + (0x24, 0x04), + (0x25, 0x08), + (0x26, 0x16), + (0x27, 0x18), + (0x28, 0x1A), + (0x29, 0x1C), + (0xFF, 0x20, 0x10, 0x18), + (0x1F, 0x02), + (0xFF, 0x20, 0x10, 0x11), + (0x15, 0x99), + (0x16, 0x99), + (0x1C, 0x88), + (0x1D, 0x88), + (0x1E, 0x88), + (0x13, 0xF0), + (0x14, 0x34), + (0xFF, 0x20, 0x10, 0x12), + (0x12, 0x89), + (0x06, 0x06), + (0x18, 0x00), + (0xFF, 0x20, 0x10, 0x11), + (0x0A, 0x00), + (0x0B, 0xF0), + (0x0C, 0xF0), + (0x6A, 0x10), + (0xFF, 0x20, 0x10, 0x11), + (0x08, 0x70), + (0x09, 0x00), + (0xFF, 0x20, 0x10, 0x00), + (0x35, 0x00), + (0xFF, 0x20, 0x10, 0x12), + (0x21, 0x70), + (0xFF, 0x20, 0x10, 0x2D), + (0x02, 0x00), + (0xFF, 0x20, 0x10, 0x00), + ), +) +# fmt: on diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 494443d695..04b0cb94f4 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -107,12 +107,12 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool stop_gracefully_{false}; bool pause_state_{false}; + std::atomic has_contributed_{false}; // Tracks if source has contributed during this session esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing - std::atomic has_contributed_{false}; // Tracks if source has contributed during this session EventGroupHandle_t event_group_{nullptr}; uint32_t stopping_start_ms_{0}; diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index 59bdffc114..4ff42c7d9c 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -139,21 +139,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_temperature_compensation(config[CONF_TEMPERATURE_COMPENSATION])) cg.add(var.set_hallconf(config[CONF_HALLCONF])) - if CONF_X_AXIS in config: - sens = await sensor.new_sensor(config[CONF_X_AXIS]) - cg.add(var.set_x_sensor(sens)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_X_AXIS, var.set_x_sensor): cg.add(var.set_resolution(0, RESOLUTION[config[CONF_X_AXIS][CONF_RESOLUTION]])) - if CONF_Y_AXIS in config: - sens = await sensor.new_sensor(config[CONF_Y_AXIS]) - cg.add(var.set_y_sensor(sens)) + if await sensors(CONF_Y_AXIS, var.set_y_sensor): cg.add(var.set_resolution(1, RESOLUTION[config[CONF_Y_AXIS][CONF_RESOLUTION]])) - if CONF_Z_AXIS in config: - sens = await sensor.new_sensor(config[CONF_Z_AXIS]) - cg.add(var.set_z_sensor(sens)) + if await sensors(CONF_Z_AXIS, var.set_z_sensor): cg.add(var.set_resolution(2, RESOLUTION[config[CONF_Z_AXIS][CONF_RESOLUTION]])) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_t_sensor(sens)) + if await sensors(CONF_TEMPERATURE, var.set_t_sensor): cg.add(var.set_t_oversampling(config[CONF_TEMPERATURE][CONF_OVERSAMPLING])) if CONF_DRDY_PIN in config: pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) diff --git a/esphome/components/mlx90614/sensor.py b/esphome/components/mlx90614/sensor.py index 0cf9b95dde..e818ea708d 100644 --- a/esphome/components/mlx90614/sensor.py +++ b/esphome/components/mlx90614/sensor.py @@ -53,12 +53,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_AMBIENT in config: - sens = await sensor.new_sensor(config[CONF_AMBIENT]) - cg.add(var.set_ambient_sensor(sens)) - - if CONF_OBJECT in config: - sens = await sensor.new_sensor(config[CONF_OBJECT]) - cg.add(var.set_object_sensor(sens)) - + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT, var.set_ambient_sensor) + if await sensors(CONF_OBJECT, var.set_object_sensor): cg.add(var.set_emissivity(config[CONF_OBJECT][CONF_EMISSIVITY])) diff --git a/esphome/components/mmc5983/sensor.py b/esphome/components/mmc5983/sensor.py index 797181690f..48c532b0c6 100644 --- a/esphome/components/mmc5983/sensor.py +++ b/esphome/components/mmc5983/sensor.py @@ -45,12 +45,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if x_config := config.get(CONF_FIELD_STRENGTH_X): - sens = await sensor.new_sensor(x_config) - cg.add(var.set_x_sensor(sens)) - if y_config := config.get(CONF_FIELD_STRENGTH_Y): - sens = await sensor.new_sensor(y_config) - cg.add(var.set_y_sensor(sens)) - if z_config := config.get(CONF_FIELD_STRENGTH_Z): - sens = await sensor.new_sensor(z_config) - cg.add(var.set_z_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) + await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor) + await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index fe93758726..cad2781cc1 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -7,6 +7,7 @@ from typing import Any, Literal, NamedTuple from esphome import pins import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_ROLE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj @@ -45,13 +46,18 @@ MULTI_CONF = True CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" -CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The client hub takes these times as 16-bit milliseconds; a Modbus timeout is far shorter anyway. +_HUB_TIME_PERIOD = cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(milliseconds=65535)), +) + # The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 # (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. @@ -273,12 +279,8 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusClient), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - cv.Optional( - CONF_SEND_WAIT_TIME, default="2000ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TURNAROUND_TIME, default="600ms" - ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_SEND_WAIT_TIME, default="2000ms"): _HUB_TIME_PERIOD, + cv.Optional(CONF_TURNAROUND_TIME, default="600ms"): _HUB_TIME_PERIOD, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py index 63a104f1a7..741996cbb8 100644 --- a/esphome/components/motion/__init__.py +++ b/esphome/components/motion/__init__.py @@ -1,13 +1,18 @@ from collections.abc import Callable +import logging import re from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS +from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS, CONF_UPDATE_INTERVAL +from esphome.core import ID from esphome.cpp_generator import MockObj, MockObjClass +import esphome.final_validate as fv from esphome.helpers import fnv1_hash_object_id +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] DOMAIN = "motion" @@ -37,6 +42,48 @@ SENSOR_SCHEMA = cv.Schema( _AXIS_REGEX = re.compile(r"^[+-]?[xyz]$", re.IGNORECASE) +# Fast motion patterns (shakes, free-fall, sudden movement) need frequent samples +# to be detected reliably; a slower parent update_interval makes them likely to be +# missed between polls. +MAX_RECOMMENDED_UPDATE_INTERVAL_MS = 100 + + +def get_motion_config(motion_id: ID) -> dict: + """Look up the (already-validated) config of the motion hub referenced by motion_id.""" + full_config = fv.full_config.get() + motion_path = full_config.get_path_for_id(motion_id)[:-1] + return full_config.get_config_for_path(motion_path) + + +def check_update_interval(motion_id: ID, feature_name: str) -> None: + """Warn if the parent motion component polls too slowly for fast motion detection.""" + motion_config = get_motion_config(motion_id) + update_interval = motion_config[CONF_UPDATE_INTERVAL] + if update_interval.total_milliseconds > MAX_RECOMMENDED_UPDATE_INTERVAL_MS: + _LOGGER.warning( + "Motion component '%s' has update_interval %s, but %s detection " + "works best with an update_interval of %dms or less.", + motion_id, + update_interval, + feature_name, + MAX_RECOMMENDED_UPDATE_INTERVAL_MS, + ) + + +def check_has_accelerometer( + motion_id: ID, feature_name: str, path: list[str] | None = None +) -> None: + """Raise if the parent motion device does not measure acceleration. + + `path` locates the error within the calling entity's config. + """ + motion_config = get_motion_config(motion_id) + if not motion_config.get(KEY_ACCELEROMETER, False): + raise cv.Invalid( + f"The motion device does not measure acceleration, required for {feature_name}", + path=path, + ) + def _axis_map(config: dict) -> dict: errors = [] diff --git a/esphome/components/motion/binary_sensor/__init__.py b/esphome/components/motion/binary_sensor/__init__.py new file mode 100644 index 0000000000..0a3876c8ab --- /dev/null +++ b/esphome/components/motion/binary_sensor/__init__.py @@ -0,0 +1,120 @@ +from collections.abc import Callable +import math +from typing import Any + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_ID, CONF_THRESHOLD, CONF_TYPE +from esphome.types import ConfigType + +from .. import ( + CONF_MOTION_ID, + MotionComponent, + check_has_accelerometer, + check_update_interval, + motion_ns, +) + +DEPENDENCIES = ["motion"] + +MotionBinarySensor = motion_ns.class_( + "MotionBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +MotionBinarySensorType = motion_ns.enum("MotionBinarySensorType") + +SENSOR_TYPES = { + "face_up": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_UP, + "face_down": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_DOWN, + "free_fall": MotionBinarySensorType.MOTION_BINARY_SENSOR_FREE_FALL, + "moving": MotionBinarySensorType.MOTION_BINARY_SENSOR_MOVING, +} + +# face_up / face_down configure their threshold as a maximum tilt angle in degrees; +# the C++ side compares against the cosine of that angle. +ANGLE_THRESHOLD_TYPES = ("face_up", "face_down") + + +def _binary_sensor_schema( + default_threshold: float, + threshold_validator: Callable[[Any], Any], + default_duration: str | None = None, +) -> cv.Schema: + schema = ( + binary_sensor.binary_sensor_schema(MotionBinarySensor) + .extend( + { + cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent), + cv.Optional( + CONF_THRESHOLD, default=default_threshold + ): threshold_validator, + } + ) + .extend(cv.COMPONENT_SCHEMA) + ) + + if default_duration is not None: + schema = schema.extend( + { + cv.Optional( + CONF_DURATION, default=default_duration + ): cv.positive_time_period_milliseconds, + } + ) + return schema + + +# Tilt angle in degrees, from horizontal, within which the device counts as face up/down. +# 0 is excluded: cos(0) == 1.0 would make the C++ comparison always false, so +# face_up/face_down would never trigger. +_angle_threshold = cv.float_range(min=0.0, max=90.0, min_included=False) +# 0 is excluded: free_fall would never trigger and moving would always be on. +_positive_threshold = cv.float_range(min=0.0, min_included=False) + +CONFIG_SCHEMA = cv.typed_schema( + { + "face_up": _binary_sensor_schema(30.0, _angle_threshold), + "face_down": _binary_sensor_schema(30.0, _angle_threshold), + "free_fall": _binary_sensor_schema(0.15, _positive_threshold, "100ms"), + "moving": _binary_sensor_schema(0.05, _positive_threshold, "2s"), + } +) + +# These types detect brief motion events, so they need frequent samples; +# face_up/face_down track a steady orientation and aren't time-sensitive. +_FAST_DETECTION_TYPES = ("free_fall", "moving") + +# face_up/face_down/free_fall are entirely accelerometer-driven; "moving" is exempt +# since it detects motion from either the accelerometer or the gyroscope. +_ACCEL_ONLY_TYPES = ("face_up", "face_down", "free_fall") + + +def _final_validate(config: dict) -> None: + sensor_type = config[CONF_TYPE] + if sensor_type in _FAST_DETECTION_TYPES: + check_update_interval(config[CONF_MOTION_ID], sensor_type.replace("_", "-")) + if sensor_type in _ACCEL_ONLY_TYPES: + check_has_accelerometer( + config[CONF_MOTION_ID], sensor_type.replace("_", "-"), path=[CONF_TYPE] + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + sensor_type = config[CONF_TYPE] + parent = await cg.get_variable(config[CONF_MOTION_ID]) + + var = cg.new_Pvariable(config[CONF_ID], parent, SENSOR_TYPES[sensor_type]) + await binary_sensor.register_binary_sensor(var, config) + await cg.register_component(var, config) + + threshold = config[CONF_THRESHOLD] + if sensor_type in ANGLE_THRESHOLD_TYPES: + # Convert the configured tilt angle (degrees) to the cosine the C++ side expects. + threshold = round(math.cos(math.radians(threshold)), 6) + cg.add(var.set_threshold(threshold)) + if (duration := config.get(CONF_DURATION)) is not None: + cg.add(var.set_duration(duration)) diff --git a/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp b/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp new file mode 100644 index 0000000000..f663b41adb --- /dev/null +++ b/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp @@ -0,0 +1,183 @@ +#include "motion_binary_sensor.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/core/application.h" +#include "esphome/core/progmem.h" + +namespace esphome::motion { + +static const char *const TAG = "motion.binary_sensor"; + +// Thresholds used to decide the device is at rest for face_up / face_down detection. +// While moving (shaking, being picked up) the orientation reading is dominated by +// linear acceleration and cannot be trusted, so those sensors block (hold) instead. +static constexpr float STILL_ACCEL_TOLERANCE = 0.12f; // max deviation of |accel| from 1g, in g +static constexpr float STILL_GYRO_THRESHOLD = 15.0f; // max angular rate magnitude, in °/s +static constexpr float GYRO_THRESHOLD_SCALE = 50.0f; // arbitrary gyro threshold scale, in °/s per g of acceleration + +MotionBinarySensor::MotionBinarySensor(MotionComponent *parent, MotionBinarySensorType type) + : parent_(parent), type_(type) {} + +bool MotionBinarySensor::is_stationary(const MotionData &data) { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) + return false; + + // Total acceleration must be close to 1g; a larger deviation means the device is + // being accelerated (shaken / moved) and the gravity direction cannot be trusted. + float mag = std::sqrt(ax * ax + ay * ay + az * az); + if (std::fabs(mag - 1.0f) > STILL_ACCEL_TOLERANCE) + return false; + + // If a gyroscope is present, also require the angular rate to be low. + float gx = data.angular_rate[X_AXIS]; + float gy = data.angular_rate[Y_AXIS]; + float gz = data.angular_rate[Z_AXIS]; + if (!std::isnan(gx) && !std::isnan(gy) && !std::isnan(gz)) { + float gmag = std::sqrt(gx * gx + gy * gy + gz * gz); + if (gmag > STILL_GYRO_THRESHOLD) + return false; + } + return true; +} + +void MotionBinarySensor::setup() { + this->parent_->add_listener([this](MotionData const &data) { this->process_motion_data_(data); }); + this->publish_state(false); // default to false until the first update +} + +PROGMEM_STRING_TABLE(MotionBinarySensorTypeNames, "face_up", "face_down", "free_fall", "moving", "unknown"); + +void MotionBinarySensor::dump_config() { + LOG_BINARY_SENSOR("", "Motion Binary Sensor", this); + ESP_LOGCONFIG( + TAG, + " Type: %s\n" + " Threshold: %.3f\n" + " Duration: %" PRIu32 " ms", + LOG_STR_ARG(MotionBinarySensorTypeNames::get_log_str(this->type_, MotionBinarySensorTypeNames::LAST_INDEX)), + this->threshold_, this->duration_); +} + +void MotionBinarySensor::process_motion_data_(const MotionData &data) { + uint32_t now = App.get_loop_component_start_time(); + + switch (this->type_) { + case MOTION_BINARY_SENSOR_FACE_UP: + case MOTION_BINARY_SENSOR_FACE_DOWN: { + // Block while the device is moving: hold the last stable state instead of + // reacting to transient acceleration spikes from shaking or handling. + if (!is_stationary(data)) + break; + + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + float mag = std::sqrt(ax * ax + ay * ay + az * az); + // is_stationary_() guarantees mag is close to 1g, so this is just a safety net. + if (mag < 0.1f) + break; + + // threshold_ is the cosine of the maximum tilt: face_up / face_down are only + // reported when the device is within that tilt of horizontal. Beyond it, both + // sensors read false. Normalising by the magnitude makes the tilt limit + // independent of any residual acceleration. + float cos_tilt = az / mag; + if (this->type_ == MOTION_BINARY_SENSOR_FACE_UP) { + this->publish_state(cos_tilt > this->threshold_); + } else { + this->publish_state(cos_tilt < -this->threshold_); + } + break; + } + case MOTION_BINARY_SENSOR_FREE_FALL: { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) { + // Don't let a gap in valid data count towards the free-fall duration. + this->free_fall_candidate_ = false; + return; + } + + float mag = std::sqrt(ax * ax + ay * ay + az * az); + + if (mag < this->threshold_) { + if (!this->free_fall_candidate_) { + this->free_fall_candidate_ = true; + this->free_fall_start_time_ = now; + } else if (now - this->free_fall_start_time_ >= this->duration_) { + this->publish_state(true); + } + } else { + this->free_fall_candidate_ = false; + this->publish_state(false); + } + break; + } + case MOTION_BINARY_SENSOR_MOVING: { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + float gx = data.angular_rate[X_AXIS]; + float gy = data.angular_rate[Y_AXIS]; + float gz = data.angular_rate[Z_AXIS]; + + bool moving = false; + + // Check acceleration delta. Require all three axes to be valid so a NaN on any + // axis can't poison last_accel_ and silently stop motion detection. + bool accel_valid = !std::isnan(ax) && !std::isnan(ay) && !std::isnan(az); + if (accel_valid) { + if (!std::isnan(this->last_accel_[0])) { + float dx = ax - this->last_accel_[0]; + float dy = ay - this->last_accel_[1]; + float dz = az - this->last_accel_[2]; + float accel_diff = std::sqrt(dx * dx + dy * dy + dz * dz); + if (accel_diff > this->threshold_) { + moving = true; + } + } + this->last_accel_[0] = ax; + this->last_accel_[1] = ay; + this->last_accel_[2] = az; + } + + // Check angular rate delta. Require all three axes to be valid for the same reason. + bool gyro_valid = !std::isnan(gx) && !std::isnan(gy) && !std::isnan(gz); + if (gyro_valid) { + if (!std::isnan(this->last_gyro_[0])) { + float dgx = gx - this->last_gyro_[0]; + float dgy = gy - this->last_gyro_[1]; + float dgz = gz - this->last_gyro_[2]; + float gyro_diff = std::sqrt(dgx * dgx + dgy * dgy + dgz * dgz); + if (gyro_diff > this->threshold_ * GYRO_THRESHOLD_SCALE) { + moving = true; + } + } + this->last_gyro_[0] = gx; + this->last_gyro_[1] = gy; + this->last_gyro_[2] = gz; + } + + // With no usable data this sample, don't assert "not moving" -- just wait for + // the next one. + if (!accel_valid && !gyro_valid) + break; + + if (moving) { + this->publish_state(true); + this->last_event_time_ = now; + } else { + if (this->state && (now - this->last_event_time_ >= this->duration_)) { + this->publish_state(false); + } + } + break; + } + } +} + +} // namespace esphome::motion diff --git a/esphome/components/motion/binary_sensor/motion_binary_sensor.h b/esphome/components/motion/binary_sensor/motion_binary_sensor.h new file mode 100644 index 0000000000..6d556598d4 --- /dev/null +++ b/esphome/components/motion/binary_sensor/motion_binary_sensor.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "../motion_component.h" + +namespace esphome::motion { + +enum MotionBinarySensorType : uint8_t { + MOTION_BINARY_SENSOR_FACE_UP = 0, + MOTION_BINARY_SENSOR_FACE_DOWN, + MOTION_BINARY_SENSOR_FREE_FALL, + MOTION_BINARY_SENSOR_MOVING, +}; + +class MotionBinarySensor : public Component, public binary_sensor::BinarySensor { + public: + explicit MotionBinarySensor(MotionComponent *parent, MotionBinarySensorType type); + + void setup() override; + void dump_config() override; + + void set_threshold(float threshold) { this->threshold_ = threshold; } + void set_duration(uint32_t duration) { this->duration_ = duration; } + + protected: + void process_motion_data_(const MotionData &data); + + /// True when the device is at rest: total acceleration is close to 1g and (if a + /// gyroscope is present) the angular rate is low. While not stationary the + /// face_up / face_down orientation is unreliable, so their updates are suspended. + static bool is_stationary(const MotionData &data); + + MotionComponent *parent_; + float threshold_{0.0f}; + uint32_t duration_{0}; + + // Tracking states + uint32_t last_event_time_{0}; + uint32_t free_fall_start_time_{0}; + + // For derivative/variance tracking + float last_accel_[3]{NAN, NAN, NAN}; + float last_gyro_[3]{NAN, NAN, NAN}; + + MotionBinarySensorType type_; + bool free_fall_candidate_{false}; +}; + +} // namespace esphome::motion diff --git a/esphome/components/motion/event/__init__.py b/esphome/components/motion/event/__init__.py new file mode 100644 index 0000000000..9c72535f13 --- /dev/null +++ b/esphome/components/motion/event/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import event +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THRESHOLD +from esphome.types import ConfigType + +from .. import ( + CONF_MOTION_ID, + MotionComponent, + check_has_accelerometer, + check_update_interval, + motion_ns, +) + +DEPENDENCIES = ["motion"] + +MotionEvent = motion_ns.class_("MotionEvent", event.Event, cg.Component) + +EVENT_TYPES = ["shake"] + +CONF_COOLDOWN = "cooldown" + +CONFIG_SCHEMA = ( + event.event_schema(MotionEvent) + .extend( + { + cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent), + cv.Optional(CONF_THRESHOLD, default=0.5): cv.float_range( + min=0.0, min_included=False + ), + cv.Optional( + CONF_COOLDOWN, default="500ms" + ): cv.positive_time_period_milliseconds, + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +def _final_validate(config: dict) -> None: + check_update_interval(config[CONF_MOTION_ID], "shake") + check_has_accelerometer(config[CONF_MOTION_ID], "shake") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_MOTION_ID]) + var = cg.new_Pvariable(config[CONF_ID], parent) + await event.register_event(var, config, event_types=EVENT_TYPES) + await cg.register_component(var, config) + + cg.add(var.set_threshold(config[CONF_THRESHOLD])) + cg.add(var.set_cooldown(config[CONF_COOLDOWN])) diff --git a/esphome/components/motion/event/motion_event.cpp b/esphome/components/motion/event/motion_event.cpp new file mode 100644 index 0000000000..5568c953fd --- /dev/null +++ b/esphome/components/motion/event/motion_event.cpp @@ -0,0 +1,57 @@ +#include "motion_event.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/core/application.h" + +namespace esphome::motion { + +static const char *const TAG = "motion.event"; + +MotionEvent::MotionEvent(MotionComponent *parent) : parent_(parent) {} + +void MotionEvent::setup() { + this->parent_->add_listener([this](MotionData const &data) { this->process_motion_data_(data); }); +} + +void MotionEvent::dump_config() { + LOG_EVENT("", "Motion Event", this); + ESP_LOGCONFIG(TAG, + " Threshold: %.3f\n" + " Cooldown: %" PRIu32 " ms", + this->threshold_, this->cooldown_); +} + +void MotionEvent::process_motion_data_(const MotionData &data) { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) { + // Reset the baseline so the next valid sample doesn't jerk-compare across the gap. + this->last_accel_[0] = NAN; + this->last_accel_[1] = NAN; + this->last_accel_[2] = NAN; + return; + } + + uint32_t now = App.get_loop_component_start_time(); + + if (!std::isnan(this->last_accel_[0])) { + float dx = ax - this->last_accel_[0]; + float dy = ay - this->last_accel_[1]; + float dz = az - this->last_accel_[2]; + float jerk_mag = std::sqrt(dx * dx + dy * dy + dz * dz); + + if (jerk_mag > this->threshold_) { + if (now - this->last_trigger_time_ >= this->cooldown_) { + this->trigger("shake"); + this->last_trigger_time_ = now; + } + } + } + + this->last_accel_[0] = ax; + this->last_accel_[1] = ay; + this->last_accel_[2] = az; +} + +} // namespace esphome::motion diff --git a/esphome/components/motion/event/motion_event.h b/esphome/components/motion/event/motion_event.h new file mode 100644 index 0000000000..e8cd9f5352 --- /dev/null +++ b/esphome/components/motion/event/motion_event.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/event/event.h" +#include "../motion_component.h" + +namespace esphome::motion { + +class MotionEvent : public Component, public event::Event { + public: + explicit MotionEvent(MotionComponent *parent); + + void setup() override; + void dump_config() override; + + void set_threshold(float threshold) { this->threshold_ = threshold; } + void set_cooldown(uint32_t cooldown) { this->cooldown_ = cooldown; } + + protected: + void process_motion_data_(const MotionData &data); + + MotionComponent *parent_; + float threshold_{0.5f}; + uint32_t cooldown_{500}; + + // State tracking for shake detection + uint32_t last_trigger_time_{0}; + float last_accel_[3]{NAN, NAN, NAN}; +}; + +} // namespace esphome::motion diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index d117ca0032..2ca920dbe0 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -23,6 +23,33 @@ static constexpr uint8_t X_AXIS = 0; static constexpr uint8_t Y_AXIS = 1; static constexpr uint8_t Z_AXIS = 2; +/// Compute the device's in-plane orientation from the gravity vector. +/// +/// Returns NAN when the device is flat (lying face up or face down), i.e. when the +/// horizontal component of gravity, normalised by the total acceleration, is below +/// `flat_threshold` (the sine of the minimum tilt angle). Otherwise returns the +/// rotation snapped to the nearest of 0, 90, 180 or 270 degrees, derived from the +/// direction of the horizontal gravity component. +inline float orientation_degrees(const MotionData &data, float flat_threshold) { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) + return NAN; + float mag = std::sqrt(ax * ax + ay * ay + az * az); + if (mag < 0.1f) + return NAN; + // Horizontal component of gravity; near zero when the device lies flat. + float h = std::sqrt(ax * ax + ay * ay); + if (h / mag < flat_threshold) + return NAN; + // Direction of the horizontal component, snapped to the nearest 90°. + float angle = std::atan2(ay, ax) * (180.0f / std::numbers::pi_v); + int quadrant = static_cast(std::lround(angle / 90.0f)); + quadrant = ((quadrant % 4) + 4) % 4; // normalise to 0..3 + return quadrant * 90.0f; +} + // Persisted calibration. `base_hash` ties the stored matrix to the build-time // (axis_map / transform_matrix) base; if the base changes the saved calibration // is ignored. Stored under a stable, ID-derived key so it overwrites in place. @@ -78,7 +105,7 @@ class MotionComponent : public PollingComponent { output[2] = input[X_AXIS] * this->matrix_[6] + input[Y_AXIS] * this->matrix_[7] + input[Z_AXIS] * this->matrix_[8]; } - LazyCallbackManager motion_data_callback_{}; + LazyCallbackManager motion_data_callback_{}; uint32_t pref_key_{0}; uint32_t base_hash_{0}; // hash of base_matrix_, captured in setup() ESPPreferenceObject pref_{}; diff --git a/esphome/components/motion/sensor.py b/esphome/components/motion/sensor.py index ad3163a01a..db8a869236 100644 --- a/esphome/components/motion/sensor.py +++ b/esphome/components/motion/sensor.py @@ -1,4 +1,6 @@ # YAML config keys +import math + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -6,6 +8,7 @@ from esphome.const import ( CONF_TYPE, ICON_ACCELERATION, ICON_ROTATE_RIGHT, + ICON_SCREEN_ROTATION, STATE_CLASS_MEASUREMENT, UNIT_DEGREE_PER_SECOND, UNIT_DEGREES, @@ -13,7 +16,7 @@ from esphome.const import ( ) from esphome.cpp_generator import MockObj from esphome.cpp_types import std_ns -import esphome.final_validate as fv +from esphome.types import ConfigType from . import ( AXES, @@ -21,6 +24,7 @@ from . import ( KEY_ACCELEROMETER, KEY_GYROSCOPE, SENSOR_SCHEMA, + get_motion_config, motion_ns, ) @@ -28,10 +32,17 @@ MotionData = motion_ns.class_("MotionData") CONF_PITCH = "pitch" CONF_ROLL = "roll" +CONF_ORIENTATION = "orientation" +CONF_FLAT_THRESHOLD = "flat_threshold" ICON_SEESAW = "mdi:seesaw" +# Minimum tilt angle (degrees) before the device is considered tilted enough to +# report an orientation. Below this the device is treated as flat (sensor reports +# NAN). Configured in degrees; converted to the sine of the angle for the C++ side. +DEFAULT_FLAT_THRESHOLD = 30.0 -def _accel_sensor_schema(): + +def _accel_sensor_schema() -> cv.Schema: return sensor.sensor_schema( unit_of_measurement=UNIT_G, icon=ICON_ACCELERATION, @@ -40,7 +51,7 @@ def _accel_sensor_schema(): ).extend(SENSOR_SCHEMA) -def _gyro_sensor_schema(): +def _gyro_sensor_schema() -> cv.Schema: return sensor.sensor_schema( unit_of_measurement=UNIT_DEGREE_PER_SECOND, icon=ICON_ROTATE_RIGHT, @@ -49,7 +60,7 @@ def _gyro_sensor_schema(): ).extend(SENSOR_SCHEMA) -def _level_sensor_schema(): +def _level_sensor_schema() -> cv.Schema: return sensor.sensor_schema( unit_of_measurement=UNIT_DEGREES, icon=ICON_SEESAW, @@ -58,6 +69,28 @@ def _level_sensor_schema(): ).extend(SENSOR_SCHEMA) +def _orientation_sensor_schema() -> cv.Schema: + # Reports a discrete rotation (0/90/180/270) or NAN when flat, so it is not a + # continuous measurement (no state_class). + return ( + sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + icon=ICON_SCREEN_ROTATION, + accuracy_decimals=0, + ) + .extend(SENSOR_SCHEMA) + .extend( + { + # 90 is excluded: sin(90) == 1.0 would make the C++ comparison always + # true, so orientation would report NAN (flat) on every reading. + cv.Optional( + CONF_FLAT_THRESHOLD, default=DEFAULT_FLAT_THRESHOLD + ): cv.float_range(min=0.0, max=90.0, max_included=False), + } + ) + ) + + _ACCELERATIONS = ["acceleration_" + a for a in AXES] _GYROSCOPES = ["gyroscope_" + g for g in AXES] _ANGULAR_RATES = ["angular_rate_" + r for r in AXES] @@ -68,20 +101,20 @@ CONFIG_SCHEMA = cv.typed_schema( **{x: _gyro_sensor_schema() for x in _GYROSCOPES}, **{x: _gyro_sensor_schema() for x in _ANGULAR_RATES}, **{x: _level_sensor_schema() for x in (CONF_PITCH, CONF_ROLL)}, + CONF_ORIENTATION: _orientation_sensor_schema(), } ) def _final_validate(config: dict) -> None: - full_config = fv.full_config.get() - motion_path = full_config.get_path_for_id(config[CONF_MOTION_ID])[:-1] - motion_config = full_config.get_config_for_path(motion_path) + motion_config = get_motion_config(config[CONF_MOTION_ID]) has_accel = motion_config.get(KEY_ACCELEROMETER, False) has_gyro = motion_config.get(KEY_GYROSCOPE, False) sensor_type = config[CONF_TYPE] if ( - sensor_type in _ACCELERATIONS or sensor_type in (CONF_ROLL, CONF_PITCH) + sensor_type in _ACCELERATIONS + or sensor_type in (CONF_ROLL, CONF_PITCH, CONF_ORIENTATION) ) and not has_accel: raise cv.Invalid( "The motion device does not measure acceleration", path=[CONF_TYPE] @@ -95,11 +128,15 @@ def _final_validate(config: dict) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj: +def build_sensor_expr(sensor_type: str, data: MockObj, config: dict) -> MockObj: """Build the C++ expression for a motion sensor type.""" # Note that is included via this component's header file. pif = std_ns.namespace("numbers").pi_v.template(cg.float_) + if sensor_type == CONF_ORIENTATION: + # The C++ helper compares against the sine of the tilt angle. + threshold = round(math.sin(math.radians(config[CONF_FLAT_THRESHOLD])), 6) + return motion_ns.orientation_degrees(data, threshold) if sensor_type == CONF_ROLL: ay = data.acceleration[1] az = data.acceleration[2] @@ -115,14 +152,14 @@ def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj: return getattr(data, str(sensor_type[:-2]))[sensor_offset] -async def to_code(config): +async def to_code(config: ConfigType) -> None: sensor_type = config[CONF_TYPE] var = await sensor.new_sensor(config) parent = await cg.get_variable(config[CONF_MOTION_ID]) data = MockObj("data") - expr = build_sensor_expr(sensor_type, data) + expr = build_sensor_expr(sensor_type, data, config) value_lambda = await cg.process_lambda( var.publish_state(expr), - [(MotionData.operator("ref"), str(data))], + [(MotionData.operator("const").operator("ref"), str(data))], ) cg.add(parent.add_listener(value_lambda)) diff --git a/esphome/components/mpl3115a2/sensor.py b/esphome/components/mpl3115a2/sensor.py index 4fbc353644..8235e2d618 100644 --- a/esphome/components/mpl3115a2/sensor.py +++ b/esphome/components/mpl3115a2/sensor.py @@ -64,13 +64,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PRESSURE in config: - sens = await sensor.new_sensor(config[CONF_PRESSURE]) - cg.add(var.set_pressure(sens)) - elif CONF_ALTITUDE in config: - sens = await sensor.new_sensor(config[CONF_ALTITUDE]) - cg.add(var.set_altitude(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PRESSURE, var.set_pressure) + await sensors(CONF_ALTITUDE, var.set_altitude) + await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/mpu6050/sensor.py b/esphome/components/mpu6050/sensor.py index a8370ced7f..bd3de2d32c 100644 --- a/esphome/components/mpu6050/sensor.py +++ b/esphome/components/mpu6050/sensor.py @@ -70,16 +70,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"accel_{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"gyro_{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_ACCEL_X, var.set_accel_x_sensor) + await sensors(CONF_GYRO_X, var.set_gyro_x_sensor) + await sensors(CONF_ACCEL_Y, var.set_accel_y_sensor) + await sensors(CONF_GYRO_Y, var.set_gyro_y_sensor) + await sensors(CONF_ACCEL_Z, var.set_accel_z_sensor) + await sensors(CONF_GYRO_Z, var.set_gyro_z_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/mpu6886/sensor.py b/esphome/components/mpu6886/sensor.py index 5bdb836128..878540d516 100644 --- a/esphome/components/mpu6886/sensor.py +++ b/esphome/components/mpu6886/sensor.py @@ -71,16 +71,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"accel_{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"gyro_{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_ACCEL_X, var.set_accel_x_sensor) + await sensors(CONF_GYRO_X, var.set_gyro_x_sensor) + await sensors(CONF_ACCEL_Y, var.set_accel_y_sensor) + await sensors(CONF_GYRO_Y, var.set_gyro_y_sensor) + await sensors(CONF_ACCEL_Z, var.set_accel_z_sensor) + await sensors(CONF_GYRO_Z, var.set_gyro_z_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ms5611/sensor.py b/esphome/components/ms5611/sensor.py index e42e0824dd..bf6d4528e6 100644 --- a/esphome/components/ms5611/sensor.py +++ b/esphome/components/ms5611/sensor.py @@ -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 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) diff --git a/esphome/components/ms8607/sensor.py b/esphome/components/ms8607/sensor.py index e5d90432d8..13377394be 100644 --- a/esphome/components/ms8607/sensor.py +++ b/esphome/components/ms8607/sensor.py @@ -68,17 +68,12 @@ 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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) + if await sensors(CONF_HUMIDITY, var.set_humidity_sensor): + humidity_config = config[CONF_HUMIDITY] humidity_device = cg.new_Pvariable(humidity_config[CONF_HUMIDITY_I2C_ID]) await i2c.register_i2c_device(humidity_device, humidity_config) cg.add(var.set_humidity_device(humidity_device)) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 19ad36612d..d9880fe6f9 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,12 +5,11 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENCRYPTION, CONF_KEY -from esphome.core import CORE, ID +from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] -DOMAIN = "noise" # Keep in sync with platformio.ini and esphome/idf_component.yml. # LIBSODIUM_VERSION must match the version noise-c pins in its manifests. @@ -74,18 +73,13 @@ def static_encryption_key(conf: ConfigType) -> str | None: return (conf.get(CONF_ENCRYPTION) or {}).get(CONF_KEY) or None -def new_psk_progmem(parent_id: ID, key: str) -> MockObj: +def new_psk_progmem(key: str) -> MockObj: """Emit the decoded key as a PROGMEM array; the component keeps a pointer so the key never occupies RAM. Components sharing one key (api and ota) share the array.""" - decoded = decode_encryption_key(key) - arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {}) - if (array := arrays.get(decoded)) is None: - array = arrays[decoded] = cg.progmem_array( - ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), - list(decoded), - ) - return array + return cg.shared_progmem_array( + "noise_psk", cg.uint8, list(decode_encryption_key(key)) + ) def encryption_schema(config: ConfigType | None) -> ConfigType: diff --git a/esphome/components/npi19/sensor.py b/esphome/components/npi19/sensor.py index 4c7db10ce9..14dcd72384 100644 --- a/esphome/components/npi19/sensor.py +++ b/esphome/components/npi19/sensor.py @@ -44,10 +44,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 raw_pressure_config := config.get(CONF_RAW_PRESSURE): - sens = await sensor.new_sensor(raw_pressure_config) - cg.add(var.set_raw_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_RAW_PRESSURE, var.set_raw_pressure_sensor) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 5ebf5b747f..86d485777c 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -9,6 +9,7 @@ import subprocess from esphome import pins from esphome.build_helpers import pch +from esphome.build_helpers.ccache import resolve_ccache_path import esphome.codegen as cg from esphome.components.zephyr import ( add_extra_script, @@ -26,6 +27,7 @@ from esphome.components.zephyr.const import ( CONF_CDC_ACM, KEY_BOARD, KEY_BOOTLOADER, + KEY_SYSBUILD, KEY_ZEPHYR, CdcAcm, ) @@ -76,6 +78,7 @@ from .framework import ( get_build_paths, setup_platformio_python_env, toolchain_tool, + wanted_west_projects, ) # force import gpio to register pin schema @@ -584,7 +587,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: raise EsphomeError("Not implemented yet") check_and_install() paths = get_build_paths() - env = get_build_env() + env = get_build_env(None) # no compile, just nrfutil build_dir = CORE.relative_pioenvs_path(CORE.name) dfu_package = build_dir / "firmware.zip" if not dfu_package.is_file(): @@ -666,7 +669,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: if not CORE.using_toolchain_platformio: check_and_install() paths = get_build_paths() - env = get_build_env() + env = get_build_env(resolve_ccache_path()) # west flash may rebuild build_dir = CORE.relative_pioenvs_path(CORE.name) west_cmd = [ str(paths["python_executable"]), @@ -821,19 +824,22 @@ _PCH_SUM_PATH = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" def _write_pch_checksum(build_dir: Path, source_dir: Path) -> None: - """Write the checksum ccache reads in place of the .gch. The app binary - dir only exists after the first configure; sysbuild nests it.""" - app_dir = build_dir / "zephyr" - if not (app_dir / "CMakeCache.txt").is_file(): - app_dir = build_dir - if not (app_dir / "CMakeCache.txt").is_file(): - return + """Write the checksum ccache reads in place of the .gch; before the + first build too, or its compiles hash the path laden .gch instead. + The app image dir follows the SDK version, like get_elf_path; + 2.9.2+ always wraps the build in sysbuild.""" + app_dir = build_dir + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 9, 2): + app_dir = build_dir / "zephyr" checksum = pch.pch_checksum( CORE.relative_src_path(), pch.PCH_DEFAULT_HEADERS, ( str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), zephyr_data()[KEY_BOARD], + # Kconfig inputs that reach autoconf.h without a .conf line + ",".join(sorted(wanted_west_projects())), + str(zephyr_data().get(KEY_SYSBUILD)), # What the Zephyr configuration is generated from *( path.read_text(encoding="utf-8") @@ -949,7 +955,10 @@ def run_compile(args, config: ConfigType) -> bool: check_and_install() paths = get_build_paths() - env = get_build_env() + # Depend mode in the shared ccache settings keeps the .gch sound + # across Kconfig flips. + ccache = resolve_ccache_path() + env = get_build_env(ccache) pch_on = _pch_usable() cmake_lists_changed = _generate_cmake_lists(pch_on) @@ -976,8 +985,6 @@ def run_compile(args, config: ConfigType) -> bool: if pch_on: pch.log_pch_in_use() - # Zephyr turns ccache on by itself when it is installed - env.update(pch.ccache_pch_env()) _write_pch_checksum(build_dir, source_dir) west_cmd = _west_build_command( diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index 89272cd406..d1f9597976 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -277,7 +277,7 @@ def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: ] if not run_command_ok( west_cmd, - env=get_build_env(), + env=get_build_env(None), # configure only, nothing compiles stream_output=True, cwd=str(paths["framework_path"]), ): diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 58c87619bf..00796562f4 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -10,6 +10,7 @@ import platform import shutil import sys +from esphome.build_helpers.ccache import ccache_env from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv @@ -194,7 +195,14 @@ def get_build_paths() -> dict: } -def get_build_env() -> dict: +def get_build_env(ccache: str | None) -> dict: + """Build the west/sdk-nrf process environment. + + ``ccache`` is the resolved binary (resolve_ccache_path), or None when + ccache is disabled or the caller never compiles; it brings the shared + managed-ccache settings and the pch sloppiness, so every caller that + may compile gets the same cache. + """ version = _get_version_str() venv_bin_dir = get_python_env_executable_path( _get_python_env_path(version), "python" @@ -211,6 +219,25 @@ def get_build_env() -> dict: # non-root builds failed to locate the SDK with it, while # ZEPHYR_SDK_INSTALL_DIR fixed the same invocation. env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(_get_toolchain_version())) + if ccache is None: + # Zephyr wraps compiles with any ccache it finds; unmanaged it + # must not cache (a sysbuild image never sees USE_CCACHE=0). + env.setdefault("CCACHE_DISABLE", "1") + else: + env.update(ccache_env(ccache, SDK_NRF_TOOLS_CACHE)) + # Drop only the per build map entry (posix, CMake's spelling); + # its from side covers no compiled sources. A spaced path cannot + # survive ccache's space split list, so it stays hashed. + source_dir = CORE.relative_build_path("zephyr").as_posix() + if any(ch.isspace() for ch in source_dir): + _LOGGER.debug( + "Whitespace in %s; the per build map stays hashed", source_dir + ) + else: + device_map = f"-fmacro-prefix-map={source_dir}=CMAKE_SOURCE_DIR" + env["CCACHE_IGNOREOPTIONS"] = ( + f"{env.get('CCACHE_IGNOREOPTIONS', '')} {device_map}".strip() + ) return env @@ -287,21 +314,62 @@ def setup_platformio_python_env() -> None: _prepend_env_path("PATH", str(env_python_path.parent)) +def _patch_framework_file(path: Path, old: str, new: str) -> bool: + """Replace ``old`` with ``new`` in a framework script, atomically and + keeping the file mode (helpers.write_file would flatten it to 0o644). + Returns False when nothing matched.""" + import tempfile + + content = path.read_text(encoding="utf-8") + patched = content.replace(old, new) + if patched == content: + return False + # Unique sibling tmp: the install lock is best effort, so two builds + # may patch at once and a shared tmp name could rename a half + # written file into place. + fd, tmp_name = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(patched) + shutil.copymode(path, tmp) + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + return True + + def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that # Python 3.12+ flags with SyntaxWarning (a future version will reject it). uf2conv = framework_path / "zephyr" / "scripts" / "build" / "uf2conv.py" - if not uf2conv.exists(): + if uf2conv.exists(): + _patch_framework_file( + uf2conv, "re.split('\\s+', line)", "re.split('\\\\s+', line)" + ) + + +def _patch_gen_defines_dts_path(framework_path: Path) -> None: + # The absolute zephyr.dts.pre path in the header's top comment is + # its only per device byte and blocks ccache sharing; emit the + # basename. Upstream candidate. + gen_defines = framework_path / "zephyr" / "scripts" / "dts" / "gen_defines.py" + if not gen_defines.exists(): return - content = uf2conv.read_text(encoding="utf-8") - patched = content.replace("re.split('\\s+', line)", "re.split('\\\\s+', line)") - if patched == content: + if _patch_framework_file( + gen_defines, " {edt.dts_path}", " {os.path.basename(edt.dts_path)}" + ): return - # Write atomically so a concurrent build never sees a truncated file - tmp = uf2conv.with_suffix(".py.tmp") - tmp.write_text(patched, encoding="utf-8") - shutil.copymode(uf2conv, tmp) - tmp.replace(uf2conv) + if "{os.path.basename(edt.dts_path)}" not in gen_defines.read_text( + encoding="utf-8" + ): + # Upstream reformatted the comment; sharing silently degrading + # would be invisible, so say it out loud. + _LOGGER.warning( + "gen_defines.py no longer matches; the devicetree header " + "stays per device and ccache sharing between devices degrades" + ) # West projects every build needs; components add others with include_west_project() @@ -335,7 +403,7 @@ def bluetooth_west_projects() -> tuple[str, ...]: return ("tinycrypt",) -def _wanted_west_projects() -> set[str]: +def wanted_west_projects() -> set[str]: projects = set(_get_data().west_projects) # Zephyr 4.1 moved the Cortex-M core headers to cmsis_6 if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 1, 0): @@ -573,7 +641,7 @@ def _check_and_install(version: str) -> None: framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" - projects = _wanted_west_projects() + projects = wanted_west_projects() if not sentinel.exists() or not zephyr_reqs.exists(): _install_framework(env_python_path, framework_path, version, projects) framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] @@ -582,6 +650,8 @@ def _check_and_install(version: str) -> None: sentinel.touch() else: _fetch_missing_west_projects(env_python_path, framework_path, version, projects) + # Every run: existing installs need it too, and it is a no-op once applied + _patch_gen_defines_dts_path(framework_path) zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" if ( diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index da789ae706..4d3e8cd6c3 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_ROLE from esphome.components.openthread.const import ( CONF_EXT_PAN_ID, CONF_NETWORK_KEY, @@ -10,7 +11,6 @@ import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_IP_ADDRESS, ENTITY_CATEGORY_DIAGNOSTIC from esphome.types import ConfigType -CONF_ROLE = "role" CONF_RLOC16 = "rloc16" CONF_EUI64 = "eui64" CONF_EXT_ADDR = "ext_addr" diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index 447671ebb3..98c6406676 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -62,6 +62,5 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) diff --git a/esphome/components/pm2005/sensor.py b/esphome/components/pm2005/sensor.py index f16c100e5e..a0c133eba1 100644 --- a/esphome/components/pm2005/sensor.py +++ b/esphome/components/pm2005/sensor.py @@ -74,14 +74,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_sensor_type(config[CONF_TYPE])) - if pm_1_0_config := config.get(CONF_PM_1_0): - sens = await sensor.new_sensor(pm_1_0_config) - cg.add(var.set_pm_1_0_sensor(sens)) - - if pm_2_5_config := config.get(CONF_PM_2_5): - sens = await sensor.new_sensor(pm_2_5_config) - cg.add(var.set_pm_2_5_sensor(sens)) - - if pm_10_0_config := config.get(CONF_PM_10_0): - sens = await sensor.new_sensor(pm_10_0_config) - cg.add(var.set_pm_10_0_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 677cfb6a5e..4cf88bafe9 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -140,9 +140,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_falling_edge_mode(count[CONF_FALLING_EDGE])) cg.add(var.set_filter_us(config[CONF_INTERNAL_FILTER])) - if CONF_TOTAL in config: - sens = await sensor.new_sensor(config[CONF_TOTAL]) - cg.add(var.set_total_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL, var.set_total_sensor) automation.register_apply_action( diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 2df1ba96e9..e523ebe756 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -92,9 +92,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) cg.add(var.set_filter_mode(config[CONF_INTERNAL_FILTER_MODE])) - if CONF_TOTAL in config: - sens = await sensor.new_sensor(config[CONF_TOTAL]) - cg.add(var.set_total_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL, var.set_total_sensor) automation.register_apply_action( diff --git a/esphome/components/radon_eye_rd200/sensor.py b/esphome/components/radon_eye_rd200/sensor.py index f38a035226..0e691e382d 100644 --- a/esphome/components/radon_eye_rd200/sensor.py +++ b/esphome/components/radon_eye_rd200/sensor.py @@ -47,9 +47,6 @@ async def to_code(config: ConfigType) -> None: await ble_client.register_ble_node(var, config) - if CONF_RADON in config: - sens = await sensor.new_sensor(config[CONF_RADON]) - cg.add(var.set_radon(sens)) - if CONF_RADON_LONG_TERM in config: - sens = await sensor.new_sensor(config[CONF_RADON_LONG_TERM]) - cg.add(var.set_radon_long_term(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RADON, var.set_radon) + await sensors(CONF_RADON_LONG_TERM, var.set_radon_long_term) diff --git a/esphome/components/rd03d/binary_sensor.py b/esphome/components/rd03d/binary_sensor.py index 2c040d0560..9971c413bd 100644 --- a/esphome/components/rd03d/binary_sensor.py +++ b/esphome/components/rd03d/binary_sensor.py @@ -30,9 +30,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) - if target_config := config.get(CONF_TARGET): - sens = await binary_sensor.new_binary_sensor(target_config) - cg.add(hub.set_target_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_TARGET, hub.set_target_binary_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/rd03d/sensor.py b/esphome/components/rd03d/sensor.py index d29656bab0..fe47e5ae32 100644 --- a/esphome/components/rd03d/sensor.py +++ b/esphome/components/rd03d/sensor.py @@ -79,9 +79,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) - if target_count_config := config.get(CONF_TARGET_COUNT): - sens = await sensor.new_sensor(target_count_config) - cg.add(hub.set_target_count_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index befef872f0..5d615f0edf 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1538,9 +1538,7 @@ def validate_rc_switch_raw_code(value): return value -def build_rc_switch_protocol(config): - if isinstance(config, int): - return rc_switch_protocol(config) +def build_custom_rc_switch_protocol(config: ConfigType) -> MockObj: pl = config[CONF_PULSE_LENGTH] return RCSwitchBase( config[CONF_SYNC][0] * pl, @@ -1553,6 +1551,24 @@ def build_rc_switch_protocol(config): ) +def rc_switch_protocol_in_flash(config: int | ConfigType) -> MockObj: + """Pointer to the protocol in flash: a built-in table entry or a shared custom table.""" + if isinstance(config, int): + return cg.RawExpression(f"&{RC_SWITCH_PROTOCOLS}[{config}]") + return cg.shared_progmem_array( + "rc_switch_custom_protocol", + RCSwitchBase, + [build_custom_rc_switch_protocol(config)], + ) + + +def rc_switch_protocol_value(config: int | ConfigType) -> MockObj: + """RAM copy of a constant protocol for the transmit actions, read from its flash table.""" + if isinstance(config, int): + return rc_switch_protocol(config) + return rc_switch_protocol_copy(rc_switch_protocol_in_flash(config)) + + RC_SWITCH_RAW_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): validate_rc_switch_raw_code, @@ -1628,6 +1644,8 @@ RC_SWITCH_TRANSMITTER = cv.Schema( ) rc_switch_protocol = ns.rc_switch_protocol +rc_switch_protocol_copy = ns.rc_switch_protocol_copy +RC_SWITCH_PROTOCOLS = ns.RC_SWITCH_PROTOCOLS RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) @@ -1642,7 +1660,7 @@ RCSwitchRawReceiver = ns.class_("RCSwitchRawReceiver", RemoteReceiverBinarySenso @register_binary_sensor("rc_switch_raw", RCSwitchRawReceiver, RC_SWITCH_RAW_SCHEMA) def rc_switch_raw_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_code(config[CONF_CODE])) @@ -1653,7 +1671,7 @@ def rc_switch_raw_binary_sensor(var, config): ) async def rc_switch_raw_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_code(await cg.templatable(config[CONF_CODE], args, cg.std_string))) @@ -1663,7 +1681,7 @@ async def rc_switch_raw_action(var, config, args): "rc_switch_type_a", RCSwitchRawReceiver, RC_SWITCH_TYPE_A_SCHEMA ) def rc_switch_type_a_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_type_a(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) @@ -1674,7 +1692,7 @@ def rc_switch_type_a_binary_sensor(var, config): ) async def rc_switch_type_a_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) @@ -1688,7 +1706,7 @@ async def rc_switch_type_a_action(var, config, args): "rc_switch_type_b", RCSwitchRawReceiver, RC_SWITCH_TYPE_B_SCHEMA ) def rc_switch_type_b_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add( var.set_type_b(config[CONF_ADDRESS], config[CONF_CHANNEL], config[CONF_STATE]) ) @@ -1701,7 +1719,7 @@ def rc_switch_type_b_binary_sensor(var, config): ) async def rc_switch_type_b_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint8))) @@ -1713,7 +1731,7 @@ async def rc_switch_type_b_action(var, config, args): "rc_switch_type_c", RCSwitchRawReceiver, RC_SWITCH_TYPE_C_SCHEMA ) def rc_switch_type_c_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add( var.set_type_c( config[CONF_FAMILY], @@ -1731,7 +1749,7 @@ def rc_switch_type_c_binary_sensor(var, config): ) async def rc_switch_type_c_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add( @@ -1748,7 +1766,7 @@ async def rc_switch_type_c_action(var, config, args): RC_SWITCH_TYPE_D_SCHEMA.extend(RC_SWITCH_TRANSMITTER), ) def rc_switch_type_d_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_type_d(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) @@ -1759,7 +1777,7 @@ def rc_switch_type_d_binary_sensor(var, config): ) async def rc_switch_type_d_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) diff --git a/esphome/components/remote_base/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index de16c55cb0..bdd6615d43 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -9,12 +9,16 @@ namespace esphome::remote_base { static const char *const TAG = "remote.rc_switch"; RCSwitchBase rc_switch_protocol(uint8_t index) { - RCSwitchBase protocol; // entry 0 is the all-zero protocol, so an out of range index from a lambda transmits nothing if (index >= std::size(RC_SWITCH_PROTOCOLS)) index = 0; - progmem_memcpy(&protocol, &RC_SWITCH_PROTOCOLS[index], sizeof(protocol)); - return protocol; + return rc_switch_protocol_copy(&RC_SWITCH_PROTOCOLS[index]); +} + +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol) { + RCSwitchBase copy; + progmem_memcpy(©, protocol, sizeof(copy)); + return copy; } void RCSwitchBase::one(RemoteTransmitData *dst) const { @@ -232,7 +236,7 @@ uint64_t decode_binary_string_mask(const std::string &data) { bool RCSwitchRawReceiver::matches(RemoteReceiveData src) { uint64_t decoded_code; uint8_t decoded_nbits; - if (!this->protocol_.decode(src, &decoded_code, &decoded_nbits)) + if (!this->protocol_->decode(src, &decoded_code, &decoded_nbits)) return false; return decoded_nbits == this->nbits_ && (decoded_code & this->mask_) == (this->code_ & this->mask_); diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index 9ccea4d15a..a102da3ff6 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/core/component.h" #include "remote_base.h" @@ -66,6 +68,9 @@ class RCSwitchBase { uint32_t one_high_{}; uint32_t one_low_{}; uint32_t inverted_{}; // bool widened so every field is a word: the table is read from flash + + // A bool here would still pad to 28 bytes, so the size check below alone would not catch it. + static_assert(std::is_same_v, "inverted_ must stay a word for flash reads"); }; // Constant-initialized and kept in flash on every platform. The decoder reads entries in place @@ -87,6 +92,9 @@ inline constexpr RCSwitchBase RC_SWITCH_PROTOCOLS[] PROGMEM = { /// RAM copy of RC_SWITCH_PROTOCOLS[index] (0 when out of range) for the transmit actions and the dumper, made with /// progmem_memcpy so no byte load ever touches the flash table on ESP8266 RCSwitchBase rc_switch_protocol(uint8_t index); +/// RAM copy of a protocol stored in flash, made with progmem_memcpy (own name: `rc_switch_protocol(0)` stays +/// unambiguous) +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol); uint64_t decode_binary_string(const std::string &data); @@ -200,7 +208,8 @@ template class RCSwitchTypeDAction : public RemoteTransmitterAct class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { public: - void set_protocol(const RCSwitchBase &a_protocol) { this->protocol_ = a_protocol; } + /// `protocol` must outlive the receiver: a RC_SWITCH_PROTOCOLS entry or a codegen flash table. + void set_protocol(const RCSwitchBase *protocol) { this->protocol_ = protocol; } void set_code(uint64_t code) { this->code_ = code; } void set_code(const std::string &code) { this->code_ = decode_binary_string(code); @@ -228,7 +237,7 @@ class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { protected: bool matches(RemoteReceiveData src) override; - RCSwitchBase protocol_; + const RCSwitchBase *protocol_{nullptr}; // in flash; decoded in place (word-only fields) uint64_t code_; uint64_t mask_{0xFFFFFFFFFFFFFFFF}; uint8_t nbits_; diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 6f93979b18..1c1c473f47 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -88,8 +88,8 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, rmt_channel_handle_t channel_{NULL}; uint32_t filter_symbols_{0}; uint32_t receive_symbols_{0}; - bool with_dma_{false}; uint32_t carrier_frequency_{0}; + bool with_dma_{false}; uint8_t carrier_duty_percent_{100}; #endif diff --git a/esphome/components/ring_buffer/ring_buffer.cpp b/esphome/components/ring_buffer/ring_buffer.cpp index 9604290cf0..dfcc53ebb2 100644 --- a/esphome/components/ring_buffer/ring_buffer.cpp +++ b/esphome/components/ring_buffer/ring_buffer.cpp @@ -10,8 +10,9 @@ namespace esphome::ring_buffer { static const char *const TAG = "ring_buffer"; RingBuffer::~RingBuffer() { - if (this->handle_ != nullptr) { - vRingbufferDelete(this->handle_); + // create() only builds the ring buffer once storage_ is allocated + if (this->storage_ != nullptr) { + vRingbufferDelete(this->handle_()); RAMAllocator allocator; allocator.deallocate(this->storage_, this->size_); } @@ -31,7 +32,12 @@ std::unique_ptr RingBuffer::create(size_t len, MemoryPreference pref return nullptr; } - rb->handle_ = xRingbufferCreateStatic(rb->size_, RINGBUF_TYPE_BYTEBUF, rb->storage_, &rb->structure_); + // handle_() assumes the returned handle is &structure_; NULL means the arguments were rejected + if (xRingbufferCreateStatic(rb->size_, RINGBUF_TYPE_BYTEBUF, rb->storage_, &rb->structure_) != rb->handle_()) { + allocator.deallocate(rb->storage_, rb->size_); + rb->storage_ = nullptr; + return nullptr; + } ESP_LOGD(TAG, "Created ring buffer with size %u", len); return rb; @@ -39,16 +45,16 @@ std::unique_ptr RingBuffer::create(size_t len, MemoryPreference pref void *RingBuffer::receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait) { length = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &length, ticks_to_wait, max_length); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &length, ticks_to_wait, max_length); return buffer_data; } -void RingBuffer::receive_release(void *item) { vRingbufferReturnItem(this->handle_, item); } +void RingBuffer::receive_release(void *item) { vRingbufferReturnItem(this->handle_(), item); } size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { size_t bytes_read = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &bytes_read, ticks_to_wait, len); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &bytes_read, ticks_to_wait, len); if (buffer_data == nullptr) { return 0; @@ -56,14 +62,14 @@ size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { std::memcpy(data, buffer_data, bytes_read); - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); if (bytes_read < len) { // Data may have wrapped around, so read a second time to receive the remainder size_t follow_up_bytes_read = 0; size_t bytes_remaining = len - bytes_read; - buffer_data = xRingbufferReceiveUpTo(this->handle_, &follow_up_bytes_read, 0, bytes_remaining); + buffer_data = xRingbufferReceiveUpTo(this->handle_(), &follow_up_bytes_read, 0, bytes_remaining); if (buffer_data == nullptr) { return bytes_read; @@ -71,7 +77,7 @@ size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { std::memcpy((void *) ((uint8_t *) (data) + bytes_read), buffer_data, follow_up_bytes_read); - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); bytes_read += follow_up_bytes_read; } @@ -89,13 +95,13 @@ size_t RingBuffer::write(const void *data, size_t len) { size_t RingBuffer::write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait, bool write_partial) { - if (!xRingbufferSend(this->handle_, data, len, ticks_to_wait)) { + if (!xRingbufferSend(this->handle_(), data, len, ticks_to_wait)) { if (!write_partial) { return 0; // Not enough space available and not allowed to write partial data } // Couldn't fit all the data, write what will fit size_t free = std::min(this->free(), len); - if (xRingbufferSend(this->handle_, data, free, 0)) { + if (xRingbufferSend(this->handle_(), data, free, 0)) { return free; } return 0; @@ -105,11 +111,11 @@ size_t RingBuffer::write_without_replacement(const void *data, size_t len, TickT size_t RingBuffer::available() const { UBaseType_t ux_items_waiting = 0; - vRingbufferGetInfo(this->handle_, nullptr, nullptr, nullptr, nullptr, &ux_items_waiting); + vRingbufferGetInfo(this->handle_(), nullptr, nullptr, nullptr, nullptr, &ux_items_waiting); return ux_items_waiting; } -size_t RingBuffer::free() const { return xRingbufferGetCurFreeSize(this->handle_); } +size_t RingBuffer::free() const { return xRingbufferGetCurFreeSize(this->handle_()); } BaseType_t RingBuffer::reset() { // Discards all the available data @@ -119,15 +125,15 @@ BaseType_t RingBuffer::reset() { bool RingBuffer::discard_bytes_(size_t discard_bytes) { size_t bytes_read = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &bytes_read, 0, discard_bytes); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &bytes_read, 0, discard_bytes); if (buffer_data != nullptr) - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); if (bytes_read < discard_bytes) { size_t wrapped_bytes_read = 0; - buffer_data = xRingbufferReceiveUpTo(this->handle_, &wrapped_bytes_read, 0, discard_bytes - bytes_read); + buffer_data = xRingbufferReceiveUpTo(this->handle_(), &wrapped_bytes_read, 0, discard_bytes - bytes_read); if (buffer_data != nullptr) { - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); bytes_read += wrapped_bytes_read; } } diff --git a/esphome/components/ring_buffer/ring_buffer.h b/esphome/components/ring_buffer/ring_buffer.h index 62094899d7..ed2d4b4345 100644 --- a/esphome/components/ring_buffer/ring_buffer.h +++ b/esphome/components/ring_buffer/ring_buffer.h @@ -115,7 +115,9 @@ class RingBuffer { /// @return True if all bytes were successfully discarded, false otherwise bool discard_bytes_(size_t discard_bytes); - RingbufHandle_t handle_{nullptr}; + // xRingbufferCreateStatic() returns the static structure itself as the handle + RingbufHandle_t handle_() const { return const_cast(&this->structure_); } + StaticRingbuffer_t structure_; uint8_t *storage_{nullptr}; size_t size_{0}; diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 7a32b79b17..055204571e 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -82,14 +82,14 @@ class Rtttl final : public Component { uint16_t note_duration_{0}; /// The duration of a whole note in milliseconds. uint16_t wholenote_duration_; + /// The current state of the RTTTL player. + State state_{State::STOPPED}; /// The time in milliseconds since microcontroller boot when the last note was started. uint32_t last_note_start_time_; /// The frequency of the current note in Hz. uint32_t output_freq_{0}; /// The gain of the output. float gain_{0.6f}; - /// The current state of the RTTTL player. - State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index b9ea75e0bc..97b27af498 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -254,7 +254,8 @@ template class ScriptExecuteAction, T } Script *script_; - Args args_; + // Empty tuple for scripts without parameters; takes no space then + [[no_unique_address]] Args args_; }; /** Wait for a script to finish before continuing. diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index e05d3b02e6..fe1481df9a 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -93,10 +93,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_update_interval_min(update_interval)) cg.add(var.set_rx_mode_only(config[CONF_RX_ONLY])) - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ae78e729e6..fa266fc81f 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -95,7 +95,9 @@ def select_schema( @setup_entity("select") async def setup_select_core_(var, config, *, options: list[str]): - cg.add(var.traits.set_options(options)) + if options: + table = cg.shared_progmem_array("select_options", cg.const_char_ptr, options) + cg.add(var.traits.set_options_static(table, len(options))) for conf in config.get(CONF_ON_VALUE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) @@ -187,14 +189,8 @@ automation.register_apply_action( async def select_is_to_code(config, condition_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) if options := config.get(CONF_OPTIONS): - # List of constant options - # Create a constexpr and pass that with a template length - arr_id = ID( - f"{condition_id}_data", - is_declaration=True, - type=global_ns.namespace("constexpr char * const"), - ) - arg = cg.static_const_array(arr_id, cg.ArrayInitializer(*options)) + # Shared flash table of option pointers, length passed as a template argument + arg = cg.shared_progmem_array("select_is_options", cg.const_char_ptr, options) template_arg = TemplateArguments(len(options), *template_arg) else: # Lambda diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index 67a5118646..23d1f47cce 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -2,13 +2,18 @@ namespace esphome::select { -void SelectTraits::set_options(const std::initializer_list &options) { this->options_ = options; } +// Runtime option lists are copied, since the argument may not outlive the select; one +// out of line copy keeps a single instance of the copy code. +void SelectTraits::set_options_copy_(const char *const *options, size_t count) { + this->options_.assign_copy(options, count); +} + +void SelectTraits::set_options(const std::initializer_list &options) { + this->set_options_copy_(options.begin(), options.size()); +} void SelectTraits::set_options(const FixedVector &options) { - this->options_.init(options.size()); - for (const auto &opt : options) { - this->options_.push_back(opt); - } + this->set_options_copy_(options.begin(), options.size()); } } // namespace esphome::select diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index e1b261bc96..47997ac7f2 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -5,14 +5,28 @@ namespace esphome::select { +/// Option strings: a shared codegen table, or a copy of a runtime list. +using SelectOptions = ConstVector; + class SelectTraits { public: + SelectTraits() = default; + SelectTraits(const SelectTraits &) = delete; + SelectTraits &operator=(const SelectTraits &) = delete; + + /// Codegen only: points at a table that outlives the select. Call before any runtime set_options; + /// it does not free a previous copy (generated setup() runs before any lambda or automation). + void set_options_static(const char *const *options, size_t count) { this->options_.assign_static(options, count); } + /// Runtime lists: the pointer list is copied, as before; the strings must still outlive the select. + void set_options(const SelectOptions &options) { this->set_options_copy_(options.data(), options.size()); } void set_options(const std::initializer_list &options); void set_options(const FixedVector &options); - const FixedVector &get_options() const { return this->options_; } + const SelectOptions &get_options() const { return this->options_; } protected: - FixedVector options_; + void set_options_copy_(const char *const *options, size_t count); + + SelectOptions options_; }; } // namespace esphome::select diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 8ab70d61ef..ea09dafe68 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -308,6 +308,9 @@ class SendspinHub final : public Component, CallbackManager group_update_callbacks_{}; bool task_stack_in_psram_{false}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool mdns_advertised_{false}; // Last state requested from mdns +#endif // Requested client state, applied from loop(). Empty until the switch restores its state. std::optional enabled_; @@ -320,7 +323,6 @@ class SendspinHub final : public Component, #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE mdns::MDNSComponent *mdns_{nullptr}; - bool mdns_advertised_{false}; // Last state requested from mdns #endif }; diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 63f5f7ad28..1f896640bf 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,7 +1,8 @@ import esphome.codegen as cg from esphome.components import switch +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns @@ -12,14 +13,19 @@ DEPENDENCIES = ["sendspin"] SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) CONFIG_SCHEMA = cv.All( - switch.switch_schema( - SendspinSwitch, - block_inverted=True, - default_restore_mode="RESTORE_DEFAULT_ON", - entity_category=ENTITY_CATEGORY_CONFIG, - ) - .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) - .extend(cv.COMPONENT_SCHEMA), + cv.typed_schema( + { + CONF_ENABLED: switch.switch_schema( + SendspinSwitch, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA), + }, + key=CONF_TYPE, + ), cv.only_on_esp32, ) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 9445e5867c..2494e9a6d1 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -53,9 +53,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) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/sht3xd/sensor.py b/esphome/components/sht3xd/sensor.py index eadca15050..baee146a22 100644 --- a/esphome/components/sht3xd/sensor.py +++ b/esphome/components/sht3xd/sensor.py @@ -56,10 +56,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_heater_enabled(config[CONF_HEATER_ENABLED])) - 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) diff --git a/esphome/components/shtcx/sensor.py b/esphome/components/shtcx/sensor.py index bda48c07ce..d6ca2b131b 100644 --- a/esphome/components/shtcx/sensor.py +++ b/esphome/components/shtcx/sensor.py @@ -51,10 +51,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 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) diff --git a/esphome/components/sim800l/binary_sensor.py b/esphome/components/sim800l/binary_sensor.py index 1cf0b4e1b8..c2bfa94fd5 100644 --- a/esphome/components/sim800l/binary_sensor.py +++ b/esphome/components/sim800l/binary_sensor.py @@ -19,8 +19,7 @@ CONFIG_SCHEMA = { async def to_code(config): - sim800l_component = await cg.get_variable(config[CONF_SIM800L_ID]) + hub = await cg.get_variable(config[CONF_SIM800L_ID]) - if CONF_REGISTERED in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_REGISTERED]) - cg.add(sim800l_component.set_registered_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_REGISTERED, hub.set_registered_binary_sensor) diff --git a/esphome/components/sim800l/sensor.py b/esphome/components/sim800l/sensor.py index 010e325df4..cc2ebb93a7 100644 --- a/esphome/components/sim800l/sensor.py +++ b/esphome/components/sim800l/sensor.py @@ -27,8 +27,7 @@ CONFIG_SCHEMA = { async def to_code(config): - sim800l_component = await cg.get_variable(config[CONF_SIM800L_ID]) + hub = await cg.get_variable(config[CONF_SIM800L_ID]) - if CONF_RSSI in config: - sens = await sensor.new_sensor(config[CONF_RSSI]) - cg.add(sim800l_component.set_rssi_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RSSI, hub.set_rssi_sensor) diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index a814f46e3b..3d2cd8ef50 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -1,18 +1,23 @@ from collections.abc import Callable, MutableMapping from dataclasses import dataclass from enum import StrEnum +from ipaddress import IPv4Address, IPv4Network import logging import esphome.codegen as cg +from esphome.components.const import CONF_ROLE from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] +socket_ns = cg.esphome_ns.namespace("socket") +Ipv4AllowEntry = socket_ns.struct("Ipv4AllowEntry") + CONF_IMPLEMENTATION = "implementation" IMPLEMENTATION_LWIP_TCP = "lwip_tcp" IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets" @@ -140,6 +145,53 @@ def require_wake_loop_threadsafe() -> None: cg.add_define("USE_SOCKET_SELECT_SUPPORT") +# For an Ipv4Allow config option; a sanity cap on the list length. +IPV4_ALLOW_SCHEMA = cv.All(cv.ensure_list(cv.ipv4network), cv.Length(max=255)) + + +_HOST = cv.Any(cv.domain, cv.hostname) + + +def ipv4_host(value: object) -> str: + """Validate an IPv4 address or a hostname; the resolver behind it is IPv4 only.""" + value = cv.string(value) + try: + cv.ipv6address(value) + except cv.Invalid: + return _HOST(value) + raise cv.Invalid( + "IPv6 addresses are not supported, use an IPv4 address or a hostname" + ) + + +def _network_order(addr: IPv4Address) -> int: + """The s_addr value for addr on the little endian targets.""" + return int.from_bytes(addr.packed, "little") + + +def add_ipv4_allow( + setter: cg.MockObj, networks: list[IPv4Network], owner_id: ID | str +) -> None: + """Emit a flash array for validated IPV4_ALLOW_SCHEMA entries and wire it to setter. + + PROGMEM on esp8266. Emits nothing for an empty list. + """ + if not networks: + return + cg.add_define("USE_SOCKET_IPV4_ALLOW") + entries = [ + cg.StructInitializer( + Ipv4AllowEntry, + ("addr", _network_order(net.network_address)), + ("mask", _network_order(net.netmask)), + ) + for net in networks + ] + arr_id = ID(f"{owner_id}_ipv4_allow", is_declaration=True, type=Ipv4AllowEntry) + arr = cg.progmem_array(arr_id, cg.ArrayInitializer(*entries)) + cg.add(setter(arr, len(entries))) + + def require_ipv4_resolve() -> None: """Compile the shared IPv4 lookup; call from a consumer's to_code.""" cg.add_define("USE_SOCKET_IPV4_RESOLVE") @@ -151,6 +203,23 @@ def require_tcp_client_link() -> None: cg.add_define("USE_SOCKET_TCP_CLIENT_LINK") +def require_tcp_listener() -> None: + """Compile the TCP listener; call from a server role's to_code.""" + require_tcp_client_link() + cg.add_define("USE_SOCKET_TCP_LISTENER") + + +def consume_role_sockets(component: str) -> Callable[[ConfigType], ConfigType]: + """Socket accounting for a role keyed client or server schema.""" + + def validator(config: ConfigType) -> ConfigType: + if config[CONF_ROLE] == "server": + consume_sockets(1, component, SocketType.TCP_LISTEN)(config) + return consume_sockets(1, component)(config) + + return validator + + CONFIG_SCHEMA = cv.Schema( { cv.SplitDefault( @@ -204,5 +273,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", "tcp_client_link.cpp": "USE_SOCKET_TCP_CLIENT_LINK", + "tcp_listener.cpp": "USE_SOCKET_TCP_LISTENER", } ) diff --git a/esphome/components/socket/ipv4_allow.h b/esphome/components/socket/ipv4_allow.h new file mode 100644 index 0000000000..f2f66edc05 --- /dev/null +++ b/esphome/components/socket/ipv4_allow.h @@ -0,0 +1,63 @@ +#pragma once + +#include "headers.h" +#include "socket.h" +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome::socket { + +/// One allowed IPv4 network, network byte order, host bits cleared. +/// Lives in flash; read via progmem_memcpy. +struct Ipv4AllowEntry { + uint32_t addr; + uint32_t mask; +}; + +/// IPv4 peers that may connect. An empty list allows every peer. +class Ipv4Allow { + public: + void set(const Ipv4AllowEntry *entries, size_t count) { + this->entries_ = entries; + this->count_ = count; + } + + /// A v4 mapped IPv6 peer is unwrapped; any other family fails a non empty list. + bool allows(const struct sockaddr *peer) const { + if (this->count_ == 0) { + return true; + } + uint32_t addr; + return sockaddr_to_ipv4(peer, &addr) && this->allows(addr); + } + + /// addr is network byte order, as it sits in a sockaddr_in. + bool allows(uint32_t addr) const { + if (this->count_ == 0) { + return true; + } + for (size_t i = 0; i != this->count_; i++) { + Ipv4AllowEntry e = this->entry(i); + if ((addr & e.mask) == e.addr) { + return true; + } + } + return false; + } + + size_t size() const { return this->count_; } + /// A copy of entry i, read from flash. + Ipv4AllowEntry entry(size_t i) const { + Ipv4AllowEntry e; + progmem_memcpy(&e, &this->entries_[i], sizeof(e)); + return e; + } + + private: + const Ipv4AllowEntry *entries_{nullptr}; + size_t count_{0}; +}; + +} // namespace esphome::socket diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f2d428af77..f6d1daa124 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -78,6 +78,25 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t #endif #endif +bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out) { + if (addr->sa_family == AF_INET) { + *out = reinterpret_cast(addr)->sin_addr.s_addr; + return true; + } +#if USE_NETWORK_IPV6 + if (addr->sa_family == AF_INET6) { + // ::ffff:a.b.c.d; s6_addr is the portable byte view on every stack. + static constexpr uint8_t V4_MAPPED_PREFIX[12] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF}; + const uint8_t *bytes = reinterpret_cast(addr)->sin6_addr.s6_addr; + if (memcmp(bytes, V4_MAPPED_PREFIX, sizeof(V4_MAPPED_PREFIX)) == 0) { + memcpy(out, bytes + sizeof(V4_MAPPED_PREFIX), sizeof(*out)); + return true; + } + } +#endif + return false; +} + // Format sockaddr into caller-provided buffer, returns length written (excluding null) size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf) { if (addr_ptr->sa_family == AF_INET && len >= sizeof(const struct sockaddr_in)) { @@ -88,29 +107,10 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s #if USE_NETWORK_IPV6 else if (addr_ptr->sa_family == AF_INET6 && len >= sizeof(sockaddr_in6)) { const auto *addr = reinterpret_cast(addr_ptr); -#ifdef USE_HOST - // Format IPv4-mapped IPv6 addresses as regular IPv4 (POSIX layout, no LWIP union) - if (IN6_IS_ADDR_V4MAPPED(&addr->sin6_addr) && - esphome_inet_ntop4(&addr->sin6_addr.s6_addr[12], buf.data(), buf.size()) != nullptr) { + uint32_t v4; + // Format a v4 mapped peer as plain IPv4. + if (sockaddr_to_ipv4(addr_ptr, &v4) && esphome_inet_ntop4(&v4, buf.data(), buf.size()) != nullptr) return strlen(buf.data()); - } -#elif defined(USE_ZEPHYR) - // Format IPv4-mapped IPv6 addresses as regular IPv4. Zephyr uses the standard POSIX - // s6_addr layout (not the LWIP union) but provides no IN6_IS_ADDR_V4MAPPED macro, so - // detect the ::ffff:0:0/96 prefix directly on the address words. - if (addr->sin6_addr.s6_addr32[0] == 0 && addr->sin6_addr.s6_addr32[1] == 0 && - addr->sin6_addr.s6_addr32[2] == htonl(0xFFFF) && - esphome_inet_ntop4(&addr->sin6_addr.s6_addr32[3], buf.data(), buf.size()) != nullptr) { - return strlen(buf.data()); - } -#elif !defined(USE_SOCKET_IMPL_LWIP_TCP) - // Format IPv4-mapped IPv6 addresses as regular IPv4 (LWIP layout) - if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 && - addr->sin6_addr.un.u32_addr[2] == htonl(0xFFFF) && - esphome_inet_ntop4(&addr->sin6_addr.un.u32_addr[3], buf.data(), buf.size()) != nullptr) { - return strlen(buf.data()); - } -#endif if (esphome_inet_ntop6(&addr->sin6_addr, buf.data(), buf.size()) != nullptr) return strlen(buf.data()); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 8ce392ff1d..9d7dedbbbf 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -137,6 +137,10 @@ inline std::unique_ptr socket_ip_loop_monitored(int type, int prot /// @return Size of the sockaddr structure used, or 0 on error socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port); +/// Network order IPv4 of an AF_INET or v4 mapped AF_INET6 peer; false for anything else. +/// addr must hold the family's full struct. +bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out); + /// Convenience overload for std::string (backward compatible). inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::string &ip_address, uint16_t port) { return set_sockaddr(addr, addrlen, ip_address.c_str(), port); diff --git a/esphome/components/socket/tcp_client_link.cpp b/esphome/components/socket/tcp_client_link.cpp index f2c1b8e4c1..9812f1835e 100644 --- a/esphome/components/socket/tcp_client_link.cpp +++ b/esphome/components/socket/tcp_client_link.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace esphome::socket { @@ -16,19 +17,23 @@ static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; // Non-blocking options and TCP keepalive for a bridged stream socket. // Keepalive is best-effort: the raw lwIP implementation (ESP8266, RP2040) // rejects it, so a half-open link there is only detected by a failed write. -static void set_stream_options(Socket *sock) { +static void set_stream_options(Socket *sock, const char *tag) { int yes = 1; + // Fails only on an invalid descriptor, or on raw lwIP after a peer reset that the next read() reports. sock->setblocking(false); - sock->setsockopt(IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); - sock->setsockopt(SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes)); + int err = sock->setsockopt(IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); + err |= sock->setsockopt(SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes)); #ifdef TCP_KEEPIDLE int idle = 30; int interval = 10; int count = 3; - sock->setsockopt(IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); - sock->setsockopt(IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); - sock->setsockopt(IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); #endif + if (err != 0) { + ESP_LOGV(tag, "Nodelay/keepalive not fully applied"); + } } void TcpClientLink::begin(const char *tag) { @@ -83,7 +88,7 @@ void TcpClientLink::try_connect_() { this->drop_(LOG_STR("Connect failed"), errno); return; } - set_stream_options(this->sock_.get()); + set_stream_options(this->sock_.get(), this->tag_); // Starts the pending-connect clock that poll() times out against. this->note_attempt(); // An immediate success is reported by the next poll(); poll_connect() sees it writable. @@ -94,7 +99,7 @@ void TcpClientLink::try_connect_() { void TcpClientLink::adopt(std::unique_ptr sock) { this->close(); - set_stream_options(sock.get()); + set_stream_options(sock.get(), this->tag_); this->sock_ = std::move(sock); this->connected_ = true; } @@ -114,7 +119,7 @@ ssize_t TcpClientLink::read(uint8_t *buf, size_t len) { return 0; } -ssize_t TcpClientLink::write(const uint8_t *buf, size_t len) { +ssize_t TcpClientLink::write_(const uint8_t *buf, size_t len) { if (!this->connected_ || len == 0) { return 0; } @@ -129,6 +134,26 @@ ssize_t TcpClientLink::write(const uint8_t *buf, size_t len) { return -1; } +size_t TcpClientLink::queue(const uint8_t *data, size_t len) { + size_t room = this->tx_free(); + if (len > room) { + len = room; + } + std::memcpy(this->tx_ + this->tx_len_, data, len); + this->tx_len_ += static_cast(len); + return len; +} + +void TcpClientLink::flush_tx_slow_() { + ssize_t sent = this->write_(this->tx_, this->tx_len_); + if (sent > 0) { + this->tx_len_ -= static_cast(sent); + if (this->tx_len_ != 0) { + std::memmove(this->tx_, this->tx_ + sent, this->tx_len_); + } + } +} + void TcpClientLink::close() { if (this->sock_ != nullptr) { this->sock_->shutdown(SHUT_RDWR); @@ -136,6 +161,7 @@ void TcpClientLink::close() { this->sock_.reset(); } this->connected_ = false; + this->tx_len_ = 0; this->resolved_.forget(); } diff --git a/esphome/components/socket/tcp_client_link.h b/esphome/components/socket/tcp_client_link.h index 065c4df562..f77ee05055 100644 --- a/esphome/components/socket/tcp_client_link.h +++ b/esphome/components/socket/tcp_client_link.h @@ -16,8 +16,9 @@ namespace esphome::socket { /// A reconnecting TCP stream driven from loop(). Owns the socket, the DNS -/// lookup and the retry backoff. A fatal read/write error closes the link -/// and schedules the next attempt; the caller sees the edge via connected(). +/// lookup, the retry backoff and the outgoing buffer. A fatal read/write +/// error closes the link and schedules the next attempt; the caller sees +/// the edge via connected(). class TcpClientLink { public: void set_host(const char *host) { this->host_ = StringRef(host); } @@ -41,7 +42,21 @@ class TcpClientLink { void adopt(std::unique_ptr sock); /// Returns bytes moved, 0 when nothing can move now, -1 when the link dropped. ssize_t read(uint8_t *buf, size_t len); - ssize_t write(const uint8_t *buf, size_t len); + /// Copy into the outgoing buffer; returns how many bytes fit. + size_t queue(const uint8_t *data, size_t len); + /// Direct access to the buffer's free tail. Fill at most tx_free() bytes, + /// then tx_commit() the count; neither is bounds checked. + uint8_t *tx_tail() { return this->tx_ + this->tx_len_; } + void tx_commit(size_t len) { this->tx_len_ += static_cast(len); } + size_t tx_free() const { return this->connected_ ? TX_BUFFER_SIZE - this->tx_len_ : 0; } + /// Send the front of the buffer; true once it is empty. + /// A partial write keeps the rest; inline no-op while nothing is queued. + bool flush_tx() { + if (this->tx_len_ != 0) { + this->flush_tx_slow_(); + } + return this->tx_len_ == 0; + } /// Close without scheduling a reconnect (shutdown). void close(); @@ -54,6 +69,11 @@ class TcpClientLink { } protected: + static constexpr size_t TX_BUFFER_SIZE = 1024; + + /// The raw stream write behind flush_tx(); drops the link on a fatal error. + ssize_t write_(const uint8_t *buf, size_t len); + void flush_tx_slow_(); void poll_slow_(); void try_connect_(); /// Close after a failure, log what and errno, schedule the next attempt. @@ -66,7 +86,9 @@ class TcpClientLink { uint32_t reconnect_interval_ms_{5000}; Ipv4Resolve resolved_; uint16_t port_{0}; + uint16_t tx_len_{0}; bool connected_{false}; + uint8_t tx_[TX_BUFFER_SIZE]{}; }; } // namespace esphome::socket diff --git a/esphome/components/socket/tcp_listener.cpp b/esphome/components/socket/tcp_listener.cpp new file mode 100644 index 0000000000..e1f00e2927 --- /dev/null +++ b/esphome/components/socket/tcp_listener.cpp @@ -0,0 +1,90 @@ +#include "tcp_listener.h" + +#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::socket { + +// One client at a time; a second connection waits in the stack until the first drops. +static constexpr int LISTEN_BACKLOG = 1; +#ifdef USE_SOCKET_IPV4_ALLOW +static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; +#endif + +void TcpListener::try_listen_(TcpClientLink &link) { + this->listen_ = socket_ip_loop_monitored(SOCK_STREAM, IPPROTO_TCP); + int err = errno; + if (this->listen_ != nullptr) { + int yes = 1; + this->listen_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + struct sockaddr_storage local; + socklen_t local_len = set_sockaddr_any(reinterpret_cast(&local), sizeof(local), link.port()); + // A blocking listener would stall loop() inside accept(), so its + // setblocking result is part of the success condition. + if (this->listen_->setblocking(false) == 0 && local_len != 0 && + this->listen_->bind(reinterpret_cast(&local), local_len) == 0 && + this->listen_->listen(LISTEN_BACKLOG) == 0) { + ESP_LOGI(this->tag_, "Listening on %u", link.port()); + return; + } + // Captured before reset(); the close inside can overwrite errno. + err = errno; + this->listen_.reset(); + } + ESP_LOGW(this->tag_, "Listen on %u failed: %d", link.port(), err); + link.note_attempt(); +} + +void TcpListener::accept_(TcpClientLink &link) { + struct sockaddr_storage peer {}; + socklen_t peer_len = sizeof(peer); + auto client = this->listen_->accept_loop_monitored(reinterpret_cast(&peer), &peer_len); + if (client == nullptr) { + // A reset during the handshake or a signal only affects that connection. + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == ECONNABORTED || errno == EINTR) { + return; + } + // Rebuild the listener after the backoff instead of spinning on it. + int err = errno; + this->listen_.reset(); + ESP_LOGW(this->tag_, "Accept failed: %d", err); + link.note_attempt(); + return; + } + const auto *sa = reinterpret_cast(&peer); + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(sa, peer_len, std::span(text)); +#ifdef USE_SOCKET_IPV4_ALLOW + if (!this->allow_.allows(sa)) { + uint32_t now = App.get_loop_component_start_time(); + if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { + this->last_reject_log_ms_ = now; + ESP_LOGW(this->tag_, "Rejected %s", text); + } + return; + } +#endif + link.adopt(std::move(client)); + ESP_LOGI(this->tag_, "Client connected from %s", text); +} + +void TcpListener::dump_config() const { +#ifdef USE_SOCKET_IPV4_ALLOW + for (size_t i = 0; i < this->allow_.size(); i++) { + Ipv4AllowEntry e = this->allow_.entry(i); + // Network order is dotted order, and the contiguous mask's popcount is the prefix. + const auto *b = reinterpret_cast(&e.addr); + ESP_LOGCONFIG(this->tag_, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], + static_cast(__builtin_popcount(e.mask))); + } +#endif +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/tcp_listener.h b/esphome/components/socket/tcp_listener.h new file mode 100644 index 0000000000..a42080e641 --- /dev/null +++ b/esphome/components/socket/tcp_listener.h @@ -0,0 +1,60 @@ +#pragma once + +#include "headers.h" + +#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) + +#ifdef USE_SOCKET_IPV4_ALLOW +#include "ipv4_allow.h" +#endif +#include "socket.h" +#include "tcp_client_link.h" + +#include +#include + +namespace esphome::socket { + +/// The server side of a bridged TCP link: owns the listen socket and the +/// allow list, accepts one peer at a time and adopts it into a TcpClientLink, +/// sharing that link's retry clock and connect port. +class TcpListener { + public: +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const Ipv4AllowEntry *entries, size_t count) { this->allow_.set(entries, count); } +#endif + + /// Call from setup(); tag names the log lines. + void begin(const char *tag) { this->tag_ = tag; } + /// Server state machine; call every loop. may_accept lets the caller hold + /// accepts until its own disconnect edge has run. + void poll(TcpClientLink &link, bool may_accept) { + if (this->listen_ == nullptr) { + if (!link.in_backoff()) { + this->try_listen_(link); + } + return; + } + if (may_accept && !link.connected() && this->listen_->ready()) { + this->accept_(link); + } + } + void close() { this->listen_.reset(); } + /// One config line per allowed network. + void dump_config() const; + + protected: + void try_listen_(TcpClientLink &link); + void accept_(TcpClientLink &link); + + std::unique_ptr listen_; + const char *tag_{nullptr}; +#ifdef USE_SOCKET_IPV4_ALLOW + uint32_t last_reject_log_ms_{0}; + Ipv4Allow allow_; +#endif +}; + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index a28e91dfd5..c8e31226ef 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -73,12 +73,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_measurement_duration(config[CONF_MEASUREMENT_DURATION])) - if peak_config := config.get(CONF_PEAK): - sens = await sensor.new_sensor(peak_config) - cg.add(var.set_peak_sensor(sens)) - if rms_config := config.get(CONF_RMS): - sens = await sensor.new_sensor(rms_config) - cg.add(var.set_rms_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PEAK, var.set_peak_sensor) + await sensors(CONF_RMS, var.set_rms_sensor) SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index c995c2c087..e084020a14 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -181,9 +181,9 @@ CONFIG_SCHEMA_BASE.add_extra(measurement_timing_check) 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] @@ -193,9 +193,8 @@ async def to_code_base(config: ConfigType) -> MockObj: var.set_temperature_sample_rate_config(temperature_config[CONF_SAMPLE_RATE]) ) - 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_sample_rate_config(pressure_config[CONF_SAMPLE_RATE])) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 78892bff22..159af15808 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -251,7 +251,7 @@ automation.register_apply_action( # so a trigger arg with the same name cannot shadow it. ( CONF_MEDIA_FILE, - audio.AudioFile.operator("ptr"), + audio.AudioFile.operator("const").operator("ptr"), lambda _, value: f"::{value}", ), (CONF_ANNOUNCEMENT, cg.bool_), diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 509984cfa2..f150a48573 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -66,7 +66,7 @@ void AudioPipeline::start_url(const std::string &uri) { this->pending_url_ = true; } -void AudioPipeline::start_file(audio::AudioFile *audio_file) { +void AudioPipeline::start_file(const audio::AudioFile *audio_file) { if (this->is_playing_) { xEventGroupSetBits(this->event_group_, PIPELINE_COMMAND_STOP); } diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index 02dad15de9..b114a7b7a5 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -74,7 +74,7 @@ class AudioPipeline final { /// @brief Starts an audio pipeline given a AudioFile pointer /// @param audio_file pointer to an AudioFile object /// @return ESP_OK if successful or an appropriate error if not - void start_file(audio::AudioFile *audio_file); + void start_file(const audio::AudioFile *audio_file); /// @brief Stops the pipeline. Sends a stop signal to each task (if running) and clears the ring buffers. /// @return ESP_OK if successful or ESP_ERR_TIMEOUT if the tasks did not indicate they stopped @@ -121,7 +121,7 @@ class AudioPipeline final { speaker::Speaker *speaker_{nullptr}; std::string current_uri_{}; - audio::AudioFile *current_audio_file_{nullptr}; + const audio::AudioFile *current_audio_file_{nullptr}; audio::AudioFileType current_audio_file_type_; audio::AudioStreamInfo current_audio_stream_info_; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index cac2f6649c..ab98935bda 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -493,7 +493,7 @@ void SpeakerMediaPlayer::loop() { #endif } -void SpeakerMediaPlayer::play_file(audio::AudioFile *media_file, bool announcement, bool enqueue) { +void SpeakerMediaPlayer::play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue) { if (!this->is_ready()) { // Ignore any commands sent before the media player is setup return; diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 889aedbc9a..e98196785c 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -28,13 +28,13 @@ struct MediaCallCommand { optional volume; optional announce; optional url; // Must be manually deleted after receiving this struct from a queue - optional file; + optional file; optional enqueue; }; struct PlaylistItem { optional url; - optional file; + optional file; }; struct VolumeRestoreState { @@ -87,7 +87,7 @@ class SpeakerMediaPlayer final : public Component, Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; } Trigger *get_volume_trigger() { return &this->volume_trigger_; } - void play_file(audio::AudioFile *media_file, bool announcement, bool enqueue); + void play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue); void set_playlist_delay_ms(AudioPipelineType pipeline_type, uint32_t delay_ms); diff --git a/esphome/components/speaker/speaker.h b/esphome/components/speaker/speaker.h index 01e9ca042e..277b36a171 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -127,9 +127,9 @@ class Speaker { #endif State state_{STATE_STOPPED}; + bool mute_state_{false}; audio::AudioStreamInfo audio_stream_info_; float volume_{1.0f}; - bool mute_state_{false}; #ifdef USE_AUDIO_DAC audio_dac::AudioDac *audio_dac_{nullptr}; diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index 7694b8f732..e4df7b443c 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -43,6 +43,10 @@ SX1509KeyTrigger = sx1509_ns.class_( def check_keys(config: ConfigType) -> ConfigType: + for ch in config.get(CONF_KEYS, ""): + if not ch.isascii(): + # Each key is reported as one byte, so only ASCII characters can be key codes + raise cv.Invalid(f"Key code {ch!r} is not an ASCII character") if ( CONF_KEYS in config and len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS] @@ -98,8 +102,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_sleep_time(conf[CONF_SLEEP_TIME])) cg.add(var.set_scan_time(conf[CONF_SCAN_TIME])) cg.add(var.set_debounce_time(conf[CONF_DEBOUNCE_TIME])) - if keys := conf.get(CONF_KEYS): - cg.add(var.set_keys(keys)) + if (keys := conf.get(CONF_KEYS)) is not None: + table = cg.shared_progmem_array( + "sx1509_keys", cg.uint8, list(keys.encode()) + ) + cg.add(var.set_keys(table)) for tconf in conf.get(CONF_ON_KEY, []): trigger = cg.new_Pvariable(tconf[CONF_TRIGGER_ID]) cg.add(var.register_key_trigger(trigger)) diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 2397049000..ee8606db96 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -48,7 +48,7 @@ void SX1509Component::loop() { uint16_t key_data = this->read_key_data(); for (auto *binary_sensor : this->keypad_binary_sensors_) binary_sensor->process(key_data); - if (this->keys_.empty()) + if (this->keys_ == nullptr) return; if (key_data == 0) { this->last_key_ = 0; @@ -64,7 +64,9 @@ void SX1509Component::loop() { break; } col -= 8; - uint8_t key = this->keys_[row * this->cols_ + col]; + if (row >= this->rows_ || col >= this->cols_) // a partial read can leave only a row or a column bit + return; + uint8_t key = progmem_read_byte(&this->keys_[row * this->cols_ + col]); if (key == this->last_key_) return; this->last_key_ = key; diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index c7aed2cddd..ef6ee6ec5e 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -51,7 +51,8 @@ class SX1509Component final : public Component, this->cols_ = cols; this->has_keypad_ = true; }; - void set_keys(std::string keys) { this->keys_ = std::move(keys); }; // NOLINT(performance-unnecessary-value-param) + /// `keys` is a codegen PROGMEM table with one key code per button. + void set_keys(const uint8_t *keys) { this->keys_ = keys; } void set_sleep_time(uint16_t sleep_time) { this->sleep_time_ = sleep_time; }; void set_scan_time(uint8_t scan_time) { this->scan_time_ = scan_time; }; void set_debounce_time(uint8_t debounce_time = 1) { this->debounce_time_ = debounce_time; }; @@ -76,7 +77,7 @@ class SX1509Component final : public Component, bool has_keypad_ = false; uint8_t rows_ = 0; uint8_t cols_ = 0; - std::string keys_; + const uint8_t *keys_{nullptr}; uint16_t sleep_time_ = 128; uint8_t scan_time_ = 1; uint8_t debounce_time_ = 1; diff --git a/esphome/components/t6615/sensor.py b/esphome/components/t6615/sensor.py index 44dba52ae8..4b055516e7 100644 --- a/esphome/components/t6615/sensor.py +++ b/esphome/components/t6615/sensor.py @@ -48,6 +48,5 @@ 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) diff --git a/esphome/components/tas58xx/__init__.py b/esphome/components/tas58xx/__init__.py index b87036984d..0931d7ba1f 100644 --- a/esphome/components/tas58xx/__init__.py +++ b/esphome/components/tas58xx/__init__.py @@ -1 +1,10 @@ +from esphome.config_helpers import filter_source_files_from_defines + CODEOWNERS = ["@mrtoy-me", "@remcom"] + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "model_tas5805m.cpp": "USE_TAS58XX_TAS5805M", + "model_tas5825m.cpp": "USE_TAS58XX_TAS5825M", + } +) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 18a60a6645..7b1e3e239e 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -11,6 +11,8 @@ from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL from esphome.cpp_generator import MockObj from esphome.types import ConfigType +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["i2c"] @@ -23,6 +25,9 @@ CONF_VOLUME_MIN_DB = "volume_min_db" CONF_VOLUME_MAX_DB = "volume_max_db" CONF_TAS58XX_ID = "tas58xx_id" +DAC_TAS5805M = "tas5805m" +DAC_TAS5825M = "tas5825m" + tas58xx_ns = cg.esphome_ns.namespace("tas58xx") TAS58xx = tas58xx_ns.class_("TAS58xx", AudioDac, cg.PollingComponent, i2c.I2CDevice) @@ -54,13 +59,20 @@ class Model: MODELS: dict[str, Model] = { - "tas5805m": Model( + DAC_TAS5805M: Model( model_info=tas58xx_ns.TAS5805M_MODEL, analog_gain_min_db=-15.5, volume_min_db=-103.0, volume_max_db=24.0, default_address=0x2D, ), + DAC_TAS5825M: Model( + model_info=tas58xx_ns.TAS5825M_MODEL, + analog_gain_min_db=-15.5, + volume_min_db=-103.0, + volume_max_db=24.0, + default_address=0x4C, + ), } @@ -162,6 +174,7 @@ for _name, _call in ( async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] + cg.add_define(f"USE_TAS58XX_{config[CONF_MODEL].upper()}") var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&{model.model_info}")) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/tas58xx/binary_sensor.py b/esphome/components/tas58xx/binary_sensor.py index 8a5bb03dd7..d057739874 100644 --- a/esphome/components/tas58xx/binary_sensor.py +++ b/esphome/components/tas58xx/binary_sensor.py @@ -1,15 +1,17 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.const import CONF_MODEL, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +import esphome.final_validate as fv from esphome.types import ConfigType -from .audio_dac import CONF_TAS58XX_ID, TAS58xx, tas58xx_ns +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import +from .audio_dac import CONF_TAS58XX_ID, DAC_TAS5825M, TAS58xx, tas58xx_ns CONF_HAVE_FAULT = "have_fault" -# Each name matches a FaultSensor value in tas58xx.h -FAULT_SENSORS = ( +# Faults common to TAS5805M and TAS5825M +COMMON_FAULT_SENSORS = ( "left_channel_dc_fault", "right_channel_dc_fault", "left_channel_over_current", @@ -20,8 +22,22 @@ FAULT_SENSORS = ( "pvdd_over_voltage", "pvdd_under_voltage", "over_temp_shutdown", - "over_temp_warning", + "over_temp_warning", # tas582x OTW Level 3 - use tas5805 naming ) +# Additional faults available on TAS5825M +TAS5825M_ONLY_FAULT_SENSORS = ( + "load_eeprom_error", + "right_channel_cbc_over_current", + "left_channel_cbc_over_current", + "left_channel_cbc_over_current_warning", + "right_channel_cbc_over_current_warning", + "over_temp_146c_warning", # tas582x OTW Level 4 + # "over_temp_122c_warning", # tas582x OTW Level 2 - not currently included + # "over_temp_112c_warning", # tas582x OTW Level 1 - not currently included +) + +# Each name matches a FaultSensor value in tas58xx.h +FAULT_SENSORS = COMMON_FAULT_SENSORS + TAS5825M_ONLY_FAULT_SENSORS FaultSensor = tas58xx_ns.enum("FaultSensor") @@ -39,13 +55,32 @@ CONFIG_SCHEMA = cv.Schema( ) +def _final_validate(config: ConfigType) -> ConfigType: + fconf = fv.full_config.get() + hub_path = fconf.get_path_for_id(config[CONF_TAS58XX_ID]) + hub_conf = fconf.get_config_for_path(hub_path[:-1]) + + if hub_conf[CONF_MODEL] == DAC_TAS5825M: + return config + + unsupported = sorted(frozenset(TAS5825M_ONLY_FAULT_SENSORS).intersection(config)) + if unsupported: + raise cv.Invalid( + f"binary_sensor platform tas58xx: '{', '.join(unsupported)}' is only available for 'model: {DAC_TAS5825M}' " + f"- Remove from YAML for 'model: {hub_conf[CONF_MODEL]}'" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_TAS58XX_ID]) - if sensor_config := config.get(CONF_HAVE_FAULT): - sens = await binary_sensor.new_binary_sensor(sensor_config) - cg.add(parent.set_have_fault_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_TAS58XX_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAVE_FAULT, hub.set_have_fault_binary_sensor) for key in FAULT_SENSORS: if sensor_config := config.get(key): sens = await binary_sensor.new_binary_sensor(sensor_config) fault = getattr(FaultSensor, f"FAULT_SENSOR_{key.upper()}") - cg.add(parent.set_fault_binary_sensor(fault, sens)) + cg.add(hub.set_fault_binary_sensor(fault, sens)) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp index e5eb5a837e..de0a0c5d75 100644 --- a/esphome/components/tas58xx/model_tas5805m.cpp +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -67,17 +67,23 @@ const ModelInfo TAS5805M_MODEL = { .fault_name = fault_name, .fault_sensor_bits = { - 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT - 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT - 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT - 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT - 15, // FAULT_SENSOR_OTP_CRC_CHECK - 14, // FAULT_SENSOR_BQ_WRITE_FAILED - 10, // FAULT_SENSOR_CLOCK_FAULT - 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE - 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE - 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN - 26, // FAULT_SENSOR_OVER_TEMP_WARNING + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + NO_BIT, // FAULT_SENSOR_LOAD_EEPROM_ERROR + NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT + NO_BIT, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT + NO_BIT, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING + NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING + NO_BIT, // FAULT_SENSOR_OVER_TEMP_146C_WARNING }, }; diff --git a/esphome/components/tas58xx/model_tas5825m.cpp b/esphome/components/tas58xx/model_tas5825m.cpp new file mode 100644 index 0000000000..3aa74c675e --- /dev/null +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -0,0 +1,119 @@ +#include "tas58xx.h" + +#include "esphome/core/hal.h" + +namespace esphome::tas58xx { + +// Remainder of the startup sequence, run after the reset. Register 0x00 selects the page. +// All writes except starting and last three register writes are exactly the startup sequence from TI PurePath Console. +// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and Page 2 registers 0x1D, 0x19 are not documented in the datasheet. + +// clang-format off +static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { + {0x03, 0x00}, // Deep Sleep + {0x7D, 0x11}, + {0x7E, 0xFF}, + {0x00, 0x01}, // Page 1 + {0x51, 0x05}, + {0x00, 0x02}, // Page 2 + {0x1D, 0x00}, + {0x19, 0x80}, + {0x00, 0x00}, // Page 0 + {0x46, 0x11}, + {0x02, 0x00}, // DEVICE_CTRL_1 - BD MODE, Damp to PBL MODE + {0x53, 0x01}, // ANA_CTRL PWM Phase Control - in phase + {0x54, 0x00}, // AGAIN 0dB + {0x03, 0x02}, // Hi-Z + {0x61, 0x0B}, // GPIO0_SEL - GPIO0 as FAULTZ output + {0x60, 0x01}, // GPIO0_OE - Enable GPIO0 as output + {0x77, 0x07}, // CBC_CONTROL enabling CBC function for warnings and faults +}; +// clang-format on + +static const LogString *model_name() { return LOG_STR("TAS5825M"); } + +// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266. +static const LogString *fault_name(uint8_t index) { + if (index == 0) + return LOG_STR("Right channel over current"); + if (index == 1) + return LOG_STR("Left channel over current"); + if (index == 2) + return LOG_STR("Right channel DC fault"); + if (index == 3) + return LOG_STR("Left channel DC fault"); + if (index == 8) + return LOG_STR("PVDD under voltage"); + if (index == 9) + return LOG_STR("PVDD over voltage"); + if (index == 10) + return LOG_STR("Clock fault"); + if (index == 13) + return LOG_STR("Load EEPROM error"); + if (index == 14) + return LOG_STR("BQ write failed"); + if (index == 15) + return LOG_STR("OTP CRC check error"); + if (index == 16) + return LOG_STR("Over temperature shutdown"); + if (index == 17) + return LOG_STR("Left channel CBC over current"); + if (index == 18) + return LOG_STR("Right channel CBC over current"); + // if (index == 24) + // return LOG_STR("Over temperature warning 112C"); // not currently included + // if (index == 25) + // return LOG_STR("Over temperature warning 122C"); // not currently included + if (index == 26) + return LOG_STR("Over temperature warning"); + if (index == 27) + return LOG_STR("Over temperature warning 146C"); + if (index == 28) + return LOG_STR("Right channel CBC over current warning"); + if (index == 29) + return LOG_STR("Left channel CBC over current warning"); + return LOG_STR("Unknown fault"); +} + +const ModelInfo TAS5825M_MODEL = { + .name = model_name, + .startup_sequence = STARTUP_SEQUENCE, + .startup_sequence_length = sizeof(STARTUP_SEQUENCE) / sizeof(STARTUP_SEQUENCE[0]), + .mixer_book = 0x8C, + .mixer_page = 0x0B, + .mixer_register = 0x14, + // The clock fault is left out of the log and have_fault: it is set whenever the I2S clock stops, which is normal + .fault_error_mask = 0x0007E30F, + .fault_warning_mask = + 0x3C000000, // 0x3F000000 if OVER_TEMP_122C_WARNING and OVER_TEMP_112C_WARNING included in future + // DC and over current faults keep the output off until cleared (datasheet 7.5.3.3.1, 7.5.3.3.2). They are not + // cleared automatically: a DC fault re-trips only after 570 ms, so a clear on every poll would pass DC to the + // speaker. + .fault_output_off_mask = 0x0000000F, + .fault_latched_mask = 0x3F07E70F, + .fault_name = fault_name, + .fault_sensor_bits = + { + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + 13, // FAULT_SENSOR_LOAD_EEPROM_ERROR + 18, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT + 17, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT + 29, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING + 28, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING + 27, // FAULT_SENSOR_OVER_TEMP_146C_WARNING + // 25, // FAULT_SENSOR_OVER_TEMP_122C_WARNING // not currently included + // 24, // FAULT_SENSOR_OVER_TEMP_112C_WARNING // not currently included + }, +}; + +} // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index 458150a125..d042d42e24 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -196,8 +196,9 @@ bool TAS58xx::read_faults_() { if (this->have_fault_binary_sensor_ != nullptr) this->have_fault_binary_sensor_->publish_state((active & model.fault_error_mask) != 0); for (uint8_t fault = 0; fault < FAULT_SENSOR_COUNT; fault++) { - if (this->fault_binary_sensors_[fault] != nullptr) - this->fault_binary_sensors_[fault]->publish_state(faults & (uint32_t{1} << model.fault_sensor_bits[fault])); + const uint8_t bit = model.fault_sensor_bits[fault]; + if (this->fault_binary_sensors_[fault] != nullptr && bit != NO_BIT) + this->fault_binary_sensors_[fault]->publish_state(faults & (uint32_t{1} << bit)); } #endif @@ -264,7 +265,7 @@ void TAS58xx::dump_config() { " Analog Gain: %.1f dB\n" " DAC Mode: %s\n" " Mixer Mode: %s\n" - " Volume Range: %.1f dB - %.1f dB", + " Volume Range: %.1f dB to %.1f dB", this->analog_gain_db_, this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"), LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_); diff --git a/esphome/components/tas58xx/tas58xx.h b/esphome/components/tas58xx/tas58xx.h index b1e9a531dd..d0f97a5cd9 100644 --- a/esphome/components/tas58xx/tas58xx.h +++ b/esphome/components/tas58xx/tas58xx.h @@ -27,9 +27,11 @@ enum MixerMode : uint8_t { MIXER_MODE_RIGHT, }; -/// Fault binary sensors that map to a single fault bit. The Python FAULT_SENSORS list uses the same names. +/// Fault binary sensors that map to a single fault bit. Note that ordering is important. +/// The Python FAULT_SENSORS list in binary_sensor.py uses the same names and ordering. enum FaultSensor : uint8_t { - FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT = 0, + // Faults common to TAS5805M and TAS5825M + FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT, FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT, FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT, FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT, @@ -40,9 +42,26 @@ enum FaultSensor : uint8_t { FAULT_SENSOR_PVDD_UNDER_VOLTAGE, FAULT_SENSOR_OVER_TEMP_SHUTDOWN, FAULT_SENSOR_OVER_TEMP_WARNING, - FAULT_SENSOR_COUNT, + // Faults available for TAS5825M Only + FAULT_SENSOR_LOAD_EEPROM_ERROR, + FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT, + FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT, + FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING, + FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING, + FAULT_SENSOR_OVER_TEMP_146C_WARNING, + // FAULT_SENSOR_OVER_TEMP_122C_WARNING, + // FAULT_SENSOR_OVER_TEMP_112C_WARNING, + FAULT_SENSOR_COUNT, // keep last }; +// compile time check +static_assert( + FAULT_SENSOR_COUNT == 17, + "enum FaultSensor altered: Update FAULT_SENSORS in binary_sensor.py and fault_sensor_bits in each model's cpp"); + +/// ModelInfo::fault_sensor_bits value for a FaultSensor that the model does not have. +static constexpr uint8_t NO_BIT = 0xFF; + /// Everything that differs between models of the family. One constant instance exists per model, see /// model_*.cpp, and each TAS58xx instance points to the one for its model. /// @@ -67,11 +86,16 @@ struct ModelInfo { /// Faults that stay set after the condition is gone and are cleared after each read uint32_t fault_latched_mask; const LogString *(*fault_name)(uint8_t index); - /// Bit index for each FaultSensor + /// Bit index for each FaultSensor, NO_BIT used where the model does not have that fault bit uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT]; }; +#ifdef USE_TAS58XX_TAS5805M extern const ModelInfo TAS5805M_MODEL; +#endif +#ifdef USE_TAS58XX_TAS5825M +extern const ModelInfo TAS5825M_MODEL; +#endif class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c::I2CDevice { public: diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 20866d1a0c..796a9fc48d 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -1,9 +1,13 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, socket, uart +from esphome.components import binary_sensor, sensor, socket, uart from esphome.components.const import ( + CONF_ALLOWED_IPS, + CONF_CONNECTED, CONF_DATA_BITS, CONF_HOST, CONF_PARITY, + CONF_RECONNECT_INTERVAL, + CONF_ROLE, CONF_STOP_BITS, ) import esphome.config_validation as cv @@ -13,55 +17,89 @@ from esphome.const import ( CONF_PORT, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, ) from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["uart", "binary_sensor", "socket"] +AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] MULTI_CONF = True +CONF_DISCONNECTS = "disconnects" + tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) -CONF_RECONNECT_INTERVAL = "reconnect_interval" -CONF_CONNECTED = "connected" +BASE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TcpUart), + cv.Required(CONF_PORT): cv.port, + cv.Optional(CONF_BAUD_RATE, default=9600): cv.int_range(min=1), + cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), + cv.Optional(CONF_PARITY, default="NONE"): cv.enum( + uart.UART_PARITY_OPTIONS, upper=True + ), + cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), + cv.Optional( + CONF_RECONNECT_INTERVAL, default="5s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) CONFIG_SCHEMA = cv.All( - cv.Schema( + cv.typed_schema( { - cv.GenerateID(): cv.declare_id(TcpUart), - cv.Required(CONF_HOST): cv.string, - cv.Required(CONF_PORT): cv.port, - cv.Optional(CONF_BAUD_RATE, default=9600): cv.int_range(min=1), - cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), - cv.Optional(CONF_PARITY, default="NONE"): cv.enum( - uart.UART_PARITY_OPTIONS, upper=True + "client": BASE_SCHEMA.extend( + { + cv.Required(CONF_HOST): socket.ipv4_host, + } ), - cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), - cv.Optional( - CONF_RECONNECT_INTERVAL, default="5s" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + "server": BASE_SCHEMA.extend( + { + cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA, + } ), - } - ).extend(cv.COMPONENT_SCHEMA), - socket.consume_sockets(1, "tcp_uart"), + }, + key=CONF_ROLE, + default_type="client", + lower=True, + ), + socket.consume_role_sockets("tcp_uart"), ) async def to_code(config: ConfigType) -> None: - socket.require_tcp_client_link() - var = cg.new_Pvariable(config[CONF_ID], config[CONF_HOST], config[CONF_PORT]) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config[CONF_ROLE] == "server": + socket.require_tcp_listener() + cg.add(var.set_server(True)) + socket.add_ipv4_allow( + var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID] + ) + else: + socket.require_tcp_client_link() + cg.add(var.set_port(config[CONF_PORT])) cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) # The socket is not clocked. These only satisfy UARTComponent and a consumer check. cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) cg.add(var.set_data_bits(config[CONF_DATA_BITS])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_parity(config[CONF_PARITY])) + if (host := config.get(CONF_HOST)) is not None: + cg.add(var.set_host(host)) binary_sensors = binary_sensor.sub_binary_sensors(config) await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index fc81f6ecfe..efc85804e9 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -14,30 +14,60 @@ static constexpr uint32_t DROP_LOG_INTERVAL_MS = 5000; void TcpUart::setup() { this->link_.begin(TAG); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.begin(TAG); +#endif if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#ifdef USE_SENSOR + if (this->disconnects_sensor_ != nullptr) { + this->disconnects_sensor_->publish_state(0); + } +#endif } void TcpUart::dump_config() { ESP_LOGCONFIG(TAG, "TCP UART:\n" - " Host: %s:%u\n" + " %s: %s:%u\n" " Reconnect Interval: %" PRIu32 "ms", - this->link_.host(), this->link_.port(), this->link_.reconnect_interval()); + this->server_ ? LOG_STR_LITERAL("Listen") : LOG_STR_LITERAL("Host"), + this->server_ ? LOG_STR_LITERAL("*") : this->link_.host(), this->link_.port(), + this->link_.reconnect_interval()); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.dump_config(); +#endif LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); +#endif +} + +void TcpUart::on_shutdown() { + this->link_.close(); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.close(); +#endif } void TcpUart::sync_link_() { bool up = this->link_.connected(); this->link_was_up_ = up; - if (!up) { + if (up) { + // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; - this->tx_len_ = 0; } if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#ifdef USE_SENSOR + // Only edges get here, so down is the falling edge. + if (!up && this->disconnects_sensor_ != nullptr) { + this->disconnects_++; + this->disconnects_sensor_->publish_state(this->disconnects_); + } +#endif } void TcpUart::read_socket_() { @@ -53,7 +83,7 @@ void TcpUart::read_socket_() { } ssize_t count = this->link_.read(this->rx_ + this->rx_end_, room); if (count <= 0) { - // A dropped link (-1) is cleaned up by sync_link_() on the next loop. + // A dropped link (-1) is seen by sync_link_() on the next loop. if (count == 0) { this->rx_pending_ = false; } @@ -63,16 +93,18 @@ void TcpUart::read_socket_() { this->rx_pending_ = static_cast(count) == room; } -void TcpUart::flush_tx_() { - ssize_t sent = this->link_.write(this->tx_, this->tx_len_); - if (sent > 0) { - this->tx_len_ -= static_cast(sent); - std::memmove(this->tx_, this->tx_ + sent, this->tx_len_); - } -} - void TcpUart::loop() { +#ifdef USE_SOCKET_TCP_LISTENER + if (this->server_) { + // link_was_up_ holds the accept until the previous drop's edge has run, + // so the sensor sees the disconnect and the new session's edge clears RX. + this->listener_.poll(this->link_, !this->link_was_up_); + } else { + this->link_.poll(); + } +#else this->link_.poll(); +#endif if (this->link_.connected() != this->link_was_up_) { this->sync_link_(); } @@ -82,25 +114,20 @@ void TcpUart::loop() { if (this->rx_pending_ || this->link_.ready()) { this->read_socket_(); } - if (this->tx_len_ != 0) { - this->flush_tx_(); - } + this->link_.flush_tx(); } void TcpUart::write_array(const uint8_t *data, size_t len) { - size_t room = this->link_.connected() ? sizeof(this->tx_) - this->tx_len_ : 0; - if (len > room) { + size_t queued = this->link_.queue(data, len); + if (queued < len) { uint32_t now = App.get_loop_component_start_time(); if (this->last_drop_log_ms_ == 0 || now - this->last_drop_log_ms_ >= DROP_LOG_INTERVAL_MS) { ESP_LOGW(TAG, "%s, dropped %u bytes", this->link_.connected() ? LOG_STR_LITERAL("TX buffer full") : LOG_STR_LITERAL("Not connected"), - static_cast(len - room)); + static_cast(len - queued)); this->last_drop_log_ms_ = now; } - len = room; } - std::memcpy(this->tx_ + this->tx_len_, data, len); - this->tx_len_ += static_cast(len); } bool TcpUart::peek_byte(uint8_t *data) { @@ -121,11 +148,13 @@ bool TcpUart::read_array(uint8_t *data, size_t len) { } uart::UARTFlushResult TcpUart::flush() { - this->flush_tx_(); - if (this->tx_len_ == 0) { - return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + bool emptied = this->link_.flush_tx(); + if (!this->link_.connected()) { + // A down link cannot have delivered anything, whether this flush dropped + // it or an earlier loop() write did. + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; } - return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; + return emptied ? uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS : uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; } } // namespace esphome::tcp_uart diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index c13d2ef6f0..1e1d48e4c2 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -1,7 +1,13 @@ #pragma once #include "esphome/components/binary_sensor/binary_sensor.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif #include "esphome/components/socket/tcp_client_link.h" +#ifdef USE_SOCKET_TCP_LISTENER +#include "esphome/components/socket/tcp_listener.h" +#endif #include "esphome/components/uart/uart_component.h" #include "esphome/core/component.h" @@ -9,22 +15,29 @@ namespace esphome::tcp_uart { -/// TCP client presented as a UART. Bytes are copied unchanged. +/// TCP client or server presented as a UART. Bytes are copied unchanged. class TcpUart : public uart::UARTComponent, public Component { public: - TcpUart(const char *host, uint16_t port) { - this->link_.set_host(host); - this->link_.set_port(port); - this->rx_buffer_size_ = RX_BUFFER_SIZE; - } + TcpUart() { this->rx_buffer_size_ = RX_BUFFER_SIZE; } + void set_host(const char *host) { this->link_.set_host(host); } + void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#ifdef USE_SENSOR + void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } +#endif +#ifdef USE_SOCKET_TCP_LISTENER + void set_server(bool server) { this->server_ = server; } +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->listener_.set_allow(entries, count); } +#endif +#endif void setup() override; void loop() override; void dump_config() override; - void on_shutdown() override { this->link_.close(); } + void on_shutdown() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void write_array(const uint8_t *data, size_t len) override; @@ -32,35 +45,40 @@ class TcpUart : public uart::UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override { return static_cast(this->rx_end_ - this->rx_start_); } // Same room write_array() grants, so consumers can apply backpressure. - size_t available_for_write() override { return this->link_.connected() ? sizeof(this->tx_) - this->tx_len_ : 0; } + size_t available_for_write() override { return this->link_.tx_free(); } uart::UARTFlushResult flush() override; bool is_connected() override { return this->link_.connected(); } #if defined(USE_ESP8266) || defined(USE_ESP32) void load_settings(bool dump_config) override {} + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience #endif protected: void check_logger_conflict() override {} void sync_link_(); void read_socket_(); - void flush_tx_(); static constexpr size_t RX_BUFFER_SIZE = 1024; - static constexpr size_t TX_BUFFER_SIZE = 1024; socket::TcpClientLink link_; +#ifdef USE_SOCKET_TCP_LISTENER + socket::TcpListener listener_; +#endif binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#ifdef USE_SENSOR + sensor::Sensor *disconnects_sensor_{nullptr}; + uint32_t disconnects_{0}; +#endif uint32_t last_drop_log_ms_{0}; - uint16_t tx_len_{0}; // rx_[rx_start_, rx_end_) holds unread bytes; read_socket_() compacts to the front. uint16_t rx_start_{0}; uint16_t rx_end_{0}; - // The link state loop() saw last; edges clear the buffers and publish the sensor. + bool server_{false}; + // The link state loop() saw last; edges publish the sensor, the up edge clears rx_. bool link_was_up_{false}; // A read stopped before EAGAIN. ready() stays false until new data arrives. bool rx_pending_{false}; uint8_t rx_[RX_BUFFER_SIZE]{}; - uint8_t tx_[TX_BUFFER_SIZE]{}; }; } // namespace esphome::tcp_uart diff --git a/esphome/components/tem3200/sensor.py b/esphome/components/tem3200/sensor.py index 289641f951..2b1839c529 100644 --- a/esphome/components/tem3200/sensor.py +++ b/esphome/components/tem3200/sensor.py @@ -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 raw_pressure_config := config.get(CONF_RAW_PRESSURE): - sens = await sensor.new_sensor(raw_pressure_config) - cg.add(var.set_raw_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_RAW_PRESSURE, var.set_raw_pressure_sensor) diff --git a/esphome/components/template/number/template_number.h b/esphome/components/template/number/template_number.h index e51e858ccf..2c4932f7e8 100644 --- a/esphome/components/template/number/template_number.h +++ b/esphome/components/template/number/template_number.h @@ -25,8 +25,8 @@ class TemplateNumber final : public number::Number, public PollingComponent { protected: void control(float value) override; bool optimistic_{false}; - float initial_value_{NAN}; bool restore_value_{false}; + float initial_value_{NAN}; Trigger set_trigger_; TemplateLambda f_; diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 1cc28a36d3..c6a1b8bd08 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -9,7 +9,8 @@ namespace esphome::template_ { -struct Empty {}; +// Distinct per member: two empty members of the same type cannot share an address +template struct Empty {}; class BaseTemplateSelect : public select::Select, public PollingComponent {}; void dump_config_helper(BaseTemplateSelect *sel_comp, bool optimistic, bool has_lambda, size_t initial_option_index, @@ -59,8 +60,8 @@ class TemplateSelect : public BaseTemplateSelect { if constexpr (RESTORE_VALUE) this->pref_.save(&index); } - [[no_unique_address]] std::conditional_t, Empty> f_{}; - [[no_unique_address]] std::conditional_t pref_{}; + [[no_unique_address]] std::conditional_t, Empty<0>> f_{}; + [[no_unique_address]] std::conditional_t> pref_{}; }; /// Template select with set_action trigger - only instantiated when set_action is configured diff --git a/esphome/components/tx20/sensor.py b/esphome/components/tx20/sensor.py index 3a24b8dc0b..77418e5284 100644 --- a/esphome/components/tx20/sensor.py +++ b/esphome/components/tx20/sensor.py @@ -44,15 +44,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_WIND_SPEED in config: - conf = config[CONF_WIND_SPEED] - sens = await sensor.new_sensor(conf) - cg.add(var.set_wind_speed_sensor(sens)) - - if CONF_WIND_DIRECTION_DEGREES in config: - conf = config[CONF_WIND_DIRECTION_DEGREES] - sens = await sensor.new_sensor(conf) - cg.add(var.set_wind_direction_degrees_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_WIND_SPEED, var.set_wind_speed_sensor) + await sensors(CONF_WIND_DIRECTION_DEGREES, var.set_wind_direction_degrees_sensor) pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 598e3df168..25fbfb7599 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -402,7 +402,9 @@ async def to_code(config): cg.add(var.set_clock_source(UART_CLOCK_SOURCES[clock_source])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_data_bits(config[CONF_DATA_BITS])) - cg.add(var.set_parity(config[CONF_PARITY])) + # Skip the setter when the config matches the C++ initializer (UART_CONFIG_PARITY_NONE). + if (parity := config[CONF_PARITY]) != "NONE": + cg.add(var.set_parity(parity)) if CONF_DEBUG in config: await debug_to_code(config[CONF_DEBUG], var) @@ -487,7 +489,8 @@ def final_validate_device_schema( return value def validate_stop_bits(value): - if value != stop_bits: + # usb_uart channels store stop bits as strings ("1", "1.5", "2"). + if float(value) != stop_bits: raise cv.Invalid( f"Component {name} requires {stop_bits} stop bits for the uart referenced by {uart_bus}" ) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 4269ef0b16..8f143b7977 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -217,7 +217,7 @@ class UARTComponent { uint32_t baud_rate_{0}; uint8_t stop_bits_{0}; uint8_t data_bits_{0}; - UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; + UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; // Must match the parity check in __init__.py #ifdef USE_UART_DEBUGGER CallbackManager debug_callback_{}; #endif diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 4eacd980db..293cb9e786 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -6,6 +6,8 @@ #include "esphome/core/log.h" #include "uart_component_libretiny.h" +#include "esphome/components/libretiny/core.h" + #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -118,6 +120,8 @@ void LibreTinyUARTComponent::setup() { #endif } + if (this->hardware_idx_ >= 0) + libretiny::ensure_serial_handle(this->hardware_idx_); this->serial_->begin(this->baud_rate_, get_config()); } diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py new file mode 100644 index 0000000000..6e7a6fef32 --- /dev/null +++ b/esphome/components/uart_tcp/__init__.py @@ -0,0 +1,161 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components.const import ( + CONF_ALLOWED_IPS, + CONF_CONNECTED, + CONF_HOST, + CONF_RECONNECT_INTERVAL, + CONF_ROLE, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEBUG, + CONF_DUMMY_RECEIVER, + CONF_ID, + CONF_PORT, + CONF_UART_ID, + DEVICE_CLASS_CONNECTIVITY, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@Bascht74"] +DOMAIN = "uart_tcp" +DEPENDENCIES = ["network", "uart"] +AUTO_LOAD = ["binary_sensor", "sensor", "socket"] +MULTI_CONF = True + +CONF_DISCONNECTS = "disconnects" + +uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") +UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) + + +BASE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(UartTcp), + cv.Required(CONF_UART_ID): cv.use_id(uart.UARTComponent), + cv.Required(CONF_PORT): cv.port, + cv.Optional( + CONF_RECONNECT_INTERVAL, default="5s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "client": BASE_SCHEMA.extend({cv.Required(CONF_HOST): socket.ipv4_host}), + "server": BASE_SCHEMA.extend( + {cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA} + ), + }, + key=CONF_ROLE, + default_type="client", + lower=True, + ), + socket.consume_role_sockets("uart_tcp"), +) + + +def _subtree_references_uart(node: object, uart_id: str) -> bool: + if isinstance(node, dict): + return any( + (key == CONF_UART_ID and str(value) == uart_id) + or _subtree_references_uart(value, uart_id) + for key, value in node.items() + ) + if isinstance(node, list): + return any(_subtree_references_uart(item, uart_id) for item in node) + return False + + +def _reject_dummy_receiver(uart_conf: ConfigType) -> ConfigType: + debug = uart_conf.get(CONF_DEBUG) + if isinstance(debug, dict) and debug.get(CONF_DUMMY_RECEIVER): + raise cv.Invalid( + "dummy_receiver reads this UART and drops the bytes uart_tcp should forward.", + [CONF_DEBUG, CONF_DUMMY_RECEIVER], + ) + return uart_conf + + +def _final_validate(config: ConfigType) -> ConfigType: + # A second reader would split the bytes with this one, and every connect + # discards what the other reader has not read yet. + full_config = fv.full_config.get() + data = full_config.data.setdefault(DOMAIN, {}) + uart_id = str(config[CONF_UART_ID]) + used = data.setdefault(CONF_UART_ID, set()) + if uart_id in used: + raise cv.Invalid( + f"The UART '{uart_id}' is already used by another 'uart_tcp' entry. " + "Each uart_tcp needs its own UART.", + [CONF_UART_ID], + ) + used.add(uart_id) + # Grouped CI builds share one bus between components, like uart's pin check. + # Bare `id:` references (a uart.write action) and lambdas are not caught. + if not CORE.testing_mode: + for domain, domain_conf in full_config.items(): + if domain != DOMAIN and _subtree_references_uart(domain_conf, uart_id): + raise cv.Invalid( + f"The UART '{uart_id}' is also used by '{domain}'. " + "uart_tcp requires exclusive use of that UART.", + [CONF_UART_ID], + ) + fv.id_declaration_match_schema(_reject_dummy_receiver)(config[CONF_UART_ID]) + + if config[CONF_ROLE] == "server": + # Two listeners on one port cannot both serve it. Only uart_tcp and + # tcp_uart servers are compared here, not other listeners such as api. + port = config[CONF_PORT] + ports = data.setdefault(CONF_PORT, set()) + if port in ports or any( + conf[CONF_ROLE] == "server" and conf[CONF_PORT] == port + for conf in full_config.get("tcp_uart", []) + ): + raise cv.Invalid( + f"Port {port} is already the listen port of another uart_tcp " + "server or of a tcp_uart server.", + [CONF_PORT], + ) + ports.add(port) + return config + + +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) + await uart.register_uart_device(var, config) + if config[CONF_ROLE] == "server": + socket.require_tcp_listener() + cg.add(var.set_server(True)) + socket.add_ipv4_allow( + var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID] + ) + else: + socket.require_tcp_client_link() + cg.add(var.set_port(config[CONF_PORT])) + cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) + if (host := config.get(CONF_HOST)) is not None: + cg.add(var.set_host(host)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp new file mode 100644 index 0000000000..4f241f8567 --- /dev/null +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -0,0 +1,153 @@ +#include "uart_tcp.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::uart_tcp { + +static const char *const TAG = "uart_tcp"; + +// Keeps the pacing product in 32 bits up to about 10 Mbaud. +static constexpr uint32_t MAX_PACE_SPAN_MS = 4000; + +void UartTcp::setup() { + this->link_.begin(TAG); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.begin(TAG); +#endif + if (this->connected_sensor_ != nullptr) { + this->connected_sensor_->publish_state(false); + } +#ifdef USE_SENSOR + if (this->disconnects_sensor_ != nullptr) { + this->disconnects_sensor_->publish_state(0); + } +#endif +} + +void UartTcp::dump_config() { + ESP_LOGCONFIG(TAG, + "UART TCP:\n" + " %s: %s:%u\n" + " Reconnect Interval: %" PRIu32 "ms", + this->server_ ? LOG_STR_LITERAL("Listen") : LOG_STR_LITERAL("Host"), + this->server_ ? LOG_STR_LITERAL("*") : this->link_.host(), this->link_.port(), + this->link_.reconnect_interval()); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.dump_config(); +#endif + LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); +#endif +} + +void UartTcp::on_shutdown() { + this->link_.close(); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.close(); +#endif +} + +void UartTcp::sync_link_() { + bool up = this->link_.connected(); + this->link_was_up_ = up; + if (up) { + // The driver kept whatever arrived while the link was down. + this->discard_uart_(); + } + if (this->connected_sensor_ != nullptr) { + this->connected_sensor_->publish_state(up); + } +#ifdef USE_SENSOR + // Only edges get here, so down is the falling edge. + if (!up && this->disconnects_sensor_ != nullptr) { + this->disconnects_++; + this->disconnects_sensor_->publish_state(this->disconnects_); + } +#endif +} + +void UartTcp::read_socket_() { + // A hardware write blocks until the driver takes every byte. Leave what does + // not fit in the socket, so TCP flow control throttles the peer. + size_t room = this->parent_->available_for_write(); + if (room == SIZE_MAX) { + // Capacity unknown on this platform; pace to the UART time since the last write, + // at most one loop interval and 4 s, so a pass woken early by the socket writes little. + uint32_t span = std::min( + {App.get_loop_component_start_time() - this->last_write_ms_, App.get_loop_interval(), MAX_PACE_SPAN_MS}); + // 10 bits per byte on the line. + uint32_t paced = this->parent_->get_baud_rate() / 10 * span / 1000; + room = std::max(1, paced); + } + if (room == 0) { + this->rx_pending_ = true; + return; + } + uint8_t tmp[READ_CHUNK]; + size_t want = std::min(room, sizeof(tmp)); + ssize_t count = this->link_.read(tmp, want); + if (count <= 0) { + // A dropped link (-1) is cleaned up by sync_link_() on the next loop. + if (count == 0) { + this->rx_pending_ = false; + } + return; + } + this->rx_pending_ = static_cast(count) == want; + this->write_array(tmp, static_cast(count)); + this->last_write_ms_ = App.get_loop_component_start_time(); +} + +void UartTcp::discard_uart_() { + // Drain exactly what was buffered while the link was down; later bytes are live. + uint8_t dump[DISCARD_CHUNK]; + size_t left = this->available(); + while (left != 0) { + size_t n = std::min(left, sizeof(dump)); + if (!this->read_array(dump, n)) { + return; + } + left -= n; + } +} + +void UartTcp::read_uart_() { + size_t want = std::min(this->available(), this->link_.tx_free()); + if (want != 0 && this->read_array(this->link_.tx_tail(), want)) { + this->link_.tx_commit(want); + } +} + +void UartTcp::loop() { +#ifdef USE_SOCKET_TCP_LISTENER + if (this->server_) { + // link_was_up_ holds the accept until the previous drop's edge has run, + // so the sensor and the stale UART discard always see the disconnect. + this->listener_.poll(this->link_, !this->link_was_up_); + } else { + this->link_.poll(); + } +#else + this->link_.poll(); +#endif + if (this->link_.connected() != this->link_was_up_) { + this->sync_link_(); + } + if (!this->link_was_up_) { + return; + } + if (this->rx_pending_ || this->link_.ready()) { + this->read_socket_(); + } + // UART bytes picked up here go out in the same pass. + this->read_uart_(); + this->link_.flush_tx(); +} + +} // namespace esphome::uart_tcp diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h new file mode 100644 index 0000000000..256e059133 --- /dev/null +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -0,0 +1,70 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/components/socket/tcp_client_link.h" +#ifdef USE_SOCKET_TCP_LISTENER +#include "esphome/components/socket/tcp_listener.h" +#endif +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::uart_tcp { + +/// Copies raw bytes between one hardware UART and one TCP socket. +class UartTcp final : public Component, public uart::UARTDevice { + public: + void set_host(const char *host) { this->link_.set_host(host); } + void set_port(uint16_t port) { this->link_.set_port(port); } + void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } + void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#ifdef USE_SENSOR + void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } +#endif +#ifdef USE_SOCKET_TCP_LISTENER + void set_server(bool server) { this->server_ = server; } +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->listener_.set_allow(entries, count); } +#endif +#endif + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + protected: + void sync_link_(); + void read_socket_(); + void read_uart_(); + void discard_uart_(); + + static constexpr size_t READ_CHUNK = 128; + // Scratch size for dropping stale UART bytes on connect. + static constexpr size_t DISCARD_CHUNK = 32; + + socket::TcpClientLink link_; +#ifdef USE_SOCKET_TCP_LISTENER + socket::TcpListener listener_; +#endif + binary_sensor::BinarySensor *connected_sensor_{nullptr}; + // Loop start time of the last socket-to-UART write; sizes the next paced write. + uint32_t last_write_ms_{0}; +#ifdef USE_SENSOR + sensor::Sensor *disconnects_sensor_{nullptr}; + uint32_t disconnects_{0}; +#endif + bool server_{false}; + // The link state loop() saw last; edges clear the buffer and publish the sensor. + bool link_was_up_{false}; + // A read stopped before EAGAIN. ready() stays false until new data arrives. + bool rx_pending_{false}; +}; + +} // namespace esphome::uart_tcp diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index d96a731e9c..316d4fc311 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -124,7 +124,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_broadcast_port(conf_port[CONF_BROADCAST_PORT])) if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) - cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]])) + # Shared flash table ended by nullptr, so the component stores only a pointer. + if addresses := [str(addr) for addr in config[CONF_ADDRESSES]]: + cg.add( + var.set_addresses( + cg.shared_progmem_array( + "udp_addresses", cg.const_char_ptr, [*addresses, cg.nullptr] + ) + ) + ) for conf in config.get(CONF_ON_RECEIVE, []): trigger_id = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) trigger = await automation.build_automation(trigger_id, trigger_argtype, conf) diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index f662cba4cf..904ed97dc0 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -11,7 +11,8 @@ static const char *const TAG = "udp"; void UDPComponent::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - for (const auto &address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; struct sockaddr saddr {}; if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) { ESP_LOGW(TAG, "Invalid address %s", address); @@ -97,7 +98,8 @@ void UDPComponent::setup() { #endif #ifdef USE_SOCKET_IMPL_LWIP_TCP // 8266 and RP2040 `Duino - for (const auto &address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; auto ipaddr = IPAddress(); if (!ipaddr.fromString(address)) { ESP_LOGW(TAG, "Invalid address %s", address); @@ -138,7 +140,8 @@ void UDPComponent::dump_config() { " Listen Port: %u\n" " Broadcast Port: %u", this->listen_port_, this->broadcast_port_); - for (const char *address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; ESP_LOGCONFIG(TAG, " Address: %s", address); } if (this->listen_address_.has_value()) { diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 274e0119ee..17e27fb770 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -20,9 +20,8 @@ namespace esphome::udp { static const size_t MAX_PACKET_SIZE = 508; class UDPComponent final : public Component { public: - void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } - /// Prevent accidental use of std::string which would dangle - void set_addresses(std::initializer_list addresses) = delete; + /// nullptr-terminated table of addresses; must outlive the component. + void set_addresses(const char *const *addresses) { this->addresses_ = addresses; } void set_listen_address(const char *listen_addr) { this->listen_address_ = network::IPAddress(listen_addr); } void set_listen_port(uint16_t port) { this->listen_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } @@ -52,7 +51,7 @@ class UDPComponent final : public Component { std::vector ipaddrs_{}; WiFiUDP udp_client_{}; #endif - FixedVector addresses_{}; + const char *const *addresses_{nullptr}; optional listen_address_{}; }; diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 5bfa959c04..6a632e05cd 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -64,13 +64,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_temperature_compensation(config[CONF_TEMPERATURE_COMPENSATION])) cg.add(var.set_temperature_coefficient(config[CONF_TEMPERATURE_COEFFICIENT])) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_EC in config: - sens = await sensor.new_sensor(config[CONF_EC]) - cg.add(var.set_ec_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_EC, var.set_ec_sensor) if CONF_TEMPERATURE_SENSOR in config: sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 5399692e3c..6f205af784 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -56,13 +56,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_PH in config: - sens = await sensor.new_sensor(config[CONF_PH]) - cg.add(var.set_ph_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_PH, var.set_ph_sensor) if CONF_TEMPERATURE_SENSOR in config: sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) diff --git a/esphome/components/unicode/__init__.py b/esphome/components/unicode/__init__.py new file mode 100644 index 0000000000..f70ffa9520 --- /dev/null +++ b/esphome/components/unicode/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/core"] diff --git a/esphome/components/unicode/unicode.cpp b/esphome/components/unicode/unicode.cpp new file mode 100644 index 0000000000..f5b4084fe0 --- /dev/null +++ b/esphome/components/unicode/unicode.cpp @@ -0,0 +1,97 @@ +#include "unicode.h" + +namespace esphome::unicode { + +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(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(utf8_str); + return code_point; +} + +} // namespace esphome::unicode diff --git a/esphome/components/unicode/unicode.h b/esphome/components/unicode/unicode.h new file mode 100644 index 0000000000..9e69d036e0 --- /dev/null +++ b/esphome/components/unicode/unicode.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace esphome::unicode { + +/** + * 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 + */ +uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length); + +} // namespace esphome::unicode diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 5d0f8be165..af58c24d43 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -7,6 +7,7 @@ from esphome.components.usb_host import ( register_usb_client, usb_device_schema, ) +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -15,6 +16,7 @@ from esphome.const import ( CONF_DEBUG, CONF_DUMMY_RECEIVER, CONF_ID, + CONF_TYPE, ) from esphome.core import CORE from esphome.cpp_types import Component @@ -64,6 +66,8 @@ class Type: self.vid = vid self.pid = pid self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) + # CDC ACM lives in usb_uart.cpp; each vendor driver has its own .cpp + self.driver = None if cls == "CdcAcm" else cls self._max_channels = max_channels self.baud_rate_required = baud_rate_required self.max_baud = max_baud @@ -121,6 +125,21 @@ uart_types = ( ), ) +_TYPES_BY_NAME = {it.name: it for it in uart_types} + + +def _driver_define(driver: str) -> str: + return f"USE_USB_UART_{driver}" + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + f"{it.driver.lower()}.cpp": _driver_define(it.driver) + for it in uart_types + if it.driver is not None + } +) + def channel_schema(type_: "Type") -> cv.Schema: schema = cv.Schema( @@ -211,6 +230,8 @@ async def to_code(config: list[ConfigType]) -> None: cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count) for device in config: + if (driver := _TYPES_BY_NAME[device[CONF_TYPE]].driver) is not None: + cg.add_define(_driver_define(driver)) var = await register_usb_client(device) # The C++ default is true; only emit the override if not device.get(CONF_CLAIM_COMM_INTERFACE, True): diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 22563209da..c99992d878 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -155,7 +155,22 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; } + void set_parity(UARTParityOptions parity) { + this->parity_ = parity; + // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. + // MARK/SPACE have no uart:: equivalent and report as NONE. + switch (parity) { + case UART_CONFIG_PARITY_EVEN: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); + break; + case UART_CONFIG_PARITY_ODD: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); + break; + default: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); + break; + } + } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } diff --git a/esphome/components/version/text_sensor.py b/esphome/components/version/text_sensor.py index cad239dc41..189d18256f 100644 --- a/esphome/components/version/text_sensor.py +++ b/esphome/components/version/text_sensor.py @@ -35,5 +35,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - cg.add(var.set_hide_hash(config[CONF_HIDE_HASH])) - cg.add(var.set_hide_timestamp(config[CONF_HIDE_TIMESTAMP])) + # Both flags default to false in C++, so only true needs a setter. + if config[CONF_HIDE_HASH]: + cg.add(var.set_hide_hash(True)) + if config[CONF_HIDE_TIMESTAMP]: + cg.add(var.set_hide_timestamp(True)) diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index 7ff6ac4d35..8537435079 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -15,8 +15,8 @@ class VersionTextSensor final : public text_sensor::TextSensor, public Component void dump_config() override; protected: - bool hide_hash_{false}; - bool hide_timestamp_{false}; + bool hide_hash_{false}; // Must match the setter check in text_sensor.py + bool hide_timestamp_{false}; // Must match the setter check in text_sensor.py }; } // namespace esphome::version diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index e99ee07099..ae5eee3e22 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -44,7 +44,7 @@ enum VoiceAssistantFeature : uint32_t { FEATURE_MULTI_CHANNEL_AUDIO = 1 << 6, }; -enum class State { +enum class State : uint8_t { IDLE, START_MICROPHONE, STARTING_MICROPHONE, @@ -96,7 +96,7 @@ struct Configuration { }; #ifdef USE_MEDIA_PLAYER -enum class MediaPlayerResponseState { +enum class MediaPlayerResponseState : uint8_t { IDLE, URL_SENT, PLAYING, @@ -237,6 +237,7 @@ class VoiceAssistant final : public Component { void set_state_(State state); void set_state_(State state, State desired_state); + bool start_udp_socket_(); void signal_stop_(); void start_playback_timeout_(); @@ -280,8 +281,6 @@ class VoiceAssistant final : public Component { Trigger timer_updated_trigger_; Trigger timer_cancelled_trigger_; Trigger &> timer_tick_trigger_; - bool has_timers_{false}; - bool timer_tick_running_{false}; microphone::MicrophoneSource *mic_source_{nullptr}; microphone::MicrophoneSource *mic_source2_{nullptr}; @@ -292,19 +291,12 @@ class VoiceAssistant final : public Component { size_t speaker_buffer_index_{0}; size_t speaker_buffer_size_{0}; size_t speaker_bytes_received_{0}; - bool wait_for_stream_end_{false}; - bool stream_ended_{false}; #endif #ifdef USE_MEDIA_PLAYER media_player::MediaPlayer *media_player_{nullptr}; std::string tts_response_url_; - bool started_streaming_tts_{false}; - - MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE}; #endif - bool local_output_{false}; - std::string conversation_id_; std::string wake_word_; @@ -324,29 +316,37 @@ class VoiceAssistant final : public Component { // prolonged one can be detected and stopped; 0 means no imbalance is currently being timed. uint32_t audio_channel_stall_start_{0}; - bool use_wake_word_; - uint8_t noise_suppression_level_; - uint8_t auto_gain_; float volume_multiplier_; uint32_t conversation_timeout_; - bool continuous_{false}; - bool silence_detection_; - - bool continue_conversation_{false}; - - State state_{State::IDLE}; - State desired_state_{State::IDLE}; - - AudioMode audio_mode_{AUDIO_MODE_UDP}; - bool udp_socket_running_{false}; - bool start_udp_socket_(); - Configuration config_{}; #ifdef USE_MICRO_WAKE_WORD micro_wake_word::MicroWakeWord *micro_wake_word_{nullptr}; #endif + + // 1 byte members grouped at the end so they share padding instead of each taking a word + State state_{State::IDLE}; + State desired_state_{State::IDLE}; + AudioMode audio_mode_{AUDIO_MODE_UDP}; + bool has_timers_{false}; + bool timer_tick_running_{false}; +#ifdef USE_SPEAKER + bool wait_for_stream_end_{false}; + bool stream_ended_{false}; +#endif +#ifdef USE_MEDIA_PLAYER + MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE}; + bool started_streaming_tts_{false}; +#endif + bool local_output_{false}; + bool use_wake_word_; + uint8_t noise_suppression_level_; + uint8_t auto_gain_; + bool continuous_{false}; + bool silence_detection_; + bool continue_conversation_{false}; + bool udp_socket_running_{false}; }; extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2b00a6f8de..6bc4d7ab5c 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -413,7 +413,16 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") - cg.add(var.set_allowed_origins(allowed_origins)) + # Shared flash table ended by nullptr, so the server stores only a pointer. + cg.add( + var.set_allowed_origins( + cg.shared_progmem_array( + "web_server_allowed_origins", + cg.const_char_ptr, + [*allowed_origins, cg.nullptr], + ) + ) + ) if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") # The scheme is fixed at build time so the unused Basic/Digest code path is compiled diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 74641738ec..90c6b167f6 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -495,7 +495,8 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const #ifdef USE_WEBSERVER_ALLOWED_ORIGINS // Otherwise the origin must be explicitly allowed via configuration. - for (const char *allowed_origin : this->allowed_origins_) { + for (const char *const *it = this->allowed_origins_; *it != nullptr; it++) { + const char *allowed_origin = *it; // A single "*" entry allows any origin. if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') return true; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 143e9a15ea..1f9b6cb934 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -254,9 +254,9 @@ class WebServer final : public Component, public AsyncWebHandler { * * This list is also used to authorize Private Network Access requests when that feature is enabled. * - * @param origins The list of allowed origins. + * @param origins nullptr-terminated table of allowed origins; must outlive the server. */ - void set_allowed_origins(std::initializer_list origins) { this->allowed_origins_ = origins; } + void set_allowed_origins(const char *const *origins) { this->allowed_origins_ = origins; } #endif // ========== INTERNAL METHODS ========== @@ -531,6 +531,9 @@ class WebServer final : public Component, public AsyncWebHandler { bool include_internal_{false}; protected: + // Declared here so it packs into the padding after include_internal_ + bool expose_log_{true}; + void add_sorting_info_(JsonObject &root, EntityBase *entity); #ifdef USE_LIGHT @@ -614,11 +617,10 @@ class WebServer final : public Component, public AsyncWebHandler { #ifdef USE_WEBSERVER_JS_INCLUDE const char *js_include_{nullptr}; #endif - bool expose_log_{true}; #ifdef USE_WEBSERVER_ALLOWED_ORIGINS // Extra origins allowed to make cross-origin browser requests ("*" means any origin). // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. - FixedVector allowed_origins_; + const char *const *allowed_origins_{nullptr}; #endif /// Check whether the given request Origin is permitted. Same-origin (matching the Host the diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 5a400dfbf3..5eac63212f 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,6 +1,6 @@ from esphome.components.esp32 import ( - add_idf_sdkconfig_option, include_builtin_idf_component, + set_idf_sdkconfig_default, ) from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv @@ -16,7 +16,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server - add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) + set_idf_sdkconfig_default("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) # Re-enable esp-tls (excluded by default to save compile time); # web_server_idf.cpp includes for digest auth include_builtin_idf_component("esp-tls") diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 510b4e50f1..9798410e23 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -761,7 +761,7 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { this->has_pending_sessions_.store(false, std::memory_order_relaxed); } for (auto *rsp : incoming) { - // Already disconnected? Drop it; skip on_connect_/session start on a dead session. + // Already disconnected? Drop it; skip session start on a dead session. if (rsp->safe_to_delete_()) { delete rsp; // NOLINT(cppcoreguidelines-owning-memory) continue; @@ -775,12 +775,7 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { continue; } this->sessions_.push_back(rsp); - // Prime first so on_connect_ observes a session that has already sent its - // initial ping/config/sorting_groups, matching the pre-refactor ordering. rsp->start_session_main_loop_(); - if (this->on_connect_) { - this->on_connect_(rsp); - } } } // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 2b62d68790..581ba8323b 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -379,7 +379,6 @@ using AsyncEventSourceClient = AsyncEventSourceResponse; class AsyncEventSource : public AsyncWebHandler { friend class AsyncEventSourceResponse; - using connect_handler_t = std::function; public: AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} @@ -394,11 +393,6 @@ class AsyncEventSource : public AsyncWebHandler { } // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; - // Callback runs on the main loop (not the httpd task) after the session's - // initial ping/config/sorting_groups have been sent. - // NOLINTNEXTLINE(readability-identifier-naming) - void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } - void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); @@ -418,7 +412,6 @@ class AsyncEventSource : public AsyncWebHandler { // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. std::vector pending_sessions_; Mutex pending_mutex_; - connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; std::atomic has_pending_sessions_{false}; }; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1ba8946841..27ebdbc976 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -368,6 +368,13 @@ def final_validate(config): "The AP will not be usable for configuration or monitoring. " "Add 'captive_portal:' or 'web_server:' to your configuration." ) + if "esp32_hosted" in full_config and any( + CONF_EAP in net for net in config.get(CONF_NETWORKS, []) + ): + _LOGGER.warning( + "WPA2 Enterprise ('eap:') is not supported by the esp32_hosted coprocessor " + "firmware ESPHome provides" + ) def _consume_wifi_sockets(config: ConfigType) -> ConfigType: @@ -507,7 +514,7 @@ CONFIG_SCHEMA = cv.All( rp2="light", bk72xx="none", rtl87xx="none", - ln882x="light", + ln882x="none", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, @@ -662,6 +669,10 @@ async def to_code(config): if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): cg.add(var.set_ap_timeout(ap_timeout)) cg.add_define("USE_WIFI_AP") + # The LN882H radio cannot run the AP and STA together; the fallback AP + # takes turns with the networks instead. + if CORE.is_ln882x: + cg.add_define("USE_WIFI_AP_EXCLUSIVE") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which # drops SoftAP support / the LWIP DHCP server when AP mode is unused. @@ -727,6 +738,10 @@ async def to_code(config): if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) + # LN882x: hand the SDK the BSSID LibreTiny 1.13 drops (see wifi_component_libretiny.cpp); remove once fixed upstream. + if CORE.is_ln882x: + cg.add_build_flag("-Wl,--wrap=wifi_sta_connect") + # post_connect_roaming defaults to true in C++ - disable if user disabled it # or if 802.11k/v is enabled (driver handles roaming natively) if ( diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 125139ad16..fd982c0d31 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -380,6 +380,25 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; /// Cooldown duration in milliseconds after adapter restart or repeated failures /// Allows WiFi hardware to stabilize before next connection attempt static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; +#ifdef USE_WIFI_AP +/// Whether the radio runs the fallback AP on its own, without STA alongside. +#ifdef USE_WIFI_AP_EXCLUSIVE +static constexpr bool WIFI_AP_EXCLUSIVE = true; +#else +static constexpr bool WIFI_AP_EXCLUSIVE = false; +#endif +#endif +#ifdef USE_WIFI_AP_EXCLUSIVE +/// On a radio that cannot run the AP and STA together: how long the fallback +/// AP stays up while nobody uses it before it pauses so the networks can be +/// tried again, and how long new credentials give the portal to answer +/// before the AP drops. +static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; +/// The AP pauses after this long even with clients counted, so a lost +/// disconnect event cannot keep the networks from being tried for good. +static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; +static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; +#endif /// Cooldown duration when fallback AP is active and captive portal may be running /// Longer interval gives users time to configure WiFi without constant connection attempts @@ -642,6 +661,14 @@ void WiFiComponent::setup() { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); +#ifdef USE_WIFI_AP_EXCLUSIVE + // The networks wait while the AP runs on its own; hand them the radio + // now rather than at the end of the dwell. + if (this->state_ == WIFI_COMPONENT_STATE_AP && this->has_sta()) { + this->pause_exclusive_ap_(); + return; + } +#endif this->wifi_mode_({}, false); } }); @@ -739,8 +766,11 @@ void WiFiComponent::start() { } #ifdef USE_CAPTIVE_PORTAL if (captive_portal::global_captive_portal != nullptr) { - this->wifi_sta_pre_setup_(); - this->start_scanning(); + // Where the radio scans alongside the AP, the portal can list networks. + if (!WIFI_AP_EXCLUSIVE) { + this->wifi_sta_pre_setup_(); + this->start_scanning(); + } captive_portal::global_captive_portal->start(); } #endif @@ -762,7 +792,7 @@ void WiFiComponent::restart_adapter() { // and check_connecting_finished() is called after cooldown without going // through start_connecting() first. Without this clear, stale errors would // trigger spurious "failed (callback)" logs. The canonical clear location - // is in start_connecting(); this is the only exception to that pattern. + // is in start_connecting() (ESP8266 also clears after wifi_station_connect()). this->error_from_callback_ = false; } @@ -862,8 +892,17 @@ void WiFiComponent::loop() { } break; } - case WIFI_COMPONENT_STATE_OFF: case WIFI_COMPONENT_STATE_AP: +#ifdef USE_WIFI_AP_EXCLUSIVE + // The networks are not tried while the AP is up, so it pauses for + // them once nobody has used it for a while. Without networks the AP + // is all there is and stays. + if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && + (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) + this->pause_exclusive_ap_(); + break; +#endif + case WIFI_COMPONENT_STATE_OFF: break; case WIFI_COMPONENT_STATE_DISABLED: return; @@ -879,11 +918,23 @@ void WiFiComponent::loop() { provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); #endif if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) { - if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) { + if (this->ap_timeout_ != 0 && + (now - this->last_connected_ > this->ap_timeout_) +#ifdef USE_WIFI_AP_EXCLUSIVE + // After a pause, or a start that failed, the networks get a full + // ap_timeout before the AP is tried again. + && now - this->ap_exclusive_changed_ > this->ap_timeout_ +#endif + ) { ESP_LOGI(TAG, "Starting fallback AP"); +#ifdef USE_WIFI_AP_EXCLUSIVE + this->ap_exclusive_changed_ = now; +#endif this->setup_ap_config_(); #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { + // Where the AP runs on its own, a portal with no AP behind it would + // only stretch the cooldowns. + if (captive_portal::global_captive_portal != nullptr && (!WIFI_AP_EXCLUSIVE || this->ap_setup_)) { // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) this->has_completed_scan_after_captive_portal_start_ = false; @@ -972,7 +1023,13 @@ network::IPAddress WiFiComponent::get_dns_address(int num) { #ifdef USE_WIFI_AP void WiFiComponent::setup_ap_config_() { +#ifdef USE_WIFI_AP_EXCLUSIVE + // The radio cannot run both, so STA goes off while the AP is up. + if (!this->wifi_mode_(false, true)) + return; +#else this->wifi_mode_({}, true); +#endif if (this->ap_setup_) return; @@ -1006,6 +1063,12 @@ void WiFiComponent::setup_ap_config_() { } } this->ap_setup_ = this->wifi_start_ap_(this->ap_); +#ifdef USE_WIFI_AP_EXCLUSIVE + // A failed start must not leave the radio in AP mode, which would refuse + // the STA attempts until the next try. + if (!this->ap_setup_) + this->wifi_mode_({}, false); +#endif char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, @@ -1030,11 +1093,40 @@ void WiFiComponent::setup_ap_config_() { } #endif - if (!this->has_sta()) { + // Where the AP runs on its own, the STA state machine also waits in this + // state until the AP pauses. + if (!this->has_sta() || (WIFI_AP_EXCLUSIVE && this->ap_setup_)) { this->state_ = WIFI_COMPONENT_STATE_AP; } } +#ifdef USE_WIFI_AP_EXCLUSIVE +void WiFiComponent::pause_exclusive_ap_() { + ESP_LOGI(TAG, "Pausing AP to try the networks"); + // Scanning and connecting turn STA back on. + this->disable_ap_(); + this->ap_setup_ = false; + // Clients drop with the AP without a disconnect event for each. + this->ap_clients_ = 0; + this->ap_exclusive_changed_ = App.get_loop_component_start_time(); + // A fresh connection cycle, as at boot, rather than resuming the one the + // AP cut short. + this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; + this->num_retried_ = 0; + this->start_initial_connection_(); +} +#endif + +void WiFiComponent::disable_ap_() { +#ifdef USE_CAPTIVE_PORTAL + if (this->is_captive_portal_active_()) { + captive_portal::global_captive_portal->end(); + } +#endif + ESP_LOGD(TAG, "Disabling AP"); + this->wifi_mode_({}, false); +} + void WiFiComponent::set_ap(const WiFiAP &ap) { this->ap_ = ap; this->has_ap_ = true; @@ -1126,6 +1218,17 @@ void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { } void WiFiComponent::connect_soon_() { +#ifdef USE_WIFI_AP_EXCLUSIVE + // New credentials from the portal: the AP pauses for them once its answer + // is out, and the STA state machine picks them up from there. + if (this->ap_setup_) { + this->set_timeout("wifi-ap-handover", WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { + if (this->ap_setup_) + this->pause_exclusive_ap_(); + }); + return; + } +#endif // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); @@ -1218,8 +1321,9 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { // Clear any stale error from previous connection attempt. // This is the canonical location for clearing the flag since all connection - // attempts go through start_connecting(). The only other clear is in - // restart_adapter() which enters COOLDOWN without calling start_connecting(). + // attempts go through start_connecting(). restart_adapter() also clears it, as it + // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again + // after wifi_station_connect(), whose callbacks fire synchronously. this->error_from_callback_ = false; if (!this->wifi_sta_connect_(ap)) { @@ -1635,15 +1739,11 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; +#ifdef USE_WIFI_AP if (this->has_ap()) { -#ifdef USE_CAPTIVE_PORTAL - if (this->is_captive_portal_active_()) { - captive_portal::global_captive_portal->end(); - } -#endif - ESP_LOGD(TAG, "Disabling AP"); - this->wifi_mode_({}, false); + this->disable_ap_(); } +#endif #ifdef USE_IMPROV_BLE if (this->is_improv_ble_active_()) { improv_ble::global_improv_component->stop(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 6791379649..9700e36e6f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -303,7 +303,7 @@ class WiFiAP { bssid_t bssid_{}; // 6 bytes, all zeros = any/not set uint8_t channel_{0}; // 1 byte, 0 = auto/not set int8_t priority_{0}; // 1 byte - bool hidden_{false}; // 1 byte (+ 3 bytes end padding to 4-byte align) + bool hidden_{false}; // 1 byte; WiFiAP is byte aligned unless manual IP or EAP adds a 4 byte aligned member }; class WiFiScanResult { @@ -506,6 +506,9 @@ class WiFiComponent final : public Component { // (In most use cases you won't need these) /// Setup WiFi interface. void setup() override; +#ifdef USE_LN882X + void on_powerdown() override; +#endif void start(); void dump_config() override; void restart_adapter(); @@ -684,6 +687,12 @@ class WiFiComponent final : public Component { protected: #ifdef USE_WIFI_AP void setup_ap_config_(); + /// End the captive portal and turn the AP off. + void disable_ap_(); +#ifdef USE_WIFI_AP_EXCLUSIVE + /// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout. + void pause_exclusive_ap_(); +#endif #endif // USE_WIFI_AP void print_connect_params_(); @@ -874,9 +883,6 @@ class WiFiComponent final : public Component { #ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED Mutex scan_result_lock_; #endif -#ifdef USE_WIFI_AP - WiFiAP ap_; -#endif #ifdef USE_WIFI_IP_STATE_LISTENERS StaticVector ip_state_listeners_; #endif @@ -919,11 +925,17 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; +#ifdef USE_WIFI_AP_EXCLUSIVE + uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused +#endif uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py + // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the + // 1-byte members so they pack into its trailing bytes instead of padding after it + WiFiAP ap_; #endif // 1-byte enums and integers @@ -983,7 +995,8 @@ class WiFiComponent final : public Component { // Bools and bitfields // Pending listener callbacks deferred from platform callbacks to main loop. - struct { + // Empty when no listener needs deferring (e.g. ESP32 without connect state listeners) + [[no_unique_address]] struct { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Deferred until state machine reaches STA_CONNECTED so wifi.connected // condition returns true in listener automations. @@ -1007,6 +1020,9 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; +#ifdef USE_WIFI_AP_EXCLUSIVE + uint8_t ap_clients_{0}; // Devices joined to the AP, which keep it from pausing +#endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; #ifdef USE_WIFI_11KV_SUPPORT diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 60ec3f9a4d..aa1fa67b7b 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -376,17 +376,17 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->wifi_apply_hostname_(); - // Reset flags, do this _before_ wifi_station_connect as the callback method - // may be called from wifi_station_connect - this->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTING); - ETS_UART_INTR_DISABLE(); ret = wifi_station_connect(); ETS_UART_INTR_ENABLE(); if (!ret) { ESP_LOGV(TAG, "wifi_station_connect failed"); + this->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); return false; } + // Reset after, not before: wifi_station_connect() synchronously reports the attempt it replaces as failed + this->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTING); + this->error_from_callback_ = false; #if USE_NETWORK_IPV6 bool connected = false; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 24bf64a99c..c44ee5b1de 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -829,6 +829,12 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { (const char *) it.ssid, bssid_buf, it.channel, get_auth_mode_str(it.authmode)); #endif s_sta_connected = true; +#if USE_NETWORK_IPV6 + // Start SLAAC on association, not after a DHCPv4 lease that may never arrive. + if (esp_err_t ll_err = esp_netif_create_ip6_linklocal(s_sta_netif); ll_err != ESP_OK) { + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(ll_err)); + } +#endif /* USE_NETWORK_IPV6 */ if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED) { // Driver-initiated roam: the WIFI_REASON_ROAMING disconnect was ignored, // so the state machine never left STA_CONNECTED. @@ -881,9 +887,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) { const auto &it = data->data.ip_got_ip; -#if USE_NETWORK_IPV6 - esp_netif_create_ip6_linklocal(s_sta_netif); -#endif /* USE_NETWORK_IPV6 */ ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; #ifdef USE_WIFI_IP_STATE_LISTENERS diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 940f2a0783..28b30628fd 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -28,6 +28,22 @@ extern "C" { } #endif +#ifdef USE_LN882X +#include +#undef DATA // clashes with setup_priority::DATA +// LibreTiny 1.13's LN882H reconnect() gives the SDK a NULL bssid; pass on the requested one. Remove once it passes +// info.bssid. Safe without a BSSID: begin() frees sta.bssid (resetNetworkInfo), so it stays NULL. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism. +extern "C" int __real_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cfg_t *scan_cfg); +extern "C" int __wrap_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cfg_t *scan_cfg) { + if (connect->bssid == nullptr) + connect->bssid = static_cast(WiFi.data)->sta.bssid; + return __real_wifi_sta_connect(connect, scan_cfg); +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif + #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -608,6 +624,9 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { +#ifdef USE_WIFI_AP_EXCLUSIVE + this->ap_clients_++; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto &it = event->data.sta_connected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -617,6 +636,10 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { +#ifdef USE_WIFI_AP_EXCLUSIVE + if (this->ap_clients_ > 0) + this->ap_clients_--; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto &it = event->data.sta_disconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -770,6 +793,13 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { yield(); +#ifdef USE_LN882X + // LibreTiny's LN882H softAP() points ap.bssid at a static array and frees it + // on the next call, tripping FreeRTOS's configASSERT until the watchdog + // resets the chip. Clear it so that a second AP start frees nothing. + static_cast(WiFi.data)->ap.bssid = nullptr; +#endif + return WiFi.softAP(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), ap.has_channel() ? ap.get_channel() : 1, ap.get_hidden()); } @@ -777,6 +807,17 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } #endif // USE_WIFI_AP +#ifdef USE_LN882X +void WiFiComponent::on_powerdown() { + // Leave the AP so it no longer holds the association when we come back. + if (WiFi.status() != WL_CONNECTED) // associated, even before DHCP has finished + return; + ESP_LOGD(TAG, "Disconnecting before powerdown"); + wifi_sta_disconnect(); // not WiFi.disconnect(): it frees the SSID its event handlers read + delay(100); // NOLINT: once per reboot or deep sleep, lets the deauth go out +} +#endif + bool WiFiComponent::wifi_disconnect_() { // Reset state first so disconnect events aren't ignored // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index ac62812fcf..3b67aa46d0 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -143,12 +143,12 @@ async def to_code(config): if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_LWIP_PPP_SUPPORT", True) - # This flag is added here because the esp_wireguard library statically + # This flag is added here because the wireguard library statically # set the size of its allowed_ips list at compile time using this value; # the '+1' modifier is relative to the device's own address that will # be automatically added to the provided list. cg.add_build_flag(f"-DCONFIG_WIREGUARD_MAX_SRC_IPS={len(allowed_ips) + 1}") - cg.add_library("droscy/esp_wireguard", "0.4.5") + cg.add_library("esphome/wireguard", "0.4.8") await cg.register_component(var, config) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index fc06569fba..1fd610f4e8 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -101,8 +101,13 @@ void Wireguard::update() { if (this->wg_peer_offline_time_ == 0) { ESP_LOGW(TAG, LOGMSG_PEER_STATUS, LOGMSG_OFFLINE, latest_handshake.c_str()); this->wg_peer_offline_time_ = millis(); + this->wg_reconnect_time_ = this->wg_peer_offline_time_; } else if (this->enabled_) { ESP_LOGD(TAG, LOGMSG_PEER_STATUS, LOGMSG_OFFLINE, latest_handshake.c_str()); + if (millis() - this->wg_reconnect_time_ >= RECONNECT_INTERVAL_MS) { + this->wg_reconnect_time_ = millis(); + this->stop_connection_(); + } this->start_connection_(); } diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index 68885f3a35..faf63cd444 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -140,6 +140,7 @@ class Wireguard final : public PollingComponent { /// The last time the remote peer become offline. uint32_t wg_peer_offline_time_ = 0; + uint32_t wg_reconnect_time_ = 0; /** \brief The latest saved handshake. * @@ -159,6 +160,7 @@ void resume_wdt(); /// Size of buffer required for mask_key_to: 5 chars + "[...]=" + null = 12 static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; +static constexpr uint32_t RECONNECT_INTERVAL_MS = 30000; /// Strip most part of the key only for secure printing void mask_key_to(char *buffer, size_t len, const char *key); diff --git a/esphome/components/xdb401/sensor.py b/esphome/components/xdb401/sensor.py index c629e46650..df274ac09b 100644 --- a/esphome/components/xdb401/sensor.py +++ b/esphome/components/xdb401/sensor.py @@ -56,10 +56,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pressure_range_bar(config[CONF_PRESSURE_RANGE_BAR])) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - 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) diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index 83c20dfbd8..eb3b042cb7 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -70,13 +70,11 @@ 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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING])) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + oversampling = config[CONF_PRESSURE][CONF_OVERSAMPLING] + cg.add(var.set_pressure_oversampling(oversampling)) cg.add(var.set_k_value(config[CONF_K_VALUE])) diff --git a/esphome/components/xiaomi_cgd1/sensor.py b/esphome/components/xiaomi_cgd1/sensor.py index 09337df5ca..162ba7016b 100644 --- a/esphome/components/xiaomi_cgd1/sensor.py +++ b/esphome/components/xiaomi_cgd1/sensor.py @@ -66,12 +66,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) - 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)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_cgdk2/sensor.py b/esphome/components/xiaomi_cgdk2/sensor.py index 83de32f411..e1d44d6bac 100644 --- a/esphome/components/xiaomi_cgdk2/sensor.py +++ b/esphome/components/xiaomi_cgdk2/sensor.py @@ -66,12 +66,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) - 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)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index 3f0df9eafe..01ca8d1f84 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -67,12 +67,7 @@ async def to_code(config: ConfigType) -> None: if CONF_BINDKEY in config: cg.add(var.set_bindkey(config[CONF_BINDKEY])) - 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)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_cgpr1/binary_sensor.py b/esphome/components/xiaomi_cgpr1/binary_sensor.py index 219432c1fc..b742f1eaec 100644 --- a/esphome/components/xiaomi_cgpr1/binary_sensor.py +++ b/esphome/components/xiaomi_cgpr1/binary_sensor.py @@ -71,12 +71,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) - if CONF_IDLE_TIME in config: - sens = await sensor.new_sensor(config[CONF_IDLE_TIME]) - cg.add(var.set_idle_time(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) - if CONF_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_IDLE_TIME, var.set_idle_time) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) diff --git a/esphome/components/xiaomi_hhccpot002/sensor.py b/esphome/components/xiaomi_hhccpot002/sensor.py index 061753c44c..73d5f3ff19 100644 --- a/esphome/components/xiaomi_hhccpot002/sensor.py +++ b/esphome/components/xiaomi_hhccpot002/sensor.py @@ -53,9 +53,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_MOISTURE in config: - sens = await sensor.new_sensor(config[CONF_MOISTURE]) - cg.add(var.set_moisture(sens)) - if CONF_CONDUCTIVITY in config: - sens = await sensor.new_sensor(config[CONF_CONDUCTIVITY]) - cg.add(var.set_conductivity(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_MOISTURE, var.set_moisture) + await sensors(CONF_CONDUCTIVITY, var.set_conductivity) diff --git a/esphome/components/xiaomi_lywsd02/sensor.py b/esphome/components/xiaomi_lywsd02/sensor.py index f79b81dce2..d37d7540c7 100644 --- a/esphome/components/xiaomi_lywsd02/sensor.py +++ b/esphome/components/xiaomi_lywsd02/sensor.py @@ -63,12 +63,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - 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)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_lywsd02mmc/sensor.py b/esphome/components/xiaomi_lywsd02mmc/sensor.py index 05e0204def..5b088318ae 100644 --- a/esphome/components/xiaomi_lywsd02mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd02mmc/sensor.py @@ -67,12 +67,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) - 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)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_lywsdcgq/sensor.py b/esphome/components/xiaomi_lywsdcgq/sensor.py index 8f0b93a22e..75a8a2d847 100644 --- a/esphome/components/xiaomi_lywsdcgq/sensor.py +++ b/esphome/components/xiaomi_lywsdcgq/sensor.py @@ -63,12 +63,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - 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)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_mhoc303/sensor.py b/esphome/components/xiaomi_mhoc303/sensor.py index 9ce544fc59..44fb9c7654 100644 --- a/esphome/components/xiaomi_mhoc303/sensor.py +++ b/esphome/components/xiaomi_mhoc303/sensor.py @@ -63,12 +63,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - 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)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_mhoc401/sensor.py b/esphome/components/xiaomi_mhoc401/sensor.py index 52cae36fcd..e74c656e51 100644 --- a/esphome/components/xiaomi_mhoc401/sensor.py +++ b/esphome/components/xiaomi_mhoc401/sensor.py @@ -67,12 +67,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) - 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)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index fa4296d7a9..c6277a515d 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -58,9 +58,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_clear_impedance(config[CONF_CLEAR_IMPEDANCE])) - if CONF_WEIGHT in config: - sens = await sensor.new_sensor(config[CONF_WEIGHT]) - cg.add(var.set_weight(sens)) - if CONF_IMPEDANCE in config: - sens = await sensor.new_sensor(config[CONF_IMPEDANCE]) - cg.add(var.set_impedance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_WEIGHT, var.set_weight) + await sensors(CONF_IMPEDANCE, var.set_impedance) diff --git a/esphome/components/xiaomi_wx08zm/binary_sensor.py b/esphome/components/xiaomi_wx08zm/binary_sensor.py index 4a6267f8b9..8e9b32b797 100644 --- a/esphome/components/xiaomi_wx08zm/binary_sensor.py +++ b/esphome/components/xiaomi_wx08zm/binary_sensor.py @@ -56,9 +56,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TABLET in config: - sens = await sensor.new_sensor(config[CONF_TABLET]) - cg.add(var.set_tablet(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TABLET, var.set_tablet) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py index 8282523129..8c47b526b1 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -67,12 +67,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) - 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)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/zyaura/sensor.py b/esphome/components/zyaura/sensor.py index 0508f456e4..cfe980f037 100644 --- a/esphome/components/zyaura/sensor.py +++ b/esphome/components/zyaura/sensor.py @@ -61,12 +61,7 @@ async def to_code(config: ConfigType) -> None: pin_data = await gpio_pin_expression(config[CONF_DATA_PIN]) cg.add(var.set_pin_data(pin_data)) - if CONF_CO2 in config: - sens = await sensor.new_sensor(config[CONF_CO2]) - cg.add(var.set_co2_sensor(sens)) - 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_CO2, var.set_co2_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/config.py b/esphome/config.py index 13ec744ce4..bc69661d8f 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1226,6 +1226,7 @@ class CoreFinalValidateStep(ConfigValidationStep): with result.catch_error([CONF_ESPHOME]): if CONF_ESPHOME in result: core_config.validate_ids_and_references(result[CONF_ESPHOME]) + core_config.validate_loop_interval(result[CONF_ESPHOME]) fv.full_config.reset(token) diff --git a/esphome/core/application.h b/esphome/core/application.h index 8ed4c09096..2f399cbdcd 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -208,8 +208,8 @@ class Application { * Each component can request a high frequency loop execution by using the HighFrequencyLoopRequester * helper in helpers.h * - * Note: This method is not called by ESPHome core code. It is only used by lambda functions - * in YAML configurations or by external components. + * Sleep per wake is capped at 2 * WDT_FEED_INTERVAL_MS (except host and ESP8266); + * raise the platform watchdog timeout to sleep longer. * * @param loop_interval The interval in milliseconds to run the core loop at. Defaults to 16 milliseconds. */ @@ -232,6 +232,7 @@ class Application { /// - ESP8266 soft WDT (~1.6 s): ~16x <-- 100 ms feed (see USE_ESP8266 below) /// - ESP8266 HW WDT (~6 s): ~60x /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// Important: if these are modified align validate_loop_interval in config.py #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: @@ -775,8 +776,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { } #endif - // Compute sleep: bounded by time-until-next-component-phase and the - // scheduler's next deadline. When a scheduler timer fires it re-enters + // Compute sleep: bounded by time-until-next-component-phase if there are + // components with loop enabled and the scheduler's next deadline. When a scheduler timer fires it re-enters // loop(), Phase A services it, and the component phase stays gated by // loop_interval_. When a background producer calls wake_loop_threadsafe() // it sets the wake_request flag and wakes select() / the task notification; @@ -795,16 +796,47 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { uint32_t delay_time = 0; if (!HighFrequencyLoopRequester::is_high_frequency()) { const uint32_t elapsed_since_phase = now - this->last_loop_; - const uint32_t until_phase = +#ifdef ESPHOME_SUSPEND_LOOP + const bool has_loop_work = + this->looping_components_active_end_ > 0 || this->dump_config_at_ < this->components_.size(); + uint32_t until_phase = std::numeric_limits::max(); + if (has_loop_work) { + until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); + } +#else + uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); +#endif const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. // On host this drains the loopback wake socket via select(); on FreeRTOS // targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. - esphome::internal::wakeable_delay(delay_time); + // Cap the sleep so the WDT feed and status-LED dispatch rate limits still get + // exercised even when loop_interval is raised or the scheduler and component + // phases are gated out for a long sleep. Waking every 2*WDT_FEED_INTERVAL_MS + // clears the feed rate limit on every wake, so the WDT is fed at least that + // often -- well inside every platform's timeout. +#if defined(USE_ESP8266) + // SDK os_timer_arm() accepts at most 0x68D7A3 ms without system_timer_reinit(); + // the SDK feeds both watchdogs while the cont task is suspended, so no WDT cap needed. + static constexpr uint32_t MAX_SLEEP_BASE = 0x68D7A3; +#elif defined(USE_HOST) + // arch_feed_wdt() is a no-op on host and ESPHOME_SUSPEND_LOOP is rejected by + // the config validator, so delay_time is already bounded by loop_interval_. + static constexpr uint32_t MAX_SLEEP_BASE = std::numeric_limits::max(); +#else + static constexpr uint32_t MAX_SLEEP_BASE = WDT_FEED_INTERVAL_MS * 2; +#endif + uint32_t max_sleep = MAX_SLEEP_BASE; +#ifdef USE_STATUS_LED + if ((this->app_state_ & STATUS_LED_MASK) != 0) { + max_sleep = std::min(max_sleep, STATUS_LED_DISPATCH_INTERVAL_MS); + } +#endif + esphome::internal::wakeable_delay(std::min(delay_time, max_sleep)); if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); } diff --git a/esphome/core/config.py b/esphome/core/config.py index 532ca7c36d..531a027647 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -43,7 +43,11 @@ from esphome.const import ( CONF_PROJECT, CONF_TRIGGER_ID, CONF_VERSION, + CONF_WATCHDOG_TIMEOUT, KEY_CORE, + PLATFORM_ESP32, + PLATFORM_HOST, + PLATFORM_RP2, PlatformFramework, __version__ as ESPHOME_VERSION, ) @@ -53,6 +57,7 @@ from esphome.core import ( CoroPriority, coroutine_with_priority, ) +import esphome.final_validate as fv from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -63,6 +68,9 @@ from esphome.helpers import ( ) from esphome.types import ConfigType +CONF_LOOP_INTERVAL = "loop_interval" +CONF_SUSPEND_LOOP = "suspend_loop" + _LOGGER = logging.getLogger(__name__) # C++ variable names and separators for app name buffers (used with MAC suffix) @@ -186,6 +194,35 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: return config +def validate_loop_interval(config: ConfigType) -> ConfigType: + if CONF_LOOP_INTERVAL in config and not (CORE.is_host or CORE.is_esp8266): + # max_loop interval is calculated from WDT_FEED_INTERVAL_MS. Make sure to align with application.h + max_loop = 600 # 2 * 300ms default for other platforms + if CORE.is_esp32: + max_loop = ( + 2 + * fv.full_config.get()[PLATFORM_ESP32][ + CONF_WATCHDOG_TIMEOUT + ].total_milliseconds + // 5 + ) + elif CORE.is_bk72xx: + max_loop = 4000 # 10000ms / 5 * 2 default value + if config[CONF_LOOP_INTERVAL].total_milliseconds > max_loop: + _LOGGER.warning( + "%s of %s exceeds the %sms maximum sleep on this platform; the loop will still " + "wake every %sms.%s", + CONF_LOOP_INTERVAL, + config[CONF_LOOP_INTERVAL], + max_loop, + max_loop, + " Raise esp32.watchdog_timeout to sleep longer." + if CORE.is_esp32 + else "", + ) + return config + + def valid_include(value: str) -> str: # Look for "<...>" includes if value.startswith("<") and value.endswith(">"): @@ -266,6 +303,15 @@ def validate_area_config(config: dict | str) -> dict[str, str | core.ID]: return cv.maybe_simple_value(AREA_SCHEMA, key=CONF_NAME)(config) +def _validate_suspend_loop(value: bool) -> bool: + # host and RP2 platforms have unwakeable delay fallbacks, so suspending the main loop is unsafe + if value and CORE.target_platform in [PLATFORM_HOST, PLATFORM_RP2]: + raise cv.Invalid( + f"Suspend loop is not available on {CORE.target_platform} platform" + ) + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -331,6 +377,19 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_DEBUG_SCHEDULER, default=False, visibility=cv.Visibility.YAML_ONLY ): cv.boolean, + cv.Optional(CONF_LOOP_INTERVAL, visibility=cv.Visibility.YAML_ONLY): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriod(milliseconds=1), + max=cv.TimePeriod(milliseconds=65535), + ), + ), + cv.Optional( + CONF_SUSPEND_LOOP, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.All( + cv.boolean, + _validate_suspend_loop, + ), cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( @@ -806,6 +865,10 @@ async def to_code(config: ConfigType) -> None: cg.add_cxx_build_flag("-Wno-volatile") if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") + if config[CONF_SUSPEND_LOOP]: + cg.add_define("ESPHOME_SUSPEND_LOOP") + if CONF_LOOP_INTERVAL in config: + cg.add(cg.App.set_loop_interval(config[CONF_LOOP_INTERVAL])) if CORE.using_arduino: CORE.add_job(add_arduino_global_workaround) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a9a2ecf7ad..ec1b6f541c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,9 @@ #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE +#define USE_API_OUTGOING_CONNECTION +#define API_OUTGOING_CONNECTION_PORT 6054 +#define API_OUTGOING_CONNECTION_DELAY 60000 #define USE_API_VARINT64 #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS @@ -408,6 +411,7 @@ #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 +#define ESPHOME_SUSPEND_LOOP #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -423,8 +427,10 @@ #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS +#define USE_SOCKET_IPV4_ALLOW #define USE_SOCKET_IPV4_RESOLVE #define USE_SOCKET_TCP_CLIENT_LINK +#define USE_SOCKET_TCP_LISTENER #define USE_LWIP_FAST_SELECT #define USE_SPEAKER @@ -461,6 +467,10 @@ #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 +#define USE_USB_UART_CH34X +#define USE_USB_UART_CP210X +#define USE_USB_UART_FT23XX +#define USE_USB_UART_PL2303 #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) @@ -493,6 +503,7 @@ #define USE_ETHERNET_CONNECT_TRIGGER #define USE_ETHERNET_DISCONNECT_TRIGGER #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 +#define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 #endif #ifdef USE_ESP32 @@ -502,6 +513,10 @@ #define USE_OPENTHREAD #define USE_ZIGBEE #endif +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#define USE_ESPECTRE +#endif #ifndef USE_OPENTHREAD #define USE_MDNS_SUPPORTS_ENABLE_DISABLE #endif @@ -525,6 +540,7 @@ // ESP8266-specific feature flags #ifdef USE_ESP8266 +#define ESPHOME_SUSPEND_LOOP #define USE_ADC_SENSOR_VCC #define USE_ESP8266_CRASH_HANDLER #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2) @@ -601,10 +617,12 @@ // to parse against. #ifdef USE_LN882X #define USE_LN882H_BLE_TRACKER +#define USE_WIFI_AP_EXCLUSIVE #else #define USE_BK72XX_BLE_TRACKER #endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define ESPHOME_SUSPEND_LOOP #define USE_BLE_SCAN_RESPONSE_MERGER #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK @@ -630,6 +648,7 @@ #ifdef USE_NRF52 #define ESPHOME_BLE_NUS_TX_RING_BUFFER_SIZE 512 #define ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE 512 +#define ESPHOME_SUSPEND_LOOP #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_LOGGER_EARLY_MESSAGE diff --git a/esphome/core/freertos_queue.h b/esphome/core/freertos_queue.h index 2f3faf818a..1067ae07cc 100644 --- a/esphome/core/freertos_queue.h +++ b/esphome/core/freertos_queue.h @@ -27,9 +27,7 @@ namespace esphome { template class FreeRTOSQueue { public: - FreeRTOSQueue() : dropped_count_(0) { - this->handle_ = xQueueCreateStatic(SIZE, sizeof(T *), this->storage_, &this->queue_buf_); - } + FreeRTOSQueue() : dropped_count_(0) { xQueueCreateStatic(SIZE, sizeof(T *), this->storage_, &this->queue_buf_); } // No destructor — ESPHome components are never destroyed. Intentionally // omitted to avoid pulling in vQueueDelete code on resource-constrained targets. @@ -44,7 +42,7 @@ template class FreeRTOSQueue { if (element == nullptr) return false; - if (xQueueSend(this->handle_, &element, 0) != pdPASS) { + if (xQueueSend(this->handle_(), &element, 0) != pdPASS) { this->increment_dropped_count(); return false; } @@ -53,7 +51,7 @@ template class FreeRTOSQueue { T *pop() { T *element; - if (xQueueReceive(this->handle_, &element, 0) != pdTRUE) { + if (xQueueReceive(this->handle_(), &element, 0) != pdTRUE) { return nullptr; } return element; @@ -80,18 +78,24 @@ template class FreeRTOSQueue { portEXIT_CRITICAL(); } - bool empty() const { return uxQueueMessagesWaiting(this->handle_) == 0; } + bool empty() const { return uxQueueMessagesWaiting(this->handle_()) == 0; } - bool full() const { return uxQueueSpacesAvailable(this->handle_) == 0; } + bool full() const { return uxQueueSpacesAvailable(this->handle_()) == 0; } - size_t size() const { return uxQueueMessagesWaiting(this->handle_); } + size_t size() const { return uxQueueMessagesWaiting(this->handle_()); } protected: // Static storage for the queue — lives in BSS, no heap allocation uint8_t storage_[SIZE * sizeof(T *)]; StaticQueue_t queue_buf_; - QueueHandle_t handle_; uint16_t dropped_count_; + + // xQueueCreateStatic() returns the static queue structure itself as the handle; + // its only other checks are asserts, and a zero length is the one we could hit + static_assert(SIZE > 0, "FreeRTOSQueue needs at least one slot"); + QueueHandle_t handle_() const { + return reinterpret_cast(const_cast(&this->queue_buf_)); + } }; } // namespace esphome diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 313daacbd0..a11b07808d 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -621,15 +620,11 @@ int8_t step_to_accuracy_decimals(float step) { return decimals; } -// Map a base64/base64url character to its 6-bit value (0-63) arithmetically. -// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). -// Supports both standard base64 (+/) and base64url (-_) alphabets. -// NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters. -// This is safe because is_base64() is ALWAYS checked before calling this function, -// preventing invalid characters from ever reaching here. The base64_decode function -// stops processing at the first invalid character due to the is_base64() check in its -// while loop condition, making this edge case harmless in practice. -static inline uint8_t base64_find_char(char c) { +static constexpr uint8_t INVALID_BASE64_CHAR = 0xFF; + +// 6-bit value of a base64 or base64url char, or INVALID_BASE64_CHAR. +// No lookup table: .rodata lives in DRAM on ESP8266. +static constexpr uint8_t base64_char_value(uint8_t c) { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') @@ -641,70 +636,35 @@ static inline uint8_t base64_find_char(char c) { return 62; if (c == '/' || c == '_') return 63; - return 0; + return INVALID_BASE64_CHAR; } -// Check if character is valid base64 or base64url -static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/') || (c == '-') || (c == '_')); } - // base64_encode (both overloads) moved to alloc_helpers.cpp size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); } -// Decode 4 base64 characters to up to 'count' output bytes, returns true if truncated. -static inline bool base64_decode_quad(uint8_t *char_array_4, int count, uint8_t *buf, size_t buf_len, size_t &out) { - for (int i = 0; i < 4; i++) - char_array_4[i] = base64_find_char(char_array_4[i]); - - uint8_t char_array_3[3]; - char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); - char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); - char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - - bool truncated = false; - for (int j = 0; j < count; j++) { - if (out < buf_len) { - buf[out++] = char_array_3[j]; - } else { - truncated = true; - } - } - return truncated; -} - size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len) { - size_t in_len = encoded_len; - int i = 0; - size_t in = 0; size_t out = 0; - uint8_t char_array_4[4]; - bool truncated = false; - - // SAFETY: The loop condition checks is_base64() before processing each character. - // This ensures base64_find_char() is only called on valid base64 characters, - // preventing the edge case where invalid chars would return 0 (same as 'A'). - while (in_len-- && (encoded_data[in] != '=') && is_base64(encoded_data[in])) { - char_array_4[i++] = encoded_data[in]; - in++; - if (i == 4) { - truncated |= base64_decode_quad(char_array_4, 3, buf, buf_len, out); - i = 0; + uint32_t accum = 0; + uint32_t bits = 0; + // Stops at '=' or any non-alphabet char; leftover bits of a partial group are dropped. + for (size_t in = 0; in < encoded_len; in++) { + uint8_t value = base64_char_value(encoded_data[in]); + if (value == INVALID_BASE64_CHAR) + break; + accum = (accum << 6) | value; + bits += 6; + if (bits >= 8) { + bits -= 8; + if (out == buf_len) { + ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); + return out; + } + buf[out++] = static_cast(accum >> bits); } } - - if (i) { - for (int j = i; j < 4; j++) - char_array_4[j] = 0; - - truncated |= base64_decode_quad(char_array_4, i - 1, buf, buf_len, out); - } - - if (truncated) { - ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); - } - return out; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 6d00e18799..b88a9d70e9 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -129,23 +129,82 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); /// @name Container utilities ///@{ -/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory) -/// Avoids copying data from flash to RAM by keeping a pointer to the flash data. -/// Similar to std::span but with minimal overhead for embedded systems. - -template class ConstVector { +/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory). +/// Iterators are raw pointers like FixedVector. With Owning = true it can also hold a heap copy it +/// owns (see the specialization below); the default view never frees and has no extra cost. +template class ConstVector { public: + using value_type = T; + + constexpr ConstVector() = default; constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} - const constexpr T &operator[](size_t i) const { return data_[i]; } - constexpr size_t size() const { return size_; } - constexpr bool empty() const { return size_ == 0; } + const T *begin() const { return this->data_; } + const T *end() const { return this->data_ + this->size_; } + const T *data() const { return this->data_; } + constexpr size_t size() const { return this->size_; } + constexpr bool empty() const { return this->size_ == 0; } + const constexpr T &operator[](size_t i) const { return this->data_[i]; } + const T &at(size_t i) const { return this->data_[i]; } protected: - const T *data_; - size_t size_; + const T *data_{nullptr}; + size_t size_{0}; }; +/// Owning variant: a codegen table that outlives it, or a heap copy of a runtime list it owns. +/// Ownership is the top bit of the size; it is not copyable, so a copy can never outlive the owner. +/// Elements must be whole words so ESP8266 can read a codegen table from flash. +template class ConstVector { + static_assert(std::is_trivially_copyable_v && sizeof(T) % sizeof(uint32_t) == 0, + "ConstVector elements must be whole words so ESP8266 can read them from flash"); + + public: + using value_type = T; + + constexpr ConstVector() = default; + constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} + ConstVector(const ConstVector &) = delete; + ConstVector &operator=(const ConstVector &) = delete; + ~ConstVector() { this->release_(); } + + const T *begin() const { return this->data_; } + const T *end() const { return this->data_ + this->size(); } + const T *data() const { return this->data_; } + size_t size() const { return this->size_ & ~OWNED_BIT; } + bool empty() const { return this->size() == 0; } + const T &operator[](size_t index) const { return this->data_[index]; } + const T &at(size_t index) const { return this->data_[index]; } + + /// Codegen only: call before any runtime copy; it does not free a previous owned copy + /// (generated setup() runs before any lambda or automation can call set_options). + void assign_static(const T *data, size_t size) { + this->data_ = data; + this->size_ = size; + } + /// Copies the list into a heap array this owns, freeing a previous owned copy. + void assign_copy(const T *data, size_t size) { + auto *table = new T[size]; // NOLINT(cppcoreguidelines-owning-memory) + std::copy(data, data + size, table); + this->release_(); + this->data_ = table; + this->size_ = size | OWNED_BIT; + } + + protected: + static constexpr size_t OWNED_BIT = size_t{1} << (sizeof(size_t) * 8 - 1); + + void release_() { + if (this->size_ & OWNED_BIT) + delete[] this->data_; // NOLINT(cppcoreguidelines-owning-memory) + } + + const T *data_{nullptr}; + size_t size_{0}; // top bit set when data_ is an owned heap copy +}; +static_assert(sizeof(ConstVector) == 2 * sizeof(void *), + "ConstVector must stay a pointer and a size"); + /// Small buffer optimization - stores data inline when small, heap-allocates for large data /// This avoids heap fragmentation for common small allocations while supporting arbitrary sizes. /// Memory management is encapsulated - callers just use set() and data(). diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 56fc83f12f..4911bdde74 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -518,7 +518,8 @@ class Scheduler { uint32_t hash_or_id, SchedulerItem::Type type, bool find_first); - Mutex lock_; + // Empty on single-threaded platforms (ESP8266, RP2), so it takes no space there + [[no_unique_address]] Mutex lock_; std::vector items_; std::vector to_add_; diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index b0c3533e04..a5ffaefe0b 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -19,7 +19,12 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) -from esphome.helpers import cpp_string_escape, indent, indent_all_but_first_and_last +from esphome.helpers import ( + cpp_string_escape, + ensure_unique_string, + indent, + indent_all_but_first_and_last, +) from esphome.types import Expression, SafeExpType, TemplateArgsType from esphome.util import OrderedDict from esphome.yaml_util import ESPHomeDataBase @@ -463,6 +468,36 @@ def progmem_array(id_, rhs) -> "MockObj": return obj +def shared_progmem_array( + name: str, type_: "MockObjClass", rhs: SafeExpType, *, share: bool = True +) -> "MockObj": + """Emit a global PROGMEM array once per distinct type and contents; later calls reuse it. + + The array is ``static constexpr``, so elements must be constant expressions and lambdas + must be captureless. Its name is made unique against every config id and variable. + ``share=False`` always emits a new array, e.g. for lambdas that may keep static state. + """ + from esphome.config import iter_ids + from esphome.config_validation import RESERVED_IDS + + arrays: dict[str, MockObj] = CORE.data.setdefault("shared_progmem_array", {}) + rhs = safe_exp(rhs) + key = f"{type_} {rhs}" + if share and (array := arrays.get(key)) is not None: + return array + used = {str(i) for i, _ in iter_ids(CORE.config)} + used |= {str(i) for i in CORE.variables} + used |= set(RESERVED_IDS) | CORE.loaded_integrations + id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_) + # Global, so any scope can use it; anything a lambda references is already declared. + CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs)) + array = MockObj(id_, ".") + CORE.register_variable(id_, array) + if share: + arrays[key] = array + return array + + def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") diff --git a/esphome/espidf/_tool_resolution.py b/esphome/espidf/_tool_resolution.py new file mode 100644 index 0000000000..624a07fa25 --- /dev/null +++ b/esphome/espidf/_tool_resolution.py @@ -0,0 +1,67 @@ +"""Shared tool resolution for the sibling idf_tools-backed scripts. + +Importable because ``python