mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6b4263a2 | ||
|
|
3e4661fe1e | ||
|
|
22153be4cd | ||
|
|
3f490fe1ed | ||
|
|
58a42fe5c2 | ||
|
|
25c0c2c97b | ||
|
|
622942482c | ||
|
|
cffd775450 | ||
|
|
8d87ba34d9 | ||
|
|
08c6585915 | ||
|
|
3ef74d17af | ||
|
|
5d04c1dc18 | ||
|
|
a3d599ac69 | ||
|
|
a4e05cd1c8 | ||
|
|
1758653330 | ||
|
|
a2feff8f68 | ||
|
|
37eae9b466 | ||
|
|
9605b34c69 | ||
|
|
bab62b345b | ||
|
|
3349046c5d | ||
|
|
3f5b8139f3 | ||
|
|
be5e28ea9e | ||
|
|
9f28638ee7 | ||
|
|
201f843e95 | ||
|
|
37a59a07bc | ||
|
|
9556c2bc4c | ||
|
|
3540012529 | ||
|
|
e0b112c584 | ||
|
|
7f0d6a8696 | ||
|
|
4a7de87bff | ||
|
|
b9d1d2f06b | ||
|
|
74c30c62ef | ||
|
|
94fbfa05de | ||
|
|
55bd63732d | ||
|
|
3ae651af7b | ||
|
|
55e8bc3b14 | ||
|
|
9938a2487d | ||
|
|
aee41d64c2 | ||
|
|
f3a5a9fbd5 | ||
|
|
8ee3c8d41d | ||
|
|
eefd2a00c7 | ||
|
|
e8852c5950 | ||
|
|
069f40f653 | ||
|
|
04dd6b3a55 | ||
|
|
2ff55e3058 | ||
|
|
e5cdda9ee5 | ||
|
|
ecec3a19c7 | ||
|
|
9823ad6abc |
@@ -466,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik
|
||||
esphome/components/sen5x/* @martgras
|
||||
esphome/components/sen6x/* @martgras @mebner86 @tuct
|
||||
esphome/components/sendspin/* @kahrendt
|
||||
esphome/components/sendspin/image/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.8.0-dev
|
||||
PROJECT_NUMBER = 2026.8.0b1
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+90
-19
@@ -21,7 +21,6 @@ from esphome.const import (
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DEASSERT_RTS_DTR,
|
||||
@@ -29,6 +28,7 @@ from esphome.const import (
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
CONF_LOGGER,
|
||||
CONF_MDNS,
|
||||
@@ -42,7 +42,7 @@ from esphome.const import (
|
||||
CONF_PORT,
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
@@ -273,8 +273,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
|
||||
if purpose == Purpose.LOGGING and not has_api():
|
||||
return (
|
||||
"Cannot view logs over the network: no 'api:' component is "
|
||||
"configured. Network log streaming requires the native API; add "
|
||||
"an 'api:' component, enable MQTT logging, or view logs over USB."
|
||||
"configured. Add an 'api:' component, enable MQTT logging, add a "
|
||||
"'web_server:' component, or view logs over USB."
|
||||
)
|
||||
if purpose == Purpose.UPLOADING and not has_ota():
|
||||
return (
|
||||
@@ -314,9 +314,12 @@ def choose_upload_log_host(
|
||||
]
|
||||
resolved.append(choose_prompt(options, purpose=purpose))
|
||||
elif device == "OTA":
|
||||
# Logs can stream over a network transport via the native API
|
||||
# or the web_server HTTP SSE feed.
|
||||
network_logging = has_api() or has_web_server_logging()
|
||||
# ensure IP adresses are used first
|
||||
if is_ip_address(CORE.address) and (
|
||||
(purpose == Purpose.LOGGING and has_api())
|
||||
(purpose == Purpose.LOGGING and network_logging)
|
||||
or (purpose == Purpose.UPLOADING and has_ota())
|
||||
):
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
@@ -328,7 +331,11 @@ def choose_upload_log_host(
|
||||
if has_mqtt_logging():
|
||||
resolved.append("MQTT")
|
||||
|
||||
if has_api() and has_non_ip_address() and has_resolvable_address():
|
||||
if (
|
||||
network_logging
|
||||
and has_non_ip_address()
|
||||
and has_resolvable_address()
|
||||
):
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
|
||||
elif purpose == Purpose.UPLOADING:
|
||||
@@ -390,7 +397,7 @@ def choose_upload_log_host(
|
||||
mqtt_config = CORE.config[CONF_MQTT]
|
||||
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
|
||||
|
||||
if has_api():
|
||||
if has_api() or has_web_server_logging():
|
||||
add_ota_options()
|
||||
|
||||
elif purpose == Purpose.UPLOADING and has_ota():
|
||||
@@ -483,6 +490,21 @@ def has_web_server_ota() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def has_web_server_logging() -> bool:
|
||||
"""Check if logs can be streamed over the web_server HTTP SSE endpoint.
|
||||
|
||||
The ``web_server`` component exposes a ``/events`` Server-Sent Events
|
||||
stream that carries ``event: log`` frames. This requires version 2+ (the
|
||||
v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
|
||||
"""
|
||||
web_conf = CORE.config.get(CONF_WEB_SERVER)
|
||||
if web_conf is None:
|
||||
return False
|
||||
if web_conf.get(CONF_VERSION, 2) == 1:
|
||||
return False
|
||||
return web_conf.get(CONF_LOG, True)
|
||||
|
||||
|
||||
def has_mqtt_ip_lookup() -> bool:
|
||||
"""Check if MQTT is available and IP lookup is supported."""
|
||||
if CONF_MQTT not in CORE.config:
|
||||
@@ -1291,25 +1313,23 @@ def _upload_via_native_api(
|
||||
def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
web_conf = config.get(CONF_WEB_SERVER)
|
||||
if not web_conf:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
|
||||
f"is not configured."
|
||||
)
|
||||
|
||||
remote_port = int(web_conf[CONF_PORT])
|
||||
auth = web_conf.get(CONF_AUTH) or {}
|
||||
username = auth.get(CONF_USERNAME)
|
||||
password = auth.get(CONF_PASSWORD)
|
||||
|
||||
from esphome import web_server_ota
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
remote_port, username, password = get_web_server_connection(config)
|
||||
return web_server_ota.run_ota(
|
||||
network_devices, remote_port, username, password, binary
|
||||
)
|
||||
|
||||
|
||||
def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
|
||||
from esphome import web_server_logs
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
port, username, password = get_web_server_connection(config)
|
||||
return web_server_logs.run_logs(network_devices, port, username, password)
|
||||
|
||||
|
||||
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
|
||||
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
|
||||
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
|
||||
@@ -1437,6 +1457,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
config, args.topic, args.username, args.password, args.client_id
|
||||
)
|
||||
|
||||
# Fall back to the web_server HTTP SSE log stream for devices that have
|
||||
# web_server: but no api: (the logging counterpart to web_server OTA).
|
||||
if has_web_server_logging() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
return _show_logs_via_web_server(config, network_devices)
|
||||
|
||||
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
|
||||
|
||||
|
||||
@@ -2509,6 +2536,49 @@ def parse_args(argv):
|
||||
return parser.parse_args(arguments)
|
||||
|
||||
|
||||
def _warn_if_source_tree_mismatch() -> None:
|
||||
"""Warn when the checkout the user is standing in is not the one being run.
|
||||
|
||||
An editable install records one absolute path, so a venv shared between git
|
||||
worktrees (or reused after a checkout is copied or renamed) keeps importing
|
||||
the tree it was installed from. Every command then silently runs, and
|
||||
compiles, sources the user is not looking at. Only fires inside a checkout,
|
||||
so ordinary installs never see it.
|
||||
"""
|
||||
try:
|
||||
cwd = Path.cwd()
|
||||
except OSError:
|
||||
return # working directory is gone; a diagnostic must not break startup
|
||||
for candidate in (cwd, *cwd.parents):
|
||||
if (candidate / "esphome" / "__main__.py").is_file():
|
||||
standing_in = candidate.resolve()
|
||||
break
|
||||
else:
|
||||
return # not inside a checkout; nothing to compare against
|
||||
|
||||
running = Path(__file__).resolve().parent.parent
|
||||
# Both sides are resolved, so on a case-sensitive filesystem this matches
|
||||
# plain equality. samefile() compares device and inode, which additionally
|
||||
# covers a case-insensitive filesystem (macOS) reaching one directory by
|
||||
# differently cased paths. Falls back to equality if either path is gone.
|
||||
try:
|
||||
same = standing_in.samefile(running)
|
||||
except OSError:
|
||||
same = standing_in == running
|
||||
if same:
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
"Running ESPHome from a different checkout than the one you are in:\n"
|
||||
" running from: %s\n"
|
||||
" you are in: %s\n"
|
||||
"The installed esphome resolves to the first, so its sources are used.\n"
|
||||
"Run 'python -m esphome' from the second to use that one instead.",
|
||||
running,
|
||||
standing_in,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
@@ -2527,6 +2597,7 @@ def run_esphome(argv):
|
||||
args.log_level = "CRITICAL"
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
_warn_if_source_tree_mismatch()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
|
||||
@@ -231,6 +231,7 @@ def validate_adc_pin(value):
|
||||
return pins.internal_gpio_input_pin_schema(29)
|
||||
return cv.only_on([PLATFORM_ESP8266])("VCC")
|
||||
|
||||
# Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0
|
||||
if str(value).upper() == "TEMPERATURE":
|
||||
return cv.only_on_rp2("TEMPERATURE")
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.rp2";
|
||||
|
||||
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
|
||||
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
|
||||
// than four.
|
||||
//
|
||||
// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
|
||||
// derives from NUM_ADC_CHANNELS, which <pico.h> settles from a board header, and
|
||||
// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
|
||||
// is only declared later, by the variant's pins_arduino.h, so the SDK constant
|
||||
// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
|
||||
// is compiled, on both arduino-pico and pico-sdk builds.
|
||||
#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
|
||||
#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
|
||||
#endif
|
||||
#if defined(PICO_RP2350) && !PICO_RP2350A
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
|
||||
#else
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
|
||||
#endif
|
||||
|
||||
void ADCSensor::setup() {
|
||||
static bool initialized = false;
|
||||
if (!initialized) {
|
||||
@@ -52,7 +71,7 @@ float ADCSensor::sample() {
|
||||
if (this->is_temperature_) {
|
||||
adc_set_temp_sensor_enabled(true);
|
||||
delay(1);
|
||||
adc_select_input(4);
|
||||
adc_select_input(TEMPERATURE_ADC_INPUT);
|
||||
|
||||
for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
|
||||
raw = adc_read();
|
||||
|
||||
@@ -67,6 +67,13 @@ def validate_config(config):
|
||||
# Alter value here so `config` command prints the recommended change
|
||||
config[CONF_ATTENUATION] = _attenuation("12db")
|
||||
|
||||
# Remove before 2027.2.0
|
||||
if config[CONF_PIN] == "TEMPERATURE":
|
||||
_LOGGER.warning(
|
||||
"[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` "
|
||||
"sensor platform instead. Will be removed in 2027.2.0"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -133,6 +140,7 @@ async def to_code(config):
|
||||
if config[CONF_PIN] == "VCC":
|
||||
cg.add_define("USE_ADC_SENSOR_VCC")
|
||||
elif config[CONF_PIN] == "TEMPERATURE":
|
||||
# Remove before 2027.2.0
|
||||
cg.add(var.set_is_temperature())
|
||||
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_PIN])
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
import esphome.components.image as espImage
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from . import image as animation_image
|
||||
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
|
||||
|
||||
# The deprecated top-level `animation:` shim gets the same batched
|
||||
# downloads as the `image:` platform form.
|
||||
PREFETCH_FILES = animation_image.PREFETCH_FILES
|
||||
|
||||
AUTO_LOAD = ["image", "file"]
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
from esphome.components.file import image as file_image
|
||||
from esphome.components.file.image import image_schema, write_image
|
||||
from esphome.components.image import Image_, validate_settings
|
||||
import esphome.config_validation as cv
|
||||
@@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
|
||||
# The animation platform shares the file platform's remote file handling,
|
||||
# including its batch-download hook.
|
||||
PREFETCH_FILES = file_image.PREFETCH_FILES
|
||||
AUTO_LOAD = ["file"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
|
||||
@@ -1760,7 +1760,7 @@ enum BluetoothDeviceRequestType {
|
||||
message BluetoothDeviceRequest {
|
||||
option (id) = 68;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
BluetoothDeviceRequestType request_type = 2;
|
||||
@@ -1771,7 +1771,7 @@ message BluetoothDeviceRequest {
|
||||
message BluetoothDeviceConnectionResponse {
|
||||
option (id) = 69;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool connected = 2;
|
||||
@@ -1782,7 +1782,7 @@ message BluetoothDeviceConnectionResponse {
|
||||
message BluetoothGATTGetServicesRequest {
|
||||
option (id) = 70;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
}
|
||||
@@ -1826,7 +1826,7 @@ message BluetoothGATTService {
|
||||
message BluetoothGATTGetServicesResponse {
|
||||
option (id) = 71;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
repeated BluetoothGATTService services = 2;
|
||||
@@ -1835,7 +1835,7 @@ message BluetoothGATTGetServicesResponse {
|
||||
message BluetoothGATTGetServicesDoneResponse {
|
||||
option (id) = 72;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
}
|
||||
@@ -1843,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse {
|
||||
message BluetoothGATTReadRequest {
|
||||
option (id) = 73;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1852,7 +1852,7 @@ message BluetoothGATTReadRequest {
|
||||
message BluetoothGATTReadResponse {
|
||||
option (id) = 74;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1864,7 +1864,7 @@ message BluetoothGATTReadResponse {
|
||||
message BluetoothGATTWriteRequest {
|
||||
option (id) = 75;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1876,7 +1876,7 @@ message BluetoothGATTWriteRequest {
|
||||
message BluetoothGATTReadDescriptorRequest {
|
||||
option (id) = 76;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1885,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest {
|
||||
message BluetoothGATTWriteDescriptorRequest {
|
||||
option (id) = 77;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1896,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest {
|
||||
message BluetoothGATTNotifyRequest {
|
||||
option (id) = 78;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1906,7 +1906,7 @@ message BluetoothGATTNotifyRequest {
|
||||
message BluetoothGATTNotifyDataResponse {
|
||||
option (id) = 79;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1917,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse {
|
||||
message SubscribeBluetoothConnectionsFreeRequest {
|
||||
option (id) = 80;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
}
|
||||
|
||||
message BluetoothConnectionsFreeResponse {
|
||||
option (id) = 81;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint32 free = 1;
|
||||
uint32 limit = 2;
|
||||
@@ -1936,7 +1936,7 @@ message BluetoothConnectionsFreeResponse {
|
||||
message BluetoothGATTErrorResponse {
|
||||
option (id) = 82;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1946,7 +1946,7 @@ message BluetoothGATTErrorResponse {
|
||||
message BluetoothGATTWriteResponse {
|
||||
option (id) = 83;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1955,7 +1955,7 @@ message BluetoothGATTWriteResponse {
|
||||
message BluetoothGATTNotifyResponse {
|
||||
option (id) = 84;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1964,7 +1964,7 @@ message BluetoothGATTNotifyResponse {
|
||||
message BluetoothDevicePairingResponse {
|
||||
option (id) = 85;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool paired = 2;
|
||||
@@ -1974,7 +1974,7 @@ message BluetoothDevicePairingResponse {
|
||||
message BluetoothDeviceUnpairingResponse {
|
||||
option (id) = 86;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool success = 2;
|
||||
@@ -1990,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest {
|
||||
message BluetoothDeviceClearCacheResponse {
|
||||
option (id) = 88;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool success = 2;
|
||||
@@ -2807,7 +2807,7 @@ message SerialProxyRequestResponse {
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 min_interval = 2; // units of 1.25ms
|
||||
@@ -2819,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest {
|
||||
message BluetoothSetConnectionParamsResponse {
|
||||
option (id) = 146;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
int32 error = 2;
|
||||
|
||||
@@ -89,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam
|
||||
static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
|
||||
|
||||
static const char *const TAG = "api.connection";
|
||||
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
void log_dropped_message(const char *tag, int line, const LogString *what) {
|
||||
esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"),
|
||||
LOG_STR_ARG(what));
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
static const int CAMERA_STOP_STREAM = 5000;
|
||||
#endif
|
||||
@@ -1236,6 +1243,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request(
|
||||
void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
|
||||
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
|
||||
}
|
||||
@@ -1269,13 +1277,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() {
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode(
|
||||
msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE);
|
||||
}
|
||||
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
@@ -1533,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
// V: fires per decoded frame with no subscription gate, so a warning
|
||||
// would flood the congested link it reports on.
|
||||
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
@@ -1575,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
|
||||
SerialProxyGetModemPinsResponse resp{};
|
||||
resp.instance = msg.instance;
|
||||
resp.line_states = proxies[msg.instance]->get_modem_pins();
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
@@ -1607,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
|
||||
break;
|
||||
}
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -1616,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_INFRARED
|
||||
@@ -1747,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Acknowledge the hello so the client can read the server name, then request
|
||||
// disconnect with the reason. Authentication is intentionally not completed.
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Hello response");
|
||||
}
|
||||
DisconnectRequest req;
|
||||
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
|
||||
return this->send_message(req);
|
||||
@@ -1772,9 +1798,8 @@ bool APIConnection::send_device_info_response_() {
|
||||
#ifdef USE_AREAS
|
||||
resp.suggested_area = StringRef(App.get_area());
|
||||
#endif
|
||||
// Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
|
||||
char mac_address[18];
|
||||
uint8_t mac[6];
|
||||
char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_mac_address_raw(mac);
|
||||
format_mac_addr_upper(mac, mac_address);
|
||||
resp.mac_address = StringRef(mac_address);
|
||||
@@ -2037,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
|
||||
resp.call_id = call_id;
|
||||
resp.success = success;
|
||||
resp.error_message = error_message;
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action response");
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message,
|
||||
@@ -2048,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
|
||||
resp.error_message = error_message;
|
||||
resp.response_data = response_data;
|
||||
resp.response_data_len = response_data_len;
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action response");
|
||||
}
|
||||
}
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return false;
|
||||
if (!this->send_message(call)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action request");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif // USE_API_HOMEASSISTANT_SERVICES
|
||||
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void APIConnection::send_time_request() {
|
||||
GetTimeRequest req;
|
||||
if (!this->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Time request");
|
||||
}
|
||||
}
|
||||
#endif // USE_HOMEASSISTANT_TIME
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) {
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
@@ -2128,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
|
||||
if (this->helper_->can_write_without_blocking())
|
||||
return true;
|
||||
if (log_out_of_space) {
|
||||
ESP_LOGV(TAG, "Cannot send message because of TCP buffer space");
|
||||
// VV: refusals are either reported by the sending call site (naming what
|
||||
// was lost) or retried without loss (the deferred batch), so this generic
|
||||
// line only duplicates them.
|
||||
ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "esphome/components/esp8266/crash_handler.h"
|
||||
#endif
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#include <functional>
|
||||
@@ -40,6 +41,16 @@ namespace esphome::api {
|
||||
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
|
||||
class APIServer;
|
||||
|
||||
// One shared flash string for every refused-frame warning: send_message()
|
||||
// fails as soon as the TCP buffer is full, and each caller only pays for its
|
||||
// short name. The guard drops the helper and its arguments below WARN.
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
void log_dropped_message(const char *tag, int line, const LogString *what);
|
||||
#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what))
|
||||
#else
|
||||
#define API_LOG_MSG_DROPPED(tag, what)
|
||||
#endif
|
||||
|
||||
// Keepalive timeout in milliseconds
|
||||
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||
@@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Returns whether this client has subscribed to Home Assistant actions; the message
|
||||
// is only handed to the send path when subscribed. A true return does not guarantee
|
||||
// delivery - it lets the caller warn when no connected client has the subscription.
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return false;
|
||||
this->send_message(call);
|
||||
return true;
|
||||
}
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call);
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
@@ -183,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
|
||||
void on_unsubscribe_bluetooth_le_advertisements_request();
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
|
||||
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
|
||||
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
|
||||
@@ -191,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
|
||||
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
|
||||
void on_subscribe_bluetooth_connections_free_request();
|
||||
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
|
||||
#endif
|
||||
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
|
||||
|
||||
#endif
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void send_time_request() {
|
||||
GetTimeRequest req;
|
||||
this->send_message(req);
|
||||
}
|
||||
void send_time_request();
|
||||
#endif
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
@@ -335,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Function pointer type for type-erased size calculation
|
||||
using CalculateSizeFn = uint32_t (*)(const void *);
|
||||
|
||||
template<typename T> bool send_message(const T &msg) {
|
||||
/// Returns false as soon as the TCP buffer is full. Marked nodiscard so we
|
||||
/// have no silent failures: every caller must handle (or log) a refusal.
|
||||
template<typename T> [[nodiscard]] bool send_message(const T &msg) {
|
||||
if constexpr (T::ESTIMATED_SIZE == 0) {
|
||||
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
|
||||
} else {
|
||||
@@ -390,7 +397,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#ifdef USE_API_NOISE
|
||||
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool send_subscribe_bluetooth_connections_free_response_();
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
|
||||
@@ -149,7 +149,7 @@ class APIFrameHelper {
|
||||
// holding data too long waiting for Nagle's timer causes buffer exhaustion
|
||||
// and dropped messages.
|
||||
//
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
|
||||
//
|
||||
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
|
||||
@@ -312,7 +312,7 @@ class APIFrameHelper {
|
||||
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
|
||||
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
|
||||
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
|
||||
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
#ifdef USE_ESP8266
|
||||
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
|
||||
#else
|
||||
|
||||
@@ -2482,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const {
|
||||
}
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
@@ -2858,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_int32(1, this->error);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->state));
|
||||
@@ -4221,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
|
||||
@@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t {
|
||||
MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
enum BluetoothDeviceRequestType : uint32_t {
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
|
||||
@@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t {
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
enum BluetoothScannerState : uint32_t {
|
||||
BLUETOOTH_SCANNER_STATE_IDLE = 0,
|
||||
BLUETOOTH_SCANNER_STATE_STARTING = 1,
|
||||
@@ -1999,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothDeviceRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 68;
|
||||
@@ -2384,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
class BluetoothScannerStateResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 126;
|
||||
@@ -3358,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 145;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
|
||||
#ifndef USE_API_VARINT64
|
||||
#define USE_API_VARINT64
|
||||
#endif
|
||||
|
||||
@@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string<enums::MediaPlayerFormatPurpose>(enu
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
template<>
|
||||
const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::BluetoothDeviceRequestType value) {
|
||||
switch (value) {
|
||||
@@ -606,6 +606,8 @@ const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::Bluet
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
template<> const char *proto_enum_to_string<enums::BluetoothScannerState>(enums::BluetoothScannerState value) {
|
||||
switch (value) {
|
||||
case enums::BLUETOOTH_SCANNER_STATE_IDLE:
|
||||
@@ -2002,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const
|
||||
}
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("address"), this->address);
|
||||
@@ -2173,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("error"), this->error);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse"));
|
||||
dump_field(out, ESPHOME_PSTR("state"), static_cast<enums::BluetoothScannerState>(this->state));
|
||||
@@ -2764,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("address"), this->address);
|
||||
|
||||
@@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothDeviceRequest::MESSAGE_TYPE: {
|
||||
BluetoothDeviceRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTGetServicesRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTReadRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTReadRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTWriteRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTWriteRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTReadDescriptorRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTWriteDescriptorRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTNotifyRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTNotifyRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"));
|
||||
@@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: {
|
||||
BluetoothSetConnectionParamsRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
|
||||
@@ -115,32 +115,32 @@ class APIServerConnectionBase {
|
||||
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_device_request(const BluetoothDeviceRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_subscribe_bluetooth_connections_free_request(){};
|
||||
#endif
|
||||
|
||||
@@ -235,7 +235,7 @@ class APIServerConnectionBase {
|
||||
void on_serial_proxy_request(const SerialProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -123,7 +123,9 @@ void APIServer::setup() {
|
||||
// Best-effort: if the send buffer is full the reason is dropped, but the
|
||||
// client still learns the window is closed when it reconnects (rejected at
|
||||
// hello) or via the socket close.
|
||||
c->send_message(req);
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -394,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) {
|
||||
void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
// We could add code to manage a second subscription type, but, since this message type is
|
||||
// very infrequent and small, we simply send it to all clients
|
||||
for (auto &c : this->active_clients())
|
||||
c->send_message(msg);
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (!c->send_message(msg)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Home ID notification");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
|
||||
for (auto &c : this->active_clients()) {
|
||||
DisconnectRequest req;
|
||||
c->send_message(req);
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ async def to_code(config):
|
||||
data.wav_support = True
|
||||
|
||||
if data.micro_decoder_support:
|
||||
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
|
||||
add_idf_component(name="esphome/micro-decoder", ref="0.4.0")
|
||||
|
||||
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
|
||||
if not data.flac_support:
|
||||
@@ -380,6 +380,8 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
|
||||
if not data.opus_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
|
||||
# Vorbis is unsupported in ESPHome, so always disable it
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False)
|
||||
if not data.wav_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
|
||||
|
||||
|
||||
@@ -364,7 +364,8 @@ bool BK72xxBLETracker::request_scan_mode(bool active) {
|
||||
if (this->scan_active_ == active)
|
||||
return true;
|
||||
this->scan_active_ = active;
|
||||
ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// V: the proxy's "Setting scanner mode" line already narrates this at D.
|
||||
ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// The controller reconciler restarts a running scan itself; the scan stays
|
||||
// logically running. An idle scanner picks the mode up on its next start.
|
||||
if (this->scan_running_)
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
@@ -36,9 +37,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"]
|
||||
|
||||
bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
|
||||
|
||||
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1;
|
||||
# raising this needs an upstream change (the layer itself supports N).
|
||||
RP2_MAX_CONNECTIONS = 1
|
||||
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and
|
||||
# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's
|
||||
# btstack_memory.cpp replaces those pools via linker --wrap (requested by
|
||||
# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself
|
||||
# belongs to the platform stack that owns the pools.
|
||||
RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS
|
||||
|
||||
# Slot limits for the hub platforms running the connection-capable proxy;
|
||||
# the backend registry itself is _PLATFORM_BACKENDS below.
|
||||
@@ -53,6 +57,19 @@ BluedroidGattClient = bluetooth_connection_ns.class_(
|
||||
|
||||
CONF_BACKEND_ID = "backend_id"
|
||||
|
||||
DOMAIN = "bluetooth_connection"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConnectionData:
|
||||
rp2_backend_count: int = 0
|
||||
|
||||
|
||||
def _get_data() -> _ConnectionData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = _ConnectionData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def _esp32_schema_fragment() -> cv.Schema:
|
||||
from esphome.components import esp32_ble_tracker
|
||||
@@ -61,8 +78,6 @@ def _esp32_schema_fragment() -> cv.Schema:
|
||||
|
||||
|
||||
def _rp2_schema_fragment() -> cv.Schema:
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
return cv.Schema(
|
||||
{cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)}
|
||||
)
|
||||
@@ -77,15 +92,29 @@ async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None:
|
||||
|
||||
|
||||
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.components import ota
|
||||
|
||||
# The backend drops its link when an OTA starts (esp32 tracker parity).
|
||||
ota.request_ota_state_listeners()
|
||||
# More than one backend outgrows the prebuilt BTstack pools: swap them for
|
||||
# the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's
|
||||
# btstack_memory.cpp. Keyed to backend registrations (the same event that
|
||||
# grows the count that sizes the pools), so single-backend builds emit no
|
||||
# flags and stay byte-identical to previous releases.
|
||||
data = _get_data()
|
||||
data.rp2_backend_count += 1
|
||||
if data.rp2_backend_count == 2:
|
||||
rp2040_ble.add_btstack_pool_overrides()
|
||||
await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlatformBackend:
|
||||
"""One platform's backend: codegen class, extra schema keys (lazy so the
|
||||
platform stack is only imported when targeted), and stack registration."""
|
||||
"""One platform's backend: codegen class, extra schema keys, and stack
|
||||
registration. The esp32 fragments import their stack lazily because those
|
||||
imports register esp32-only automations as a side effect; rp2040_ble is
|
||||
side-effect-free, so it is imported at module scope (the cap constant
|
||||
needs it there anyway)."""
|
||||
|
||||
backend_class: cg.MockObjClass
|
||||
schema_fragment: Callable[[], cv.Schema]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#include <esp_gattc_api.h>
|
||||
#endif
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -44,14 +44,13 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
// Address-scoped Bluedroid maintenance shared by every esp32 proxy build,
|
||||
// including advertisement-only ones where no GATT backend (and none of the
|
||||
// gated surface above) is compiled - so this block sits outside that gate.
|
||||
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
|
||||
// the advertisement-only arm no longer dispatches these requests at all.
|
||||
|
||||
conn_err_t unpair_device(uint64_t address) {
|
||||
esp_bd_addr_t bda;
|
||||
@@ -66,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) {
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_ESP32 && USE_BLE_GATT_CLIENT
|
||||
|
||||
@@ -16,17 +16,14 @@
|
||||
#include <esp_err.h>
|
||||
#endif
|
||||
|
||||
// The connection-aware API request handlers are compiled: a GATT backend is
|
||||
// wired by codegen (one slot per connection). This is the single spelling of
|
||||
// that predicate - the hub wrapper and the API request handlers gate on it.
|
||||
// The wrapper serves the proxy's API surface, so it compiles only when a
|
||||
// backend AND the proxy are present; advertisement-only and backend-only
|
||||
// builds get the clean-error handlers instead. Address-scoped maintenance
|
||||
// (unpair, cache clear) still works there through the per-platform free
|
||||
// functions below.
|
||||
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
|
||||
#define BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif
|
||||
// USE_BLUETOOTH_PROXY_CONNECTIONS is the single spelling of "this build has
|
||||
// proxy connection slots": codegen emits it per configured slot, and each
|
||||
// slot brings a GATT backend, so it also implies USE_BLE_GATT_CLIENT (not
|
||||
// the converse: a backend can exist without proxy slots). The hub
|
||||
// wrapper, the proxy's connection surface and the API's connection messages
|
||||
// all gate on it. The address-scoped maintenance functions below are only
|
||||
// reached from that gated surface; the #else stubs just keep this header
|
||||
// parsing on arms without a backend.
|
||||
|
||||
namespace esphome::api {
|
||||
class BluetoothGATTGetServicesResponse;
|
||||
@@ -68,12 +65,12 @@ static constexpr bool SUPPORTS_CACHE_CLEARING = false;
|
||||
#endif
|
||||
|
||||
// Address-scoped (not connection-scoped) maintenance requests.
|
||||
#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT))
|
||||
#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t unpair_device(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
#endif
|
||||
#ifdef USE_ESP32
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t clear_gatt_cache(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
@@ -92,6 +89,10 @@ static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVIC
|
||||
// delivered near the client's 30 s timeout could land on a fresh request's
|
||||
// empty accumulator and cache as an empty database.
|
||||
static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30;
|
||||
// Owed-ack retries stop after ~25 s of subscribed drain time from the first
|
||||
// refusal, keeping most of the client's 30 s GATT window for congestion to
|
||||
// clear while still bounding how stale a delivered reply can be.
|
||||
static constexpr uint16_t PENDING_ACK_RETRY_LIMIT = 250;
|
||||
|
||||
// ---- Service-streaming size budget, shared by every platform's streamer ----
|
||||
|
||||
@@ -151,7 +152,7 @@ inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uu
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// Result of close_service_batch: keep filling the batch or send it now.
|
||||
/// An oversized service is packed alone; a failed (backpressured) send is
|
||||
/// retried from the batch start, so no service is silently skipped.
|
||||
@@ -163,6 +164,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND };
|
||||
/// cannot drift.
|
||||
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service,
|
||||
uint8_t connection_index, const char *address_str);
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
// The in-place streamer serves the proxy's service-discovery API; backend-only
|
||||
// builds compile without the proxy headers or the streamer.
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
#include "bluetooth_connection.h"
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
@@ -391,12 +391,14 @@ void BluedroidGattClient::deliver_pending_search_() {
|
||||
this->listener_->on_service_discovery_done(this->search_status_);
|
||||
}
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// The wrapper's compile-time streamer detection must keep finding this
|
||||
// method; a signature drift would silently fall back to the table streamer,
|
||||
// which proxy builds compile without a materializer.
|
||||
static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); });
|
||||
|
||||
// Bound by the SERVICE STREAMING HAZARD note at the top of
|
||||
// bluetooth_connection_hub.cpp: never skip a batch, never send done early.
|
||||
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
if (this->services_released_) {
|
||||
// Released under the stream: park without services-done so a partial
|
||||
@@ -527,11 +529,13 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
|
||||
// On a failed send, rewind the cursor so the batch is retried instead of
|
||||
// silently skipped.
|
||||
if (!api_conn->send_message(resp)) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_);
|
||||
conn.note_batch_stalled_();
|
||||
conn.send_service_ = batch_start;
|
||||
return;
|
||||
}
|
||||
conn.batch_stalled_ = false;
|
||||
}
|
||||
#endif // USE_BLUETOOTH_PROXY
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
// ---- events ----
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothConnection;
|
||||
#endif
|
||||
|
||||
@@ -79,7 +79,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
ble_device_base::GattServiceTable get_service_table() { return {}; }
|
||||
void release_services();
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// In-place service streamer (the proxy wrapper detects and prefers it):
|
||||
/// builds one api response batch directly from Bluedroid's cached database,
|
||||
/// so the streaming peak is the response itself - the old esp32 model.
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
// The proxy's per-slot connection wrapper, shared by every platform.
|
||||
//
|
||||
// SERVICE STREAMING HAZARD - read before touching the streaming code here or
|
||||
// in the platform streamers (bluetooth_connection_bluedroid.cpp).
|
||||
//
|
||||
// A V3 client caches the service list it receives as the device's complete,
|
||||
// permanent database. Nothing on the wire marks a list as partial, so a
|
||||
// stream that is truncated, has a skipped batch, or is terminated early
|
||||
// would be cached whole and poison every later session with the device.
|
||||
//
|
||||
// The rule: it is always better to send nothing and let the client time out
|
||||
// than to let services-done follow an incomplete stream. Concretely:
|
||||
// - a refused batch rewinds the cursor and is retried, never skipped;
|
||||
// - services-done is sent only after every batch was accepted;
|
||||
// - every interruption (subscriber lost or swapped, backend abort,
|
||||
// bounds-check failure) parks or aborts WITHOUT services-done and drops
|
||||
// any owed done;
|
||||
// - a new GetServices supersedes an owed done, so a stale done can never
|
||||
// land on a fresh request's empty accumulator and cache it as empty.
|
||||
// The client only caches a list terminated by services-done within the same
|
||||
// request; timeouts, disconnects and errors raise instead of caching.
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
|
||||
@@ -16,6 +36,9 @@ static const char *const TAG = "bluetooth_connection";
|
||||
void BluetoothConnection::set_address(uint64_t address) {
|
||||
// Keep the proxy's pre-allocated connections-free message in step
|
||||
this->proxy_->update_address_slot_(this->address_, address);
|
||||
// Slot changing hands: anything owed belonged to the old address. The
|
||||
// choke point for every reassignment, not just reset_connection_()'s path.
|
||||
this->clear_owed_flags_();
|
||||
this->address_ = address;
|
||||
if (address == 0) {
|
||||
this->address_str_[0] = '\0';
|
||||
@@ -73,6 +96,8 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) {
|
||||
this->state_ = ClientState::IDLE;
|
||||
this->services_discovered_ = false;
|
||||
this->paired_ = false;
|
||||
// Link gone: the slot may hold a different device before the drain runs.
|
||||
this->clear_owed_flags_();
|
||||
this->backend_->release_services();
|
||||
this->proxy_->reset_connection_slot_(this, reason);
|
||||
}
|
||||
@@ -103,11 +128,15 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
|
||||
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
|
||||
// The API client has the services cached; never discover them. No
|
||||
// discovery phase needs the fast interval, so settle straight into the
|
||||
// shared steady-state parameters. On esp32 the backend already set the
|
||||
// same values as prefer-params before opening, so this request is
|
||||
// usually redundant there - kept because rp2 has no prefer-params and
|
||||
// the explicit update is its only path to the steady-state interval.
|
||||
// shared steady-state parameters. Both backends already open cached
|
||||
// connections with these values (esp32 prefer-params, rp2 initiating
|
||||
// params), so this request is normally redundant - kept as a backstop
|
||||
// in case the initial parameters were negotiated away.
|
||||
this->state_ = ClientState::ESTABLISHED;
|
||||
// The one D-level line for a cached connect; the uncached path narrates
|
||||
// through "Discovery finished" instead.
|
||||
ESP_LOGD(TAG, "[%d] [%s] Connected with cached services, sending connected (mtu=%u)", this->connection_index_,
|
||||
this->address_str_, mtu);
|
||||
int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL,
|
||||
ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0,
|
||||
ble_device_base::MEDIUM_CONN_TIMEOUT);
|
||||
@@ -116,7 +145,7 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
|
||||
ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_,
|
||||
param_err);
|
||||
}
|
||||
this->proxy_->send_device_connection(this->address_, true, mtu);
|
||||
this->send_connected_reply_();
|
||||
this->proxy_->send_connections_free();
|
||||
return;
|
||||
}
|
||||
@@ -154,22 +183,136 @@ void BluetoothConnection::on_service_discovery_done(int error) {
|
||||
this->mtu_);
|
||||
this->state_ = ClientState::ESTABLISHED;
|
||||
this->services_discovered_ = true;
|
||||
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
|
||||
this->send_connected_reply_();
|
||||
this->proxy_->send_connections_free();
|
||||
}
|
||||
|
||||
void BluetoothConnection::flush_owed_replies_() {
|
||||
// Connected first: the client should never see services-done or an ack for
|
||||
// a link it has not been told is up. Structural, not size-dependent: a
|
||||
// still-owed connected reply defers the smaller sends to the next tick.
|
||||
if (this->connected_reply_owed_) {
|
||||
this->send_connected_reply_();
|
||||
if (this->connected_reply_owed_) {
|
||||
// The retry limits are wall-clock windows: age the deferred budgets so
|
||||
// a reply cannot outlive the window it was sized for.
|
||||
if (this->send_service_ == SERVICES_DONE_PENDING) {
|
||||
this->age_services_done_();
|
||||
}
|
||||
if (this->has_pending_ack_()) {
|
||||
this->age_pending_ack_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this->send_service_ == SERVICES_DONE_PENDING) {
|
||||
this->send_services_done_();
|
||||
}
|
||||
if (this->has_pending_ack_()) {
|
||||
this->flush_pending_ack_();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::send_connected_reply_() {
|
||||
if (this->proxy_->send_device_connection(this->address_, true, this->mtu_)) {
|
||||
this->connected_reply_owed_ = false;
|
||||
return;
|
||||
}
|
||||
// Warn on the leading edge only, as elsewhere: the drop must be visible but
|
||||
// must not add traffic to the connection that just refused a frame.
|
||||
if (!this->connected_reply_owed_) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Connected reply deferred, TCP buffer full", this->connection_index_, this->address_str_);
|
||||
this->connected_reply_owed_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_,
|
||||
operation, handle, status);
|
||||
}
|
||||
|
||||
void BluetoothConnection::note_batch_stalled_() {
|
||||
if (this->batch_stalled_)
|
||||
return;
|
||||
this->batch_stalled_ = true;
|
||||
ESP_LOGW(TAG, "[%d] [%s] Service batch deferred, TCP buffer full; retrying", this->connection_index_,
|
||||
this->address_str_);
|
||||
}
|
||||
|
||||
/// Both payload-free acks are just (address, handle); only the type differs.
|
||||
template<typename Response>
|
||||
static bool send_handle_reply(api::APIConnection *api_connection, uint64_t address, uint16_t handle) {
|
||||
Response resp;
|
||||
resp.address = address;
|
||||
resp.handle = handle;
|
||||
return api_connection->send_message(resp);
|
||||
}
|
||||
|
||||
/// Sole construction site, so a re-offer cannot drift from the original.
|
||||
bool BluetoothConnection::try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) {
|
||||
if (kind == PendingAck::PENDING_ACK_ERROR) {
|
||||
// Proxy owns the error reply and reports a refusal the same way.
|
||||
return this->proxy_->send_gatt_error(this->address_, handle, error);
|
||||
}
|
||||
auto *api_connection = this->proxy_->get_api_connection();
|
||||
if (api_connection == nullptr)
|
||||
return true; // Nobody subscribed: nothing is owed
|
||||
switch (kind) {
|
||||
case PendingAck::PENDING_ACK_WRITE:
|
||||
return send_handle_reply<api::BluetoothGATTWriteResponse>(api_connection, this->address_, handle);
|
||||
case PendingAck::PENDING_ACK_NOTIFY:
|
||||
return send_handle_reply<api::BluetoothGATTNotifyResponse>(api_connection, this->address_, handle);
|
||||
case PendingAck::PENDING_ACK_NONE:
|
||||
case PendingAck::PENDING_ACK_ERROR: // returned above
|
||||
return true;
|
||||
}
|
||||
// No default label above, so a new enumerator is a -Wswitch warning rather
|
||||
// than a silent notify reply. This return only satisfies -Wreturn-type.
|
||||
return true;
|
||||
}
|
||||
|
||||
void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) {
|
||||
if (this->try_send_ack_(kind, handle, error))
|
||||
return;
|
||||
// Report a newly owed reply and a displaced one; displacing is the case
|
||||
// that loses a reply. Re-refusing the same one stays quiet.
|
||||
if (!this->has_pending_ack_()) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_,
|
||||
this->address_str_, handle);
|
||||
} else if (this->pending_ack_handle_ != handle || this->pending_ack_ != kind) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X dropped for handle 0x%04X", this->connection_index_,
|
||||
this->address_str_, this->pending_ack_handle_, handle);
|
||||
}
|
||||
this->latch_pending_ack_(kind, handle, error);
|
||||
}
|
||||
|
||||
void BluetoothConnection::flush_pending_ack_() {
|
||||
if (!this->has_pending_ack_())
|
||||
return;
|
||||
if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) {
|
||||
this->clear_pending_ack_();
|
||||
return;
|
||||
}
|
||||
this->age_pending_ack_();
|
||||
}
|
||||
|
||||
void BluetoothConnection::age_pending_ack_() {
|
||||
if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) {
|
||||
// Undeliverable: past here the client has given up and may have re-asked,
|
||||
// and a late reply would answer the new request instead of this one.
|
||||
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X undeliverable, abandoning", this->connection_index_,
|
||||
this->address_str_, this->pending_ack_handle_);
|
||||
this->clear_pending_ack_();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
|
||||
// Late completion for a freed slot; nothing to report.
|
||||
if (this->address_ == 0)
|
||||
return;
|
||||
if (error != 0) {
|
||||
this->log_gatt_operation_error_("reading char/descriptor", handle, error);
|
||||
this->proxy_->send_gatt_error(this->address_, handle, error);
|
||||
this->send_gatt_error_(handle, error);
|
||||
return;
|
||||
}
|
||||
auto *api_connection = this->proxy_->get_api_connection();
|
||||
@@ -180,6 +323,8 @@ void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, u
|
||||
resp.handle = handle;
|
||||
resp.set_data(data, len);
|
||||
if (!api_connection->send_message(resp)) {
|
||||
// Not latched: would mean holding the payload through the congestion
|
||||
// that refused it. The client's read timeout arbitrates.
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_);
|
||||
}
|
||||
}
|
||||
@@ -189,18 +334,10 @@ void BluetoothConnection::on_write_result(uint16_t handle, int error) {
|
||||
return;
|
||||
if (error != 0) {
|
||||
this->log_gatt_operation_error_("writing char/descriptor", handle, error);
|
||||
this->proxy_->send_gatt_error(this->address_, handle, error);
|
||||
this->send_gatt_error_(handle, error);
|
||||
return;
|
||||
}
|
||||
auto *api_connection = this->proxy_->get_api_connection();
|
||||
if (api_connection == nullptr)
|
||||
return;
|
||||
api::BluetoothGATTWriteResponse resp;
|
||||
resp.address = this->address_;
|
||||
resp.handle = handle;
|
||||
if (!api_connection->send_message(resp)) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_);
|
||||
}
|
||||
this->send_ack_(PendingAck::PENDING_ACK_WRITE, handle);
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) {
|
||||
@@ -209,18 +346,10 @@ void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int err
|
||||
if (error != 0) {
|
||||
this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle,
|
||||
error);
|
||||
this->proxy_->send_gatt_error(this->address_, handle, error);
|
||||
this->send_gatt_error_(handle, error);
|
||||
return;
|
||||
}
|
||||
auto *api_connection = this->proxy_->get_api_connection();
|
||||
if (api_connection == nullptr)
|
||||
return;
|
||||
api::BluetoothGATTNotifyResponse resp;
|
||||
resp.address = this->address_;
|
||||
resp.handle = handle;
|
||||
if (!api_connection->send_message(resp)) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_);
|
||||
}
|
||||
this->send_ack_(PendingAck::PENDING_ACK_NOTIFY, handle);
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
@@ -235,6 +364,8 @@ void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, u
|
||||
resp.handle = handle;
|
||||
resp.set_data(data, len);
|
||||
if (!api_connection->send_message(resp)) {
|
||||
// Not latched, same reason as the read reply. Notify data is lossy: the
|
||||
// peripheral will not resend it.
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_);
|
||||
}
|
||||
}
|
||||
@@ -251,6 +382,7 @@ conn_err_t BluetoothConnection::check_connected_op_(const char *action, const ch
|
||||
}
|
||||
|
||||
conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE);
|
||||
if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
|
||||
@@ -259,6 +391,7 @@ conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
|
||||
|
||||
conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length,
|
||||
bool response) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE);
|
||||
if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
|
||||
@@ -266,6 +399,7 @@ conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint
|
||||
}
|
||||
|
||||
conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE);
|
||||
if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
|
||||
@@ -276,6 +410,7 @@ conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
|
||||
// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP).
|
||||
conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length,
|
||||
bool /*response*/) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE);
|
||||
if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
|
||||
@@ -283,6 +418,7 @@ conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t
|
||||
}
|
||||
|
||||
conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) {
|
||||
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NOTIFY);
|
||||
if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK)
|
||||
return err;
|
||||
ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_,
|
||||
@@ -310,7 +446,13 @@ void BluetoothConnection::send_services_done_() {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_);
|
||||
this->services_done_retries_ = 0;
|
||||
this->send_service_ = SERVICES_DONE_PENDING;
|
||||
} else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) {
|
||||
} else {
|
||||
this->age_services_done_();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::age_services_done_() {
|
||||
if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) {
|
||||
// Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates.
|
||||
ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_);
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
@@ -413,11 +555,13 @@ void BluetoothConnection::send_service_for_discovery_() {
|
||||
// (bounded: a subscriber that stays gone ends streaming via the api-lost
|
||||
// rewind above).
|
||||
if (!api_conn->send_message(resp)) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_);
|
||||
this->note_batch_stalled_();
|
||||
this->send_service_ = batch_start;
|
||||
return;
|
||||
}
|
||||
this->batch_stalled_ = false;
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// The wrapper exists to serve the proxy's API surface; direct consumers
|
||||
// drive the backend themselves, so backend-only builds compile this header
|
||||
// empty.
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_client_state.h"
|
||||
#include "bluetooth_connection_gatt_backend.h"
|
||||
@@ -25,6 +25,16 @@ namespace esphome::bluetooth_connection {
|
||||
using ClientState = ble_device_base::ClientState;
|
||||
using ConnectionType = ble_device_base::ConnectionType;
|
||||
|
||||
/// A refused GATT reply owed to the current subscriber. Payload-free only:
|
||||
/// these rebuild from address + handle + error, so a retry costs no buffered
|
||||
/// data. Read and notify-data carry payloads and are deliberately absent.
|
||||
enum class PendingAck : uint8_t {
|
||||
PENDING_ACK_NONE = 0,
|
||||
PENDING_ACK_WRITE,
|
||||
PENDING_ACK_NOTIFY,
|
||||
PENDING_ACK_ERROR,
|
||||
};
|
||||
|
||||
class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
public:
|
||||
/// Wire the platform backend. Called from codegen before setup.
|
||||
@@ -77,8 +87,9 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
bool connected() const { return this->state_ == ClientState::ESTABLISHED; }
|
||||
void set_connection_type(ConnectionType ct) {
|
||||
this->connection_type_ = ct;
|
||||
// The bluedroid backend branches on the type itself (prefer-params and
|
||||
// the with-cache report at OPEN_EVT); the others ignore it.
|
||||
// Both backends branch on the type before connecting (bluedroid picks
|
||||
// prefer-params and the with-cache report at OPEN_EVT; rp2 picks the
|
||||
// initiating parameters), so this must be set before the connect starts.
|
||||
this->backend_->set_connection_type(ct);
|
||||
}
|
||||
// Latched at discovery completion rather than read from the backend table:
|
||||
@@ -115,6 +126,58 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
this->pending_error_ = err;
|
||||
}
|
||||
}
|
||||
|
||||
/// Latch a refused reply for the proxy drain. One slot per connection,
|
||||
/// newest wins: a GATT client works one request at a time, and a discarded
|
||||
/// reply falls back to the timeout it would have hit anyway.
|
||||
void latch_pending_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0) {
|
||||
this->pending_ack_retries_ = 0;
|
||||
this->pending_ack_ = kind;
|
||||
this->pending_ack_handle_ = handle;
|
||||
this->pending_ack_error_ = error;
|
||||
}
|
||||
void clear_pending_ack_() { this->pending_ack_ = PendingAck::PENDING_ACK_NONE; }
|
||||
/// Drop an owed reply this re-ask makes stale. Clients match futures on
|
||||
/// response type as well as handle, so an owed error (which resolves any op
|
||||
/// on the handle) is cleared by any re-ask, other kinds only by their own.
|
||||
void supersede_pending_ack_(uint16_t handle, PendingAck kind) {
|
||||
if (this->has_pending_ack_() && this->pending_ack_handle_ == handle &&
|
||||
(this->pending_ack_ == PendingAck::PENDING_ACK_ERROR || this->pending_ack_ == kind)) {
|
||||
this->clear_pending_ack_();
|
||||
}
|
||||
}
|
||||
bool has_pending_ack_() const { return this->pending_ack_ != PendingAck::PENDING_ACK_NONE; }
|
||||
/// Warn on the stall's leading edge only. The batch is never lost (the
|
||||
/// caller rewinds the cursor), and a warning per attempt would add traffic
|
||||
/// to the connection already refusing frames. Both streamers route here.
|
||||
void note_batch_stalled_();
|
||||
/// Send the connected=true reply, latching it if the API refuses. Rebuilt
|
||||
/// from address_ and mtu_, so the latch is one bit; a dropped confirmation
|
||||
/// leaves the client timing out while this slot holds a live link. No retry
|
||||
/// bound: the slot's lifetime is the bound (teardown clears the flag).
|
||||
void send_connected_reply_();
|
||||
/// Re-offer everything this slot owes. One entry point so the proxy drain
|
||||
/// does not have to know which latches exist.
|
||||
void flush_owed_replies_();
|
||||
/// Drop everything this slot owes, in one write to the shared tail byte.
|
||||
void clear_owed_flags_() {
|
||||
this->pending_ack_ = PendingAck::PENDING_ACK_NONE;
|
||||
this->batch_stalled_ = false;
|
||||
this->connected_reply_owed_ = false;
|
||||
}
|
||||
/// Sole construction site for these replies, shared by send and retry.
|
||||
bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error);
|
||||
/// First attempt: send, and latch it for the drain if the API refuses.
|
||||
void send_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0);
|
||||
/// Report a rejected request. Latched like a completion reply, so a
|
||||
/// refused frame does not strand the client for its whole timeout.
|
||||
void send_gatt_error_(uint16_t handle, conn_err_t error) {
|
||||
this->send_ack_(PendingAck::PENDING_ACK_ERROR, handle, error);
|
||||
}
|
||||
/// Re-offer the owed reply; clears on success, stays owed on a refusal.
|
||||
void flush_pending_ack_();
|
||||
/// Advance the retry budget and abandon at the limit, without sending.
|
||||
void age_pending_ack_();
|
||||
// A backend providing its own streamer (see the contract doc) builds the
|
||||
// response in place from its stack cache; the rest use the table streamer.
|
||||
// Template so the discarded branch is not odr-checked against backends
|
||||
@@ -130,6 +193,7 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
/// interrupted stream must never be declared complete (the client's
|
||||
/// timeout arbitrates), and an owed done is dropped with it.
|
||||
void park_service_stream_() {
|
||||
this->batch_stalled_ = false;
|
||||
if (this->send_service_ >= 0) {
|
||||
this->backend_->release_services();
|
||||
this->send_service_ = DONE_SENDING_SERVICES;
|
||||
@@ -143,6 +207,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
/// retries). Callers release the table first; the message needs only the
|
||||
/// address.
|
||||
void send_services_done_();
|
||||
/// Advance the retry budget and abandon at the limit, without sending.
|
||||
void age_services_done_();
|
||||
void reset_connection_(conn_err_t reason);
|
||||
conn_err_t check_connected_op_(const char *action, const char *type) const;
|
||||
void log_gatt_operation_error_(const char *operation, uint16_t handle, int status);
|
||||
@@ -152,23 +218,33 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
bluetooth_proxy::BluetoothProxy *proxy_{nullptr};
|
||||
ble_device_base::BLEGattConnection *backend_{nullptr};
|
||||
|
||||
// Group 2: 2-byte types
|
||||
// Group 2: 2-byte types. Exactly 4 bytes, so address_ below stays
|
||||
// 8-aligned with no padding (the vptr makes Group 1 12 bytes, not 8).
|
||||
int16_t send_service_{INIT_SENDING_SERVICES};
|
||||
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
|
||||
|
||||
// Group 3: 8-byte and 4-byte types
|
||||
uint64_t address_{0};
|
||||
conn_err_t pending_error_{0};
|
||||
// Full width: the GATT error domain is open-ended (ble_gatt_client.h) and
|
||||
// forwarded untranslated, so narrowing would corrupt platform codes.
|
||||
conn_err_t pending_ack_error_{0};
|
||||
|
||||
// Group 4: Arrays
|
||||
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
|
||||
// Parked here rather than in Group 2: address_str_ ends 2-aligned, so this
|
||||
// uses tail slack instead of pushing address_ out by 6 bytes of padding.
|
||||
uint16_t pending_ack_handle_{0};
|
||||
|
||||
// Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48.
|
||||
// Group 5: bit-packed tail. The first two bytes were already full, so the
|
||||
// first added bit forced a third and took the 8-aligned object 48 -> 56;
|
||||
// the handle, error and retry counter ride in that padding. Four bitfield
|
||||
// bits left; another byte-sized member costs 8 per slot.
|
||||
static_assert(static_cast<uint8_t>(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow");
|
||||
static_assert(static_cast<uint8_t>(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2),
|
||||
"connection_type_ bitfield too narrow");
|
||||
// Ordered so neither byte's fields straddle a storage unit: 3+5 and
|
||||
// 4+2+1+1 fill the two tail bytes exactly.
|
||||
// 4+2+1+1 fill the first two tail bytes exactly.
|
||||
ClientState state_ : 3 {ClientState::IDLE};
|
||||
static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow");
|
||||
uint8_t services_done_retries_ : 5 {0};
|
||||
@@ -176,8 +252,23 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
ConnectionType connection_type_ : 2 {ConnectionType::V1};
|
||||
bool paired_ : 1 {false};
|
||||
bool services_discovered_ : 1 {false};
|
||||
static_assert(static_cast<uint8_t>(PendingAck::PENDING_ACK_ERROR) < (1 << 2), "pending_ack_ bitfield too narrow");
|
||||
PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE};
|
||||
/// Set while a refused batch is retrying, so only the first one warns.
|
||||
bool batch_stalled_ : 1 {false};
|
||||
/// An owed connected=true reply; the proxy's paced drain re-offers it.
|
||||
bool connected_reply_owed_ : 1 {false};
|
||||
// Plain byte after the bitfields: takes the padding byte instead of
|
||||
// straddling pending_ack_'s storage unit and growing the object.
|
||||
static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow");
|
||||
uint8_t pending_ack_retries_{0};
|
||||
};
|
||||
|
||||
// Pins the grouping above: pending_ack_handle_ in Group 2 instead would pad
|
||||
// address_ out and reach 64. 32-bit only; the host unit tests build 64-bit.
|
||||
static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56,
|
||||
"BluetoothConnection layout regressed on a 32-bit target");
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
@@ -26,6 +26,13 @@ using ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
// and keeps the scan inhibited, so the engine cancels after 20 s. The
|
||||
// disconnect timeout mirrors the esp32 CLOSE_EVT safety net.
|
||||
static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000;
|
||||
// Budget after a cancel is in flight: its completion normally lands within
|
||||
// tens of ms, and while the engine waits it pins the stack-wide connect slot,
|
||||
// so a lost completion must cost seconds, not another full connect budget.
|
||||
static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000;
|
||||
// Pending engines re-attempt gap_connect on this cadence instead of every
|
||||
// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock.
|
||||
static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50;
|
||||
// Can-send windows normally open within a connection interval (tens of ms).
|
||||
static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500;
|
||||
|
||||
@@ -54,6 +61,7 @@ RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {};
|
||||
uint8_t RP2GattClient::instance_count = 0;
|
||||
btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {};
|
||||
btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {};
|
||||
RP2GattClient *RP2GattClient::connect_owner = nullptr;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) {
|
||||
@@ -84,6 +92,7 @@ void RP2GattClient::setup() {
|
||||
// One locked section: the slot store lands before the count bump, and a
|
||||
// live HCI handler (N > 1 builds) cannot read a half-written registry.
|
||||
BluetoothLock lock;
|
||||
this->engine_index_ = instance_count;
|
||||
instances[instance_count] = this;
|
||||
instance_count++;
|
||||
// One HCI event handler for all engine instances (BTstack supports
|
||||
@@ -96,9 +105,24 @@ void RP2GattClient::setup() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
ota::get_global_ota_callback()->add_global_state_listener(this);
|
||||
#endif
|
||||
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
|
||||
// esp32 parity (its tracker disconnects every client at OTA start): free
|
||||
// the shared radio for the transfer. No restore needed; the client
|
||||
// reconnects, and on success the device reboots anyway.
|
||||
if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) {
|
||||
this->gatt_disconnect();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); }
|
||||
@@ -124,34 +148,56 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *
|
||||
if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) {
|
||||
break;
|
||||
}
|
||||
bd_addr_t peer;
|
||||
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
|
||||
uint8_t status = gap_subevent_le_connection_complete_get_status(packet);
|
||||
hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet);
|
||||
// Route to the engine that is waiting for this peer.
|
||||
for (uint8_t i = 0; i < instance_count; i++) {
|
||||
RP2GattClient *inst = instances[i];
|
||||
if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) {
|
||||
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
|
||||
break;
|
||||
bd_addr_t peer;
|
||||
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
|
||||
// Route by ownership, not address: gap_connect refuses a new
|
||||
// create-connection until the previous completion is processed, so the
|
||||
// event belongs to the owner by construction. Cancel completions carry
|
||||
// a zeroed peer address on this controller, so an address match would
|
||||
// drop them and pin the owner until its backstop.
|
||||
RP2GattClient *inst = connect_owner;
|
||||
static constexpr bd_addr_t ZERO_ADDR = {};
|
||||
if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 &&
|
||||
memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) {
|
||||
// Addressed completion for a peer the owner is not connecting to: a
|
||||
// success delayed past a cancel and an ownership handoff (the cancel
|
||||
// idles the stack's request immediately) must not stamp the old
|
||||
// procedure's link onto the new owner. Zero-address (cancel)
|
||||
// completions need no such guard: BTstack only emits them while its
|
||||
// request state is idle, and a new owner re-arms that state when it
|
||||
// claims the token, so a stale cancel completion is swallowed by the
|
||||
// stack, never re-attributed. A successful stale link still needs
|
||||
// disposal (same hazard as the unowned branch below).
|
||||
if (status == 0) {
|
||||
gap_disconnect(con_handle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
connect_owner = nullptr;
|
||||
if (inst == nullptr) {
|
||||
if (status == 0) {
|
||||
// Nobody owns this late link (the owner escalated first): tear it
|
||||
// down here or the hci_connection_t leaks and the peer answers
|
||||
// DISALLOWED until reboot.
|
||||
gap_disconnect(con_handle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (status == 0) {
|
||||
// Stamp the handle here in the BTstack context: a disconnection
|
||||
// racing the queued CONNECTED event arrives in this same context
|
||||
// and must route by handle (it carries no address).
|
||||
inst->con_handle_ = con_handle;
|
||||
}
|
||||
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
|
||||
break;
|
||||
}
|
||||
case HCI_EVENT_DISCONNECTION_COMPLETE: {
|
||||
hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet);
|
||||
RP2GattClient *inst = instance_for_con_handle(con_handle);
|
||||
if (inst == nullptr && instance_count == 1) {
|
||||
// The main loop may not have recorded the handle yet (the CONNECTED
|
||||
// event is still queued); with a single engine the connecting
|
||||
// instance is unambiguous, so route there to close the
|
||||
// accept-then-drop window. With multiple engines the event has no
|
||||
// address to match on, so it must be dropped instead of guessed.
|
||||
RP2GattClient *candidate = instances[0];
|
||||
if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) {
|
||||
inst = candidate;
|
||||
}
|
||||
}
|
||||
// Routable even against a still-queued CONNECTED event: the handle is
|
||||
// stamped in this context at connection-complete time.
|
||||
RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet));
|
||||
if (inst != nullptr) {
|
||||
inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0);
|
||||
}
|
||||
@@ -393,44 +439,73 @@ void RP2GattClient::loop() {
|
||||
if (dropped > 0) {
|
||||
// Control events must not be lost; the connection state is no longer
|
||||
// trustworthy — recover with a forced teardown.
|
||||
ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped);
|
||||
ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped);
|
||||
this->gatt_disconnect();
|
||||
}
|
||||
uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count();
|
||||
if (notify_dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped);
|
||||
ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped);
|
||||
}
|
||||
|
||||
if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
if (this->state_ == EngineState::CONNECT_PENDING) {
|
||||
uint32_t now = millis();
|
||||
if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Connect timeout");
|
||||
if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) {
|
||||
if (!this->connect_cancel_attempted_) {
|
||||
this->connect_cancel_attempted_ = true;
|
||||
BluetoothLock lock;
|
||||
// Never reached the radio; nothing stack-side to cancel.
|
||||
ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_);
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
} else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) {
|
||||
this->connect_retry_ms_ = now;
|
||||
if (int err = this->try_gap_connect_(); err != 0) {
|
||||
this->fail_connection_(static_cast<uint8_t>(err));
|
||||
}
|
||||
}
|
||||
} else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
uint32_t now = millis();
|
||||
bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID &&
|
||||
this->connect_cancel_attempted_;
|
||||
uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS;
|
||||
if (now - this->connect_started_ > budget) {
|
||||
ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_);
|
||||
bool link_up = this->state_ != EngineState::CONNECTING;
|
||||
bool cancel_sent = false;
|
||||
if (!link_up) {
|
||||
BluetoothLock lock;
|
||||
// Handle check under the lock: a success completion can stamp it in
|
||||
// the BTstack context right up to this point, and escalating past a
|
||||
// live link would orphan it (the queued CONNECTED event is dropped
|
||||
// by the state guard once fail_connection_ runs).
|
||||
link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID;
|
||||
if (!link_up && connect_owner == this) {
|
||||
// gap_connect_cancel is stack-global; only the engine whose
|
||||
// create-connection is in flight may issue it. First timeout:
|
||||
// cancel and give the completion a grace period. Second: the
|
||||
// completion was lost, re-issue the cancel in case the procedure
|
||||
// still runs (a no-op on an idle stack), then escalate.
|
||||
gap_connect_cancel();
|
||||
// The cancel produces a connection-complete event with a failure
|
||||
// status, which drives the normal failure path; restart the timer
|
||||
// so a lost event escalates below instead of wedging here.
|
||||
this->connect_started_ = now;
|
||||
} else {
|
||||
// The cancel's completion never arrived: reclaim the slot and the
|
||||
// scan rather than cancelling forever.
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
cancel_sent = !this->connect_cancel_attempted_;
|
||||
}
|
||||
} else {
|
||||
// The link is up (MTU exchange stalled): tear it down properly so the
|
||||
// controller frees its side; the DISCONNECTING safety net below
|
||||
// reclaims state if the disconnection event is lost. Dropping engine
|
||||
// state without gap_disconnect would leak the live link and the
|
||||
// single GATT slot for the rest of the boot.
|
||||
this->connect_cancel_attempted_ = true;
|
||||
}
|
||||
if (link_up) {
|
||||
// The link is up (stamped mid-timeout or MTU exchange stalled): tear
|
||||
// it down properly so the controller frees its side; the
|
||||
// DISCONNECTING safety net below reclaims state if the disconnection
|
||||
// event is lost. Dropping engine state without gap_disconnect would
|
||||
// leak the live link and this engine's GATT slot for the rest of the
|
||||
// boot.
|
||||
this->gatt_disconnect();
|
||||
} else if (cancel_sent) {
|
||||
// The cancel produces a connection-complete event with a failure
|
||||
// status, which drives the normal failure path; restart the timer so
|
||||
// a lost event escalates on the short cancel budget.
|
||||
this->connect_started_ = now;
|
||||
} else {
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
}
|
||||
}
|
||||
} else if (this->state_ == EngineState::DISCONNECTING) {
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Disconnect timeout, forcing idle");
|
||||
ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_);
|
||||
this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
}
|
||||
} else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP &&
|
||||
@@ -446,7 +521,7 @@ void RP2GattClient::loop() {
|
||||
}
|
||||
}
|
||||
if (timed_out) {
|
||||
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
|
||||
ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_);
|
||||
this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
|
||||
}
|
||||
} else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() &&
|
||||
@@ -467,7 +542,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
|
||||
case RP2GattEvent::MTU_EXCHANGED:
|
||||
if (this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
this->mtu_ = event.value;
|
||||
ESP_LOGD(TAG, "MTU %u", this->mtu_);
|
||||
ESP_LOGV(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_);
|
||||
this->state_ = EngineState::READY;
|
||||
// Scanning resumes and runs alongside the established connection.
|
||||
this->release_scan_inhibit_();
|
||||
@@ -515,7 +590,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
|
||||
return;
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "Connect failed, status=0x%02x", status);
|
||||
ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status);
|
||||
this->fail_connection_(status);
|
||||
return;
|
||||
}
|
||||
@@ -539,7 +614,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
|
||||
}
|
||||
this->con_handle_ = con_handle;
|
||||
this->state_ = EngineState::MTU_EXCHANGE;
|
||||
ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle);
|
||||
ESP_LOGV(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle);
|
||||
BluetoothLock lock;
|
||||
// One wildcard listener covers notifications/indications for every
|
||||
// characteristic on this connection; the CCCD writes come from the API
|
||||
@@ -564,6 +639,24 @@ void RP2GattClient::release_scan_inhibit_() {
|
||||
}
|
||||
|
||||
void RP2GattClient::fail_connection_(uint8_t reason) {
|
||||
{
|
||||
// Timeout escalation can fire with the completion event lost; release the
|
||||
// stack-wide connect slot so pending engines can proceed. Until the old
|
||||
// completion is processed, gap_connect answers any peer with DISALLOWED
|
||||
// (the request-level guard in hci.c); a cancel idles that request
|
||||
// immediately, and a late addressed completion from the old procedure is
|
||||
// then dropped by the owner-peer cross-check in the handler.
|
||||
BluetoothLock lock;
|
||||
if (connect_owner == this) {
|
||||
connect_owner = nullptr;
|
||||
}
|
||||
if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
// A success completion stamped the handle between the escalation
|
||||
// decision and this lock: tear the link down before cleanup wipes the
|
||||
// handle, or it leaks its pool block for the rest of the boot.
|
||||
gap_disconnect(this->con_handle_);
|
||||
}
|
||||
}
|
||||
this->cleanup_link_state_();
|
||||
this->release_scan_inhibit_();
|
||||
this->state_ = EngineState::IDLE;
|
||||
@@ -577,14 +670,19 @@ void RP2GattClient::cleanup_link_state_() {
|
||||
while ((stale = this->notify_queue_.pop()) != nullptr) {
|
||||
this->notify_pool_.release(stale);
|
||||
}
|
||||
// The wildcard listener is registered on the normal connect path right
|
||||
// after con_handle_ is recorded; the cancel branch tears down before
|
||||
// registering, where stop_listening on an unregistered entry is a no-op.
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
// con_handle_ may be stamped in the BTstack context before the main loop
|
||||
// registers the listener, so a valid handle does not imply a registration;
|
||||
// stop_listening on an unregistered entry is a benign no-op. One lock
|
||||
// scope around check and reset so an IRQ stamp cannot land in between
|
||||
// (unreachable today — ownership is released before cleanup — but the
|
||||
// invariant lives three functions away).
|
||||
{
|
||||
BluetoothLock lock;
|
||||
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
|
||||
}
|
||||
this->con_handle_ = HCI_CON_HANDLE_INVALID;
|
||||
}
|
||||
this->con_handle_ = HCI_CON_HANDLE_INVALID;
|
||||
this->notify_subscription_count_ = 0;
|
||||
this->cancel_requested_ = false;
|
||||
this->op_type_ = OpType::NONE;
|
||||
@@ -596,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) {
|
||||
if (this->state_ == EngineState::IDLE) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason);
|
||||
ESP_LOGV(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason);
|
||||
this->fail_connection_(reason);
|
||||
}
|
||||
|
||||
@@ -654,7 +752,7 @@ int RP2GattClient::discover_services() {
|
||||
RAMAllocator<ServiceArena> allocator(RAMAllocator<ServiceArena>::ALLOC_INTERNAL);
|
||||
this->arena_ = allocator.allocate(1);
|
||||
if (this->arena_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Service table allocation failed");
|
||||
ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_);
|
||||
return ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
}
|
||||
new (this->arena_) ServiceArena();
|
||||
@@ -760,8 +858,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) {
|
||||
|
||||
void RP2GattClient::finish_discovery_(int error) {
|
||||
this->discovery_phase_ = DiscoveryPhase::NONE;
|
||||
ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_,
|
||||
this->char_count_, this->desc_count_);
|
||||
ESP_LOGV(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_,
|
||||
error, this->service_count_, this->char_count_, this->desc_count_);
|
||||
if (error == 0 && this->truncated_) {
|
||||
// A partial table must not stream: V3 clients cache the database
|
||||
// permanently, so an incomplete one would be wrong forever.
|
||||
@@ -839,22 +937,68 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) {
|
||||
this->parent_->inhibit_scan();
|
||||
this->connect_cancel_attempted_ = false;
|
||||
this->cancel_requested_ = false;
|
||||
// Bounds the queued wait; restarted when gap_connect is accepted so the
|
||||
// radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the
|
||||
// sum via a disconnect request).
|
||||
this->connect_started_ = millis();
|
||||
if (int err = this->try_gap_connect_(); err != 0) {
|
||||
this->release_scan_inhibit_();
|
||||
return err;
|
||||
}
|
||||
this->enable_loop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One outgoing LE create-connection exists stack-wide: issue it if no other
|
||||
// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry.
|
||||
// Returns nonzero only for hard failures (state untouched; caller cleans up).
|
||||
int RP2GattClient::try_gap_connect_() {
|
||||
// Unlocked peek: single core, aligned pointer; a stale value costs one loop
|
||||
// pass and the locked re-check below is authoritative. Keeps the per-loop
|
||||
// pending retry from taking BluetoothLock just to find the radio busy.
|
||||
if (connect_owner != nullptr) {
|
||||
this->state_ = EngineState::CONNECT_PENDING;
|
||||
return 0;
|
||||
}
|
||||
uint8_t status;
|
||||
{
|
||||
BluetoothLock lock;
|
||||
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL,
|
||||
0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
|
||||
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
|
||||
if (connect_owner != nullptr) {
|
||||
status = ERROR_CODE_COMMAND_DISALLOWED;
|
||||
} else {
|
||||
// esp32 parity: cached connections come up at MEDIUM already (nothing
|
||||
// consumes the fast interval without a discovery phase), so there is no
|
||||
// post-connect update procedure to race or silently lose; sustained
|
||||
// FAST intervals also starve WiFi on the shared CYW43 radio.
|
||||
// Without-cache runs FAST for discovery and steps down in
|
||||
// finish_discovery_.
|
||||
bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE;
|
||||
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW,
|
||||
cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL,
|
||||
cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0,
|
||||
cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
|
||||
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
|
||||
if (status == 0) {
|
||||
connect_owner = this;
|
||||
// Still under the lock: a synthesized failure completion can fire in
|
||||
// the BTstack context the instant it releases, and completion routing
|
||||
// requires CONNECTING — set after the fact, the event is discarded
|
||||
// and the engine burns its whole budget waiting for it.
|
||||
this->state_ = EngineState::CONNECTING;
|
||||
this->connect_started_ = millis();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status);
|
||||
this->release_scan_inhibit_();
|
||||
return status;
|
||||
if (status == 0) {
|
||||
return 0;
|
||||
}
|
||||
this->state_ = EngineState::CONNECTING;
|
||||
this->connect_started_ = millis();
|
||||
this->enable_loop();
|
||||
return 0;
|
||||
if (status == ERROR_CODE_COMMAND_DISALLOWED) {
|
||||
// Radio busy with another engine's connect; resolved from loop().
|
||||
this->state_ = EngineState::CONNECT_PENDING;
|
||||
return 0;
|
||||
}
|
||||
ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
int RP2GattClient::gatt_disconnect() {
|
||||
@@ -863,6 +1007,10 @@ int RP2GattClient::gatt_disconnect() {
|
||||
return GATT_ERR_NOT_CONNECTED;
|
||||
case EngineState::DISCONNECTING:
|
||||
return 0; // already on its way down
|
||||
case EngineState::CONNECT_PENDING:
|
||||
// Nothing issued stack-side; the invalid handle takes the refused
|
||||
// path below without touching the stack.
|
||||
break;
|
||||
case EngineState::CONNECTING: {
|
||||
if (this->con_handle_ == HCI_CON_HANDLE_INVALID) {
|
||||
// The cancel can lose the race against a successful connection
|
||||
@@ -871,9 +1019,18 @@ int RP2GattClient::gatt_disconnect() {
|
||||
// attempt, so a lost completion escalates on the next timeout tick.
|
||||
this->cancel_requested_ = true;
|
||||
this->connect_cancel_attempted_ = true;
|
||||
// Grace period for the cancel completion: the client's disconnect
|
||||
// often lands right at the engine's own deadline, and without the
|
||||
// restart the loop timeout fires first and reports before the
|
||||
// completion can finish the teardown cleanly.
|
||||
this->connect_started_ = millis();
|
||||
BluetoothLock lock;
|
||||
gap_connect_cancel();
|
||||
// Completion arrives as a failed connection-complete event.
|
||||
// Owner: the cancel completes as a failed connection-complete. Not
|
||||
// the owner (completion already resolved in the BTstack context): the
|
||||
// queued event drives the same teardown, nothing to cancel.
|
||||
if (connect_owner == this) {
|
||||
gap_connect_cancel();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
@@ -881,20 +1038,23 @@ int RP2GattClient::gatt_disconnect() {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
uint8_t status;
|
||||
{
|
||||
BluetoothLock lock;
|
||||
status = gap_disconnect(this->con_handle_);
|
||||
}
|
||||
if (status != 0) {
|
||||
// Refused (handle already gone): complete via the event queue so the
|
||||
// listener cannot re-enter disconnect() mid-call. BluetoothLock stops
|
||||
// the IRQ producer, so this main-loop push is SPSC-safe.
|
||||
ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status);
|
||||
uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER;
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
{
|
||||
BluetoothLock lock;
|
||||
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
|
||||
status = gap_disconnect(this->con_handle_);
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status);
|
||||
}
|
||||
}
|
||||
if (status != 0) {
|
||||
// Refused (handle already gone) or never issued (CONNECT_PENDING):
|
||||
// complete via the event queue so the listener cannot re-enter
|
||||
// disconnect mid-call. BluetoothLock stops the IRQ producer, so this
|
||||
// main-loop push is SPSC-safe.
|
||||
BluetoothLock lock;
|
||||
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
|
||||
}
|
||||
this->state_ = EngineState::DISCONNECTING;
|
||||
this->disconnecting_started_ = millis();
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#endif
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
#include <array>
|
||||
@@ -71,7 +75,13 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8;
|
||||
// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot.
|
||||
static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4;
|
||||
|
||||
class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040BLE> {
|
||||
class RP2GattClient final : public Component,
|
||||
public Parented<rp2040_ble::RP2040BLE>
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
,
|
||||
public ota::OTAGlobalStateListener
|
||||
#endif
|
||||
{
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
@@ -95,18 +105,26 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
int pair();
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
|
||||
ble_device_base::GattServiceTable get_service_table();
|
||||
// No connection-type branching on this backend.
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) {}
|
||||
// Cached connections initiate at MEDIUM parameters (esp32 parity); FAST is
|
||||
// reserved for the discovery phase of uncached connects.
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
|
||||
void release_services();
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Drop the connection while an OTA runs (esp32 parity): an active link
|
||||
// competes with the transfer for the shared radio.
|
||||
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
// Link/engine state. Discovery and GATT ops have their own cursors below —
|
||||
// the link stays READY while they run.
|
||||
enum class EngineState : uint8_t {
|
||||
IDLE,
|
||||
CONNECTING, // gap_connect issued, waiting for connection complete
|
||||
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
|
||||
READY, // on_connection_state(true) delivered
|
||||
CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection
|
||||
CONNECTING, // gap_connect issued, waiting for connection complete
|
||||
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
|
||||
READY, // on_connection_state(true) delivered
|
||||
DISCONNECTING,
|
||||
};
|
||||
|
||||
@@ -143,6 +161,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
int issue_descriptor_query_(uint16_t char_index);
|
||||
void finish_discovery_(int error);
|
||||
void fail_connection_(uint8_t reason);
|
||||
int try_gap_connect_();
|
||||
void cleanup_link_state_();
|
||||
bool notify_subscribed_(uint16_t handle) const;
|
||||
static void can_write_no_rsp_trampoline(void *context);
|
||||
@@ -171,8 +190,12 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
|
||||
// Group 3: 4-byte types
|
||||
uint32_t connect_started_{0};
|
||||
uint32_t connect_retry_ms_{0}; // last CONNECT_PENDING gap_connect attempt
|
||||
uint32_t disconnecting_started_{0};
|
||||
uint32_t write_no_rsp_started_{0};
|
||||
// Unscoped C enum, so int-sized: lives with the 4-byte members to keep the
|
||||
// padding at the tail.
|
||||
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
|
||||
|
||||
// Group 4: 2-byte types (table counters written from the handler during
|
||||
// discovery, read from the main loop after the phase's QUERY_COMPLETE)
|
||||
@@ -191,8 +214,9 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
// listener's deliveries on this list (esp32 parity for enable=false).
|
||||
std::array<uint16_t, RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS> notify_subscriptions_{};
|
||||
uint8_t notify_subscription_count_{0};
|
||||
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
|
||||
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
|
||||
uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot
|
||||
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
|
||||
ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE};
|
||||
EngineState state_{EngineState::IDLE};
|
||||
DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE};
|
||||
OpType op_type_{OpType::NONE};
|
||||
@@ -214,6 +238,12 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
static btstack_packet_callback_registration_t hci_event_registration;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static btstack_packet_callback_registration_t sm_event_registration;
|
||||
// The engine whose gap_connect is in flight: BTstack allows one outgoing LE
|
||||
// create-connection stack-wide, and gap_connect_cancel is global, so only
|
||||
// the owner may cancel. Written under BluetoothLock from the main loop,
|
||||
// cleared in the BTstack context when the procedure resolves.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static RP2GattClient *connect_owner;
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
@@ -151,20 +151,23 @@ def _validate_no_active(config: ConfigType) -> ConfigType:
|
||||
@functools.cache
|
||||
def _rp2_config_schema() -> cv.All:
|
||||
"""Full proxy on the rp2 BLE hub: active connections through the BTstack
|
||||
GATT client backend in bluetooth_connection. The slot limit comes from the
|
||||
prebuilt BTstack library (one connection today); the code is built for N."""
|
||||
GATT client backend in bluetooth_connection. Multi-slot builds replace the
|
||||
prebuilt library's one-client BTstack pools via linker --wrap, owned by
|
||||
rp2040_ble and requested when a second backend registers."""
|
||||
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
|
||||
|
||||
def populate_connections(config: ConfigType) -> ConfigType:
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
# One wrapper + backend pair per slot, declared during validation so
|
||||
# their ids exist for codegen (the esp32 arm's `connections` pattern).
|
||||
if not config[CONF_ACTIVE]:
|
||||
return config
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [
|
||||
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
|
||||
],
|
||||
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
|
||||
}
|
||||
|
||||
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
|
||||
@@ -182,8 +185,8 @@ def _rp2_config_schema() -> cv.All:
|
||||
min=1,
|
||||
max=max_conn,
|
||||
msg=f"rp2 supports at most {max_conn} connection slot(s); "
|
||||
"the framework's BTstack library is built with "
|
||||
f"MAX_NR_GATT_CLIENTS {max_conn}",
|
||||
"the BTstack pool overrides in rp2040_ble are sized "
|
||||
f"for {max_conn}",
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -205,6 +208,11 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
|
||||
# this define whenever a proxy is present (zero on advertisement-only
|
||||
# hubs); sized here so it can never diverge from the loop below.
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections))
|
||||
if connections:
|
||||
# Gates the connection and GATT half of the API surface. A proxy
|
||||
# without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never
|
||||
# sends those requests and their handlers and encoders are dead.
|
||||
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
|
||||
for connection_conf in connections:
|
||||
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
|
||||
connection = cg.new_Pvariable(connection_conf[CONF_ID])
|
||||
|
||||
@@ -70,9 +70,10 @@ void BluetoothProxy::send_polled_scanner_state_() {
|
||||
#endif // USE_BLE_SCANNER_STATE_CALLBACK
|
||||
|
||||
void BluetoothProxy::setup() {
|
||||
// BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy.
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
|
||||
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
|
||||
#endif
|
||||
|
||||
// Capture the configured scan mode from YAML before any API changes
|
||||
this->configured_scan_active_ = this->hub_->scan_active();
|
||||
@@ -103,7 +104,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
|
||||
|
||||
this->response_.advertisements_len++;
|
||||
|
||||
ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi);
|
||||
ESP_LOGVV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi);
|
||||
|
||||
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
|
||||
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
|
||||
@@ -111,7 +112,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
|
||||
connection->address_str(), ble_device_base::client_state_to_string(state));
|
||||
@@ -120,7 +121,20 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec
|
||||
void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) {
|
||||
ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message);
|
||||
}
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) {
|
||||
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address);
|
||||
}
|
||||
|
||||
void BluetoothProxy::log_reply_deferred_(const char *what, uint64_t address) {
|
||||
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " deferred, TCP buffer full", what, address);
|
||||
}
|
||||
|
||||
void BluetoothProxy::log_reply_displaced_(const char *what, uint64_t owed, uint64_t address) {
|
||||
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, what, owed, address);
|
||||
}
|
||||
|
||||
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
|
||||
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
|
||||
@@ -129,11 +143,21 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ
|
||||
void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action,
|
||||
const char *type) {
|
||||
this->log_not_connected_gatt_(action, type);
|
||||
this->send_gatt_error(address, handle, GATT_NOT_CONNECTED);
|
||||
if (!this->send_gatt_error(address, handle, GATT_NOT_CONNECTED)) {
|
||||
// No connection, so nothing to latch against; the client's timeout arbitrates.
|
||||
this->log_reply_dropped_("Not-connected", address);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void BluetoothProxy::log_advertisement_flush_() {
|
||||
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
|
||||
void BluetoothProxy::log_advertisement_flush_(bool sent) {
|
||||
if (sent) {
|
||||
// VV: one line per flush drowns a verbose log in any busy environment.
|
||||
ESP_LOGVV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
|
||||
} else {
|
||||
// The rare congestion signal stays at V.
|
||||
ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len);
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::dump_config() {
|
||||
@@ -144,7 +168,7 @@ void BluetoothProxy::dump_config() {
|
||||
this->get_bluetooth_mac_address_pretty(mac_str);
|
||||
const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)";
|
||||
const char *scan_mode = this->configured_scan_active_ ? "active" : "passive";
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Bluetooth Proxy:\n"
|
||||
" Active: %s\n"
|
||||
@@ -162,12 +186,9 @@ void BluetoothProxy::dump_config() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused.
|
||||
void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) {
|
||||
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
|
||||
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
|
||||
void BluetoothProxy::register_connection(BluetoothConnection *connection) {
|
||||
if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) {
|
||||
// Cannot happen with codegen-sized registration; a silent drop would
|
||||
// surface later as a null proxy_ dereference, so refuse loudly.
|
||||
@@ -178,7 +199,6 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c
|
||||
connection->connection_index_ = this->connection_count_;
|
||||
this->connections_[this->connection_count_++] = connection;
|
||||
connection->proxy_ = this;
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); }
|
||||
@@ -197,7 +217,7 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v
|
||||
|
||||
void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) {
|
||||
// Match before free entry so one address never occupies two pool slots.
|
||||
PendingDisconnect *free_entry = nullptr;
|
||||
PendingReply *free_entry = nullptr;
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto &owed = this->pending_disconnections_[i];
|
||||
if (owed.matches(address)) {
|
||||
@@ -209,12 +229,12 @@ void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t e
|
||||
}
|
||||
}
|
||||
if (free_entry != nullptr) {
|
||||
this->log_reply_deferred_("Disconnect", address);
|
||||
free_entry->set(address, error);
|
||||
return;
|
||||
}
|
||||
// Every entry is owed: evict the first so the newest loss is not silent too.
|
||||
ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full",
|
||||
(unsigned long long) this->pending_disconnections_[0].address());
|
||||
this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), address);
|
||||
this->pending_disconnections_[0].set(address, error);
|
||||
}
|
||||
|
||||
@@ -224,19 +244,39 @@ void BluetoothProxy::clear_pending_disconnection_(uint64_t address) {
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
if (this->pending_disconnections_[i].matches(address)) {
|
||||
this->pending_disconnections_[i].clear();
|
||||
return; // latch_pending_disconnection_ keeps at most one entry per address
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
|
||||
if (!this->send_device_connection(connection->get_address(), false, 0, reason)) {
|
||||
// The client has no other way to learn of an unsolicited disconnect;
|
||||
// latch and let loop()'s paced drain deliver it. V by design: a louder
|
||||
// level would ride the same congested link this reports on.
|
||||
ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(),
|
||||
connection->address_str());
|
||||
this->latch_pending_disconnection_(connection->get_address(), reason);
|
||||
void BluetoothProxy::answer_device_disconnected_(uint64_t address) {
|
||||
if (this->send_device_connection(address, false)) {
|
||||
// A landed answer satisfies any owed notification for the address; a
|
||||
// drained duplicate would follow it otherwise.
|
||||
this->clear_pending_disconnection_(address);
|
||||
return;
|
||||
}
|
||||
// Not latched: the client's own request timeout arbitrates, and pooling
|
||||
// these would let a request retry loop displace an unsolicited disconnect.
|
||||
this->log_reply_dropped_("Disconnect", address);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_disconnected_(uint64_t address, conn_err_t error) {
|
||||
if (this->send_device_connection(address, false, 0, error)) {
|
||||
// A later disconnect landing for an address that still has one owed would
|
||||
// otherwise have the drain repeat it.
|
||||
this->clear_pending_disconnection_(address);
|
||||
return;
|
||||
}
|
||||
// A dropped disconnect leaves the client believing the link is live, so
|
||||
// every GATT operation on it times out until something else corrects it.
|
||||
// latch_pending_disconnection_() reports the leading edge.
|
||||
this->latch_pending_disconnection_(address, error);
|
||||
}
|
||||
|
||||
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
|
||||
// The client has no other way to learn of an unsolicited disconnect.
|
||||
this->send_device_disconnected_(connection->get_address(), reason);
|
||||
connection->set_address(0);
|
||||
connection->send_service_ = INIT_SENDING_SERVICES;
|
||||
this->send_connections_free();
|
||||
@@ -282,18 +322,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
auto *connection = this->get_connection_(msg.address, true);
|
||||
if (connection == nullptr) {
|
||||
ESP_LOGW(TAG, "No free connections available");
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
return;
|
||||
}
|
||||
if (!msg.has_address_type) {
|
||||
ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(),
|
||||
connection->address_str());
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
return;
|
||||
}
|
||||
if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) {
|
||||
this->log_connection_request_ignored_(connection, connection->state());
|
||||
this->send_device_connection(msg.address, true);
|
||||
connection->send_connected_reply_();
|
||||
this->send_connections_free();
|
||||
return;
|
||||
} else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) {
|
||||
@@ -320,7 +360,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: {
|
||||
auto *connection = this->get_connection_(msg.address, false);
|
||||
if (connection == nullptr) {
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
this->send_connections_free();
|
||||
return;
|
||||
}
|
||||
@@ -328,7 +368,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
connection->disconnect();
|
||||
} else {
|
||||
connection->set_address(0);
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
this->send_connections_free();
|
||||
}
|
||||
break;
|
||||
@@ -372,7 +412,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
}
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
|
||||
ESP_LOGE(TAG, "V1 connections removed");
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->answer_device_disconnected_(msg.address);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -387,7 +427,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms
|
||||
|
||||
auto err = connection->read_characteristic(msg.handle);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +440,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &
|
||||
|
||||
auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +453,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead
|
||||
|
||||
auto err = connection->read_descriptor(msg.handle);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +466,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri
|
||||
|
||||
auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,14 +517,15 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
|
||||
|
||||
auto err = connection->notify_characteristic(msg.handle, msg.enable);
|
||||
if (err != CONN_OK) {
|
||||
this->send_gatt_error(msg.address, msg.handle, err);
|
||||
connection->send_gatt_error_(msg.handle, err);
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
// Send results unchecked (esp32 parity): a drop resolves via the client timeout.
|
||||
// Not latched (esp32 parity): the request is idempotent, so a drop resolves
|
||||
// via the client timeout and a retry gives the same answer. Still reported.
|
||||
|
||||
auto *connection = this->get_connection_(msg.address, false);
|
||||
api::BluetoothSetConnectionParamsResponse resp;
|
||||
@@ -495,7 +536,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
connection ? static_cast<int>(connection->get_connection_index()) : -1,
|
||||
connection ? connection->address_str() : "unknown");
|
||||
resp.error = GATT_NOT_CONNECTED;
|
||||
this->api_connection_->send_message(resp);
|
||||
if (!this->api_connection_->send_message(resp)) {
|
||||
this->log_reply_dropped_("Connection-params", msg.address);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -506,10 +549,12 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
static_cast<uint16_t>(std::min(msg.max_interval, max_val)),
|
||||
static_cast<uint16_t>(std::min(msg.latency, max_val)),
|
||||
static_cast<uint16_t>(std::min(msg.timeout, max_val)));
|
||||
this->api_connection_->send_message(resp);
|
||||
if (!this->api_connection_->send_message(resp)) {
|
||||
this->log_reply_dropped_("Connection-params", msg.address);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
@@ -552,7 +597,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::loop() {
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// Stream pending service-discovery batches every iteration; the streamer
|
||||
// handles a vanished API connection itself.
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
@@ -566,17 +611,19 @@ void BluetoothProxy::loop() {
|
||||
return;
|
||||
this->last_advertisement_flush_time_ = now;
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
if (this->connections_free_pending_ && this->api_connection_ != nullptr) {
|
||||
// Resend a dropped slot-state update, paced by the 100 ms gate so the
|
||||
// retry does not hammer the congestion it exists to survive; the
|
||||
// advertisement-only arm answers DISCONNECT requests with this message
|
||||
// too, so the drain compiles on every proxy build.
|
||||
// retry does not hammer the congestion it exists to survive. Every build
|
||||
// sends this at subscribe time (api_connection.cpp), so the drain
|
||||
// compiles on every proxy build.
|
||||
this->connections_free_pending_ = false;
|
||||
this->send_connections_free(this->api_connection_);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// The API subscriber is gone: tear down any connections it left behind
|
||||
// (disconnect() on an already-disconnecting slot is a no-op).
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
@@ -589,18 +636,28 @@ void BluetoothProxy::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// Paced retries of owed per-slot notifications; subscriber swaps clear
|
||||
// stale latches before this runs.
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto *connection = this->connections_[i];
|
||||
if (connection->send_service_ == SERVICES_DONE_PENDING) {
|
||||
connection->send_services_done_();
|
||||
}
|
||||
this->connections_[i]->flush_owed_replies_();
|
||||
}
|
||||
// Address-keyed, not slot-keyed, so it gets its own loop; bounded by
|
||||
// connection_count_ like the latch and clear helpers. Not pre-cleared:
|
||||
// the sender clears on success and re-latches on refusal, keeping the
|
||||
// latch's leading-edge warn honest (same shape as the unpair drain).
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto &owed = this->pending_disconnections_[i];
|
||||
if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) {
|
||||
owed.clear();
|
||||
}
|
||||
if (owed.empty())
|
||||
continue;
|
||||
this->send_device_disconnected_(owed.address(), owed.error());
|
||||
}
|
||||
|
||||
// An owed unpair reply. Not pre-cleared: the sender clears on success and
|
||||
// re-latches on refusal, keeping its leading-edge warn guard honest.
|
||||
if (!this->pending_unpairing_.empty()) {
|
||||
conn_err_t error = this->pending_unpairing_.error();
|
||||
this->send_device_unpairing(this->pending_unpairing_.address(), error == CONN_OK, error);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -617,83 +674,57 @@ void BluetoothProxy::loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
|
||||
#ifndef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
|
||||
// Advertisement-only proxy. GATT client connections are excluded at compile
|
||||
// time (no connection backend on this platform, or active: false), so every
|
||||
// connection-oriented request is answered with a clean error instead of
|
||||
// silence, and Home Assistant treats the proxy as passive.
|
||||
|
||||
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
|
||||
switch (msg.request_type) {
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE:
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE:
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT:
|
||||
ESP_LOGW(TAG, "Active connections are not supported on this platform");
|
||||
this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT:
|
||||
// Not an error: the device is already disconnected, which is the requested state.
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->send_connections_free();
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
|
||||
this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: {
|
||||
// Address-scoped maintenance needs no connection slot: real on esp32
|
||||
// (Bluedroid bond table), the stub elsewhere keeps the old error reply.
|
||||
conn_err_t ret = bluetooth_connection::unpair_device(msg.address);
|
||||
this->send_device_unpairing(msg.address, ret == CONN_OK, ret);
|
||||
break;
|
||||
}
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: {
|
||||
conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address);
|
||||
this->send_device_clear_cache(msg.address, ret == CONN_OK, ret);
|
||||
break;
|
||||
#ifdef USE_WIFI
|
||||
// Wi-Fi (or a coexistence build that can fall back to it): every other
|
||||
// non-empty 100 ms tick (~200 ms) gives partial batches time to fill
|
||||
// toward BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE, so the air gets fewer,
|
||||
// fuller frames. Full batches still ship immediately from the queueing
|
||||
// path, and the owed-reply drains above keep the 100 ms cadence.
|
||||
if (this->response_.advertisements_len != 0) {
|
||||
if (this->adv_flush_toggle_) {
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
this->adv_flush_toggle_ = !this->adv_flush_toggle_;
|
||||
} else {
|
||||
// Nothing pending (idle, or a full batch just shipped inline): arm so
|
||||
// the next batch ships on the next tick.
|
||||
this->adv_flush_toggle_ = true;
|
||||
}
|
||||
#else
|
||||
// No Wi-Fi in the build (ethernet): no airtime worth trading latency for,
|
||||
// so partial batches flush every tick.
|
||||
this->flush_pending_advertisements_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic");
|
||||
void BluetoothProxy::reset_owed_replies_() {
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
this->connections_free_pending_ = false;
|
||||
#endif
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
// Owed on unsubscribe; on subscribe the trailing send_scanner_state_()
|
||||
// re-drives it from the hub, so clearing it there is free.
|
||||
this->scanner_state_pending_ = false;
|
||||
#else
|
||||
// Force a poll-arm mismatch: a frame refused at subscribe time could
|
||||
// otherwise match the stale detector and never be retried. Inert on
|
||||
// unsubscribe: loop() returns at the no-subscriber gate before the
|
||||
// detector runs, and a re-subscribe re-arms this anyway.
|
||||
this->last_scan_running_ = !this->hub_->scan_running();
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
this->pending_unpairing_.clear();
|
||||
this->pending_disconnections_.fill({});
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
// Neither a partial stream's tail nor an owed done belongs to the next
|
||||
// session; silence (the client's timeout) arbitrates.
|
||||
auto *connection = this->connections_[i];
|
||||
connection->park_service_stream_();
|
||||
connection->clear_owed_flags_();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
// Send results unchecked (esp32 parity): a drop resolves via the client timeout.
|
||||
api::BluetoothSetConnectionParamsResponse resp;
|
||||
resp.address = msg.address;
|
||||
resp.error = GATT_NOT_CONNECTED;
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#endif // !BLUETOOTH_CONNECTION_HAS_GATT
|
||||
|
||||
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
|
||||
if (api_connection != this->api_connection_) {
|
||||
if (this->api_connection_ != nullptr) {
|
||||
@@ -710,15 +741,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
|
||||
}
|
||||
// Stale retry latches belong to the previous subscriber's session; a
|
||||
// re-subscribe by the current one keeps what it is still owed.
|
||||
this->connections_free_pending_ = false;
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
// Neither a partial stream's tail nor an owed done belongs to the new
|
||||
// session; silence (the client's timeout) arbitrates.
|
||||
this->connections_[i]->park_service_stream_();
|
||||
}
|
||||
this->pending_disconnections_.fill({});
|
||||
#endif
|
||||
this->reset_owed_replies_();
|
||||
}
|
||||
this->api_connection_ = api_connection;
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
@@ -735,12 +758,10 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
|
||||
return;
|
||||
}
|
||||
this->api_connection_ = nullptr;
|
||||
this->connections_free_pending_ = false;
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
this->scanner_state_pending_ = false;
|
||||
#endif
|
||||
this->reset_owed_replies_();
|
||||
}
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void BluetoothProxy::send_connections_free() {
|
||||
if (this->api_connection_ != nullptr) {
|
||||
this->send_connections_free(this->api_connection_);
|
||||
@@ -776,14 +797,14 @@ bool BluetoothProxy::send_gatt_services_done(uint64_t address) {
|
||||
return this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
|
||||
bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
return true; // Nobody subscribed: nothing is owed, only a refused frame reports false
|
||||
api::BluetoothGATTErrorResponse call;
|
||||
call.address = address;
|
||||
call.handle = handle;
|
||||
call.error = error;
|
||||
this->api_connection_->send_message(call);
|
||||
return this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) {
|
||||
@@ -794,22 +815,46 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err
|
||||
call.paired = paired;
|
||||
call.error = error;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
if (!this->api_connection_->send_message(call)) {
|
||||
// Not latched: a retried PAIR is answered from is_paired(), so the client
|
||||
// recovers on its own. Still worth saying it happened.
|
||||
this->log_reply_dropped_("Pairing", address);
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
// An owed success is the authoritative answer: a later attempt for the
|
||||
// same address fails only because the first already removed the bond.
|
||||
if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) &&
|
||||
this->pending_unpairing_.error() == CONN_OK) {
|
||||
success = true;
|
||||
error = CONN_OK;
|
||||
}
|
||||
api::BluetoothDeviceUnpairingResponse call;
|
||||
call.address = address;
|
||||
call.success = success;
|
||||
call.error = error;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
if (this->api_connection_->send_message(call)) {
|
||||
// A later unpair landing for an address that still has one owed would
|
||||
// otherwise have the drain repeat it.
|
||||
if (this->pending_unpairing_.matches(address)) {
|
||||
this->pending_unpairing_.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this->pending_unpairing_.empty()) {
|
||||
this->log_reply_deferred_("Unpair", address);
|
||||
} else if (!this->pending_unpairing_.matches(address)) {
|
||||
this->log_reply_displaced_("Unpair", this->pending_unpairing_.address(), address);
|
||||
}
|
||||
this->pending_unpairing_.set(address, error);
|
||||
}
|
||||
|
||||
// Shared by both platform paths: the neutral bluetooth_device_request() uses it to
|
||||
// answer a clear-cache request with a clean error, so it must not be esp32-guarded.
|
||||
// GATT arm only: the advertisement-only arm no longer dispatches CLEAR_CACHE,
|
||||
// so its response encoder would be dead weight there.
|
||||
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
@@ -818,8 +863,12 @@ void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, con
|
||||
call.success = success;
|
||||
call.error = error;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
if (!this->api_connection_->send_message(call)) {
|
||||
// Not latched: clear-cache is idempotent, so a retry gives the same answer.
|
||||
this->log_reply_dropped_("Clear-cache", address);
|
||||
}
|
||||
}
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ using bluetooth_connection::DONE_SENDING_SERVICES;
|
||||
using bluetooth_connection::INIT_SENDING_SERVICES;
|
||||
using bluetooth_connection::SERVICES_DONE_PENDING;
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
|
||||
using ClientState = ble_device_base::ClientState;
|
||||
#endif
|
||||
@@ -60,12 +60,10 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
|
||||
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
|
||||
};
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
/// One owed freed-slot connected=false notification in a single word: the
|
||||
/// 48-bit address in the low bits, the sign-extending 16-bit reason on top.
|
||||
/// Every reason that reaches the pool (esp_gatt_status_t,
|
||||
/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t.
|
||||
class PendingDisconnect {
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// One owed address-keyed reply in a single word: 48-bit address low, 16-bit
|
||||
/// error on top. Every error that reaches it fits int16_t.
|
||||
class PendingReply {
|
||||
public:
|
||||
constexpr void set(uint64_t address, conn_err_t error) {
|
||||
// Mask: the address originates from the client, and a stray high bit
|
||||
@@ -73,7 +71,9 @@ class PendingDisconnect {
|
||||
this->word_ = (address & ADDRESS_MASK) | (static_cast<uint64_t>(static_cast<uint16_t>(error)) << 48);
|
||||
}
|
||||
constexpr void clear() { this->word_ = 0; }
|
||||
// Whole-word test: set() is only ever given a live (nonzero) address.
|
||||
// Whole-word test: only (address 0, error 0) reads back as nothing owed.
|
||||
// A zero-address failure still latches, which is correct - that reply is
|
||||
// owed too. Neither backend can unpair address 0 successfully.
|
||||
constexpr bool empty() const { return this->word_ == 0; }
|
||||
// Masked like set(), so a stray high bit cannot defeat the pool lookups.
|
||||
constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); }
|
||||
@@ -86,19 +86,19 @@ class PendingDisconnect {
|
||||
};
|
||||
// Pin the packing at compile time: mask and sign round-trip for every
|
||||
// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit).
|
||||
constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) {
|
||||
PendingDisconnect p;
|
||||
constexpr bool pending_reply_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) {
|
||||
PendingReply p;
|
||||
p.set(address, error);
|
||||
return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address);
|
||||
}
|
||||
static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1));
|
||||
static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F));
|
||||
static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110));
|
||||
static_assert(PendingDisconnect{}.empty());
|
||||
static_assert(pending_reply_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1));
|
||||
static_assert(pending_reply_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F));
|
||||
static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110));
|
||||
static_assert(PendingReply{}.empty());
|
||||
#endif
|
||||
|
||||
class BluetoothProxy final : public Component {
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// Allow the connection to update connections_free_response_
|
||||
friend bluetooth_connection::BluetoothConnection;
|
||||
#endif
|
||||
@@ -109,9 +109,9 @@ class BluetoothProxy final : public Component {
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void register_connection(BluetoothConnection *connection);
|
||||
#endif // BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
#ifndef USE_ESP32
|
||||
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
|
||||
// snapshots scan_active()/scan_running() and installs the raw callback, and
|
||||
@@ -120,6 +120,7 @@ class BluetoothProxy final : public Component {
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
|
||||
#endif // !USE_ESP32
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
|
||||
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
|
||||
void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg);
|
||||
@@ -128,6 +129,7 @@ class BluetoothProxy final : public Component {
|
||||
void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg);
|
||||
void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg);
|
||||
void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg);
|
||||
#endif
|
||||
|
||||
void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags);
|
||||
void unsubscribe_api_connection(api::APIConnection *api_connection);
|
||||
@@ -137,18 +139,23 @@ class BluetoothProxy final : public Component {
|
||||
return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12);
|
||||
}
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// False only when a subscriber refused the frame; true = delivered or
|
||||
/// nobody subscribed. Request-answer callers ignore the result (client
|
||||
/// timeouts cover those); only reset_connection_slot_ latches for retry.
|
||||
/// nobody subscribed. Refusals latch in send_device_disconnected_() and
|
||||
/// send_connected_reply_(); other callers report via log_reply_dropped_().
|
||||
bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
|
||||
void send_connections_free();
|
||||
void send_connections_free(api::APIConnection *api_connection);
|
||||
/// Same convention as send_device_connection: false only on a refused frame.
|
||||
bool send_gatt_services_done(uint64_t address);
|
||||
void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
|
||||
/// False only when the API refused the frame, so the reply is still owed.
|
||||
bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
|
||||
void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK);
|
||||
void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK);
|
||||
/// No default error: the drain rebuilds success as (error == CONN_OK), so a
|
||||
/// caller that omitted it would have a reported failure resent as a success.
|
||||
void send_device_unpairing(uint64_t address, bool success, conn_err_t error);
|
||||
void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK);
|
||||
#endif
|
||||
|
||||
void bluetooth_scanner_set_mode(bool active);
|
||||
|
||||
@@ -227,30 +234,27 @@ class BluetoothProxy final : public Component {
|
||||
void flush_pending_advertisements_() {
|
||||
if (this->response_.advertisements_len == 0)
|
||||
return;
|
||||
this->api_connection_->send_message(this->response_);
|
||||
// Perishable and the highest-frequency send here: a drop only reports at
|
||||
// V, anything louder would be the flood the batch pacing exists to avoid.
|
||||
[[maybe_unused]] bool sent = this->api_connection_->send_message(this->response_);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
this->log_advertisement_flush_();
|
||||
this->log_advertisement_flush_(sent);
|
||||
#endif
|
||||
this->response_.advertisements_len = 0;
|
||||
}
|
||||
void log_advertisement_flush_();
|
||||
void log_advertisement_flush_(bool sent);
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
|
||||
void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state);
|
||||
void log_connection_info_(BluetoothConnection *connection, const char *message);
|
||||
#endif
|
||||
void log_not_connected_gatt_(const char *action, const char *type);
|
||||
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
/// Keep the pre-allocated connections-free message in step when a
|
||||
/// connection slot changes address (0 = free). Called from the connection
|
||||
/// classes' set_address().
|
||||
// maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the
|
||||
// body is removed, and the free < MAX compare would trip -Wtype-limits.
|
||||
void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) {
|
||||
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
|
||||
void update_address_slot_(uint64_t old_address, uint64_t new_address) {
|
||||
auto &resp = this->connections_free_response_;
|
||||
if (new_address == 0 && old_address != 0) {
|
||||
if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
|
||||
@@ -267,7 +271,6 @@ class BluetoothProxy final : public Component {
|
||||
}
|
||||
this->replace_allocated_slot_(0, new_address);
|
||||
}
|
||||
#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
|
||||
}
|
||||
void replace_allocated_slot_(uint64_t find_value, uint64_t set_value);
|
||||
void log_slot_accounting_mismatch_();
|
||||
@@ -279,21 +282,48 @@ class BluetoothProxy final : public Component {
|
||||
void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason);
|
||||
/// Drop any owed freed-slot notification for this address (client reconnected).
|
||||
void clear_pending_disconnection_(uint64_t address);
|
||||
/// Send connected=false and pool it for the paced drain if refused. A
|
||||
/// dropped disconnect desynchronises the proxy: the client keeps a link it
|
||||
/// believes is live and every operation on it times out. Unsolicited and
|
||||
/// drained notifications only; request answers use the variant below.
|
||||
void send_device_disconnected_(uint64_t address, conn_err_t error = CONN_OK);
|
||||
/// Answer a request with connected=false. Never pools: a refusal falls back
|
||||
/// to the client's request timeout, keeping the pool for the unsolicited
|
||||
/// notifications the client cannot recover on its own.
|
||||
void answer_device_disconnected_(uint64_t address);
|
||||
/// Pool a refused freed-slot notification for the paced drain.
|
||||
void latch_pending_disconnection_(uint64_t address, conn_err_t error);
|
||||
#endif
|
||||
|
||||
/// Drop everything the ending session was owed. One list, so a new latch is
|
||||
/// one edit rather than two call sites where an omission looks deliberate.
|
||||
/// Drops state only, never sends: api_connection_ is the departing
|
||||
/// subscriber on subscribe and nullptr on unsubscribe.
|
||||
void reset_owed_replies_();
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
/// Report a reply we deliberately do not latch, so no drop is silent.
|
||||
void log_reply_dropped_(const char *what, uint64_t address);
|
||||
/// A latched reply's leading edge; the drain's re-refusals stay quiet.
|
||||
void log_reply_deferred_(const char *what, uint64_t address);
|
||||
/// A latched reply lost to a newer one for a different address.
|
||||
void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address);
|
||||
#endif
|
||||
|
||||
// Memory optimized layout for 32-bit systems
|
||||
// Group 1: Pointers (4 bytes each, naturally aligned)
|
||||
api::APIConnection *api_connection_{nullptr};
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// Group 2: Fixed-size array of connection pointers
|
||||
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
|
||||
// Address-keyed pool of owed freed-slot notifications; loop() resends.
|
||||
// Proxy-only state, kept off BluetoothConnection; entries are not tied to
|
||||
// slot indices.
|
||||
std::array<PendingDisconnect, BLUETOOTH_PROXY_MAX_CONNECTIONS> pending_disconnections_{};
|
||||
std::array<PendingReply, BLUETOOTH_PROXY_MAX_CONNECTIONS> pending_disconnections_{};
|
||||
// Owed unpair reply. The bond is already gone when the send is refused, so
|
||||
// a retry is told the unpair failed when it succeeded. One slot: a second
|
||||
// refused unpair displaces the first, as happened to both before this.
|
||||
PendingReply pending_unpairing_{};
|
||||
#endif
|
||||
ble_device_base::BLEHub *hub_{nullptr};
|
||||
// Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below
|
||||
@@ -303,17 +333,27 @@ class BluetoothProxy final : public Component {
|
||||
// BLE advertisement batching
|
||||
api::BluetoothLERawAdvertisementsResponse response_;
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// Pre-allocated response message - always ready to send
|
||||
api::BluetoothConnectionsFreeResponse connections_free_response_;
|
||||
#endif
|
||||
|
||||
// Group 4: 1-byte types grouped together
|
||||
bool active_;
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
// A dropped send (full TCP buffer) would leave the API client with a stale
|
||||
// slot state forever; the cached response is current by construction, so
|
||||
// retrying it from loop() is an idempotent resync.
|
||||
bool connections_free_pending_{false};
|
||||
uint8_t connection_count_{0};
|
||||
#endif
|
||||
bool configured_scan_active_{false}; // Configured scan mode from YAML
|
||||
#ifdef USE_WIFI
|
||||
/// Wi-Fi only: flush on every other non-empty tick (~200 ms) so partial
|
||||
/// batches fill; an idle tick re-arms, so the first batch after a gap
|
||||
/// still ships on the next tick. See loop().
|
||||
bool adv_flush_toggle_{false};
|
||||
#endif
|
||||
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
|
||||
// A dropped push (full TX buffer) is re-queried from the hub and resent
|
||||
// from loop(); the hub's current state is idempotent by construction.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import core, external_files
|
||||
@@ -12,6 +11,8 @@ from esphome.const import (
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_TEMPERATURE_OFFSET,
|
||||
)
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@neffs", "@kbx81"]
|
||||
CONFLICTS_WITH = ["bme680_bsec"]
|
||||
@@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = {
|
||||
|
||||
|
||||
def _compute_local_file_path(url: str) -> Path:
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
return base_dir / key
|
||||
return external_files.compute_local_file_path(DOMAIN, url)
|
||||
|
||||
|
||||
def _compute_url(config: dict) -> str:
|
||||
@@ -105,6 +102,42 @@ def download_bme68x_blob(config):
|
||||
return config
|
||||
|
||||
|
||||
# Shared by the schema and the prefetch hook so they cannot drift.
|
||||
_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True)
|
||||
_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True)
|
||||
# Key -> (validator, default) for the defaulted options that select the blob.
|
||||
_BLOB_OPTIONS = {
|
||||
CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"),
|
||||
CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"),
|
||||
CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"),
|
||||
}
|
||||
|
||||
|
||||
def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None:
|
||||
"""Raw entry to its BSEC2 blob; None when a value is unrecognized.
|
||||
|
||||
Applies the schema defaults and validators read-only; skipped entries
|
||||
are left to the schema validator.
|
||||
"""
|
||||
try:
|
||||
spec = {
|
||||
key: validator(str(entry.get(key, default))) # pylint: disable=not-callable
|
||||
for key, (validator, default) in _BLOB_OPTIONS.items()
|
||||
}
|
||||
spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, "")))
|
||||
if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None:
|
||||
spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR(
|
||||
str(algorithm_output)
|
||||
)
|
||||
except cv.Invalid:
|
||||
return None
|
||||
url = _compute_url(spec)
|
||||
return RemoteFile(url, _compute_local_file_path(url))
|
||||
|
||||
|
||||
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref)
|
||||
|
||||
|
||||
def validate_bme68x(config):
|
||||
if CONF_ALGORITHM_OUTPUT not in config:
|
||||
return config
|
||||
@@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = (
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BME68xBSEC2Component),
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True),
|
||||
cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum(
|
||||
ALGORITHM_OUTPUT_OPTIONS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum(
|
||||
OPERATING_AGE_OPTIONS, lower=True
|
||||
),
|
||||
cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum(
|
||||
SAMPLE_RATE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum(
|
||||
VOLTAGE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Required(CONF_MODEL): _MODEL_VALIDATOR,
|
||||
cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR,
|
||||
**{
|
||||
cv.Optional(key, default=default): validator
|
||||
for key, (validator, default) in _BLOB_OPTIONS.items()
|
||||
},
|
||||
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta,
|
||||
cv.Optional(
|
||||
CONF_STATE_SAVE_INTERVAL, default="6hours"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
from esphome.components import bme68x_bsec2, i2c
|
||||
from esphome.components.bme68x_bsec2 import (
|
||||
CONFIG_SCHEMA_BASE,
|
||||
BME68xBSEC2Component,
|
||||
@@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
MULTI_CONF = True
|
||||
|
||||
# The user-facing domain is this module (the base component only appears
|
||||
# via AUTO_LOAD), so the batch-download hook must be re-exported here to
|
||||
# take effect.
|
||||
PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES
|
||||
|
||||
bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c")
|
||||
BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_(
|
||||
"BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice
|
||||
|
||||
@@ -14,7 +14,7 @@ static const char *const TAG = "captive_portal";
|
||||
void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
|
||||
AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json"));
|
||||
stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate"));
|
||||
char mac_s[18];
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
const char *mac_str = get_mac_address_pretty_into_buffer(mac_s);
|
||||
#ifdef USE_ESP8266
|
||||
stream->print(ESPHOME_F("{\"mac\":\""));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include <esp_sleep.h>
|
||||
#include <esp_idf_version.h>
|
||||
|
||||
@@ -249,7 +250,7 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
const char *reset_reason = get_reset_reason_(std::span<char, RESET_REASON_BUFFER_SIZE>(reset_buffer));
|
||||
const char *wakeup_cause = get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE>(wakeup_buffer));
|
||||
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_mac_address_raw(mac);
|
||||
|
||||
ESP_LOGD(TAG,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#include "debug_component.h"
|
||||
#ifdef USE_RP2
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <Arduino.h>
|
||||
#include <hardware/clocks.h>
|
||||
#include <hardware/watchdog.h>
|
||||
#if defined(PICO_RP2350)
|
||||
#include <hardware/structs/powman.h>
|
||||
@@ -68,13 +69,14 @@ const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFE
|
||||
|
||||
const char *DebugComponent::get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer) { return ""; }
|
||||
|
||||
uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); }
|
||||
// RAMAllocator already implements the free-heap calculation for this platform, so it is not duplicated here.
|
||||
uint32_t DebugComponent::get_free_heap_() { return RAMAllocator<uint8_t>().get_free_heap_size(); }
|
||||
|
||||
size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE> buffer, size_t pos) {
|
||||
constexpr size_t size = DEVICE_INFO_BUFFER_SIZE;
|
||||
char *buf = buffer.data();
|
||||
|
||||
uint32_t cpu_freq = RP2040::f_cpu();
|
||||
uint32_t cpu_freq = clock_get_hz(clk_sys);
|
||||
ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq);
|
||||
pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq);
|
||||
|
||||
|
||||
@@ -3280,27 +3280,45 @@ def copy_files():
|
||||
__version__,
|
||||
)
|
||||
|
||||
# Remote extra build files are fetched into the shared download cache in
|
||||
# one parallel batch (conditional requests skip unchanged files), then
|
||||
# copied into the build tree like their local counterparts.
|
||||
sources: dict[str, Path] = {}
|
||||
remote: list[tuple[str, str]] = []
|
||||
for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values():
|
||||
name: str = file[KEY_NAME]
|
||||
path: Path = file[KEY_PATH]
|
||||
if str(path).startswith("http"):
|
||||
import requests
|
||||
|
||||
from esphome.happy_eyeballs import ensure_happy_eyeballs
|
||||
|
||||
ensure_happy_eyeballs()
|
||||
|
||||
try:
|
||||
req = requests.get(path, timeout=30)
|
||||
req.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise EsphomeError(
|
||||
f"Could not download extra build file {path}: {e}"
|
||||
) from e
|
||||
CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True)
|
||||
CORE.relative_build_path(name).write_bytes(req.content)
|
||||
remote.append((name, str(path)))
|
||||
else:
|
||||
copy_file_if_changed(path, CORE.relative_build_path(name))
|
||||
sources[name] = path
|
||||
if remote:
|
||||
# Imported lazily: requests (via external_files) is a heavy import
|
||||
# and remote extra build files are rare.
|
||||
from esphome import external_files
|
||||
|
||||
downloads: list[external_files.RemoteFile] = []
|
||||
for name, url in remote:
|
||||
cache_path = external_files.compute_local_file_path(KEY_ESP32, url)
|
||||
# Unverifiable bytes: an unrevalidated copy is an error, matching
|
||||
# the old always-download behavior on network failure.
|
||||
downloads.append(
|
||||
external_files.RemoteFile(url, cache_path, allow_stale=False)
|
||||
)
|
||||
sources[name] = cache_path
|
||||
try:
|
||||
external_files.download_content_many(
|
||||
downloads, description="extra build file(s)"
|
||||
)
|
||||
except cv.MultipleInvalid as e:
|
||||
details = "; ".join(str(err) for err in e.errors)
|
||||
raise EsphomeError(
|
||||
f"Could not download extra build file(s): {details}"
|
||||
) from e
|
||||
except cv.Invalid as e:
|
||||
raise EsphomeError(f"Could not download extra build file(s): {e}") from e
|
||||
for name, source in sources.items():
|
||||
copy_file_if_changed(source, CORE.relative_build_path(name))
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
|
||||
@@ -109,7 +109,7 @@ void set_mac_address(uint8_t *mac) { esp_base_mac_addr_set(mac); }
|
||||
|
||||
bool has_custom_mac_address() {
|
||||
#if !defined(USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC)
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
// do not use 'esp_efuse_mac_get_custom(mac)' because it drops an error in the logs whenever it fails
|
||||
#ifndef USE_ESP32_VARIANT_ESP32
|
||||
return (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS) == ESP_OK) &&
|
||||
|
||||
@@ -55,6 +55,7 @@ void ESP32BLETracker::setup() {
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
|
||||
if (state == ota::OTA_STARTED) {
|
||||
ESP_LOGD(TAG, "Stopping scan for OTA");
|
||||
this->scan_continuous_before_ota_ = this->scan_continuous_;
|
||||
this->stop_scan();
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
@@ -190,7 +191,9 @@ void ESP32BLETracker::loop() {
|
||||
void ESP32BLETracker::start_scan() { this->start_scan_(true); }
|
||||
|
||||
void ESP32BLETracker::stop_scan() {
|
||||
ESP_LOGD(TAG, "Stopping scan.");
|
||||
// V to match the start log: the mode-switch and OTA callers narrate their
|
||||
// reason at D themselves, and the user-facing stop action is deliberate.
|
||||
ESP_LOGV(TAG, "Stopping scan.");
|
||||
this->scan_continuous_ = false;
|
||||
this->stop_scan_();
|
||||
}
|
||||
@@ -199,8 +202,9 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_();
|
||||
|
||||
void ESP32BLETracker::stop_scan_() {
|
||||
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
|
||||
// If scanner is already idle, there's nothing to stop - this is not an error
|
||||
if (this->scanner_state_ != ScannerState::IDLE) {
|
||||
// IDLE means there is nothing to stop; STOPPING means a stop is already in
|
||||
// flight and will finish on its own. Neither is an error.
|
||||
if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) {
|
||||
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -140,11 +140,17 @@ class EthernetComponent final : public Component {
|
||||
bool is_disabled() { return this->disabled_; }
|
||||
bool is_enabled() { return !this->disabled_; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/// esp_netif handle, used by network for default-route arbitration.
|
||||
/// nullptr until the driver/netif installation has run.
|
||||
esp_netif_t *get_esp_netif() { return this->eth_netif_; }
|
||||
#endif
|
||||
|
||||
void set_type(EthernetType type);
|
||||
#ifdef USE_ETHERNET_MANUAL_IP
|
||||
void set_manual_ip(const ManualIP &manual_ip);
|
||||
#endif
|
||||
void set_fixed_mac(const std::array<uint8_t, 6> &mac) { this->fixed_mac_ = mac; }
|
||||
void set_fixed_mac(const std::array<uint8_t, MAC_ADDRESS_SIZE> &mac) { this->fixed_mac_ = mac; }
|
||||
|
||||
network::IPAddresses get_ip_addresses();
|
||||
network::IPAddress get_dns_address(uint8_t num);
|
||||
@@ -336,7 +342,7 @@ class EthernetComponent final : public Component {
|
||||
bool ipv6_setup_done_{false};
|
||||
#endif /* LWIP_IPV6 */
|
||||
|
||||
optional<std::array<uint8_t, 6>> fixed_mac_;
|
||||
optional<std::array<uint8_t, MAC_ADDRESS_SIZE>> fixed_mac_;
|
||||
|
||||
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
|
||||
StaticVector<EthernetIPStateListener *, ESPHOME_ETHERNET_IP_STATE_LISTENERS> ip_state_listeners_;
|
||||
|
||||
@@ -429,9 +429,9 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
#endif // !USE_ETHERNET_SPI
|
||||
|
||||
// use ESP internal eth mac
|
||||
uint8_t mac_addr[6];
|
||||
uint8_t mac_addr[MAC_ADDRESS_SIZE];
|
||||
if (this->fixed_mac_.has_value()) {
|
||||
memcpy(mac_addr, this->fixed_mac_->data(), 6);
|
||||
memcpy(mac_addr, this->fixed_mac_->data(), MAC_ADDRESS_SIZE);
|
||||
} else {
|
||||
esp_read_mac(mac_addr, ESP_MAC_ETH);
|
||||
}
|
||||
@@ -789,16 +789,25 @@ void EthernetComponent::start_connect_() {
|
||||
|
||||
#ifdef USE_ETHERNET_MANUAL_IP
|
||||
if (this->manual_ip_.has_value()) {
|
||||
LwIPLock lock;
|
||||
// Set DNS through esp_netif so the servers are stored in the netif's own
|
||||
// dns[] array; raw dns_setserver() would be lost when the default-route
|
||||
// arbitration re-applies the default netif's DNS.
|
||||
// Log-only on failure: the link still has a working IP/gateway, so degraded
|
||||
// name resolution does not justify marking the whole component failed.
|
||||
esp_netif_dns_info_t dns{};
|
||||
if (this->manual_ip_->dns1.is_set()) {
|
||||
ip_addr_t d;
|
||||
d = this->manual_ip_->dns1;
|
||||
dns_setserver(0, &d);
|
||||
dns.ip = this->manual_ip_->dns1;
|
||||
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
if (this->manual_ip_->dns2.is_set()) {
|
||||
ip_addr_t d;
|
||||
d = this->manual_ip_->dns2;
|
||||
dns_setserver(1, &d);
|
||||
dns.ip = this->manual_ip_->dns2;
|
||||
err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
@@ -926,7 +935,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
// External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless
|
||||
// of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC.
|
||||
if (this->fixed_mac_.has_value()) {
|
||||
memcpy(mac, this->fixed_mac_->data(), 6);
|
||||
memcpy(mac, this->fixed_mac_->data(), MAC_ADDRESS_SIZE);
|
||||
} else {
|
||||
esp_read_mac(mac, ESP_MAC_ETH);
|
||||
}
|
||||
@@ -944,7 +953,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() {
|
||||
|
||||
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_eth_mac_address_raw(mac);
|
||||
format_mac_addr_upper(mac, buf.data());
|
||||
return buf.data();
|
||||
|
||||
@@ -245,7 +245,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
if (this->eth_ != nullptr) {
|
||||
this->eth_->macAddress(mac);
|
||||
} else {
|
||||
memset(mac, 0, 6);
|
||||
memset(mac, 0, MAC_ADDRESS_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() {
|
||||
|
||||
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
uint8_t mac[6];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_eth_mac_address_raw(mac);
|
||||
format_mac_addr_upper(mac, buf.data());
|
||||
return buf.data();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -43,15 +42,13 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# If the MDI file cannot be downloaded within this time, abort.
|
||||
IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
|
||||
|
||||
SOURCE_LOCAL = "local"
|
||||
SOURCE_WEB = "web"
|
||||
|
||||
@@ -65,16 +62,16 @@ MDI_SOURCES = {
|
||||
SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/",
|
||||
}
|
||||
|
||||
# Shared by the schema validator and the prefetch extractor so they cannot
|
||||
# drift.
|
||||
_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$")
|
||||
|
||||
def compute_local_image_path(value) -> Path:
|
||||
|
||||
def compute_local_image_path(value: str | ConfigType) -> Path:
|
||||
url = value[CONF_URL] if isinstance(value, dict) else value
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
# Downloaded files are cached under the shared `image` domain directory so
|
||||
# the cache location is unaffected by which platform requested the file.
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
return base_dir / key
|
||||
return external_files.compute_local_file_path(DOMAIN, url)
|
||||
|
||||
|
||||
def local_path(value):
|
||||
@@ -83,16 +80,20 @@ def local_path(value):
|
||||
|
||||
|
||||
def download_file(url, path):
|
||||
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
|
||||
# The shared NETWORK_TIMEOUT applies; a per-caller timeout would be
|
||||
# silently ignored on a per-run memo hit anyway (memos key by path).
|
||||
external_files.download_content(url, path)
|
||||
return str(path)
|
||||
|
||||
|
||||
def download_gh_svg(value, source):
|
||||
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
|
||||
def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]:
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN) / source
|
||||
path = base_dir / f"{mdi_id}.svg"
|
||||
return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg"
|
||||
|
||||
url = MDI_SOURCES[source] + mdi_id + ".svg"
|
||||
|
||||
def download_gh_svg(value: str | ConfigType, source: str) -> str:
|
||||
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
|
||||
url, path = _gh_svg_url_path(mdi_id, source)
|
||||
return download_file(url, path)
|
||||
|
||||
|
||||
@@ -101,17 +102,53 @@ def download_image(value):
|
||||
return download_file(value, compute_local_image_path(value))
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
def _parse_remote_shorthand(value: str) -> RemoteFile | None:
|
||||
"""Parse a string `file:` shorthand to its remote file; None if local.
|
||||
|
||||
Raises cv.Invalid for a malformed icon name. Shared by the schema
|
||||
validator and the prefetch extractor so they cannot drift.
|
||||
"""
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) == 2 and parts[0] in MDI_SOURCES:
|
||||
match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1])
|
||||
if match is None:
|
||||
if _MDI_ICON_RE.match(parts[1]) is None:
|
||||
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
|
||||
return download_gh_svg(parts[1], parts[0])
|
||||
|
||||
return RemoteFile(*_gh_svg_url_path(parts[1], parts[0]))
|
||||
if value.startswith(("http://", "https://")):
|
||||
return download_image(value)
|
||||
return RemoteFile(value, compute_local_image_path(value))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_file_ref(value: object) -> RemoteFile | None:
|
||||
"""Map a raw, pre-schema `file:` value to its remote file.
|
||||
|
||||
Returns None for local files and anything it does not recognize; the
|
||||
schema validators stay authoritative.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return _parse_remote_shorthand(value)
|
||||
except cv.Invalid:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
source = value.get(CONF_SOURCE)
|
||||
if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str):
|
||||
return RemoteFile(url, compute_local_image_path(url))
|
||||
if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str):
|
||||
return RemoteFile(*_gh_svg_url_path(icon, source))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None:
|
||||
return _extract_file_ref(entry.get(CONF_FILE))
|
||||
|
||||
|
||||
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref)
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
if (remote := _parse_remote_shorthand(value)) is not None:
|
||||
return download_file(remote.url, remote.path)
|
||||
|
||||
value = cv.file_(value)
|
||||
return local_path(value)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from collections.abc import MutableMapping
|
||||
from collections.abc import Iterable, MutableMapping
|
||||
import functools
|
||||
import hashlib
|
||||
from itertools import accumulate
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -17,7 +16,6 @@ from freetype import (
|
||||
FT_Exception,
|
||||
ft_pixel_mode_mono,
|
||||
)
|
||||
import requests
|
||||
|
||||
from esphome import external_files
|
||||
import esphome.codegen as cg
|
||||
@@ -36,7 +34,7 @@ from esphome.const import (
|
||||
CONF_WEIGHT,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.happy_eyeballs import ensure_happy_eyeballs
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -296,46 +294,80 @@ def validate_weight_name(value):
|
||||
return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)]
|
||||
|
||||
|
||||
def _compute_local_font_path(value: dict) -> Path:
|
||||
url = value[CONF_URL]
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
_LOGGER.debug("_compute_local_font_path: %s", base_dir / key)
|
||||
return base_dir / key
|
||||
def _web_font_path(value: dict) -> Path:
|
||||
return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf"
|
||||
|
||||
|
||||
def download_gfont(value):
|
||||
def _gfonts_css_url(value: dict) -> str:
|
||||
return (
|
||||
f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}"
|
||||
f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
|
||||
)
|
||||
|
||||
|
||||
def _gfonts_cache_path(value: dict, suffix: str) -> Path:
|
||||
name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1"
|
||||
return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}"
|
||||
|
||||
|
||||
def _gfonts_ttf_path(value: dict) -> Path:
|
||||
return _gfonts_cache_path(value, "ttf")
|
||||
|
||||
|
||||
def _gfonts_css_path(value: dict) -> Path:
|
||||
return _gfonts_cache_path(value, "css")
|
||||
|
||||
|
||||
def _parse_gfonts_css(css: str) -> str | None:
|
||||
"""Extract the truetype URL from a Google Fonts CSS response."""
|
||||
match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def download_gfont(value: ConfigType) -> ConfigType:
|
||||
if value in FONT_CACHE:
|
||||
return value
|
||||
name = (
|
||||
f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
|
||||
)
|
||||
url = f"https://fonts.googleapis.com/css2?family={name}"
|
||||
path = (
|
||||
external_files.compute_local_file_dir(DOMAIN)
|
||||
/ f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf"
|
||||
)
|
||||
path = _gfonts_ttf_path(value)
|
||||
if not external_files.is_file_recent(path, value[CONF_REFRESH]):
|
||||
_LOGGER.debug("download_gfont: path=%s", path)
|
||||
url = _gfonts_css_url(value)
|
||||
css_path = _gfonts_css_path(value)
|
||||
try:
|
||||
ensure_happy_eyeballs()
|
||||
req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
|
||||
req.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
css_bytes = external_files.download_content(url, css_path)
|
||||
except cv.Invalid as e:
|
||||
raise cv.Invalid(
|
||||
f"Could not download font at {url}, please check the fonts exists "
|
||||
f"at google fonts ({e})"
|
||||
) from e
|
||||
match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
|
||||
if match is None:
|
||||
if not (
|
||||
external_files.is_fresh_this_run(css_path) or CORE.skip_external_update
|
||||
):
|
||||
# Same rule as PREFETCH_FILES stage two: a CSS body that could
|
||||
# not be revalidated may name a rotated ttf URL. Use the cached
|
||||
# font instead (the failed check already warned).
|
||||
if path.exists():
|
||||
FONT_CACHE[value] = path
|
||||
return value
|
||||
raise cv.Invalid(
|
||||
f"Could not extract ttf file from gfonts response for {name}, "
|
||||
f"please report this."
|
||||
f"Could not refresh the Google Fonts CSS for "
|
||||
f"{value[CONF_FAMILY]} and no cached font is available"
|
||||
)
|
||||
try:
|
||||
css = css_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as e:
|
||||
# Do not leave an unusable body in the cache to be served again.
|
||||
css_path.unlink(missing_ok=True)
|
||||
raise cv.Invalid(
|
||||
f"Bad response from Google Fonts for {value[CONF_FAMILY]}: "
|
||||
f"not a text document"
|
||||
) from e
|
||||
ttf_url = _parse_gfonts_css(css)
|
||||
if ttf_url is None:
|
||||
css_path.unlink(missing_ok=True)
|
||||
raise cv.Invalid(
|
||||
f"Could not extract ttf file from gfonts response for "
|
||||
f"{value[CONF_FAMILY]}, please report this."
|
||||
)
|
||||
|
||||
ttf_url = match.group(1)
|
||||
_LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
|
||||
|
||||
external_files.download_content(ttf_url, path)
|
||||
@@ -346,11 +378,11 @@ def download_gfont(value):
|
||||
return value
|
||||
|
||||
|
||||
def download_web_font(value):
|
||||
def download_web_font(value: ConfigType) -> ConfigType:
|
||||
if value in FONT_CACHE:
|
||||
return value
|
||||
url = value[CONF_URL]
|
||||
path = _compute_local_font_path(value) / "font.ttf"
|
||||
path = _web_font_path(value)
|
||||
|
||||
external_files.download_content(url, path)
|
||||
_LOGGER.debug("download_web_font: path=%s", path)
|
||||
@@ -358,13 +390,18 @@ def download_web_font(value):
|
||||
return value
|
||||
|
||||
|
||||
# Shared by the schema and the prefetch extractor so they cannot drift.
|
||||
_DEFAULT_WEIGHT = "regular"
|
||||
_DEFAULT_ITALIC = False
|
||||
_DEFAULT_REFRESH = "1d"
|
||||
_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name)
|
||||
_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh)
|
||||
|
||||
EXTERNAL_FONT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WEIGHT, default="regular"): cv.Any(
|
||||
cv.int_, validate_weight_name
|
||||
),
|
||||
cv.Optional(CONF_ITALIC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh),
|
||||
cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR,
|
||||
cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean,
|
||||
cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -387,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$")
|
||||
|
||||
|
||||
def _shorthand_to_file_dict(value: str) -> ConfigType | None:
|
||||
"""Typed-dict form of a remote font shorthand.
|
||||
|
||||
Shared by the schema validator and the prefetch extractor so the two
|
||||
cannot drift. Returns None for values that are not remote shorthand
|
||||
(i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand.
|
||||
"""
|
||||
if value.startswith("gfonts://"):
|
||||
match = re.match(r"^gfonts://([^@]+)(@.+)?$", value)
|
||||
if match is None:
|
||||
if (match := _GFONTS_SHORTHAND_RE.match(value)) is None:
|
||||
raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it")
|
||||
family = match.group(1)
|
||||
weight = match.group(2)
|
||||
data = {
|
||||
data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)}
|
||||
if match.group(2):
|
||||
data[CONF_WEIGHT] = match.group(2)[1:]
|
||||
return data
|
||||
if value.startswith(("http://", "https://")):
|
||||
return {CONF_TYPE: TYPE_WEB, CONF_URL: value}
|
||||
return None
|
||||
|
||||
|
||||
def _extract_remote_font(value: object) -> ConfigType | None:
|
||||
"""Map a raw, pre-schema font `file:` value to a normalized remote spec.
|
||||
|
||||
Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for
|
||||
the prefetch hooks; returns None for local fonts and anything it does
|
||||
not recognize. A wrong answer only wastes or misses a prefetch, the
|
||||
schema validators stay authoritative.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = _shorthand_to_file_dict(value)
|
||||
except cv.Invalid:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
font_type = value.get(CONF_TYPE)
|
||||
if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str):
|
||||
return {CONF_TYPE: TYPE_WEB, CONF_URL: url}
|
||||
if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str):
|
||||
try:
|
||||
italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC))
|
||||
weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT))
|
||||
refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH))
|
||||
except cv.Invalid:
|
||||
return None
|
||||
return {
|
||||
CONF_TYPE: TYPE_GFONTS,
|
||||
CONF_FAMILY: family,
|
||||
CONF_WEIGHT: weight,
|
||||
CONF_ITALIC: italic,
|
||||
CONF_REFRESH: refresh,
|
||||
}
|
||||
if weight is not None:
|
||||
data[CONF_WEIGHT] = weight[1:]
|
||||
return font_file_schema(data)
|
||||
return None
|
||||
|
||||
if value.startswith(("http://", "https://")):
|
||||
return font_file_schema(
|
||||
{
|
||||
CONF_TYPE: TYPE_WEB,
|
||||
CONF_URL: value,
|
||||
}
|
||||
)
|
||||
|
||||
return font_file_schema(
|
||||
{
|
||||
CONF_TYPE: TYPE_LOCAL,
|
||||
CONF_PATH: value,
|
||||
}
|
||||
)
|
||||
def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]:
|
||||
"""Yield the remote spec of every `file:` value, including extras."""
|
||||
for entry in entries:
|
||||
values = [entry.get(CONF_FILE)]
|
||||
extras = entry.get(CONF_EXTRAS)
|
||||
if isinstance(extras, dict):
|
||||
# The schema runs cv.ensure_list on extras, so a bare mapping
|
||||
# is valid raw config; mirror that normalization here.
|
||||
extras = [extras]
|
||||
if isinstance(extras, list):
|
||||
values.extend(
|
||||
extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict)
|
||||
)
|
||||
for value in values:
|
||||
if (spec := _extract_remote_font(value)) is not None:
|
||||
yield spec
|
||||
|
||||
|
||||
def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]:
|
||||
"""Batch-download hook: web fonts, then Google Fonts CSS, then ttf.
|
||||
|
||||
Stage one fetches web fonts and the CSS of stale gfonts; stage two
|
||||
parses the now-cached CSS for the ttf URLs it names.
|
||||
"""
|
||||
stage1: list[RemoteFile] = []
|
||||
# Keyed by cache path: the same font at several sizes is one download,
|
||||
# one freshness stat, and one stage-two CSS parse.
|
||||
stale_gfonts: dict[Path, ConfigType] = {}
|
||||
seen_web: set[Path] = set()
|
||||
for spec in _iter_remote_specs(entries):
|
||||
if spec[CONF_TYPE] == TYPE_WEB:
|
||||
if (path := _web_font_path(spec)) not in seen_web:
|
||||
seen_web.add(path)
|
||||
stage1.append(RemoteFile(spec[CONF_URL], path))
|
||||
elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and (
|
||||
not external_files.is_file_recent(
|
||||
_gfonts_ttf_path(spec), spec[CONF_REFRESH]
|
||||
)
|
||||
):
|
||||
stale_gfonts[css_path] = spec
|
||||
stage1.append(RemoteFile(_gfonts_css_url(spec), css_path))
|
||||
yield stage1
|
||||
|
||||
yield [
|
||||
RemoteFile(ttf_url, _gfonts_ttf_path(spec))
|
||||
for css_path, spec in stale_gfonts.items()
|
||||
# Only trust CSS that stage one actually refreshed this run; a
|
||||
# leftover from an earlier run may name a rotated ttf URL.
|
||||
if external_files.is_fresh_this_run(css_path)
|
||||
and css_path.exists()
|
||||
and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace")))
|
||||
is not None
|
||||
]
|
||||
|
||||
|
||||
def validate_file_shorthand(value: object) -> ConfigType:
|
||||
value = cv.string_strict(value)
|
||||
if (data := _shorthand_to_file_dict(value)) is None:
|
||||
data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value}
|
||||
return font_file_schema(data)
|
||||
|
||||
|
||||
TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||
|
||||
@@ -29,6 +29,8 @@ from esphome.const import (
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
AUTO_LOAD = ["touchscreen"]
|
||||
@@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None:
|
||||
|
||||
def _cache_path(url: str) -> Path:
|
||||
"""Cache path for a downloaded firmware blob, keyed by URL."""
|
||||
key = hashlib.sha256(url.encode()).hexdigest()[:8]
|
||||
return external_files.compute_local_file_dir(DOMAIN) / key
|
||||
return external_files.compute_local_file_path(DOMAIN, url)
|
||||
|
||||
|
||||
def firmware_path(firmware: dict) -> Path:
|
||||
@@ -156,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None:
|
||||
firmware = entry.get(CONF_FIRMWARE)
|
||||
if firmware is None:
|
||||
model = str(entry.get(CONF_MODEL, "CUSTOM")).upper()
|
||||
firmware = MODELS.get(model, {}).get(CONF_FIRMWARE)
|
||||
if (
|
||||
isinstance(firmware, dict)
|
||||
and CONF_FILE not in firmware
|
||||
and isinstance(url := firmware.get(CONF_URL), str)
|
||||
):
|
||||
return RemoteFile(url, _cache_path(url))
|
||||
return None
|
||||
|
||||
|
||||
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref)
|
||||
|
||||
|
||||
def _config_schema(config):
|
||||
model_option = {
|
||||
cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
CONF_IS_CONNECTED = "is_connected"
|
||||
|
||||
HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_(
|
||||
"HoermannHcpConnectedBinarySensor", binary_sensor.BinarySensor, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp),
|
||||
cv.Optional(CONF_IS_CONNECTED): binary_sensor.binary_sensor_schema(
|
||||
HoermannHcpConnectedBinarySensor,
|
||||
device_class=DEVICE_CLASS_CONNECTIVITY,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_IS_CONNECTED),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if (conf := config.get(CONF_IS_CONNECTED)) is not None:
|
||||
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
|
||||
var = await binary_sensor.new_binary_sensor(conf, parent)
|
||||
await cg.register_component(var, conf)
|
||||
@@ -0,0 +1,18 @@
|
||||
#include "hoermann_hcp_binary_sensor.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
static const char *const TAG = "hoermann_hcp.binary_sensor";
|
||||
|
||||
void HoermannHcpConnectedBinarySensor::setup() {
|
||||
// Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive
|
||||
// their timers.
|
||||
this->parent_->add_on_state_callback([this]() { this->publish_state(this->parent_->is_valid()); });
|
||||
this->publish_initial_state(this->parent_->is_valid());
|
||||
}
|
||||
|
||||
void HoermannHcpConnectedBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Hoermann HCP Connected", this); }
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "../hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
class HoermannHcpConnectedBinarySensor : public binary_sensor::BinarySensor, public Component {
|
||||
public:
|
||||
explicit HoermannHcpConnectedBinarySensor(HoermannHcp *parent) : parent_(parent) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
HoermannHcp *const parent_;
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -13,10 +13,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 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};
|
||||
|
||||
// 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.
|
||||
@@ -58,17 +65,29 @@ void HoermannHcp::update() {
|
||||
// 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_) {
|
||||
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
|
||||
this->next_command_ = nullptr;
|
||||
this->command_written_at_ = 0;
|
||||
this->clear_target_();
|
||||
// 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);
|
||||
}
|
||||
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->command_queued_at_ > this->connection_timeout_ms_) {
|
||||
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_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_();
|
||||
}
|
||||
if (this->changed_) {
|
||||
this->changed_ = false;
|
||||
this->state_callback_.call();
|
||||
@@ -151,6 +170,16 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
|
||||
this->on_state_reg_(registers[2]);
|
||||
if (registers.size() > 1)
|
||||
this->on_position_reg_(registers[1]);
|
||||
if (registers.size() > 6) {
|
||||
this->on_light_reg_(registers[6]);
|
||||
return {};
|
||||
}
|
||||
// Nothing refreshes the lamp any more, so what was read before must not be commanded against.
|
||||
this->set_light_seen_(false);
|
||||
if (!this->short_broadcast_logged_) {
|
||||
this->short_broadcast_logged_ = true;
|
||||
ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast<unsigned>(registers.size()));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -165,11 +194,11 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) {
|
||||
this->command_written_at_ = millis();
|
||||
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
|
||||
registers.push_back(command->pressed_value);
|
||||
registers.push_back(0x0000);
|
||||
registers.push_back(command->pressed_value_2);
|
||||
return;
|
||||
}
|
||||
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
|
||||
// Still inside the key-press window, so keep presenting 0x0000.
|
||||
// Between the two events there is nothing to report, including in the second register.
|
||||
push_zeros(registers, 2);
|
||||
return;
|
||||
}
|
||||
@@ -177,8 +206,12 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) {
|
||||
ESP_LOGD(TAG, "Released '%s' command", command->name);
|
||||
this->command_written_at_ = 0;
|
||||
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(0x0000);
|
||||
registers.push_back(command->released_value_2);
|
||||
}
|
||||
|
||||
void HoermannHcp::on_position_reg_(uint16_t value) {
|
||||
@@ -225,6 +258,13 @@ void HoermannHcp::on_state_reg_(uint16_t value) {
|
||||
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
|
||||
}
|
||||
|
||||
// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
|
||||
// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here.
|
||||
void HoermannHcp::on_light_reg_(uint16_t value) {
|
||||
this->set_light_seen_(true);
|
||||
this->set_light_on_((value & 0x0010) != 0);
|
||||
}
|
||||
|
||||
bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
if (!this->valid_) {
|
||||
// Queueing now would fire the command whenever the controller comes back, which may be much later.
|
||||
@@ -236,7 +276,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
return false;
|
||||
}
|
||||
// A new command supersedes any half-open target the door was still travelling to.
|
||||
this->clear_target_();
|
||||
if (command.clears_target)
|
||||
this->clear_target_();
|
||||
this->next_command_ = &command;
|
||||
this->command_queued_at_ = millis();
|
||||
return true;
|
||||
@@ -245,6 +286,31 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
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::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");
|
||||
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_)) {
|
||||
@@ -270,6 +336,7 @@ bool HoermannHcp::set_position(float position) {
|
||||
if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
|
||||
return false;
|
||||
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_;
|
||||
@@ -292,9 +359,48 @@ 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_();
|
||||
// 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);
|
||||
this->short_broadcast_logged_ = false;
|
||||
}
|
||||
|
||||
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;
|
||||
this->clear_target_();
|
||||
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;
|
||||
}
|
||||
|
||||
void HoermannHcp::set_door_state_(DoorState state) {
|
||||
@@ -333,4 +439,26 @@ void HoermannHcp::clear_target_() {
|
||||
this->target_started_ = false;
|
||||
}
|
||||
|
||||
void HoermannHcp::set_light_on_(bool on) {
|
||||
if (this->light_on_ == 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.
|
||||
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_();
|
||||
}
|
||||
|
||||
void HoermannHcp::set_light_seen_(bool seen) {
|
||||
if (this->light_seen_ == seen)
|
||||
return;
|
||||
this->light_seen_ = seen;
|
||||
// A resting door changes nothing else, so without this the light would never hear about it.
|
||||
this->changed_ = true;
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
|
||||
@@ -22,11 +22,15 @@ enum class DoorState : uint8_t {
|
||||
};
|
||||
|
||||
// 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. The second command register remains zero.
|
||||
// short delay the released value. Each half also carries a second register, which only the lamp command uses.
|
||||
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};
|
||||
};
|
||||
|
||||
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
@@ -52,19 +56,41 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
bool impulse_door();
|
||||
bool stop_door();
|
||||
bool set_position(float position);
|
||||
bool toggle_light();
|
||||
|
||||
DoorState get_door_state() const { return this->door_state_; }
|
||||
float get_current_position() const { return this->current_position_; }
|
||||
bool is_valid() const { return this->valid_; }
|
||||
bool is_light_on() const { return this->light_on_; }
|
||||
// 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();
|
||||
|
||||
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_();
|
||||
// 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 push_command_registers_(modbus::RegisterValues ®isters);
|
||||
void on_position_reg_(uint16_t value);
|
||||
void on_state_reg_(uint16_t value);
|
||||
void on_light_reg_(uint16_t value);
|
||||
|
||||
void set_valid_(bool valid);
|
||||
void set_door_state_(DoorState state);
|
||||
@@ -72,6 +98,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
void update_current_position_();
|
||||
bool has_target_() const { return this->target_position_ != 0.0f; }
|
||||
void clear_target_();
|
||||
void set_light_on_(bool on);
|
||||
void set_light_seen_(bool seen);
|
||||
|
||||
CallbackManager<void()> state_callback_;
|
||||
|
||||
@@ -82,8 +110,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
// Pending command / key-press state machine.
|
||||
const HoermannHcpCommand *next_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};
|
||||
|
||||
// A command is "pressed" for this long before its end value is sent.
|
||||
uint16_t key_press_delay_ms_{100};
|
||||
@@ -102,9 +135,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
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 short_broadcast_logged_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
HoermannHcpLight = hoermann_hcp_ns.class_(
|
||||
"HoermannHcpLight", light.LightOutput, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
light.light_schema(HoermannHcpLight, light.LightType.BINARY)
|
||||
.extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)})
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
|
||||
var = await light.new_light(config, parent)
|
||||
await cg.register_component(var, config)
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "hoermann_hcp_light.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
static const char *const TAG = "hoermann_hcp.light";
|
||||
|
||||
light::LightTraits HoermannHcpLight::get_traits() {
|
||||
auto traits = light::LightTraits();
|
||||
traits.set_supported_color_modes({light::ColorMode::ON_OFF});
|
||||
return traits;
|
||||
}
|
||||
|
||||
void HoermannHcpLight::setup() {
|
||||
// Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then.
|
||||
this->status_set_warning(LOG_STR("waiting for the bus controller"));
|
||||
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
|
||||
}
|
||||
|
||||
void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; }
|
||||
|
||||
void HoermannHcpLight::write_state(light::LightState *state) {
|
||||
bool binary;
|
||||
state->current_values_as_binary(&binary);
|
||||
// A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on,
|
||||
// so it is recognised by the value it carried rather than by the current one.
|
||||
const optional<bool> published = this->published_state_;
|
||||
this->published_state_.reset();
|
||||
// LightState::setup() always performs a call, so the very first write here is the restored state coming back
|
||||
// rather than a request.
|
||||
const bool restored = !this->boot_replay_done_;
|
||||
this->boot_replay_done_ = true;
|
||||
const bool heading_on = this->parent_->is_light_heading_on();
|
||||
if (binary == heading_on)
|
||||
return;
|
||||
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.
|
||||
return;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Light command was not accepted by the door");
|
||||
}
|
||||
}
|
||||
// Nothing was sent, so the entity has to go back to showing the lamp rather than the request.
|
||||
this->publish_lamp_state_(heading_on);
|
||||
}
|
||||
|
||||
void HoermannHcpLight::update_from_state_() {
|
||||
if (this->light_state_ == nullptr)
|
||||
return;
|
||||
if (!this->parent_->is_valid()) {
|
||||
this->status_set_warning(LOG_STR("bus controller not responding"));
|
||||
return;
|
||||
}
|
||||
if (!this->parent_->is_light_known()) {
|
||||
// Commands are refused until the door says, so say so rather than looking healthy and doing nothing.
|
||||
this->status_set_warning(LOG_STR("door has not reported the lamp"));
|
||||
return;
|
||||
}
|
||||
this->status_clear_warning();
|
||||
const bool heading_on = this->parent_->is_light_heading_on();
|
||||
if (this->light_state_->remote_values.is_on() != heading_on)
|
||||
this->publish_lamp_state_(heading_on);
|
||||
}
|
||||
|
||||
// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours.
|
||||
void HoermannHcpLight::publish_lamp_state_(bool on) {
|
||||
this->published_state_ = on;
|
||||
auto call = this->light_state_->make_call();
|
||||
call.set_state(on);
|
||||
// The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash.
|
||||
call.set_save(false);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "../hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
class HoermannHcpLight : public light::LightOutput, public Component {
|
||||
public:
|
||||
explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {}
|
||||
|
||||
void setup() override;
|
||||
void setup_state(light::LightState *state) override;
|
||||
light::LightTraits get_traits() override;
|
||||
void write_state(light::LightState *state) override;
|
||||
|
||||
protected:
|
||||
void update_from_state_();
|
||||
void publish_lamp_state_(bool on);
|
||||
|
||||
HoermannHcp *const parent_;
|
||||
light::LightState *light_state_{nullptr};
|
||||
// Value last published and not yet seen come back, so the write carrying it is that publish, not a request.
|
||||
optional<bool> published_state_;
|
||||
// Set by the first write_state(), which is always the restored state replayed on boot.
|
||||
bool boot_replay_done_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -39,7 +39,7 @@ bool Mutex::try_lock() { return static_cast<std::mutex *>(handle_)->try_lock();
|
||||
void Mutex::unlock() { static_cast<std::mutex *>(handle_)->unlock(); }
|
||||
|
||||
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
|
||||
static const uint8_t esphome_host_mac_address[6] = USE_ESPHOME_HOST_MAC_ADDRESS;
|
||||
static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS;
|
||||
memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,76 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "internal_temperature.h"
|
||||
|
||||
#include "Arduino.h"
|
||||
#include <cmath>
|
||||
#include <hardware/adc.h>
|
||||
#include <pico/time.h>
|
||||
|
||||
// The RP2 variant headers (pulled in transitively by Arduino.h) define
|
||||
// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted
|
||||
// into the constant below. Nothing here uses the Arduino definition, so drop
|
||||
// it for this file. Not restored with pop_macro: the uses below would then be
|
||||
// substituted again.
|
||||
#undef ADC_RESOLUTION
|
||||
|
||||
namespace esphome::internal_temperature {
|
||||
|
||||
static const char *const TAG = "internal_temperature.rp2";
|
||||
|
||||
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
|
||||
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
|
||||
// than four.
|
||||
//
|
||||
// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
|
||||
// derives from NUM_ADC_CHANNELS, which <pico.h> settles from a board header, and
|
||||
// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
|
||||
// is only declared later, by the variant's pins_arduino.h, so the SDK constant
|
||||
// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
|
||||
// is compiled, on both arduino-pico and pico-sdk builds.
|
||||
#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
|
||||
#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
|
||||
#endif
|
||||
#if defined(PICO_RP2350) && !PICO_RP2350A
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
|
||||
#else
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
|
||||
#endif
|
||||
static constexpr float ADC_VREF = 3.3f;
|
||||
static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit
|
||||
// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721
|
||||
static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f;
|
||||
static constexpr float REFERENCE_VOLTAGE = 0.706f;
|
||||
static constexpr float VOLTS_PER_DEGREE = 0.001721f;
|
||||
// The sensor is powered down again after each read, so every conversion is the
|
||||
// first one after enabling. Let the bias circuitry settle first, matching what
|
||||
// the adc component does for its own temperature readings.
|
||||
static constexpr uint32_t SETTLE_TIME_US = 1000;
|
||||
|
||||
static float read_internal_temperature() {
|
||||
// adc_init() resets the ADC block, so this runs at most once for this
|
||||
// component. The adc component guards its own adc_init() the same way, so a
|
||||
// redundant reset is still possible when both are used. That is harmless
|
||||
// because both re-select their input on every read.
|
||||
static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
if (!adc_ready) {
|
||||
adc_init();
|
||||
adc_ready = true;
|
||||
}
|
||||
|
||||
adc_set_temp_sensor_enabled(true);
|
||||
busy_wait_us(SETTLE_TIME_US);
|
||||
adc_select_input(TEMPERATURE_ADC_INPUT);
|
||||
const uint16_t raw = adc_read();
|
||||
adc_set_temp_sensor_enabled(false);
|
||||
|
||||
const float voltage = raw * (ADC_VREF / ADC_RESOLUTION);
|
||||
return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE;
|
||||
}
|
||||
|
||||
void InternalTemperatureSensor::update() {
|
||||
float temperature = NAN;
|
||||
bool success = false;
|
||||
|
||||
temperature = analogReadTemp();
|
||||
temperature = read_internal_temperature();
|
||||
success = (temperature != 0.0f);
|
||||
|
||||
if (success && std::isfinite(temperature)) {
|
||||
|
||||
@@ -116,7 +116,8 @@ bool LN882HBLETracker::request_scan_mode(bool active) {
|
||||
if (this->scan_active_ == active)
|
||||
return true;
|
||||
this->scan_active_ = active;
|
||||
ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// V: the proxy's "Setting scanner mode" line already narrates this at D.
|
||||
ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and
|
||||
// no period reset: the scan logically continues, only the mode changes.
|
||||
if (this->scan_running_) {
|
||||
|
||||
@@ -166,12 +166,7 @@ MANIFEST_SCHEMA_V2 = cv.Schema(
|
||||
|
||||
|
||||
def _compute_local_file_path(config: dict) -> Path:
|
||||
url = config[CONF_URL]
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN)
|
||||
return base_dir / key
|
||||
return external_files.compute_local_file_path(DOMAIN, config[CONF_URL])
|
||||
|
||||
|
||||
def _convert_manifest_v1_to_v2(v1_manifest):
|
||||
@@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
external_files.download_content_many(
|
||||
((url, path / "manifest.json") for path, url in http_models.items()),
|
||||
(
|
||||
external_files.RemoteFile(url, path / "manifest.json")
|
||||
for path, url in http_models.items()
|
||||
),
|
||||
description="wake word manifest(s)",
|
||||
)
|
||||
|
||||
model_files: list[tuple[str, Path]] = []
|
||||
model_files: list[external_files.RemoteFile] = []
|
||||
errors: list[cv.Invalid] = []
|
||||
for path, url in http_models.items():
|
||||
try:
|
||||
@@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType:
|
||||
cv.Invalid(f"Manifest file at {url} is missing the 'model' key")
|
||||
)
|
||||
continue
|
||||
model_files.append((urljoin(url, model), path / model))
|
||||
model_files.append(external_files.RemoteFile(urljoin(url, model), path / model))
|
||||
if errors:
|
||||
raise cv.MultipleInvalid(errors)
|
||||
|
||||
|
||||
@@ -2,9 +2,15 @@ from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.const import (
|
||||
CONF_DIRECTION,
|
||||
CONF_ID,
|
||||
CONF_ON_STATE,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.core import ID, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression, MockObj
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
CODEOWNERS = ["@crnjan"]
|
||||
@@ -13,6 +19,8 @@ DOMAIN = "mitsubishi_cn105"
|
||||
|
||||
CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id"
|
||||
CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval"
|
||||
CONF_VANE = "vane"
|
||||
CONF_VERTICAL = "vertical"
|
||||
|
||||
mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN)
|
||||
|
||||
@@ -22,6 +30,22 @@ MitsubishiCN105Component = mitsubishi_ns.class_(
|
||||
uart.UARTDevice,
|
||||
)
|
||||
|
||||
VaneState = mitsubishi_ns.struct("VaneState")
|
||||
VaneCall = mitsubishi_ns.class_("VaneCall")
|
||||
VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode")
|
||||
|
||||
# The insertion order must match VALUES in
|
||||
# select/mitsubishi_cn105_vane_select_vertical.cpp.
|
||||
VERTICAL_VANE_DIRECTIONS = {
|
||||
"AUTO": VerticalVaneMode.VERTICAL_VANE_MODE_AUTO,
|
||||
"1": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_1,
|
||||
"2": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_2,
|
||||
"3": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_3,
|
||||
"4": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_4,
|
||||
"5": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_5,
|
||||
"SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING,
|
||||
}
|
||||
|
||||
SetRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"SetRemoteTemperatureAction",
|
||||
automation.Action,
|
||||
@@ -34,6 +58,11 @@ ClearRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
cg.Parented.template(MitsubishiCN105Component),
|
||||
)
|
||||
|
||||
VaneControlAction = mitsubishi_ns.class_(
|
||||
"VaneControlAction",
|
||||
automation.Action,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -42,6 +71,11 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(
|
||||
CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s"
|
||||
): cv.update_interval,
|
||||
cv.Optional(CONF_VANE): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -80,6 +114,15 @@ async def to_code(config: ConfigType) -> None:
|
||||
config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL]
|
||||
)
|
||||
)
|
||||
if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE):
|
||||
cg.add_global(mitsubishi_ns.using)
|
||||
for conf in on_state:
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_vane_state_callback",
|
||||
[(VaneState.operator("const").operator("ref"), "x")],
|
||||
conf,
|
||||
)
|
||||
|
||||
|
||||
REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema(
|
||||
@@ -135,3 +178,70 @@ async def clear_temperature_action_to_code(
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
|
||||
|
||||
VANE_CONTROL_FIELDS = (
|
||||
(
|
||||
(CONF_VERTICAL, CONF_DIRECTION),
|
||||
"vertical.set_direction",
|
||||
VerticalVaneMode,
|
||||
),
|
||||
)
|
||||
|
||||
VANE_CONTROL_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component),
|
||||
cv.Optional(CONF_VERTICAL): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_DIRECTION): cv.templatable(
|
||||
cv.enum(VERTICAL_VANE_DIRECTIONS, upper=True)
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
f"{DOMAIN}.vane.control",
|
||||
VaneControlAction,
|
||||
VANE_CONTROL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def vane_control_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_global(mitsubishi_ns.using)
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
normalized_args = [
|
||||
(cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name)
|
||||
for t, name in args
|
||||
]
|
||||
forwarded_args = ", ".join(name for _, name in args)
|
||||
body_lines: list[str] = []
|
||||
|
||||
for path, setter, type_ in VANE_CONTROL_FIELDS:
|
||||
if (section := config.get(path[0])) is None:
|
||||
continue
|
||||
if (value := section.get(path[1])) is None:
|
||||
continue
|
||||
if isinstance(value, Lambda):
|
||||
inner = await cg.process_lambda(
|
||||
value,
|
||||
normalized_args,
|
||||
return_type=type_,
|
||||
)
|
||||
body_lines.append(f"call.{setter}(({inner})({forwarded_args}));")
|
||||
else:
|
||||
body_lines.append(f"call.{setter}({cg.safe_exp(value)});")
|
||||
|
||||
apply_lambda = LambdaExpression(
|
||||
["\n".join(body_lines)],
|
||||
[(VaneCall.operator("ref"), "call"), *normalized_args],
|
||||
capture="",
|
||||
return_type=cg.void,
|
||||
)
|
||||
return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
#include <type_traits>
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
template<typename... Ts>
|
||||
@@ -20,4 +22,21 @@ class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<Mitsu
|
||||
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class VaneControlAction : public Action<Ts...> {
|
||||
public:
|
||||
using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t<Ts> &...);
|
||||
|
||||
VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
auto call = this->parent_->make_vane_call();
|
||||
this->apply_(call, x...);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
protected:
|
||||
MitsubishiCN105Component *parent_;
|
||||
ApplyFn apply_;
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -27,8 +27,15 @@ void MitsubishiCN105Component::setup() { this->hp_.initialize(); }
|
||||
|
||||
void MitsubishiCN105Component::loop() {
|
||||
if (this->hp_.update()) {
|
||||
this->status_callback_.call();
|
||||
this->notify_status_listeners_();
|
||||
}
|
||||
}
|
||||
|
||||
void VaneCall::perform() {
|
||||
if (const auto &direction = this->vertical.get_direction(); direction.has_value()) {
|
||||
this->parent_->set_vane_mode(static_cast<MitsubishiCN105::VaneMode>(*direction));
|
||||
}
|
||||
this->parent_->publish_status();
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -6,9 +6,50 @@
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
#include <utility>
|
||||
#include <optional>
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
enum VerticalVaneMode : uint8_t {
|
||||
VERTICAL_VANE_MODE_AUTO = static_cast<uint8_t>(MitsubishiCN105::VaneMode::AUTO),
|
||||
VERTICAL_VANE_MODE_POSITION_1 = static_cast<uint8_t>(MitsubishiCN105::VaneMode::POSITION_1),
|
||||
VERTICAL_VANE_MODE_POSITION_2 = static_cast<uint8_t>(MitsubishiCN105::VaneMode::POSITION_2),
|
||||
VERTICAL_VANE_MODE_POSITION_3 = static_cast<uint8_t>(MitsubishiCN105::VaneMode::POSITION_3),
|
||||
VERTICAL_VANE_MODE_POSITION_4 = static_cast<uint8_t>(MitsubishiCN105::VaneMode::POSITION_4),
|
||||
VERTICAL_VANE_MODE_POSITION_5 = static_cast<uint8_t>(MitsubishiCN105::VaneMode::POSITION_5),
|
||||
VERTICAL_VANE_MODE_SWING = static_cast<uint8_t>(MitsubishiCN105::VaneMode::SWING),
|
||||
VERTICAL_VANE_MODE_UNKNOWN = static_cast<uint8_t>(MitsubishiCN105::VaneMode::UNKNOWN),
|
||||
};
|
||||
|
||||
struct VaneState {
|
||||
struct Vertical {
|
||||
VerticalVaneMode direction;
|
||||
};
|
||||
|
||||
Vertical vertical;
|
||||
};
|
||||
|
||||
class MitsubishiCN105Component;
|
||||
|
||||
struct VaneCall {
|
||||
struct Vertical {
|
||||
void set_direction(VerticalVaneMode direction) { this->direction_ = direction; }
|
||||
const std::optional<VerticalVaneMode> &get_direction() const { return this->direction_; }
|
||||
|
||||
protected:
|
||||
std::optional<VerticalVaneMode> direction_;
|
||||
};
|
||||
|
||||
explicit VaneCall(MitsubishiCN105Component *parent) : parent_(parent) {}
|
||||
|
||||
Vertical vertical;
|
||||
|
||||
void perform();
|
||||
|
||||
protected:
|
||||
MitsubishiCN105Component *parent_;
|
||||
};
|
||||
|
||||
class MitsubishiCN105Component : public Component, public uart::UARTDevice {
|
||||
public:
|
||||
explicit MitsubishiCN105Component() : hp_(*this) {}
|
||||
@@ -29,6 +70,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice {
|
||||
void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); }
|
||||
void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); }
|
||||
void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); }
|
||||
VaneCall make_vane_call() { return VaneCall(this); }
|
||||
|
||||
const MitsubishiCN105::Status &status() const { return this->hp_.status(); }
|
||||
bool is_status_initialized() const { return this->hp_.is_status_initialized(); }
|
||||
@@ -38,15 +80,27 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice {
|
||||
this->status_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
template<typename F> void add_on_vane_state_callback(F &&callback) {
|
||||
this->vane_state_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
void publish_status() {
|
||||
if (this->is_status_initialized()) {
|
||||
this->status_callback_.call();
|
||||
this->notify_status_listeners_();
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
void notify_status_listeners_() {
|
||||
this->status_callback_.call();
|
||||
this->vane_state_callback_.call(VaneState{
|
||||
.vertical = {.direction = static_cast<VerticalVaneMode>(this->status().vane_mode)},
|
||||
});
|
||||
}
|
||||
|
||||
MitsubishiCN105 hp_;
|
||||
CallbackManager<void()> status_callback_;
|
||||
LazyCallbackManager<void(const VaneState &)> vane_state_callback_;
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -6,6 +6,7 @@ from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
MITSUBISHI_CN105_DEVICE_SCHEMA,
|
||||
VERTICAL_VANE_DIRECTIONS,
|
||||
MitsubishiCN105Component,
|
||||
mitsubishi_ns,
|
||||
register_mitsubishi_cn105_device,
|
||||
@@ -15,9 +16,6 @@ DEPENDENCIES = ["mitsubishi_cn105"]
|
||||
|
||||
CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction"
|
||||
|
||||
# The insertion order must match VALUES in mitsubishi_cn105_vane_select_vertical.cpp.
|
||||
VERTICAL_VANE_DIRECTIONS = ["Auto", "1", "2", "3", "4", "5", "Swing"]
|
||||
|
||||
MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_(
|
||||
"MitsubishiCN105VerticalVaneDirectionSelect",
|
||||
select.Select,
|
||||
@@ -42,6 +40,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await select.register_select(
|
||||
var,
|
||||
vertical_vane_direction,
|
||||
options=VERTICAL_VANE_DIRECTIONS,
|
||||
options=[direction.capitalize() for direction in VERTICAL_VANE_DIRECTIONS],
|
||||
)
|
||||
await register_mitsubishi_cn105_device(var, config)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in select.py.
|
||||
// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in the hub's __init__.py.
|
||||
// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based
|
||||
// Select API, so Python option order and this array must stay aligned.
|
||||
static constexpr std::array VALUES{
|
||||
|
||||
@@ -815,6 +815,16 @@ void ModbusClientHub::send_next_frame_() {
|
||||
}
|
||||
|
||||
cmd->sent();
|
||||
if (cmd->frame.address() == BROADCAST_ADDRESS) {
|
||||
// A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above
|
||||
// reports the transmission, and the entry then retires with no terminal callback instead of
|
||||
// occupying the waiting slot until the send-wait timeout expires. The turnaround delay already
|
||||
// spaces the next frame; the following sweep erases the entry.
|
||||
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected (fire-and-forget)");
|
||||
cmd->complete_broadcast();
|
||||
this->sweep_needed_ = true;
|
||||
return;
|
||||
}
|
||||
this->waiting_for_response_ = true;
|
||||
}
|
||||
|
||||
@@ -1033,9 +1043,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size());
|
||||
return false;
|
||||
}
|
||||
// classify() drives both the broadcast guard and the continuous check below; compute it once.
|
||||
const CommandPriority priority = ModbusDeviceCommand::classify(pdu[0]);
|
||||
|
||||
// A broadcast (address 0) is never answered (Modbus 4.1), so it is only meaningful for a command that
|
||||
// changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code -
|
||||
// as it could never deliver a result, so the caller learns via the false return (and on_not_sent).
|
||||
// 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half
|
||||
// lands on every server and its unanswerable read half is simply discarded. An exception-flagged custom
|
||||
// code (0x80 bit set) is refused: is_function_code_custom() masks that bit away, so exclude it explicitly
|
||||
// here to match classify()'s exception-first handling of the write side.
|
||||
if (address == BROADCAST_ADDRESS && priority != CommandPriority::WRITE &&
|
||||
(!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) {
|
||||
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
|
||||
const bool mutates = ModbusDeviceCommand::classify(pdu[0]) == CommandPriority::WRITE;
|
||||
const bool mutates = priority == CommandPriority::WRITE;
|
||||
bool continuous = false;
|
||||
if (options.continuous) {
|
||||
if (mutates) {
|
||||
|
||||
@@ -171,6 +171,15 @@ struct ModbusDeviceCommand {
|
||||
this->pending = 0;
|
||||
this->device = nullptr;
|
||||
}
|
||||
// Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already
|
||||
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with NO terminal
|
||||
// callback and the sweep erases it. Unlike response()/error()/timed_out(), it delivers nothing.
|
||||
// A broadcast only carries a write or a custom code (reads are refused at queue_pdu()), and every such
|
||||
// code caps pending at 1, so pending is always 1 here - clear it.
|
||||
void complete_broadcast() {
|
||||
this->state = FrameState::RETIRED;
|
||||
this->pending = 0;
|
||||
}
|
||||
// Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++).
|
||||
void requeue(uint16_t seq) {
|
||||
this->state = FrameState::READY;
|
||||
@@ -270,7 +279,8 @@ class ModbusClientHub : public Modbus {
|
||||
};
|
||||
/// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and
|
||||
/// goes out later from loop(), so a true return means accepted into the machine (it will resolve in
|
||||
/// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means
|
||||
/// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets
|
||||
/// only on_sent()), NOT that anything reached the wire - that is on_sent(). False means
|
||||
/// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap
|
||||
/// duplicate) and no callback of any kind will follow; the false return is the whole story.
|
||||
bool queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
|
||||
@@ -411,13 +421,14 @@ class ModbusServerHub : public Modbus {
|
||||
/// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data),
|
||||
/// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by
|
||||
/// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return)
|
||||
/// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request.
|
||||
/// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all
|
||||
/// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from
|
||||
/// gets none, and a broadcast (address 0) gets on_sent() with NO terminal, since a broadcast is never
|
||||
/// answered (Modbus 4.1). on_sent() is additional, once per transmission, never for an on_not_sent()
|
||||
/// request. on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog,
|
||||
/// all from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from
|
||||
/// inside a callback is safe (picked up by the next sweep). Exceptions to "exactly one terminal":
|
||||
/// clear_tx_queue_for_device() drops the caller's own frames silently; a continuous poll's cycles are
|
||||
/// its own accounting (a one-shot duplicate downgrades the poll to a one-shot; a continuous duplicate
|
||||
/// merges into it).
|
||||
/// a broadcast is fire-and-forget (on_sent, no terminal); clear_tx_queue_for_device() drops the caller's
|
||||
/// own frames silently; a continuous poll's cycles are its own accounting (a one-shot duplicate
|
||||
/// downgrades the poll to a one-shot; a continuous duplicate merges into it).
|
||||
///
|
||||
/// Invariants:
|
||||
/// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing
|
||||
@@ -531,8 +542,9 @@ class ModbusClientDevice {
|
||||
this);
|
||||
}
|
||||
/// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will
|
||||
/// follow, false = refused at the door and nothing further happens. Neither means the frame is on
|
||||
/// the wire; on_sent() reports that.
|
||||
/// follow (except a broadcast (address 0), which is never answered and so gets only on_sent()),
|
||||
/// false = refused at the door and nothing further happens. Neither means the frame is on the wire;
|
||||
/// on_sent() reports that.
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
|
||||
return this->parent_->queue_pdu(this->address_, pdu, this, options);
|
||||
}
|
||||
@@ -548,8 +560,9 @@ class ModbusClientDevice {
|
||||
this->parent_->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
|
||||
}
|
||||
// The typed request builders below all queue through queue_pdu(), so they share its contract: true
|
||||
// means the request is queued and will resolve in exactly one terminal callback, false means it was
|
||||
// refused outright with no callback. Neither says the frame has been transmitted - on_sent() does.
|
||||
// means the request is queued and will resolve in exactly one terminal callback (except a broadcast
|
||||
// (address 0), which is never answered and so gets only on_sent()), false means it was refused outright
|
||||
// with no callback. Neither says the frame has been transmitted - on_sent() does.
|
||||
// Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which
|
||||
// create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return.
|
||||
bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities,
|
||||
|
||||
@@ -64,7 +64,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
|
||||
protected:
|
||||
/// The hub refuses some sends at the door with no callback (a duplicate write already pending, a full
|
||||
/// queue, or an empty PDU - which is how the create_*_pdu() builders reject out-of-spec input). Every
|
||||
/// send still gets exactly one outcome, so resolve refusals here via on_not_sent.
|
||||
/// send still gets exactly one outcome (a broadcast (address 0) is the exception - never answered, it
|
||||
/// resolves through on_sent() alone), so resolve refusals here via on_not_sent.
|
||||
/// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and
|
||||
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
|
||||
void send_or_resolve_(std::span<const uint8_t> pdu) {
|
||||
|
||||
@@ -109,8 +109,22 @@ void ModbusCommandItem::on_not_sent(std::span<const uint8_t> request_pdu) {
|
||||
// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent
|
||||
// trigger reflects when the frame actually went out, not when it was queued.
|
||||
void ModbusCommandItem::on_sent(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ != nullptr)
|
||||
this->controller_->command_sent(static_cast<int>(this->function_code_), this->start_address_);
|
||||
if (this->controller_ == nullptr)
|
||||
return;
|
||||
this->controller_->command_sent(static_cast<int>(this->function_code_), this->start_address_);
|
||||
// A broadcast (address 0) is never answered (Modbus 4.1), so the hub delivers no terminal callback.
|
||||
// on_sent is this command's only callback, so drop the one-shot from the queue here, or it would leak.
|
||||
// Test the address the frame went to, not address_: a custom command's frame carries its own address
|
||||
// (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.)
|
||||
uint8_t wire_address = this->address_;
|
||||
if (this->function_code_ == FunctionCode::CUSTOM) {
|
||||
std::span<const uint8_t> frame =
|
||||
this->custom_data_ != nullptr ? std::span<const uint8_t>(*this->custom_data_) : this->payload;
|
||||
if (!frame.empty())
|
||||
wire_address = frame[0];
|
||||
}
|
||||
if (wire_address == modbus::BROADCAST_ADDRESS)
|
||||
this->controller_->unqueue_command(this);
|
||||
}
|
||||
|
||||
bool ModbusCommandItem::on_no_response(std::span<const uint8_t> request_pdu) {
|
||||
|
||||
@@ -39,6 +39,12 @@ KEY_NETWORK_PRIORITY = "network_priority"
|
||||
# NETWORK_PLAN.md for the full multi-interface roadmap.
|
||||
VALID_NETWORK_TYPES = ["ethernet", "wifi"]
|
||||
|
||||
# Interfaces NetworkComponent::loop() knows how to arbitrate the default route
|
||||
# for. Deliberately NOT derived from VALID_NETWORK_TYPES: extending that list
|
||||
# without extending the C++ arbitration (and then this set) is caught in
|
||||
# _final_validate() as a config error instead of a silently mis-routed interface.
|
||||
ARBITRATED_NETWORK_TYPES = frozenset({"ethernet", "wifi"})
|
||||
|
||||
# Setup priority base values — first in list gets the highest priority.
|
||||
#
|
||||
# The base equals the historical setup_priority::WIFI / ::ETHERNET default
|
||||
@@ -310,7 +316,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
"""Check that every interface named in 'priority' has a corresponding component block."""
|
||||
full = fv.full_config.get()
|
||||
for entry in config.get(CONF_PRIORITY, []):
|
||||
priority_list = config.get(CONF_PRIORITY, [])
|
||||
for entry in priority_list:
|
||||
iface = entry["interface"]
|
||||
if iface not in full:
|
||||
raise cv.Invalid(
|
||||
@@ -319,6 +326,24 @@ def _final_validate(config: ConfigType) -> None:
|
||||
[CONF_PRIORITY],
|
||||
)
|
||||
|
||||
# Tripwire for future interface types (openthread, modem): the C++ default-route
|
||||
# arbitration pivots on USE_NETWORK_PRIMARY_INTERFACE_WIFI and only knows
|
||||
# ethernet and wifi. Extend NetworkComponent::loop() before allowing another
|
||||
# type here. Unreachable until VALID_NETWORK_TYPES grows.
|
||||
if (
|
||||
len(priority_list) > 1
|
||||
and (
|
||||
unsupported := {e["interface"] for e in priority_list}
|
||||
- ARBITRATED_NETWORK_TYPES
|
||||
)
|
||||
and CORE.is_esp32
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"Default-route arbitration does not support: "
|
||||
f"{', '.join(sorted(unsupported))}",
|
||||
[CONF_PRIORITY],
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
@@ -337,10 +362,22 @@ async def to_code(config):
|
||||
# network/util.cpp resolves the reported address (get_use_address_to,
|
||||
# get_ip_addresses) in a fixed ethernet-first order; a wifi-first priority
|
||||
# list is the only case that deviates from it, so it is the only case that
|
||||
# needs a define. Runtime (active-interface) selection is a planned follow-up.
|
||||
# needs a define.
|
||||
if priority_list[0]["interface"] == "wifi":
|
||||
cg.add_define("USE_NETWORK_PRIMARY_INTERFACE_WIFI")
|
||||
|
||||
# With more than one interface, NetworkComponent::loop() arbitrates the
|
||||
# default route (ESP-IDF's fixed route_prio values would always favor
|
||||
# WiFi). ESP32 only: the arbitration needs esp_netif, which both
|
||||
# frameworks build from source.
|
||||
# The ethernet/wifi-only assumption behind the arbitration is enforced in
|
||||
# _final_validate() so a future unsupported type fails as a config error.
|
||||
if len(priority_list) > 1 and CORE.is_esp32:
|
||||
cg.add_define("USE_NETWORK_DEFAULT_ROUTE")
|
||||
# Have lwIP switch to the DNS servers of the netif that owns the
|
||||
# default route whenever the arbitration changes it.
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF", True)
|
||||
|
||||
_LOGGER.info(
|
||||
"Network interface priority: %s",
|
||||
" > ".join(entry["interface"] for entry in priority_list),
|
||||
|
||||
@@ -6,6 +6,20 @@
|
||||
#include "esp_err.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_event.h"
|
||||
|
||||
#ifdef USE_NETWORK_DEFAULT_ROUTE
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esp_netif_net_stack.h"
|
||||
#include "lwip/netif.h"
|
||||
#ifdef USE_ETHERNET
|
||||
#include "esphome/components/ethernet/ethernet_component.h"
|
||||
#endif
|
||||
#ifdef USE_WIFI
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace esphome::network {
|
||||
|
||||
static const char *const TAG = "network";
|
||||
@@ -29,5 +43,81 @@ void NetworkComponent::setup() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_NETWORK_DEFAULT_ROUTE
|
||||
static esp_netif_t *connected_wifi_netif() {
|
||||
#ifdef USE_WIFI
|
||||
auto *wifi = wifi::global_wifi_component;
|
||||
if (wifi != nullptr && wifi->is_connected())
|
||||
return wifi->get_esp_netif_sta();
|
||||
#endif
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static esp_netif_t *connected_ethernet_netif() {
|
||||
#ifdef USE_ETHERNET
|
||||
auto *eth = ethernet::global_eth_component;
|
||||
if (eth != nullptr && eth->is_connected())
|
||||
return eth->get_esp_netif();
|
||||
#endif
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void NetworkComponent::loop() {
|
||||
// Pin the default route to the first connected interface in the user's priority
|
||||
// order; ESP-IDF's own route_prio selection would always favor WiFi.
|
||||
// USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list;
|
||||
// it selects the reported address in util.cpp and doubles as the route-order
|
||||
// pivot here — the two uses must stay in sync.
|
||||
esp_netif_t *best;
|
||||
#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI
|
||||
best = connected_wifi_netif();
|
||||
if (best == nullptr)
|
||||
best = connected_ethernet_netif();
|
||||
#else
|
||||
best = connected_ethernet_netif();
|
||||
if (best == nullptr)
|
||||
best = connected_wifi_netif();
|
||||
#endif
|
||||
if (best == nullptr) {
|
||||
// Forget the last winner: stopping its netif cleared lwIP's default route and
|
||||
// IDF's manual override suppresses re-election, so reconnect must re-assert it.
|
||||
this->default_netif_ = nullptr;
|
||||
return;
|
||||
}
|
||||
if (best == this->default_netif_) {
|
||||
// Same winner as the last assert. Still re-assert if lwIP's default route is
|
||||
// not the winner's netif: a winner whose netif bounced down and up between two
|
||||
// polls would otherwise stay routeless (stopping a netif nulls lwIP's
|
||||
// netif_default). Checking lwIP directly keeps this independent of IDF's
|
||||
// re-election bookkeeping (esp_netif_get_default_netif() cannot detect it).
|
||||
// Throttled: LwIPLock is the global lwIP core mutex, and this branch runs on
|
||||
// every pass once the route has settled.
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_route_check_ < ROUTE_CHECK_INTERVAL_MS)
|
||||
return;
|
||||
this->last_route_check_ = now;
|
||||
bool route_is_ours;
|
||||
{
|
||||
LwIPLock lock;
|
||||
route_is_ours = static_cast<void *>(netif_default) == esp_netif_get_netif_impl(best);
|
||||
}
|
||||
if (route_is_ours)
|
||||
return;
|
||||
}
|
||||
esp_err_t err = esp_netif_set_default_netif(best);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to set default interface: (%d) %s", err, esp_err_to_name(err));
|
||||
// Cache the intent anyway: subsequent passes take the same-winner branch
|
||||
// above, so retries are throttled to ROUTE_CHECK_INTERVAL_MS and the lwIP
|
||||
// verification keeps re-attempting until the route is actually ours.
|
||||
this->default_netif_ = best;
|
||||
this->last_route_check_ = App.get_loop_component_start_time();
|
||||
return;
|
||||
}
|
||||
this->default_netif_ = best;
|
||||
ESP_LOGI(TAG, "Default interface: %s", esp_netif_get_desc(best));
|
||||
}
|
||||
#endif // USE_NETWORK_DEFAULT_ROUTE
|
||||
|
||||
} // namespace esphome::network
|
||||
#endif
|
||||
|
||||
@@ -3,12 +3,30 @@
|
||||
#if defined(USE_NETWORK) && defined(USE_ESP32)
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
#ifdef USE_NETWORK_DEFAULT_ROUTE
|
||||
// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h
|
||||
// into this header.
|
||||
using esp_netif_t = struct esp_netif_obj;
|
||||
#endif
|
||||
|
||||
namespace esphome::network {
|
||||
class NetworkComponent final : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
// AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance.
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
#ifdef USE_NETWORK_DEFAULT_ROUTE
|
||||
void loop() override;
|
||||
|
||||
protected:
|
||||
// Verify-lwIP-route interval for the settled state; keeps the global lwIP core
|
||||
// mutex off the hot loop path.
|
||||
static constexpr uint32_t ROUTE_CHECK_INTERVAL_MS = 1000;
|
||||
// Last netif this component made the default; avoids redundant esp_netif calls.
|
||||
esp_netif_t *default_netif_{nullptr};
|
||||
uint32_t last_route_check_{0};
|
||||
#endif
|
||||
};
|
||||
} // namespace esphome::network
|
||||
#endif
|
||||
|
||||
@@ -10,16 +10,33 @@ namespace esphome::network {
|
||||
// an AP that uses a previous interface for NAT).
|
||||
|
||||
bool is_disabled() {
|
||||
// The network is disabled only when every configured interface with a
|
||||
// disable() lifecycle is disabled; one enabled interface means traffic can flow.
|
||||
bool disabled = false;
|
||||
#ifdef USE_MODEM
|
||||
if (modem::global_modem_component != nullptr)
|
||||
return modem::global_modem_component->is_disabled();
|
||||
if (modem::global_modem_component != nullptr) {
|
||||
if (!modem::global_modem_component->is_disabled())
|
||||
return false;
|
||||
disabled = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_WIFI
|
||||
if (wifi::global_wifi_component != nullptr)
|
||||
return wifi::global_wifi_component->is_disabled();
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
if (!wifi::global_wifi_component->is_disabled())
|
||||
return false;
|
||||
disabled = true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
|
||||
#ifdef USE_ETHERNET
|
||||
if (ethernet::global_eth_component != nullptr) {
|
||||
if (!ethernet::global_eth_component->is_disabled())
|
||||
return false;
|
||||
disabled = true;
|
||||
}
|
||||
#endif
|
||||
return disabled;
|
||||
}
|
||||
|
||||
const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf) {
|
||||
|
||||
@@ -52,7 +52,8 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Return whether the network is disabled (only wifi for now)
|
||||
/// Return whether the network is disabled: every configured interface with a
|
||||
/// disable() lifecycle (modem, wifi, ethernet) is disabled.
|
||||
bool is_disabled();
|
||||
/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator
|
||||
static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70;
|
||||
|
||||
@@ -388,6 +388,74 @@ async def to_code(config):
|
||||
_configure_lwip()
|
||||
|
||||
|
||||
# --- lwIP sizing. See _configure_lwip() for the platform comparison table. ---
|
||||
|
||||
# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS.
|
||||
# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk.
|
||||
LWIP_TCP_SND_BUF = "(4*TCP_MSS)"
|
||||
|
||||
# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS.
|
||||
LWIP_TCP_WND = "(4*TCP_MSS)"
|
||||
|
||||
# TCP_SND_QUEUELEN: max pbufs queued per PCB for the send buffer
|
||||
# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS
|
||||
# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32
|
||||
LWIP_TCP_SND_QUEUELEN = 17
|
||||
|
||||
# MEMP_NUM_TCP_SEG: pool shared by every PCB, so it must not be the per-PCB
|
||||
# queue length — lwIP's sanity check only demands >=, the floor for a single
|
||||
# connection. 2× lets two PCBs fill up before the rest see ERR_MEM. Measured
|
||||
# at 20 bytes per entry, so under 700 bytes total.
|
||||
LWIP_MEMP_NUM_TCP_SEG = 2 * LWIP_TCP_SND_QUEUELEN
|
||||
|
||||
# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny.
|
||||
# 16 matches ESP32 (vs arduino-pico's 24). Receive side only; the send path
|
||||
# copies into PBUF_RAM out of MEM_SIZE.
|
||||
LWIP_PBUF_POOL_SIZE = 16
|
||||
|
||||
# MEM_SIZE: lwIP heap backing PBUF_RAM, where tcp_write() copies outgoing
|
||||
# data. TCP_OVERSIZE defaults to TCP_MSS, so each queued segment takes a full
|
||||
# MSS block whatever was written (pbuf 16 + PBUF_TRANSPORT 54 + MSS 1460 +
|
||||
# block header ≈ 1.5KB); a PCB at a full TCP_SND_BUF holds four, ~6KB.
|
||||
#
|
||||
# Two of those is ~12KB of arduino-pico's 16KB heap and already fails: mem.c
|
||||
# is first-fit, so a *contiguous* 1.5KB block must be free, and at 75%
|
||||
# occupancy interleaved with ARP/DHCP/DNS/mDNS the largest run collapses well
|
||||
# before the total does — hence the intermittent failures. With rp2's
|
||||
# max_connections of 4, a third sender has nothing left.
|
||||
#
|
||||
# 32KB is arduino-pico's own next tier (__LWIP_MEMMULT=2 boards).
|
||||
# Must stay under 64000 or lwIP widens mem_size_t to u32_t.
|
||||
LWIP_MEM_SIZE = 32768
|
||||
|
||||
|
||||
def build_lwip_defines(
|
||||
tcp_sockets: int, udp_sockets: int, listening_tcp: int
|
||||
) -> dict[str, str]:
|
||||
"""Render the lwIP override values for the Jinja2 template.
|
||||
|
||||
The template uses #include_next to chain to the framework's original
|
||||
lwipopts.h, then #undef/#define only these. Split out from
|
||||
_configure_lwip() so the values that actually reach the generated header
|
||||
can be checked without standing up CORE.
|
||||
|
||||
Both malloc flags stay 0 (framework defaults); see _configure_lwip(). The
|
||||
static pools are the only IRQ-safe allocator on this platform, so the fix
|
||||
is to size them correctly rather than to make them dynamic.
|
||||
"""
|
||||
return {
|
||||
"TCP_SND_BUF": LWIP_TCP_SND_BUF,
|
||||
"TCP_WND": LWIP_TCP_WND,
|
||||
"TCP_SND_QUEUELEN": str(LWIP_TCP_SND_QUEUELEN),
|
||||
"MEM_SIZE": str(LWIP_MEM_SIZE),
|
||||
"MEMP_NUM_TCP_SEG": str(LWIP_MEMP_NUM_TCP_SEG),
|
||||
"PBUF_POOL_SIZE": str(LWIP_PBUF_POOL_SIZE),
|
||||
"MEMP_NUM_TCP_PCB": str(tcp_sockets),
|
||||
"MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp),
|
||||
"MEMP_NUM_UDP_PCB": str(udp_sockets),
|
||||
}
|
||||
|
||||
|
||||
def _configure_lwip() -> None:
|
||||
"""Configure lwIP options for RP2040 by generating a custom lwipopts.h.
|
||||
|
||||
@@ -407,25 +475,36 @@ def _configure_lwip() -> None:
|
||||
────────────────────────────────────────────────────────────────
|
||||
TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS
|
||||
TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS
|
||||
TCP_SND_QUEUELEN ~8 17 32 17
|
||||
MEM_LIBC_MALLOC 1 1 0 0*
|
||||
MEMP_MEM_MALLOC 1 1 0 0**
|
||||
MEM_SIZE N/A*** N/A*** 16KB 16KB
|
||||
MEM_SIZE N/A*** N/A*** 16KB 32KB
|
||||
PBUF_POOL_SIZE 10 16 24 16
|
||||
MEMP_NUM_TCP_SEG 10 16 32 17
|
||||
MEMP_NUM_TCP_SEG 10 16 32 34****
|
||||
MEMP_NUM_TCP_PCB 5 16 5 dynamic
|
||||
MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** dynamic
|
||||
MEMP_NUM_TCP_PCB_LISTEN 4 16 8***** dynamic
|
||||
MEMP_NUM_UDP_PCB 4 16 7 dynamic
|
||||
TCP_SND_QUEUELEN ~8 17 32 17
|
||||
|
||||
* MEM_LIBC_MALLOC must stay 0: arduino-pico uses
|
||||
PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from
|
||||
a low-priority pendsv IRQ. The pico-sdk explicitly blocks
|
||||
MEM_LIBC_MALLOC=1 because libc malloc uses mutexes (unsafe in IRQ).
|
||||
** MEMP_MEM_MALLOC must stay 0: the dedicated lwIP heap (MEM_SIZE=16KB)
|
||||
is too small to hold all pools dynamically. The PBUF_POOL alone needs
|
||||
~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate BSS savings.
|
||||
*** ESP8266/ESP32 use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool).
|
||||
**** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN.
|
||||
** MEMP_MEM_MALLOC must stay 0 for IRQ safety, not size. memp_malloc()
|
||||
pops the pool free list inside SYS_ARCH_PROTECT, but lwIP's heap takes
|
||||
its protection from LWIP_ALLOW_MEM_FREE_FROM_OTHER_CONTEXT (default 0),
|
||||
so under NO_SYS=1 mem_malloc()/mem_free() are unprotected — and memp.c
|
||||
calls mem_malloc() outside the guard anyway. RX pbufs would then be
|
||||
allocated from the pendsv IRQ on the same unguarded free list the main
|
||||
loop uses for tcp_write(). Tried on hardware: faults within seconds on
|
||||
CYW43. Ethernet survives only because it polls from the main loop.
|
||||
*** ESP8266/ESP32 ship MEMP_MEM_MALLOC=1, so their pool entries come from
|
||||
the heap on demand and MEMP_NUM_*/PBUF_POOL_SIZE are labels, not caps
|
||||
(MEM_LIBC_MALLOC=1 points that heap at the system heap). Both flags are
|
||||
0 here, so ours are hard limits; don't copy their numbers.
|
||||
**** MEMP_NUM_TCP_SEG is *global* while TCP_SND_QUEUELEN is *per-PCB*, so
|
||||
sizing it to the per-PCB value lets one busy connection drain it for
|
||||
every other. 2× covers two PCBs; MEM_SIZE is the real limit past that.
|
||||
***** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN.
|
||||
"dynamic" = auto-calculated from component socket registrations via
|
||||
socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN.
|
||||
"""
|
||||
@@ -444,48 +523,7 @@ def _configure_lwip() -> None:
|
||||
# UDP PCBs (2) are absorbed by the generous minimum of 6.
|
||||
listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen)
|
||||
|
||||
# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS.
|
||||
# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk.
|
||||
tcp_snd_buf = "(4*TCP_MSS)"
|
||||
|
||||
# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS.
|
||||
tcp_wnd = "(4*TCP_MSS)"
|
||||
|
||||
# TCP_SND_QUEUELEN: max pbufs queued for send buffer
|
||||
# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS
|
||||
# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32
|
||||
tcp_snd_queuelen = 17
|
||||
# MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check)
|
||||
memp_num_tcp_seg = tcp_snd_queuelen
|
||||
|
||||
# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny.
|
||||
# 16 matches ESP32 (vs arduino-pico's 24). With MEMP_MEM_MALLOC=1,
|
||||
# this is a max count (allocated on demand from heap).
|
||||
pbuf_pool_size = 16
|
||||
|
||||
# Build the lwIP override defines for the Jinja2 template.
|
||||
# The template uses #include_next to chain to the framework's original
|
||||
# lwipopts.h, then #undef/#define only the values we need to change.
|
||||
#
|
||||
# Note: MEMP_MEM_MALLOC stays 0 (framework default). While the memp
|
||||
# allocations use the dedicated lwIP heap (IRQ-safe), the 16KB MEM_SIZE
|
||||
# is too small to hold all pools dynamically under stress. The PBUF_POOL
|
||||
# alone needs ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate
|
||||
# the BSS savings.
|
||||
#
|
||||
# MEM_LIBC_MALLOC stays 0 (framework default): arduino-pico uses
|
||||
# PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from
|
||||
# a low-priority pendsv IRQ where libc malloc (mutex-based) is unsafe.
|
||||
lwip_defines: dict[str, str] = {
|
||||
"TCP_SND_BUF": tcp_snd_buf,
|
||||
"TCP_WND": tcp_wnd,
|
||||
"TCP_SND_QUEUELEN": str(tcp_snd_queuelen),
|
||||
"MEMP_NUM_TCP_SEG": str(memp_num_tcp_seg),
|
||||
"PBUF_POOL_SIZE": str(pbuf_pool_size),
|
||||
"MEMP_NUM_TCP_PCB": str(tcp_sockets),
|
||||
"MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp),
|
||||
"MEMP_NUM_UDP_PCB": str(udp_sockets),
|
||||
}
|
||||
lwip_defines = build_lwip_defines(tcp_sockets, udp_sockets, listening_tcp)
|
||||
|
||||
# Store for copy_files() to generate the header
|
||||
CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines
|
||||
@@ -500,7 +538,8 @@ def _configure_lwip() -> None:
|
||||
udp_min = " (min)" if udp_sockets > sc.udp else ""
|
||||
listen_min = " (min)" if listening_tcp > sc.tcp_listen else ""
|
||||
_LOGGER.info(
|
||||
"Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]",
|
||||
"Configuring lwIP: %d byte heap; TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]",
|
||||
LWIP_MEM_SIZE,
|
||||
tcp_sockets,
|
||||
tcp_min,
|
||||
sc.tcp_details,
|
||||
@@ -521,7 +560,7 @@ def _generate_lwipopts_h() -> None:
|
||||
in the build directory, and a pre-build script injects this directory
|
||||
into the compiler include path before the framework's own include dir.
|
||||
"""
|
||||
from jinja2 import Environment
|
||||
from jinja2 import Environment, StrictUndefined
|
||||
|
||||
lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS)
|
||||
if not lwip_defines:
|
||||
@@ -534,7 +573,10 @@ def _generate_lwipopts_h() -> None:
|
||||
template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
jinja_env = Environment(keep_trailing_newline=True)
|
||||
# StrictUndefined: a placeholder with no value would otherwise render
|
||||
# empty, emitting a bare #define that compiles and silently means
|
||||
# something else in lwIP's config.
|
||||
jinja_env = Environment(keep_trailing_newline=True, undefined=StrictUndefined)
|
||||
template = jinja_env.from_string(template_text)
|
||||
content = template.render(**lwip_defines)
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ RP2_BOARD_PINS = {
|
||||
{%- endfor %}
|
||||
}
|
||||
|
||||
# RP2350 boards carry a {{ rp2350_die_key | repr }} key holding the die letter:
|
||||
# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B
|
||||
# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu
|
||||
# choice and so is not known here. The key is absent on non-RP2350 boards.
|
||||
BOARDS = {
|
||||
{%- for name, info in boards %}
|
||||
{{ name | repr }}: {
|
||||
|
||||
@@ -1533,6 +1533,10 @@ RP2_BOARD_PINS = {
|
||||
},
|
||||
}
|
||||
|
||||
# RP2350 boards carry a 'die' key holding the die letter:
|
||||
# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B
|
||||
# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu
|
||||
# choice and so is not known here. The key is absent on non-RP2350 boards.
|
||||
BOARDS = {
|
||||
"0xcb_helios": {
|
||||
"name": "0xCB Helios",
|
||||
@@ -1548,6 +1552,7 @@ BOARDS = {
|
||||
"name": "MyMakers RP2350B",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"MyRP_bot": {
|
||||
"name": "MyMakers RP2040",
|
||||
@@ -1588,11 +1593,13 @@ BOARDS = {
|
||||
"name": "Adafruit Feather RP2350 Adalogger",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"adafruit_feather_rp2350_hstx": {
|
||||
"name": "Adafruit Feather RP2350 HSTX",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"adafruit_feather_scorpio": {
|
||||
"name": "Adafruit Feather RP2040 SCORPIO",
|
||||
@@ -1618,6 +1625,7 @@ BOARDS = {
|
||||
"name": "Adafruit Fruit Jam RP2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"adafruit_itsybitsy": {
|
||||
"name": "Adafruit ItsyBitsy RP2040",
|
||||
@@ -1643,6 +1651,7 @@ BOARDS = {
|
||||
"name": "Adafruit Metro RP2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"adafruit_qtpy": {
|
||||
"name": "Adafruit QT Py RP2040",
|
||||
@@ -1763,16 +1772,19 @@ BOARDS = {
|
||||
"name": "iLabs Challenger 2350 BConnect",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"challenger_2350_nbiot": {
|
||||
"name": "iLabs Challenger 2350 NB-IoT",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"challenger_2350_wifi6_ble5": {
|
||||
"name": "iLabs Challenger 2350 WiFi/BLE",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"challenger_nb_2040_wifi": {
|
||||
"name": "iLabs Challenger NB 2040 WiFi",
|
||||
@@ -1788,6 +1800,7 @@ BOARDS = {
|
||||
"name": "Cytron IRIV IO Controller",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"cytron_maker_nano_rp2040": {
|
||||
"name": "Cytron Maker Nano RP2040",
|
||||
@@ -1808,6 +1821,7 @@ BOARDS = {
|
||||
"name": "Cytron Motion 2350 Pro",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"datanoisetv_picoadk": {
|
||||
"name": "DatanoiseTV PicoADK",
|
||||
@@ -1818,6 +1832,7 @@ BOARDS = {
|
||||
"name": "DatanoiseTV PicoADK v2",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"degz_suibo": {
|
||||
"name": "Degz Robotics Suibo RP2040",
|
||||
@@ -1863,6 +1878,7 @@ BOARDS = {
|
||||
"name": "Generic RP2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": None,
|
||||
},
|
||||
"groundstudio_marble_pico": {
|
||||
"name": "GroundStudio Marble Pico",
|
||||
@@ -1873,6 +1889,7 @@ BOARDS = {
|
||||
"name": "iLabs CPico 2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"ilabs_rpico32": {
|
||||
"name": "iLabs RPICO32",
|
||||
@@ -1888,6 +1905,7 @@ BOARDS = {
|
||||
"name": "Architeuthis Flux Jumperless V5",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"melopero_cookie_rp2040": {
|
||||
"name": "Melopero Cookie RP2040",
|
||||
@@ -1928,16 +1946,19 @@ BOARDS = {
|
||||
"name": "Olimex Pico2BB48",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"olimex_pico2xl": {
|
||||
"name": "Olimex Pico2XL",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"olimex_pico2xxl": {
|
||||
"name": "Olimex Pico2XXL",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"olimex_rp2040pico30": {
|
||||
"name": "Olimex RP2040-Pico30",
|
||||
@@ -1963,6 +1984,7 @@ BOARDS = {
|
||||
"name": "Pimoroni Explorer",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"pimoroni_pga2040": {
|
||||
"name": "Pimoroni PGA2040",
|
||||
@@ -1973,16 +1995,19 @@ BOARDS = {
|
||||
"name": "Pimoroni PGA2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"pimoroni_pico_plus_2": {
|
||||
"name": "Pimoroni PicoPlus2",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"pimoroni_pico_plus_2w": {
|
||||
"name": "Pimoroni PicoPlus2W",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
"wifi": True,
|
||||
"max_virtual_pin": 64,
|
||||
},
|
||||
@@ -1995,11 +2020,13 @@ BOARDS = {
|
||||
"name": "Pimoroni Plasma2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"pimoroni_plasma2350w": {
|
||||
"name": "Pimoroni Plasma2350W",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
"wifi": True,
|
||||
},
|
||||
"pimoroni_servo2040": {
|
||||
@@ -2016,6 +2043,7 @@ BOARDS = {
|
||||
"name": "Pimoroni Tiny2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"pintronix_pinmax": {
|
||||
"name": "Pintronix PinMax",
|
||||
@@ -2046,11 +2074,13 @@ BOARDS = {
|
||||
"name": "Raspberry Pi Pico 2",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"rpipico2w": {
|
||||
"name": "Raspberry Pi Pico 2W",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
"wifi": True,
|
||||
"max_virtual_pin": 64,
|
||||
},
|
||||
@@ -2085,6 +2115,7 @@ BOARDS = {
|
||||
"name": "Seeed XIAO RP2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"silicognition_rp2040_shim": {
|
||||
"name": "Silicognition RP2040-Shim",
|
||||
@@ -2100,6 +2131,7 @@ BOARDS = {
|
||||
"name": "Soldered Electronics NULA RP2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
"wifi": True,
|
||||
},
|
||||
"solderparty_rp2040_stamp": {
|
||||
@@ -2111,21 +2143,25 @@ BOARDS = {
|
||||
"name": "Solder Party RP2350 Stamp",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"solderparty_rp2350_stamp_xl": {
|
||||
"name": "Solder Party RP2350 Stamp XL",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"sparkfun_iotnode_lorawanrp2350": {
|
||||
"name": "SparkFun IoT Node LoRaWAN",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"sparkfun_iotredboard_rp2350": {
|
||||
"name": "SparkFun IoT RedBoard RP2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
"wifi": True,
|
||||
},
|
||||
"sparkfun_micromodrp2040": {
|
||||
@@ -2142,6 +2178,7 @@ BOARDS = {
|
||||
"name": "SparkFun ProMicro RP2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"sparkfun_thingplusrp2040": {
|
||||
"name": "SparkFun Thing Plus RP2040",
|
||||
@@ -2152,6 +2189,7 @@ BOARDS = {
|
||||
"name": "SparkFun Thing Plus RP2350",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
"wifi": True,
|
||||
"max_virtual_pin": 64,
|
||||
},
|
||||
@@ -2159,6 +2197,7 @@ BOARDS = {
|
||||
"name": "SparkFun XRP Controller",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
"wifi": True,
|
||||
"max_virtual_pin": 64,
|
||||
},
|
||||
@@ -2233,32 +2272,38 @@ BOARDS = {
|
||||
"name": "Waveshare RP2350 LCD 0.96",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"waveshare_rp2350_pizero": {
|
||||
"name": "Waveshare RP2350 PiZero",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"waveshare_rp2350_plus": {
|
||||
"name": "Waveshare RP2350 Plus",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"waveshare_rp2350_zero": {
|
||||
"name": "Waveshare RP2350 Zero",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"waveshare_rp2350b_plus_w": {
|
||||
"name": "Waveshare RP2350B Plus W",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
"wifi": True,
|
||||
},
|
||||
"weact_rp2350b": {
|
||||
"name": "WeAct Studio RP2350B Core Board",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
"die": "B",
|
||||
},
|
||||
"wiznet_5100s_evb_pico": {
|
||||
"name": "WIZnet W5100S-EVB-Pico",
|
||||
@@ -2269,6 +2314,7 @@ BOARDS = {
|
||||
"name": "WIZnet W5100S-EVB-Pico2",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"wiznet_5500_evb_pico": {
|
||||
"name": "WIZnet W5500-EVB-Pico",
|
||||
@@ -2279,6 +2325,7 @@ BOARDS = {
|
||||
"name": "WIZnet W5500-EVB-Pico2",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"wiznet_55rp20_evb_pico": {
|
||||
"name": "WIZnet W55RP20-EVB-Pico",
|
||||
@@ -2294,6 +2341,7 @@ BOARDS = {
|
||||
"name": "WIZnet W6300-EVB-Pico2",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 29,
|
||||
"die": "A",
|
||||
},
|
||||
"wiznet_wizfi360_evb_pico": {
|
||||
"name": "WIZnet WizFi360-EVB-Pico",
|
||||
|
||||
@@ -37,13 +37,23 @@ MCU_MAX_PIN = {
|
||||
"rp2350": 47, # GPIO 0-47 (RP2350B; A-die boards are narrowed to 29 below)
|
||||
}
|
||||
DEFAULT_MAX_PIN = 29
|
||||
# The RP2350 comes in two die variants: RP2350A exposes GPIO 0-29, RP2350B
|
||||
# GPIO 0-47. Variant headers declare the die via PICO_RP2350A (1 = A, 0 = B).
|
||||
# The RP2350 currently comes in two die variants: RP2350A exposes GPIO 0-29,
|
||||
# RP2350B GPIO 0-47. Variant headers declare the die via PICO_RP2350A
|
||||
# (1 = A, 0 = B).
|
||||
RP2350_DIE_A = "A"
|
||||
RP2350_DIE_B = "B"
|
||||
RP2350A_MAX_PIN = 29
|
||||
# Key recording the die letter on RP2350 board entries. Holds a letter rather
|
||||
# than a bool so a future die can be named instead of forced into "not A".
|
||||
RP2350_DIE_KEY = "die"
|
||||
|
||||
PIN_DEFINE_RE = re.compile(r"#define\s+PIN_(\w+)\s+\((\d+)u\)")
|
||||
# Accepts the literal forms seen in these headers: 1, (1), 1u, (1u)
|
||||
RP2350A_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350A\s+(\S+)")
|
||||
# Only PICO_RP2350A exists today. A define for any other die letter means the
|
||||
# A/B assumption below no longer holds. The trailing \b keeps this from
|
||||
# matching unrelated names such as PICO_RP2350_A2_SUPPORTED.
|
||||
OTHER_DIE_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350(?!A\b)([B-Z])\b")
|
||||
RP2350A_MENU_PLACEHOLDER = "__PICO_RP2350A"
|
||||
|
||||
|
||||
@@ -62,23 +72,30 @@ def parse_variant_pins(variant_dir: Path) -> dict[str, int]:
|
||||
return pins
|
||||
|
||||
|
||||
def parse_variant_is_rp2350a(variant_dir: Path) -> bool:
|
||||
"""Return True if the variant declares an RP2350A die (GPIO 0-29 only).
|
||||
def parse_variant_rp2350_die(variant_dir: Path) -> str | None:
|
||||
"""Return the RP2350 die letter the variant declares, or None if unknown.
|
||||
|
||||
Generic boards leave the die a build-time menu choice (PICO_RP2350A is set
|
||||
to a __PICO_RP2350A placeholder rather than a literal); those return False
|
||||
so they keep the permissive B-die pin range.
|
||||
to a __PICO_RP2350A placeholder rather than a literal); those return None,
|
||||
meaning the die is genuinely unknown at code generation time. They keep the
|
||||
permissive B-die pin range, but that is a fallback and must not be recorded
|
||||
as a known die.
|
||||
|
||||
A missing or unrecognized define raises: silently treating it as B-die
|
||||
would widen pin validation back to GPIO 47 on A-die boards, so a framework
|
||||
bump that changes the header format must fail loudly here instead.
|
||||
bump that changes the header format must fail loudly here instead. The same
|
||||
goes for a die beyond A and B: PICO_RP2350A is a yes/no answer about the A
|
||||
die, so "not A" can only be read as B while A and B are the whole family.
|
||||
"""
|
||||
header = variant_dir / "pins_arduino.h"
|
||||
match = (
|
||||
RP2350A_DEFINE_RE.search(header.read_text(encoding="utf-8"))
|
||||
if header.exists()
|
||||
else None
|
||||
)
|
||||
text = header.read_text(encoding="utf-8") if header.exists() else ""
|
||||
if other_die := OTHER_DIE_DEFINE_RE.search(text):
|
||||
raise ValueError(
|
||||
f"{header}: found a PICO_RP2350{other_die.group(1)} define; the "
|
||||
"RP2350 gained a die beyond A and B, so PICO_RP2350A being 0 no "
|
||||
"longer means the B die"
|
||||
)
|
||||
match = RP2350A_DEFINE_RE.search(text)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"{header}: no PICO_RP2350A define found; cannot classify the "
|
||||
@@ -86,14 +103,14 @@ def parse_variant_is_rp2350a(variant_dir: Path) -> bool:
|
||||
)
|
||||
value = match.group(1)
|
||||
if value == RP2350A_MENU_PLACEHOLDER:
|
||||
return False
|
||||
return None
|
||||
literal = value.strip("()u")
|
||||
if not literal.isdigit():
|
||||
raise ValueError(
|
||||
f"{header}: unrecognized PICO_RP2350A value {value!r}; cannot "
|
||||
"classify the RP2350 die (A exposes GPIO 0-29, B exposes GPIO 0-47)"
|
||||
)
|
||||
return int(literal) == 1
|
||||
return RP2350_DIE_A if int(literal) == 1 else RP2350_DIE_B
|
||||
|
||||
|
||||
def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]:
|
||||
@@ -104,7 +121,7 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]:
|
||||
board_pins = {}
|
||||
boards = {}
|
||||
variant_pins_cache: dict[str, dict[str, int]] = {}
|
||||
variant_rp2350a_cache: dict[str, bool] = {}
|
||||
variant_die_cache: dict[str, str | None] = {}
|
||||
|
||||
for json_file in sorted(json_dir.glob("*.json")):
|
||||
board_name = json_file.stem
|
||||
@@ -123,12 +140,14 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]:
|
||||
has_wifi = "PICO_CYW43_SUPPORTED=1" in extra_flags
|
||||
|
||||
max_pin = MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN)
|
||||
die: str | None = None
|
||||
if mcu == "rp2350":
|
||||
if variant not in variant_rp2350a_cache:
|
||||
variant_rp2350a_cache[variant] = parse_variant_is_rp2350a(
|
||||
if variant not in variant_die_cache:
|
||||
variant_die_cache[variant] = parse_variant_rp2350_die(
|
||||
variants_dir / variant
|
||||
)
|
||||
if variant_rp2350a_cache[variant]:
|
||||
die = variant_die_cache[variant]
|
||||
if die == RP2350_DIE_A:
|
||||
max_pin = RP2350A_MAX_PIN
|
||||
|
||||
board_entry: dict = {
|
||||
@@ -136,6 +155,10 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]:
|
||||
"mcu": mcu,
|
||||
"max_pin": max_pin,
|
||||
}
|
||||
if mcu == "rp2350":
|
||||
# Recorded explicitly because max_pin cannot express the die:
|
||||
# 29 also means RP2040, and 47 also means "die not known yet".
|
||||
board_entry[RP2350_DIE_KEY] = die
|
||||
if has_wifi:
|
||||
board_entry["wifi"] = True
|
||||
boards[board_name] = board_entry
|
||||
@@ -218,6 +241,7 @@ def generate(arduino_pico_path: Path) -> str:
|
||||
cyw43_gpio_offset=CYW43_GPIO_OFFSET,
|
||||
cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1,
|
||||
default_max_pin=DEFAULT_MAX_PIN,
|
||||
rp2350_die_key=RP2350_DIE_KEY,
|
||||
board_pins=sorted(board_pins.items()),
|
||||
boards=sorted(boards.items()),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "crash_handler.h"
|
||||
#endif
|
||||
|
||||
#include "hardware/clocks.h"
|
||||
#include "hardware/watchdog.h"
|
||||
|
||||
// Empty rp2 namespace block to satisfy ci-custom's lint_namespace check.
|
||||
@@ -33,7 +34,8 @@ void arch_init() {
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); }
|
||||
// clock_get_hz(clk_sys) is the SDK query for the current system clock frequency in Hz.
|
||||
uint32_t arch_get_cpu_freq_hz() { return clock_get_hz(clk_sys); }
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
|
||||
@@ -20,13 +20,24 @@
|
||||
#undef TCP_WND
|
||||
#define TCP_WND {{ TCP_WND }}
|
||||
|
||||
// Queued segment limits: derived from 4xMSS buffer size, matching ESP32
|
||||
// Per-PCB send queue: derived from 4xMSS buffer size, matching ESP32
|
||||
#undef TCP_SND_QUEUELEN
|
||||
#define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }}
|
||||
|
||||
// Segment pool: global across every PCB, so it is sized above the per-PCB
|
||||
// queue length rather than equal to it. lwIP's sanity check only requires
|
||||
// >= TCP_SND_QUEUELEN, which is the floor for a single connection.
|
||||
#undef MEMP_NUM_TCP_SEG
|
||||
#define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }}
|
||||
|
||||
// lwIP heap backing PBUF_RAM, which is what tcp_write() copies into.
|
||||
// Raised from arduino-pico's 16KB: TCP_OVERSIZE is TCP_MSS, so a single PCB
|
||||
// at a full TCP_SND_BUF pins about 6KB. Two of those left the 16KB heap at
|
||||
// 75%, and mem.c is first-fit, so the largest contiguous run ran out well
|
||||
// before the total did.
|
||||
#undef MEM_SIZE
|
||||
#define MEM_SIZE {{ MEM_SIZE }}
|
||||
|
||||
// Packet buffer pool: 16 matches ESP32 (down from 24)
|
||||
#undef PBUF_POOL_SIZE
|
||||
#define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from collections.abc import Callable, MutableMapping
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["rp2"]
|
||||
@@ -8,6 +11,15 @@ CODEOWNERS = ["@bdraco"]
|
||||
|
||||
CONF_RP2040_BLE_ID = "rp2040_ble_id"
|
||||
|
||||
KEY_RP2040_BLE = "rp2040_ble"
|
||||
KEY_USED_CONNECTION_SLOTS = "used_connection_slots"
|
||||
|
||||
# Hard platform cap on concurrent GATT connections: the BTstack pool overrides
|
||||
# in btstack_memory.cpp are sized from ESPHOME_BLE_GATT_CLIENT_COUNT with this
|
||||
# as the ceiling. 3 matches the esp32 default and stays within the
|
||||
# controller's resources (MAX_NR_CONTROLLER_ACL_BUFFERS 3).
|
||||
MAX_CONNECTIONS = 3
|
||||
|
||||
rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble")
|
||||
RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component)
|
||||
|
||||
@@ -30,13 +42,67 @@ def _validate_board(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_board
|
||||
def consume_connection_slots(
|
||||
value: int, consumer: str
|
||||
) -> Callable[[MutableMapping], MutableMapping]:
|
||||
"""Reserve BLE connection slots for a component (the esp32_ble pattern);
|
||||
the total is checked against MAX_CONNECTIONS in final validation."""
|
||||
|
||||
def _consume_connection_slots(config: MutableMapping) -> MutableMapping:
|
||||
data: dict = CORE.data.setdefault(KEY_RP2040_BLE, {})
|
||||
slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, [])
|
||||
slots.extend([consumer] * value)
|
||||
return config
|
||||
|
||||
return _consume_connection_slots
|
||||
|
||||
|
||||
def validate_connection_slots() -> None:
|
||||
"""Fail when consumers claimed more slots than the platform cap."""
|
||||
# Skip in testing mode to allow component grouping (esp32_ble parity).
|
||||
if CORE.testing_mode:
|
||||
return
|
||||
used = CORE.data.get(KEY_RP2040_BLE, {}).get(KEY_USED_CONNECTION_SLOTS, [])
|
||||
if len(used) > MAX_CONNECTIONS:
|
||||
raise cv.Invalid(
|
||||
f"BLE components require {len(used)} connection slots but the "
|
||||
f"rp2 maximum is {MAX_CONNECTIONS}. "
|
||||
f"Components: {', '.join(used)}"
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
_validate_board(config)
|
||||
validate_connection_slots()
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
# Once per registered scan listener; sizes the controller's StaticVector
|
||||
# listener storage.
|
||||
request_scan_listener_slot = cg.slot_counter("RP2040_BLE_SCAN_LISTENER_COUNT")
|
||||
|
||||
# The four btstack_memory accessors whose static pools are baked into the
|
||||
# prebuilt liblwip-bt.a; every internal use crosses an object boundary in the
|
||||
# archive, so --wrap intercepts them all (see btstack_memory.cpp).
|
||||
_BTSTACK_POOL_SYMBOLS = (
|
||||
"btstack_memory_gatt_client_get",
|
||||
"btstack_memory_gatt_client_free",
|
||||
"btstack_memory_hci_connection_get",
|
||||
"btstack_memory_hci_connection_free",
|
||||
)
|
||||
|
||||
|
||||
def add_btstack_pool_overrides() -> None:
|
||||
"""Emit the --wrap flags that swap the prebuilt BTstack pools for the
|
||||
ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in btstack_memory.cpp. Called by
|
||||
bluetooth_connection when a second GATT backend registers; idempotent
|
||||
(build flags are a set)."""
|
||||
for symbol in _BTSTACK_POOL_SYMBOLS:
|
||||
cg.add_build_flag(f"-Wl,--wrap={symbol}")
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Replaces the gatt_client / hci_connection static pools baked into
|
||||
// arduino-pico's prebuilt liblwip-bt.a (built with MAX_NR_GATT_CLIENTS 1,
|
||||
// MAX_NR_HCI_CONNECTIONS 2) with pools sized from ESPHOME_BLE_GATT_CLIENT_COUNT.
|
||||
// add_btstack_pool_overrides() in this component's codegen emits the matching
|
||||
// -Wl,--wrap flags, requested by bluetooth_connection when more than one GATT
|
||||
// backend registers; single-backend builds emit no flags and this file
|
||||
// compiles to nothing, leaving the prebuilt pools in charge. Layout safety:
|
||||
// the framework defines ENABLE_CLASSIC / ENABLE_BLE for every user TU
|
||||
// whenever PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH is set (this component
|
||||
// always sets it), so sizeof() here matches the archive.
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) && (ESPHOME_BLE_GATT_CLIENT_COUNT > 1)
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::rp2040_ble {
|
||||
namespace {
|
||||
|
||||
// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or
|
||||
// a changed ENABLE_* macro) shifting the struct layout must fail the build
|
||||
// here, not overrun the pool blocks at runtime. Sizes differ per core
|
||||
// architecture (measured from each archive's own storage symbols). GCC only:
|
||||
// the clang-tidy frontend lays these structs out differently, and the guard
|
||||
// targets the real link.
|
||||
#ifndef __clang__
|
||||
#ifdef __riscv
|
||||
static_assert(sizeof(gatt_client_t) == 140 && sizeof(hci_connection_t) == 3740, "BTstack layout changed");
|
||||
#else
|
||||
static_assert(sizeof(gatt_client_t) == 128 && sizeof(hci_connection_t) == 3688, "BTstack layout changed");
|
||||
#endif
|
||||
#endif // __clang__
|
||||
|
||||
// One gatt_client_t per configured connection slot. An hci_connection_t is
|
||||
// held from gap_connect() to DISCONNECTION_COMPLETE (scanning holds none);
|
||||
// +1 mirrors the prebuilt library's own headroom (2 connections for 1 GATT
|
||||
// client) so a teardown/re-connect overlap can never starve a slot.
|
||||
constexpr int HCI_CONNECTION_POOL_SIZE = ESPHOME_BLE_GATT_CLIENT_COUNT + 1;
|
||||
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp)
|
||||
gatt_client_t gatt_client_storage[ESPHOME_BLE_GATT_CLIENT_COUNT];
|
||||
btstack_memory_pool_t gatt_client_pool;
|
||||
hci_connection_t hci_connection_storage[HCI_CONNECTION_POOL_SIZE];
|
||||
btstack_memory_pool_t hci_connection_pool;
|
||||
|
||||
// Static init: pool_create only links a free list through its own storage,
|
||||
// and BTstack first allocates long after static construction.
|
||||
struct PoolInit {
|
||||
PoolInit() {
|
||||
btstack_memory_pool_create(&gatt_client_pool, gatt_client_storage, ESPHOME_BLE_GATT_CLIENT_COUNT,
|
||||
sizeof(gatt_client_t));
|
||||
btstack_memory_pool_create(&hci_connection_pool, hci_connection_storage, HCI_CONNECTION_POOL_SIZE,
|
||||
sizeof(hci_connection_t));
|
||||
}
|
||||
} pool_init;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp)
|
||||
|
||||
} // namespace
|
||||
|
||||
// Exact semantics of btstack_memory.c's static-pool arm: zeroed block on
|
||||
// success, NULL when exhausted; free returns the block to the pool. The
|
||||
// prebuilt pools stay resident in .bss (~7.4 KB, kept live by
|
||||
// btstack_memory_init in the archive) — dead weight here, not a leak.
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" gatt_client_t *__real_btstack_memory_gatt_client_get(void);
|
||||
extern "C" void __real_btstack_memory_gatt_client_free(gatt_client_t *gatt_client);
|
||||
extern "C" hci_connection_t *__real_btstack_memory_hci_connection_get(void);
|
||||
extern "C" void __real_btstack_memory_hci_connection_free(hci_connection_t *hci_connection);
|
||||
|
||||
namespace {
|
||||
// Fails the link if the corresponding --wrap flag is missing: __real_* only
|
||||
// exists while --wrap is in effect, and each wrap function anchors its own
|
||||
// symbol so dropping any single flag fails loudly. A code reference is used
|
||||
// because the framework links with --gc-sections, which discards an
|
||||
// unreferenced data anchor regardless of [[gnu::used]] (and this toolchain
|
||||
// does not emit SHF_GNU_RETAIN for [[gnu::retain]]).
|
||||
template<typename T> void anchor_wrap(T *symbol) { asm volatile("" ::"r"(symbol)); }
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
gatt_client_t *__wrap_btstack_memory_gatt_client_get(void) {
|
||||
anchor_wrap(&__real_btstack_memory_gatt_client_get);
|
||||
void *buffer = btstack_memory_pool_get(&gatt_client_pool);
|
||||
if (buffer != nullptr) {
|
||||
memset(buffer, 0, sizeof(gatt_client_t));
|
||||
}
|
||||
return static_cast<gatt_client_t *>(buffer);
|
||||
}
|
||||
|
||||
void __wrap_btstack_memory_gatt_client_free(gatt_client_t *gatt_client) {
|
||||
anchor_wrap(&__real_btstack_memory_gatt_client_free);
|
||||
btstack_memory_pool_free(&gatt_client_pool, gatt_client);
|
||||
}
|
||||
|
||||
hci_connection_t *__wrap_btstack_memory_hci_connection_get(void) {
|
||||
anchor_wrap(&__real_btstack_memory_hci_connection_get);
|
||||
void *buffer = btstack_memory_pool_get(&hci_connection_pool);
|
||||
if (buffer != nullptr) {
|
||||
memset(buffer, 0, sizeof(hci_connection_t));
|
||||
}
|
||||
return static_cast<hci_connection_t *>(buffer);
|
||||
}
|
||||
|
||||
void __wrap_btstack_memory_hci_connection_free(hci_connection_t *hci_connection) {
|
||||
anchor_wrap(&__real_btstack_memory_hci_connection_free);
|
||||
btstack_memory_pool_free(&hci_connection_pool, hci_connection);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
|
||||
} // namespace esphome::rp2040_ble
|
||||
|
||||
#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT && ESPHOME_BLE_GATT_CLIENT_COUNT > 1
|
||||
@@ -165,7 +165,8 @@ bool RP2BLETracker::request_scan_mode(bool active) {
|
||||
if (this->scan_active_ == active)
|
||||
return true;
|
||||
this->scan_active_ = active;
|
||||
ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// V: the proxy's "Setting scanner mode" line already narrates this at D.
|
||||
ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// Apply to a running scan by restarting the CONTROLLER scan with the new
|
||||
// mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the
|
||||
// scan logically continues, only the request mode changes), no period reset.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
@@ -6,9 +6,13 @@ from esphome.components import esp32, network, psram, socket, wifi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_FORMAT,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_SOURCE,
|
||||
CONF_TASK_STACK_IN_PSRAM,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import TemplateArgsType
|
||||
@@ -20,12 +24,16 @@ CODEOWNERS = ["@kahrendt"]
|
||||
DEPENDENCIES = ["network"]
|
||||
DOMAIN = "sendspin"
|
||||
|
||||
CONF_DISPLAY_OFFSET = "display_offset"
|
||||
CONF_SENDSPIN_ID = "sendspin_id"
|
||||
|
||||
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
||||
CONF_FIXED_DELAY = "fixed_delay"
|
||||
CONF_DECODE_MEMORY = "decode_memory"
|
||||
|
||||
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
|
||||
MAX_ARTWORK_SLOTS = 4
|
||||
|
||||
# sendspin-cpp library lives in the global `sendspin` namespace.
|
||||
sendspin_library_ns = cg.global_ns.namespace("sendspin")
|
||||
|
||||
@@ -36,9 +44,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
|
||||
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
|
||||
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
||||
|
||||
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
||||
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
||||
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
||||
IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP")
|
||||
|
||||
SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True)
|
||||
IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM")
|
||||
IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST")
|
||||
|
||||
# Library Structs
|
||||
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
|
||||
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
|
||||
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
|
||||
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
|
||||
|
||||
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
|
||||
# preference for the player role's transfer buffers.
|
||||
@@ -76,6 +95,7 @@ class SendspinConfiguration:
|
||||
player_support: bool = False
|
||||
visualizer_support: bool = False
|
||||
|
||||
artwork_preferences: list[ConfigType] = field(default_factory=list)
|
||||
player_config: ConfigType | None = None
|
||||
|
||||
|
||||
@@ -110,6 +130,22 @@ def request_visualizer_support() -> None:
|
||||
_get_data().visualizer_support = True
|
||||
|
||||
|
||||
def register_artwork_preference(config: ConfigType) -> int:
|
||||
"""Register an artwork slot preference and return the slot it was given.
|
||||
|
||||
A slot is a preference's position in the list, which is also the order the roles are
|
||||
advertised to the server in.
|
||||
"""
|
||||
request_artwork_support()
|
||||
preferences = _get_data().artwork_preferences
|
||||
if len(preferences) >= MAX_ARTWORK_SLOTS:
|
||||
raise cv.Invalid(
|
||||
f"Too many Sendspin image slots. Maximum is {MAX_ARTWORK_SLOTS}."
|
||||
)
|
||||
preferences.append(config)
|
||||
return len(preferences) - 1
|
||||
|
||||
|
||||
def register_player_config(config: ConfigType) -> None:
|
||||
"""Register the player role config from the media source subcomponent."""
|
||||
data = _get_data()
|
||||
@@ -211,6 +247,29 @@ async def to_code(config: ConfigType) -> None:
|
||||
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
|
||||
if data.artwork_support:
|
||||
cg.add_define("USE_SENDSPIN_ARTWORK", True)
|
||||
|
||||
# require_frame_done is always on: SendspinImageSlot always acks a delivery, either
|
||||
# immediately or from the transition_finished action.
|
||||
preference_structs = [
|
||||
cg.StructInitializer(
|
||||
ImageSlotPreference,
|
||||
("source", pref[CONF_SOURCE]),
|
||||
("format", pref[CONF_FORMAT]),
|
||||
("width", pref[CONF_WIDTH]),
|
||||
("height", pref[CONF_HEIGHT]),
|
||||
("require_frame_done", True),
|
||||
("display_offset_ms", pref[CONF_DISPLAY_OFFSET]),
|
||||
)
|
||||
for pref in data.artwork_preferences
|
||||
]
|
||||
|
||||
artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM))
|
||||
artwork_config = cg.StructInitializer(
|
||||
ArtworkRoleConfig,
|
||||
("preferred_formats", preference_structs),
|
||||
("psram_stack", artwork_psram_stack),
|
||||
)
|
||||
cg.add(var.set_artwork_config(artwork_config))
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Sendspin image platform."""
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import runtime_image
|
||||
from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FORMAT,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
CONF_RESIZE,
|
||||
CONF_SOURCE,
|
||||
CONF_TYPE,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DISPLAY_OFFSET,
|
||||
CONF_SENDSPIN_ID,
|
||||
IMAGE_FORMAT_BMP,
|
||||
IMAGE_FORMAT_JPEG,
|
||||
IMAGE_FORMAT_PNG,
|
||||
IMAGE_SOURCE_ALBUM,
|
||||
IMAGE_SOURCE_ARTIST,
|
||||
SendspinHub,
|
||||
register_artwork_preference,
|
||||
sendspin_ns,
|
||||
)
|
||||
|
||||
AUTO_LOAD = ["runtime_image"]
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
DEPENDENCIES = ["sendspin"]
|
||||
|
||||
# runtime_image refuses to size a buffer beyond this, so anything larger fails at setup rather
|
||||
# than at validation. The library's ImageSlotPreference width/height fields are uint16_t, which
|
||||
# is the looser of the two bounds.
|
||||
MAX_IMAGE_DIMENSION = 32767
|
||||
|
||||
# Sanity bound for display_offset; the library field is int32_t milliseconds and offsets beyond
|
||||
# a few seconds around the track boundary are meaningless.
|
||||
MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60)
|
||||
MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60)
|
||||
|
||||
CONF_SLOT = "slot"
|
||||
CONF_CURRENT_IMAGE = "current_image"
|
||||
CONF_TRANSITION_IMAGE = "transition_image"
|
||||
CONF_ON_IMAGE_DISPLAY = "on_image_display"
|
||||
CONF_ON_IMAGE_CLEAR = "on_image_clear"
|
||||
CONF_ON_IMAGE_ERROR = "on_image_error"
|
||||
|
||||
# Map runtime_image's validated format string to the sendspin library's SendspinImageFormat enum.
|
||||
# runtime_image accepts "JPG" as an alias for JPEG, so both keys map to the JPEG enum.
|
||||
_FORMAT_TO_SENDSPIN_ENUM = {
|
||||
"JPEG": IMAGE_FORMAT_JPEG,
|
||||
"JPG": IMAGE_FORMAT_JPEG,
|
||||
"PNG": IMAGE_FORMAT_PNG,
|
||||
"BMP": IMAGE_FORMAT_BMP,
|
||||
}
|
||||
|
||||
# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it
|
||||
# would never receive artwork while still paying for two frame buffers, so it is not offered here.
|
||||
IMAGE_SOURCES = {
|
||||
"ALBUM": IMAGE_SOURCE_ALBUM,
|
||||
"ARTIST": IMAGE_SOURCE_ARTIST,
|
||||
}
|
||||
|
||||
# The platform entry configures an artwork slot; the images it shows are declared inside it. The
|
||||
# slot itself is the automation target (triggers and the transition_finished action).
|
||||
SendspinImageSlot = sendspin_ns.class_(
|
||||
"SendspinImageSlot",
|
||||
cg.Component,
|
||||
cg.Parented.template(SendspinHub),
|
||||
)
|
||||
ArtworkImageView = sendspin_ns.class_("ArtworkImageView", Image_)
|
||||
|
||||
# A dict rather than a bare ID so per-image options can be added later without a new top-level key.
|
||||
_IMAGE_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.declare_id(ArtworkImageView)})
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_IMAGE_DISPLAY,
|
||||
"add_on_image_display_callback",
|
||||
[(cg.uint32, "lateness_ms")],
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_IMAGE_CLEAR, "add_on_image_clear_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_IMAGE_ERROR, "add_on_image_error_callback"),
|
||||
)
|
||||
|
||||
|
||||
def _assign_slot_and_register(config: ConfigType) -> ConfigType:
|
||||
"""Register the artwork preference with the hub and record the slot it was given."""
|
||||
width, height = config[CONF_RESIZE]
|
||||
if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_RESIZE}' width and height must be {MAX_IMAGE_DIMENSION} or less",
|
||||
path=[CONF_RESIZE],
|
||||
)
|
||||
|
||||
config[CONF_SLOT] = register_artwork_preference(
|
||||
{
|
||||
CONF_SOURCE: config[CONF_SOURCE],
|
||||
CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]],
|
||||
CONF_WIDTH: width,
|
||||
CONF_HEIGHT: height,
|
||||
CONF_DISPLAY_OFFSET: config[CONF_DISPLAY_OFFSET].total_milliseconds,
|
||||
}
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# The format, type, resize, transparency, byte order and placeholder keys all describe the slot:
|
||||
# they set what is requested from the server and how it is decoded, not either individual image.
|
||||
# Only the IDs are per-image, so runtime_image_schema declares the slot itself.
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
runtime_image.runtime_image_schema(SendspinImageSlot).extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SendspinImageSlot),
|
||||
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
|
||||
# Narrow runtime_image's format list to what the library can request, so the
|
||||
# accepted set and the enum map below cannot drift apart.
|
||||
cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True),
|
||||
cv.Required(CONF_RESIZE): cv.dimensions,
|
||||
cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA,
|
||||
cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA,
|
||||
cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum(
|
||||
IMAGE_SOURCES, upper=True
|
||||
),
|
||||
# Positive fires on_image_display before the server's display timestamp (negative
|
||||
# delays it), so a cross-fade can straddle the track boundary.
|
||||
cv.Optional(CONF_DISPLAY_OFFSET, default="0ms"): cv.All(
|
||||
cv.time_period,
|
||||
# The library field is whole milliseconds; reject finer values rather than
|
||||
# silently rounding them down to zero.
|
||||
cv.time_period_in_milliseconds_,
|
||||
cv.Range(min=MIN_DISPLAY_OFFSET, max=MAX_DISPLAY_OFFSET),
|
||||
),
|
||||
cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_IMAGE_CLEAR): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation({}),
|
||||
}
|
||||
),
|
||||
runtime_image.validate_runtime_image_settings,
|
||||
cv.only_on_esp32,
|
||||
_assign_slot_and_register,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
settings = await runtime_image.process_runtime_image_config(config)
|
||||
|
||||
def make_view(view_id: ID) -> cg.MockObj:
|
||||
# Views start with no frame; the slot points them at its buffers in setup(). The size is
|
||||
# given up front so the view is well formed before then. LVGL picks it up from the first
|
||||
# lvgl.image.update in on_image_display, not from the widget's initial src: at that point
|
||||
# the view still has no frame, so its descriptor is empty.
|
||||
view = cg.new_Pvariable(
|
||||
view_id,
|
||||
cg.nullptr,
|
||||
settings.width,
|
||||
settings.height,
|
||||
settings.image_type_enum,
|
||||
settings.transparent,
|
||||
)
|
||||
add_metadata(
|
||||
view_id,
|
||||
settings.width,
|
||||
settings.height,
|
||||
config[CONF_TYPE],
|
||||
config[CONF_TRANSPARENCY],
|
||||
)
|
||||
return view
|
||||
|
||||
current_image = make_view(config[CONF_CURRENT_IMAGE][CONF_ID])
|
||||
if settings.placeholder is not None:
|
||||
cg.add(current_image.set_placeholder(settings.placeholder))
|
||||
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
config[CONF_SLOT],
|
||||
current_image,
|
||||
settings.width,
|
||||
settings.height,
|
||||
settings.format_enum,
|
||||
settings.image_type_enum,
|
||||
settings.transparent,
|
||||
settings.byte_order_big_endian,
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
|
||||
|
||||
if (transition_image := config.get(CONF_TRANSITION_IMAGE)) is not None:
|
||||
cg.add(var.set_transition_image(make_view(transition_image[CONF_ID])))
|
||||
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
SendspinImageTransitionFinishedAction = sendspin_ns.class_(
|
||||
"SendspinImageTransitionFinishedAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(SendspinImageSlot),
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"sendspin.image.transition_finished",
|
||||
SendspinImageTransitionFinishedAction,
|
||||
automation.maybe_simple_id(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(SendspinImageSlot),
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def sendspin_image_transition_finished_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> cg.MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "sendspin_image.h"
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
template<typename... Ts>
|
||||
class SendspinImageTransitionFinishedAction final : public Action<Ts...>, public Parented<SendspinImageSlot> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->transition_finished(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,261 @@
|
||||
#include "sendspin_image.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.image";
|
||||
|
||||
// How long a displayed frame may wait for sendspin.image.transition_finished before a warning
|
||||
// names the missing ack. Generous next to a typical fade of a second or two.
|
||||
static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000;
|
||||
|
||||
// THREAD CONTEXT: Main loop. Children set up after the hub, so the artwork role already exists.
|
||||
void SendspinImageSlot::setup() {
|
||||
const size_t frame_size = this->decode_sink_.get_buffer_size(this->width_, this->height_);
|
||||
if (frame_size == 0) {
|
||||
// The sink would refuse a buffer of these dimensions, so every decode would fall back to
|
||||
// allocating one of its own. Fail here instead, where the dimensions are already known.
|
||||
ESP_LOGE(TAG, "Cannot decode artwork at %dx%d", this->width_, this->height_);
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
for (uint8_t *&buffer : this->buffers_) {
|
||||
buffer = allocator.allocate(frame_size);
|
||||
if (buffer == nullptr) {
|
||||
ESP_LOGE(TAG, "Could not allocate %zu bytes for an artwork frame. Largest free block: %zu", frame_size,
|
||||
allocator.get_max_free_block_size());
|
||||
for (uint8_t *&allocated : this->buffers_) {
|
||||
allocator.deallocate(allocated, frame_size);
|
||||
allocated = nullptr;
|
||||
}
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
// Both buffers start black, so a transition has something to fade from before any artwork
|
||||
// has arrived.
|
||||
memset(buffer, 0, frame_size);
|
||||
}
|
||||
|
||||
// Point both views at buffers_[current_index_] rather than the buffer the first decode writes
|
||||
// into, so they name a frame that stays black until artwork arrives.
|
||||
this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
|
||||
if (this->transition_image_ != nullptr) {
|
||||
this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
|
||||
}
|
||||
|
||||
this->parent_->add_image_decode_callback(
|
||||
[this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat) {
|
||||
if (slot == this->slot_)
|
||||
this->on_decode_(data, length);
|
||||
});
|
||||
this->parent_->add_image_display_callback([this](uint8_t slot, uint32_t lateness_ms) {
|
||||
if (slot == this->slot_)
|
||||
this->on_display_(lateness_ms);
|
||||
});
|
||||
this->parent_->add_image_clear_callback([this](uint8_t slot) {
|
||||
if (slot == this->slot_)
|
||||
this->on_clear_();
|
||||
});
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Dedicated artwork decode thread. The data pointer is valid only for this call.
|
||||
void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) {
|
||||
uint8_t *target;
|
||||
{
|
||||
// The lock makes the main loop's last swap of current_index_ visible here. The frame_done gate
|
||||
// is what guarantees the buffer it picks out is not still needed by the main loop.
|
||||
LockGuard lock(this->pending_mutex_);
|
||||
target = this->buffers_[this->current_index_ ^ 1];
|
||||
}
|
||||
|
||||
// The server letterboxes artwork onto a canvas of exactly the requested dimensions, so the sink
|
||||
// is pinned to them: a decode that asks for anything else is a malformed payload and drops the
|
||||
// frame.
|
||||
if (!this->decode_sink_.set_external_buffer(target, this->width_, this->height_)) {
|
||||
// setup() rules this out, but decoding without the handover would allocate a frame-sized
|
||||
// buffer on this thread, which is exactly what the permanent buffers exist to avoid.
|
||||
this->report_error_();
|
||||
return;
|
||||
}
|
||||
|
||||
const bool decoded = this->decode_frame_(data, length, target);
|
||||
// Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is
|
||||
// safe on every path.
|
||||
this->decode_sink_.release();
|
||||
|
||||
if (!decoded) {
|
||||
// The buffer keeps whatever the failed decode painted into it, but no view names it while a
|
||||
// decode can run, so nothing shows it.
|
||||
this->report_error_();
|
||||
return;
|
||||
}
|
||||
|
||||
LockGuard lock(this->pending_mutex_);
|
||||
this->frame_pending_ = true;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Artwork decode thread, with target already handed to the sink.
|
||||
bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const uint8_t *target) {
|
||||
if (!this->decode_sink_.begin_decode(length)) {
|
||||
ESP_LOGE(TAG, "Could not start decode");
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t total_consumed = 0;
|
||||
while (total_consumed < length) {
|
||||
int consumed = this->decode_sink_.feed_data(const_cast<uint8_t *>(data) + total_consumed, length - total_consumed);
|
||||
if (consumed <= 0) {
|
||||
// <0 is a decode error; 0 means the decoder cannot make progress (truncated/corrupt data).
|
||||
ESP_LOGE(TAG, "Decode failed at offset %zu (result %d)", total_consumed, consumed);
|
||||
return false;
|
||||
}
|
||||
total_consumed += consumed;
|
||||
}
|
||||
|
||||
if (!this->decode_sink_.end_decode()) {
|
||||
ESP_LOGE(TAG, "Could not finalize decode");
|
||||
return false;
|
||||
}
|
||||
|
||||
// A decode that asked for other dimensions had the buffer taken away from it, so it painted
|
||||
// nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on
|
||||
// regardless, so the frame is dropped here.
|
||||
return this->decode_sink_.decoded_into(target);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (fired once the slot's offset-shifted display deadline is reached).
|
||||
void SendspinImageSlot::on_display_(uint32_t lateness_ms) {
|
||||
bool frame_ready;
|
||||
{
|
||||
LockGuard lock(this->pending_mutex_);
|
||||
frame_ready = this->frame_pending_;
|
||||
this->frame_pending_ = false;
|
||||
if (frame_ready) {
|
||||
// The decoded frame becomes the current one; the frame it replaces becomes the outgoing
|
||||
// frame, and the next decode target once the transition is acked.
|
||||
this->current_index_ ^= 1;
|
||||
}
|
||||
}
|
||||
if (!frame_ready) {
|
||||
// The decode for this display failed, so there is nothing new to show. The delivery still owes
|
||||
// its ack or the library would withhold every later frame for this slot.
|
||||
this->parent_->artwork_frame_done(this->slot_);
|
||||
return;
|
||||
}
|
||||
|
||||
// The frame this display replaces is only real artwork if something was already on screen.
|
||||
const bool outgoing_is_artwork = this->showing_artwork_;
|
||||
this->showing_artwork_ = true;
|
||||
this->apply_frames_(outgoing_is_artwork);
|
||||
|
||||
// Armed before the trigger fires so an automation that acks synchronously still counts, and armed
|
||||
// for the first frame too so the contract stays uniform: one transition_finished per display.
|
||||
this->transition_pending_ = this->transition_image_ != nullptr;
|
||||
if (this->transition_pending_) {
|
||||
// The library holds back further deliveries until the ack, with no timeout, so an automation
|
||||
// that never reaches the action stalls the slot with nothing in the log. Name the cause after
|
||||
// a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame
|
||||
// that was already acked and superseded.
|
||||
this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() {
|
||||
if (this->transition_pending_) {
|
||||
ESP_LOGW(TAG,
|
||||
"Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until "
|
||||
"sendspin.image.transition_finished runs or the stream is cleared",
|
||||
this->slot_);
|
||||
}
|
||||
});
|
||||
}
|
||||
this->image_display_callback_.call(lateness_ms);
|
||||
if (this->transition_image_ == nullptr) {
|
||||
this->finish_transition_();
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop.
|
||||
void SendspinImageSlot::finish_transition_() {
|
||||
this->transition_pending_ = false;
|
||||
if (this->transition_image_ != nullptr) {
|
||||
// Move it off the buffer the next decode writes into. What it shows does not change: the
|
||||
// buffer it moves to holds the artwork the transition just settled on.
|
||||
this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
|
||||
this->transition_image_->set_showing_artwork(this->showing_artwork_);
|
||||
}
|
||||
// The ack wakes the decode thread, which may start writing buffers_[current_index_ ^ 1] straight
|
||||
// away, so nothing may still name that buffer by the time this runs.
|
||||
this->parent_->artwork_frame_done(this->slot_);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from the sendspin.image.transition_finished action).
|
||||
void SendspinImageSlot::transition_finished() {
|
||||
if (!this->transition_pending_) {
|
||||
return;
|
||||
}
|
||||
this->finish_transition_();
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (fired on stream end or clear for this slot).
|
||||
void SendspinImageSlot::on_clear_() {
|
||||
{
|
||||
LockGuard lock(this->pending_mutex_);
|
||||
// Drop a frame that was decoded but never displayed; its buffer stays the decode target.
|
||||
this->frame_pending_ = false;
|
||||
}
|
||||
// No pixels are touched and the views keep naming the frames they had: a widget goes on drawing
|
||||
// the last artwork until the automation points it elsewhere or hides it. Only the display lambda
|
||||
// path stops drawing the artwork, falling back to the placeholder.
|
||||
this->current_image_->set_showing_artwork(false);
|
||||
if (this->transition_image_ != nullptr) {
|
||||
// Point it away from the decode target, as at setup, so it cannot show a frame being decoded.
|
||||
this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
|
||||
this->transition_image_->set_showing_artwork(false);
|
||||
}
|
||||
this->showing_artwork_ = false;
|
||||
// Drops a running transition. Its automation cannot be cancelled here, so a late
|
||||
// transition_finished() can ack the next stream's first frame early, showing it without its
|
||||
// transition. The ack count stays right.
|
||||
this->transition_pending_ = false;
|
||||
this->image_clear_callback_.call();
|
||||
// A clear is itself a delivery owing exactly one ack, and it supersedes any un-acked frame --
|
||||
// including one whose transition never signalled transition_finished(), so a stalled slot
|
||||
// recovers here.
|
||||
this->parent_->artwork_frame_done(this->slot_);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop.
|
||||
void SendspinImageSlot::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Artwork slot %u:\n"
|
||||
" Dimensions: %dx%d\n"
|
||||
" Frame buffers: 2 x %zu bytes\n"
|
||||
" Transition image: %s",
|
||||
this->slot_, this->width_, this->height_,
|
||||
this->decode_sink_.get_buffer_size(this->width_, this->height_),
|
||||
YESNO(this->transition_image_ != nullptr));
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop.
|
||||
void SendspinImageSlot::apply_frames_(bool transition_is_artwork) {
|
||||
this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
|
||||
this->current_image_->set_showing_artwork(true);
|
||||
if (this->transition_image_ != nullptr) {
|
||||
this->transition_image_->set_frame(this->buffers_[this->current_index_ ^ 1], this->width_, this->height_);
|
||||
this->transition_image_->set_showing_artwork(transition_is_artwork);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Artwork decode thread. Triggers must run on the main loop; defer() is thread-safe
|
||||
// here because the hub enables wake_loop_threadsafe support.
|
||||
void SendspinImageSlot::report_error_() {
|
||||
this->defer([this]() { this->image_error_callback_.call(); });
|
||||
}
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,185 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
|
||||
|
||||
#include "esphome/components/image/image.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <sendspin/artwork_role.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
/// @brief Decode-only RuntimeImage that decodes into a buffer owned by SendspinImageSlot.
|
||||
///
|
||||
/// Runs exclusively on the sendspin library's artwork decode thread. RuntimeImage's decode path
|
||||
/// overwrites the fields the display reads (data_start_/width_/height_), so it must never be the
|
||||
/// object shown on screen.
|
||||
class ArtworkDecodeSink : public runtime_image::RuntimeImage {
|
||||
public:
|
||||
using runtime_image::RuntimeImage::RuntimeImage;
|
||||
|
||||
/// @brief True when the decode ended with the given buffer still in place.
|
||||
///
|
||||
/// An external buffer is dropped rather than resized, so a decode that wanted other dimensions
|
||||
/// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but
|
||||
/// the PNG decoder ignores it and reports success, so the outcome is checked here as well.
|
||||
bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; }
|
||||
};
|
||||
|
||||
/// @brief A non-owning image::Image view over a buffer owned by SendspinImageSlot.
|
||||
///
|
||||
/// Each slot publishes its frames through these: one for the artwork on screen, and optionally a
|
||||
/// second for the outgoing frame during a cross-fade. A view always names a frame, black to begin
|
||||
/// with, so LVGL can be given it as a widget source before any artwork exists. Main loop only.
|
||||
class ArtworkImageView : public image::Image {
|
||||
public:
|
||||
using image::Image::Image;
|
||||
|
||||
void set_frame(const uint8_t *data, int width, int height) {
|
||||
this->data_start_ = data;
|
||||
this->width_ = width;
|
||||
this->height_ = height;
|
||||
#ifdef USE_LVGL
|
||||
// Keep the descriptor LVGL is handed in step with the frame. This does not redraw anything:
|
||||
// only setting a widget's source invalidates it.
|
||||
this->get_lv_image_dsc();
|
||||
#endif
|
||||
}
|
||||
|
||||
/// @brief Records whether the frame on show is real artwork rather than the black it starts as.
|
||||
///
|
||||
/// Only changes what the display lambda path draws. The frame itself is left alone, so anything
|
||||
/// reading the pixels directly (an LVGL widget) keeps drawing the last artwork until it is
|
||||
/// pointed elsewhere.
|
||||
void set_showing_artwork(bool showing_artwork) { this->showing_artwork_ = showing_artwork; }
|
||||
|
||||
void set_placeholder(image::Image *placeholder) { this->placeholder_ = placeholder; }
|
||||
|
||||
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override {
|
||||
if (!this->showing_artwork_) {
|
||||
// Nothing worth showing yet: the placeholder if there is one, otherwise leave the area be
|
||||
// rather than paint a blank frame over it.
|
||||
if (this->placeholder_ != nullptr) {
|
||||
this->placeholder_->draw(x, y, display, color_on, color_off);
|
||||
}
|
||||
return;
|
||||
}
|
||||
image::Image::draw(x, y, display, color_on, color_off);
|
||||
}
|
||||
|
||||
protected:
|
||||
image::Image *placeholder_{nullptr};
|
||||
bool showing_artwork_{false};
|
||||
};
|
||||
|
||||
/// @brief A single artwork slot: owns the frame buffers and publishes them to its image views.
|
||||
///
|
||||
/// BUFFERS: two buffers, allocated zeroed at setup and never freed. One holds the frame the current
|
||||
/// image shows; the other holds the outgoing frame a transition shows, and is where the next
|
||||
/// artwork is decoded. Each display swaps their roles.
|
||||
///
|
||||
/// THREADING: the sendspin library decodes on a dedicated thread and fires display/clear on the
|
||||
/// main loop. Decoding runs into decode_sink_, which writes into the buffer the current image is
|
||||
/// not showing; the swap that puts it on screen happens on the main loop. Every slot enables the
|
||||
/// library's require_frame_done gate, which withholds further deliveries for the slot (buffering
|
||||
/// the newest payload, latest wins) until the hub's artwork_frame_done() runs. That gate is what
|
||||
/// makes two buffers enough: no decode starts while the main loop still needs the outgoing frame.
|
||||
///
|
||||
/// LVGL: publishing a frame to a view updates the descriptor LVGL was handed but does not
|
||||
/// invalidate the widget, so every widget's source must be set again on each display.
|
||||
class SendspinImageSlot : public SendspinChild {
|
||||
public:
|
||||
SendspinImageSlot(uint8_t slot, ArtworkImageView *current_image, int width, int height,
|
||||
runtime_image::ImageFormat format, image::ImageType type, image::Transparency transparency,
|
||||
bool is_big_endian)
|
||||
: decode_sink_(format, type, transparency, nullptr, is_big_endian, width, height),
|
||||
current_image_(current_image),
|
||||
width_(width),
|
||||
height_(height),
|
||||
slot_(slot) {}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
template<typename F> void add_on_image_display_callback(F &&callback) {
|
||||
this->image_display_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_image_clear_callback(F &&callback) {
|
||||
this->image_clear_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_image_error_callback(F &&callback) {
|
||||
this->image_error_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/// @brief Sets the optional view a transition draws the outgoing artwork from.
|
||||
///
|
||||
/// It holds the outgoing frame while a transition is running and the current frame at any other
|
||||
/// time, so it always names a picture and never the frame being decoded.
|
||||
///
|
||||
/// Setting it is also what defers the library ack to transition_finished(): the ack releases the
|
||||
/// outgoing frame to be decoded over, and this view is the only thing that still names it.
|
||||
void set_transition_image(ArtworkImageView *transition_image) { this->transition_image_ = transition_image; }
|
||||
|
||||
/// @brief Signals that the display transition for the last frame has finished.
|
||||
///
|
||||
/// Acks the library so the next artwork can be delivered, which also hands the outgoing frame's
|
||||
/// buffer over to be decoded into. Safe no-op when no transition is pending (e.g. no transition
|
||||
/// image is configured, a clear already ended the transition, or the call is a duplicate). Must
|
||||
/// run on the main loop thread; exposed as the sendspin.image.transition_finished action.
|
||||
void transition_finished();
|
||||
|
||||
protected:
|
||||
void on_decode_(const uint8_t *data, size_t length);
|
||||
bool decode_frame_(const uint8_t *data, size_t length, const uint8_t *target);
|
||||
void on_display_(uint32_t lateness_ms);
|
||||
void on_clear_();
|
||||
void finish_transition_();
|
||||
void apply_frames_(bool transition_is_artwork);
|
||||
void report_error_();
|
||||
|
||||
ArtworkDecodeSink decode_sink_;
|
||||
|
||||
// The two frame buffers, allocated in setup() and never freed. Their contents are written on the
|
||||
// decode thread and read by whatever draws the views, so only their roles are swapped, never the
|
||||
// pointers themselves.
|
||||
std::array<uint8_t *, 2> buffers_{};
|
||||
|
||||
// pending_mutex_ guards the two fields below, the only state shared across threads. Everything
|
||||
// after them is touched on the main loop only.
|
||||
Mutex pending_mutex_;
|
||||
// Index into buffers_ of the frame the current image shows. buffers_[current_index_ ^ 1] holds
|
||||
// the outgoing frame and is the next decode target. Written on the main loop, read on the
|
||||
// decode thread.
|
||||
uint8_t current_index_{0};
|
||||
// Set on the decode thread once a frame is waiting in buffers_[current_index_ ^ 1].
|
||||
bool frame_pending_{false};
|
||||
|
||||
// True once artwork has been displayed, until the next clear; decides whether the outgoing frame
|
||||
// is real artwork or the black the buffers start as. Main loop only.
|
||||
bool showing_artwork_{false};
|
||||
// True while a displayed frame awaits transition_finished(); gates duplicate or stray calls
|
||||
// so exactly one ack reaches the library per delivery. Main loop only.
|
||||
bool transition_pending_{false};
|
||||
|
||||
ArtworkImageView *current_image_;
|
||||
ArtworkImageView *transition_image_{nullptr};
|
||||
int width_;
|
||||
int height_;
|
||||
uint8_t slot_;
|
||||
|
||||
LazyCallbackManager<void(uint32_t)> image_display_callback_{};
|
||||
LazyCallbackManager<void()> image_clear_callback_{};
|
||||
LazyCallbackManager<void()> image_error_callback_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -34,11 +34,7 @@ void SendspinMediaPlayer::setup() {
|
||||
new_state = media_player::MEDIA_PLAYER_STATE_IDLE;
|
||||
break;
|
||||
}
|
||||
if (this->state != new_state) {
|
||||
this->state = new_state;
|
||||
this->publish_state();
|
||||
ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state));
|
||||
}
|
||||
this->set_playback_state_(new_state);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,11 +48,27 @@ void SendspinMediaPlayer::setup() {
|
||||
}
|
||||
});
|
||||
|
||||
// The connection dropped, so nothing is playing. The server never gets to send a final "stopped" group update, so
|
||||
// without this the entity keeps reporting playing indefinitely. Volume and mute keep their last values, since
|
||||
// media_player has no way to express an unknown volume.
|
||||
this->parent_->add_controller_state_clear_callback(
|
||||
[this]() { this->set_playback_state_(media_player::MEDIA_PLAYER_STATE_IDLE); });
|
||||
|
||||
// Publish an initial state
|
||||
this->state = media_player::MEDIA_PLAYER_STATE_IDLE;
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (called from the callbacks registered in setup())
|
||||
void SendspinMediaPlayer::set_playback_state_(media_player::MediaPlayerState new_state) {
|
||||
if (this->state == new_state) {
|
||||
return;
|
||||
}
|
||||
this->state = new_state;
|
||||
this->publish_state();
|
||||
ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state));
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked by the media_player framework)
|
||||
media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() {
|
||||
auto traits = media_player::MediaPlayerTraits();
|
||||
|
||||
@@ -25,6 +25,9 @@ class SendspinMediaPlayer final : public SendspinChild, public media_player::Med
|
||||
// Receives commands from HA
|
||||
void control(const media_player::MediaPlayerCall &call) override;
|
||||
|
||||
/// @brief Publishes @p new_state if it differs from the current state.
|
||||
void set_playback_state_(media_player::MediaPlayerState new_state);
|
||||
|
||||
float volume_increment_{0.05f};
|
||||
bool muted_{false};
|
||||
};
|
||||
|
||||
@@ -21,6 +21,12 @@ namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.hub";
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
// Indexed by the library enums, which start at zero and are contiguous.
|
||||
static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"};
|
||||
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"};
|
||||
#endif
|
||||
|
||||
void SendspinHub::setup() {
|
||||
auto config = this->build_client_config_();
|
||||
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
|
||||
@@ -37,6 +43,11 @@ void SendspinHub::setup() {
|
||||
this->client_->set_network_provider(this);
|
||||
this->client_->set_persistence_provider(this);
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
this->artwork_role_ = &this->client_->add_artwork(this->artwork_config_);
|
||||
this->artwork_role_->set_listener(this);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
this->controller_role_ = &this->client_->add_controller();
|
||||
this->controller_role_->set_listener(this);
|
||||
@@ -67,6 +78,18 @@ void SendspinHub::dump_config() {
|
||||
" Client ID: %s\n"
|
||||
" Task stack in PSRAM: %s",
|
||||
get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
// Slot indices come from the order the image platform entries were declared, so the log is the
|
||||
// only place the mapping from a slot to the artwork it asked for can be read back.
|
||||
uint8_t slot = 0;
|
||||
for (const auto &preference : this->artwork_config_.preferred_formats) {
|
||||
ESP_LOGCONFIG(TAG, " Artwork slot %u: %s as %s, %ux%u, display offset %" PRId32 " ms", slot++,
|
||||
IMAGE_SOURCE_NAMES[static_cast<uint8_t>(preference.source)],
|
||||
IMAGE_FORMAT_NAMES[static_cast<uint8_t>(preference.format)], preference.width, preference.height,
|
||||
preference.display_offset_ms);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- Delegating methods ---
|
||||
@@ -174,6 +197,30 @@ std::optional<uint32_t> SendspinHub::load_last_server_hash() {
|
||||
|
||||
// --- Sendspin role specific methods/overrides ---
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too
|
||||
void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length,
|
||||
sendspin::SendspinImageFormat format) {
|
||||
this->artwork_image_decode_callbacks_.call(slot, data, length, format);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (fired from client_->loop() once the slot's offset-shifted display
|
||||
// deadline is reached; lateness_ms reports how far past the deadline the display slipped)
|
||||
void SendspinHub::on_image_display(uint8_t slot, uint32_t lateness_ms) {
|
||||
this->artwork_image_display_callbacks_.call(slot, lateness_ms);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (fired from client_->loop())
|
||||
void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); }
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from SendspinImageSlot once a delivery is fully presented)
|
||||
void SendspinHub::artwork_frame_done(uint8_t slot) {
|
||||
if (this->artwork_role_ != nullptr) {
|
||||
this->artwork_role_->frame_done(slot);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
|
||||
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
|
||||
@@ -192,6 +239,12 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman
|
||||
void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) {
|
||||
this->controller_state_callbacks_.call(state);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop())
|
||||
// Unlike metadata, this cannot be fanned out as a default-constructed state object: volume and muted are plain values
|
||||
// rather than optionals, so children would read a real-looking 0% volume where we mean no value at all. A separate
|
||||
// callback lets each child clear only what it can represent.
|
||||
void SendspinHub::on_controller_state_clear() { this->controller_state_clear_callbacks_.call(); }
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
@@ -200,6 +253,12 @@ void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadat
|
||||
this->metadata_update_callbacks_.call(metadata);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop())
|
||||
// The cached metadata was dropped because the connection to the server was lost, so what the children now mirror is
|
||||
// the empty state. Fanning that out as a default-constructed state object rather than through a separate callback
|
||||
// keeps one code path in the children: every field is nullopt, which they already publish as empty/unknown.
|
||||
void SendspinHub::on_metadata_clear() { this->metadata_update_callbacks_.call(sendspin::ServerMetadataStateObject{}); }
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
uint32_t SendspinHub::get_track_progress_ms() const {
|
||||
if (this->is_ready()) {
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
#include <sendspin/config.h>
|
||||
#include <sendspin/types.h>
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
#include <sendspin/artwork_role.h>
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
#include <sendspin/controller_role.h>
|
||||
#endif
|
||||
@@ -69,6 +72,9 @@ struct StaticDelayPref {
|
||||
/// (for services the library pulls; e.g., persistence, network readiness).
|
||||
/// - User -> library communication uses exposed functions on the client and role objects that the user calls.
|
||||
class SendspinHub final : public Component,
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
public sendspin::ArtworkRoleListener,
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
public sendspin::ControllerRoleListener,
|
||||
#endif
|
||||
@@ -121,6 +127,27 @@ class SendspinHub final : public Component,
|
||||
|
||||
// --- Sendspin role specific methods ---
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; }
|
||||
|
||||
/// @brief Acknowledges the most recent artwork delivery (display or clear) for a slot.
|
||||
///
|
||||
/// Every slot is configured with the library's require_frame_done gate, which withholds the
|
||||
/// next delivery for the slot until this is called. Exactly one ack is owed per delivery; a
|
||||
/// redundant call is a safe no-op in the library. Must be called from the main loop thread.
|
||||
void artwork_frame_done(uint8_t slot);
|
||||
|
||||
template<typename F> void add_image_decode_callback(F &&callback) {
|
||||
this->artwork_image_decode_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_image_display_callback(F &&callback) {
|
||||
this->artwork_image_display_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_image_clear_callback(F &&callback) {
|
||||
this->artwork_image_clear_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
|
||||
std::optional<bool> mute = std::nullopt);
|
||||
@@ -128,9 +155,18 @@ class SendspinHub final : public Component,
|
||||
template<typename F> void add_controller_state_callback(F &&callback) {
|
||||
this->controller_state_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/// @brief Registers a callback that fires when the connection is lost and the cached controller state is dropped.
|
||||
template<typename F> void add_controller_state_clear_callback(F &&callback) {
|
||||
this->controller_state_clear_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
/// @brief Registers a callback that fires when the server sends metadata.
|
||||
///
|
||||
/// Also fires when the connection is lost, with an all-empty state object (every field nullopt, timestamp 0) meaning
|
||||
/// the cached metadata was dropped. Subscribers must treat an absent field as cleared, not as no update.
|
||||
template<typename F> void add_metadata_update_callback(F &&callback) {
|
||||
this->metadata_update_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
@@ -171,13 +207,34 @@ class SendspinHub final : public Component,
|
||||
|
||||
// --- Sendspin role specific methods/overrides/member variables ---
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override;
|
||||
|
||||
void on_image_display(uint8_t slot, uint32_t lateness_ms) override;
|
||||
|
||||
void on_image_clear(uint8_t slot) override;
|
||||
|
||||
sendspin::ArtworkRoleConfig artwork_config_{};
|
||||
sendspin::ArtworkRole *artwork_role_{nullptr};
|
||||
|
||||
// Callback fan-out to child components; they filter by slot as needed.
|
||||
CallbackManager<void(uint8_t, const uint8_t *, size_t, sendspin::SendspinImageFormat)>
|
||||
artwork_image_decode_callbacks_{};
|
||||
CallbackManager<void(uint8_t, uint32_t)> artwork_image_display_callbacks_{};
|
||||
CallbackManager<void(uint8_t)> artwork_image_clear_callbacks_{};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
sendspin::ControllerRole *controller_role_{nullptr};
|
||||
|
||||
void on_controller_state(const sendspin::ServerStateControllerObject &state) override;
|
||||
|
||||
// Callback fan-out to child components; they filter as needed
|
||||
CallbackManager<void(const sendspin::ServerStateControllerObject &)> controller_state_callbacks_{};
|
||||
void on_controller_state_clear() override;
|
||||
|
||||
// Callback fan-out to child components; they filter as needed. Only a media_player subscribes, while the switch
|
||||
// action and the media source enable the controller role without one, so keep the idle cost to a single pointer.
|
||||
LazyCallbackManager<void(const sendspin::ServerStateControllerObject &)> controller_state_callbacks_{};
|
||||
LazyCallbackManager<void()> controller_state_clear_callbacks_{};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
@@ -185,6 +242,8 @@ class SendspinHub final : public Component,
|
||||
|
||||
void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override;
|
||||
|
||||
void on_metadata_clear() override;
|
||||
|
||||
// Callback fan-out to child components; they filter as needed
|
||||
CallbackManager<void(const sendspin::ServerMetadataStateObject &)> metadata_update_callbacks_{};
|
||||
#endif
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include <sendspin/metadata_role.h>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.sensor";
|
||||
@@ -20,6 +22,13 @@ void SendspinTrackProgressSensor::dump_config() {
|
||||
void SendspinTrackProgressSensor::setup() {
|
||||
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
|
||||
if (!metadata.progress.has_value()) {
|
||||
// Progress is unknown: the server has not reported it, or it was cleared (e.g. on disconnect). Stop polling and
|
||||
// report unknown rather than leaving the last position frozen on the frontend. Only the transition is published;
|
||||
// NAN never compares equal to itself, so an unguarded publish would repeat on every metadata update.
|
||||
this->stop_poller();
|
||||
if (!std::isnan(this->get_raw_state())) {
|
||||
this->publish_state(NAN);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const auto &progress = metadata.progress.value();
|
||||
@@ -34,6 +43,11 @@ void SendspinTrackProgressSensor::setup() {
|
||||
this->start_poller();
|
||||
}
|
||||
});
|
||||
|
||||
// PollingComponent starts the poller before setup(), but there is nothing to interpolate yet:
|
||||
// get_track_progress_ms() returns 0 until the server reports a position, so polling now would publish 0 every tick
|
||||
// from boot until the first metadata arrives. The callback above starts it once playback is running.
|
||||
this->stop_poller();
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop.
|
||||
@@ -80,15 +94,19 @@ std::optional<float> SendspinMetadataSensor::extract_value_(const sendspin::Serv
|
||||
// (SendspinHub dispatches metadata from client_->loop()).
|
||||
void SendspinMetadataSensor::setup() {
|
||||
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
|
||||
if (auto value = this->extract_value_(metadata)) {
|
||||
this->publish_if_changed_(*value);
|
||||
}
|
||||
// A field the server has not provided, or has explicitly cleared, is published as NAN (the sensor convention for
|
||||
// unknown) rather than skipped, so a value that goes away does not linger from the previous track.
|
||||
this->publish_if_changed_(this->extract_value_(metadata).value_or(NAN));
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes.
|
||||
void SendspinMetadataSensor::publish_if_changed_(float value) {
|
||||
if (this->get_raw_state() != value) {
|
||||
const float current = this->get_raw_state();
|
||||
// The raw state starts as NAN, so a field that is already cleared when the first update arrives is suppressed here
|
||||
// as well: the frontend still shows the sensor as unknown, which is what a clear means. NAN never compares equal to
|
||||
// itself, so a field that stays cleared would republish on every metadata update without the second check.
|
||||
if (current != value && !(std::isnan(current) && std::isnan(value))) {
|
||||
this->publish_state(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,40 +12,40 @@ static const char *const TAG = "sendspin.text_sensor";
|
||||
|
||||
void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); }
|
||||
|
||||
// A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to
|
||||
// show, so return the empty string and let the caller publish it; returning early would leave the previous track's
|
||||
// value on display.
|
||||
//
|
||||
// The empty string is not the same as unknown. A text sensor reports unknown through the API's missing_state flag,
|
||||
// which follows has_state(), and has_state() is only ever set, never cleared. Once a real value has been published,
|
||||
// an empty state is the closest we can get. The numeric sensors publish NAN, which does read as unknown.
|
||||
const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const {
|
||||
switch (this->metadata_type_) {
|
||||
case SendspinTextMetadataTypes::TITLE:
|
||||
if (metadata.title.has_value())
|
||||
return metadata.title.value().c_str();
|
||||
return nullptr;
|
||||
return metadata.title.has_value() ? metadata.title.value().c_str() : "";
|
||||
case SendspinTextMetadataTypes::ARTIST:
|
||||
if (metadata.artist.has_value())
|
||||
return metadata.artist.value().c_str();
|
||||
return nullptr;
|
||||
return metadata.artist.has_value() ? metadata.artist.value().c_str() : "";
|
||||
case SendspinTextMetadataTypes::ALBUM:
|
||||
if (metadata.album.has_value())
|
||||
return metadata.album.value().c_str();
|
||||
return nullptr;
|
||||
return metadata.album.has_value() ? metadata.album.value().c_str() : "";
|
||||
case SendspinTextMetadataTypes::ALBUM_ARTIST:
|
||||
if (metadata.album_artist.has_value())
|
||||
return metadata.album_artist.value().c_str();
|
||||
return nullptr;
|
||||
return metadata.album_artist.has_value() ? metadata.album_artist.value().c_str() : "";
|
||||
}
|
||||
return nullptr;
|
||||
return "";
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop
|
||||
// (SendspinHub dispatches metadata from client_->loop()).
|
||||
void SendspinTextSensor::setup() {
|
||||
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
|
||||
if (const char *value = this->extract_value_(metadata)) {
|
||||
this->publish_if_changed_(value);
|
||||
}
|
||||
this->publish_if_changed_(this->extract_value_(metadata));
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies.
|
||||
void SendspinTextSensor::publish_if_changed_(const char *value) {
|
||||
// The state starts empty, so a field that is already cleared when the first update arrives is suppressed here: the
|
||||
// entity stays unknown rather than being dropped out of it for good by an empty publish. Later clears do publish the
|
||||
// empty string and fire on_value with it.
|
||||
if (this->get_raw_state() != value) {
|
||||
this->publish_state(value);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from esphome import pins
|
||||
from esphome import external_files, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light, sensor, uart
|
||||
from esphome.components.const import CONF_SHA256
|
||||
@@ -28,8 +26,9 @@ from esphome.const import (
|
||||
UNIT_VOLT,
|
||||
UNIT_WATT,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.happy_eyeballs import ensure_happy_eyeballs
|
||||
from esphome.core import HexInt
|
||||
from esphome.external_files import RemoteFile
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DOMAIN = "shelly_dimmer"
|
||||
AUTO_LOAD = ["sensor"]
|
||||
@@ -76,46 +75,85 @@ def parse_firmware_version(value):
|
||||
return major, minor
|
||||
|
||||
|
||||
def get_firmware(value):
|
||||
def _firmware_cache_path(name: str) -> Path:
|
||||
return external_files.compute_local_file_dir(DOMAIN) / f"{name}_fw_stm.bin"
|
||||
|
||||
|
||||
def _firmware_path(url: str, sha: str | None) -> Path:
|
||||
"""Cache path for a firmware blob: sha-keyed when verifiable, else
|
||||
URL-keyed. Shared by the validator and the prefetch hook."""
|
||||
return _firmware_cache_path(
|
||||
sha.lower() if sha else external_files.url_cache_key(url)
|
||||
)
|
||||
|
||||
|
||||
def get_firmware(value: ConfigType) -> list[HexInt] | None:
|
||||
if not value[CONF_UPDATE]:
|
||||
return None
|
||||
|
||||
def dl(url):
|
||||
try:
|
||||
ensure_happy_eyeballs()
|
||||
req = requests.get(url, timeout=30)
|
||||
req.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e
|
||||
|
||||
h = hashlib.new("sha256")
|
||||
h.update(req.content)
|
||||
return req.content, h.hexdigest()
|
||||
|
||||
url = value[CONF_URL]
|
||||
|
||||
if CONF_SHA256 in value: # we have a hash, enable caching
|
||||
path = Path(CORE.data_dir) / DOMAIN / (value[CONF_SHA256] + "_fw_stm.bin")
|
||||
|
||||
if not path.is_file():
|
||||
firmware_data, dl_hash = dl(url)
|
||||
|
||||
if dl_hash != value[CONF_SHA256]:
|
||||
raise cv.Invalid(
|
||||
f"Hash mismatch for {url}: {dl_hash} != {value[CONF_SHA256]}"
|
||||
)
|
||||
|
||||
path.parent.mkdir(exist_ok=True, parents=True)
|
||||
path.write_bytes(firmware_data)
|
||||
|
||||
else:
|
||||
if expected := value.get(CONF_SHA256):
|
||||
expected = expected.lower()
|
||||
path = _firmware_path(url, expected)
|
||||
if path.is_file():
|
||||
firmware_data = path.read_bytes()
|
||||
else: # no caching, download every time
|
||||
firmware_data, dl_hash = dl(url)
|
||||
if hashlib.sha256(firmware_data).hexdigest() == expected:
|
||||
return [HexInt(x) for x in firmware_data]
|
||||
# A corrupted or foreign cache entry must never be trusted just
|
||||
# because the file exists; discard it and download again.
|
||||
path.unlink()
|
||||
firmware_data = external_files.download_content(url, path)
|
||||
if (actual := hashlib.sha256(firmware_data).hexdigest()) != expected:
|
||||
path.unlink(missing_ok=True)
|
||||
raise cv.Invalid(f"Hash mismatch for {url}: {actual} != {expected}")
|
||||
else:
|
||||
# No hash to verify the bytes, so an unrevalidated copy is an
|
||||
# error rather than a silent fallback.
|
||||
firmware_data = external_files.download_content(
|
||||
url,
|
||||
_firmware_path(url, None),
|
||||
allow_stale=False,
|
||||
)
|
||||
|
||||
return [HexInt(x) for x in firmware_data]
|
||||
|
||||
|
||||
def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None:
|
||||
firmware = entry.get(CONF_FIRMWARE)
|
||||
if not isinstance(firmware, dict):
|
||||
return None
|
||||
try:
|
||||
# cv.boolean, not truthiness: `update: "false"` is a valid False.
|
||||
if not cv.boolean(firmware.get(CONF_UPDATE, False)):
|
||||
return None
|
||||
except cv.Invalid:
|
||||
return None
|
||||
url = firmware.get(CONF_URL)
|
||||
sha = firmware.get(CONF_SHA256)
|
||||
if url is None and (known := KNOWN_FIRMWARE.get(str(firmware.get(CONF_VERSION)))):
|
||||
url, sha = known
|
||||
if not isinstance(url, str):
|
||||
return None
|
||||
if sha is not None:
|
||||
# Reject anything but a well-formed hash; a raw string would
|
||||
# otherwise become a path component before validation runs.
|
||||
try:
|
||||
sha = validate_sha256(sha)
|
||||
except (cv.Invalid, ValueError, TypeError):
|
||||
return None
|
||||
path = _firmware_path(url, sha)
|
||||
if sha is not None and path.is_file():
|
||||
# Content-addressed and already on disk; get_firmware verifies it
|
||||
# by hash, so there is nothing to revalidate.
|
||||
return None
|
||||
# No hash means no stale copies, matching the validator's policy.
|
||||
return RemoteFile(url, path, allow_stale=sha is not None)
|
||||
|
||||
|
||||
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref)
|
||||
|
||||
|
||||
def validate_firmware(value):
|
||||
config = value.copy()
|
||||
if CONF_URL not in config:
|
||||
|
||||
@@ -12,7 +12,7 @@ static const char *const TAG = "tinyusb";
|
||||
void TinyUSB::setup() {
|
||||
// Use the device's MAC address as its serial number if no serial number is defined
|
||||
if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) {
|
||||
static char mac_addr_buf[13];
|
||||
static char mac_addr_buf[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac_addr_buf);
|
||||
this->string_descriptor_[SERIAL_NUMBER] = mac_addr_buf;
|
||||
}
|
||||
|
||||
@@ -248,7 +248,13 @@ void VoiceAssistant::stream_api_audio_() {
|
||||
msg.data2_len = available2;
|
||||
}
|
||||
|
||||
this->api_client_->send_message(msg);
|
||||
if (!this->api_client_->send_message(msg)) {
|
||||
// Keep the chunk exposed and retry next pass, the same shape as
|
||||
// APIConnection::try_send_camera_image_(): the slice is only lost if
|
||||
// the ring buffer overflows before the TCP buffer clears, instead of
|
||||
// on every refusal. The api layer already reports the refusal at V.
|
||||
return;
|
||||
}
|
||||
|
||||
this->audio_source_->consume(available);
|
||||
if (this->audio_source2_ != nullptr) {
|
||||
@@ -477,7 +483,9 @@ void VoiceAssistant::loop() {
|
||||
|
||||
api::VoiceAssistantAnnounceFinished msg;
|
||||
msg.success = true;
|
||||
this->api_client_->send_message(msg);
|
||||
if (!this->api_client_->send_message(msg)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Announce-finished");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -741,7 +749,9 @@ void VoiceAssistant::signal_stop_() {
|
||||
ESP_LOGD(TAG, "Signaling stop");
|
||||
api::VoiceAssistantRequest msg;
|
||||
msg.start = false;
|
||||
this->api_client_->send_message(msg);
|
||||
if (!this->api_client_->send_message(msg)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Stop request");
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceAssistant::start_playback_timeout_() {
|
||||
@@ -753,7 +763,9 @@ void VoiceAssistant::start_playback_timeout_() {
|
||||
return;
|
||||
api::VoiceAssistantAnnounceFinished msg;
|
||||
msg.success = true;
|
||||
this->api_client_->send_message(msg);
|
||||
if (!this->api_client_->send_message(msg)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Announce-finished");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#if defined(USE_NETWORK) && !defined(USE_ZEPHYR)
|
||||
#include "esphome/components/button/button.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#else
|
||||
@@ -27,7 +28,7 @@ class WakeOnLanButton final : public button::Button, public Component {
|
||||
#endif
|
||||
void press_action() override;
|
||||
uint16_t port_{9};
|
||||
uint8_t macaddr_[6];
|
||||
uint8_t macaddr_[MAC_ADDRESS_SIZE];
|
||||
};
|
||||
|
||||
} // namespace esphome::wake_on_lan
|
||||
|
||||
@@ -510,7 +510,7 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) {
|
||||
response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str());
|
||||
response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true"));
|
||||
response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str());
|
||||
char mac_s[18];
|
||||
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
response->addHeader(ESPHOME_F("Private-Network-Access-ID"), get_mac_address_pretty_into_buffer(mac_s));
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
@@ -1117,7 +1117,7 @@ void WiFiComponent::connect_soon_() {
|
||||
|
||||
void WiFiComponent::start_connecting(const WiFiAP &ap) {
|
||||
// Log connection attempt at INFO level with priority
|
||||
char bssid_s[18];
|
||||
char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
int8_t priority = 0;
|
||||
|
||||
if (ap.has_bssid()) {
|
||||
@@ -2068,7 +2068,7 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() {
|
||||
(old_priority > std::numeric_limits<int8_t>::min()) ? (old_priority - 1) : std::numeric_limits<int8_t>::min();
|
||||
this->set_sta_priority(failed_bssid.value(), new_priority);
|
||||
}
|
||||
char bssid_s[18];
|
||||
char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
format_mac_addr_upper(failed_bssid.value().data(), bssid_s);
|
||||
ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "",
|
||||
bssid_s, old_priority, new_priority);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user