Merge remote-tracking branch 'upstream/dev' into neutral-ble-client

# Conflicts:
#	esphome/components/bluetooth_connection/__init__.py
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection.h
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/core/defines.h
#	tests/components/bluetooth_connection/__init__.py
This commit is contained in:
J. Nick Koston
2026-08-12 15:04:09 -05:00
272 changed files with 14662 additions and 1518 deletions
+1 -1
View File
@@ -466,7 +466,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
with:
run: |
. venv/bin/activate
+2
View File
@@ -238,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek
esphome/components/hm3301/* @freekode
esphome/components/hmac_md5/* @dwmw2
esphome/components/hmac_sha256/* @dwmw2
esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/switch/* @Links2004
@@ -465,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
+1 -1
View File
@@ -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.9.0-dev
# 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
View File
@@ -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:
+1
View File
@@ -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")
+20 -1
View File
@@ -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();
+8
View File
@@ -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])
+5
View File
@@ -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"]
+5
View File
@@ -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"]
+94 -22
View File
@@ -19,6 +19,7 @@ service APIConnection {
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
option (needs_authentication) = false;
}
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
rpc list_entities (ListEntitiesRequest) returns (void) {}
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
@@ -243,6 +244,12 @@ message SerialProxyInfo {
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
//
// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They
// have moved to that message as of API 1.15, but are still sent here so that
// older clients keep working. Do NOT mark them (deprecated) until the removal
// release: in this repo (deprecated) makes the generator drop the field
// entirely, so the device would stop sending it.
message DeviceInfoResponse {
option (id) = 10;
option (source) = SOURCE_SERVER;
@@ -280,6 +287,8 @@ message DeviceInfoResponse {
// Deprecated in API version 1.9
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
@@ -288,11 +297,14 @@ message DeviceInfoResponse {
// Deprecated in API version 1.10
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
// Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15.
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15.
string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Supports receiving and saving api encryption key
@@ -305,10 +317,13 @@ message DeviceInfoResponse {
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
// Indicates if Z-Wave proxy support is available and features supported
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Serial proxy instance metadata
// Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15.
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
// Device is unprovisioned and accepts Noise handshakes with the well-known
@@ -317,6 +332,63 @@ message DeviceInfoResponse {
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
}
// ==================== DEVICE CAPABILITIES ====================
// Asks the device which optional features it supports.
//
// This message exists so that DeviceInfoResponse does not have to keep growing
// a flat list of feature flags. DeviceInfoResponse is served before
// authentication, so it is limited to identity information. Capabilities are
// only served on an authenticated connection (encrypted as well, when
// encryption is configured).
//
// Clients that see api_version >= 1.15 should read these values from
// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
// Older clients keep reading DeviceInfoResponse, which still carries the same
// values, so this is not a breaking change.
message DeviceCapabilitiesRequest {
option (id) = 149;
option (source) = SOURCE_CLIENT;
// Empty
}
// Each feature gets its own sub-message so that it can gain fields over time
// without crowding the top-level field numbering.
//
// Note: a sub-message whose fields are all at their default value is not sent
// at all, so the presence of a sub-message is not a reliable test for "this
// feature is compiled in". Clients should test a value inside it, for example
// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
message BluetoothProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
string mac_address = 2 [(max_data_length) = 17, (force) = true];
}
message VoiceAssistantCapabilities {
// Bitmask of the features this voice assistant supports
uint32 feature_flags = 1;
}
message ZWaveProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
uint32 home_id = 2;
}
message DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
repeated SerialProxyInfo serial_proxies = 4
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
}
message ListEntitiesRequest {
option (id) = 11;
option (source) = SOURCE_CLIENT;
@@ -1688,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;
@@ -1699,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;
@@ -1710,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;
}
@@ -1754,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;
@@ -1763,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;
}
@@ -1771,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;
@@ -1780,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;
@@ -1792,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;
@@ -1804,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;
@@ -1813,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;
@@ -1824,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;
@@ -1834,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;
@@ -1845,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;
@@ -1864,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;
@@ -1874,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;
@@ -1883,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;
@@ -1892,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;
@@ -1902,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;
@@ -1918,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;
@@ -2735,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
@@ -2747,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;
+103 -17
View File
@@ -23,6 +23,7 @@
#include "esphome/core/application.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#ifdef USE_PROVISIONING
@@ -88,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
@@ -1235,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);
}
@@ -1268,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
@@ -1532,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
@@ -1574,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) {
@@ -1606,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:
@@ -1615,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
@@ -1735,7 +1760,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 14;
resp.api_version_minor = 15;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1746,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);
@@ -1771,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);
@@ -1849,8 +1875,7 @@ bool APIConnection::send_device_info_response_() {
#endif
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
// Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
char bluetooth_mac[18];
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_mac_address = StringRef(bluetooth_mac);
#endif
@@ -1904,6 +1929,35 @@ bool APIConnection::send_device_info_response_() {
return this->send_message(resp);
}
bool APIConnection::send_device_capabilities_response_() {
// These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks
// below in sync with send_device_info_response_() until those copies are removed.
DeviceCapabilitiesResponse resp;
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac);
#endif
#ifdef USE_VOICE_ASSISTANT
resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
#endif
#ifdef USE_ZWAVE_PROXY
resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id();
#endif
#ifdef USE_SERIAL_PROXY
size_t serial_proxy_index = 0;
for (auto const &proxy : App.get_serial_proxies()) {
if (serial_proxy_index >= SERIAL_PROXY_COUNT)
break;
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
}
#endif
return this->send_message(resp);
}
void APIConnection::on_hello_request(const HelloRequest &msg) {
if (!this->send_hello_response_(msg)) {
this->on_fatal_error();
@@ -1925,6 +1979,11 @@ void APIConnection::on_device_info_request() {
this->on_fatal_error();
}
}
void APIConnection::on_device_capabilities_request() {
if (!this->send_device_capabilities_response_()) {
this->on_fatal_error();
}
}
#ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
@@ -2003,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,
@@ -2014,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
@@ -2094,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;
}
+22 -13
View File
@@ -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
@@ -266,6 +271,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_device_capabilities_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() {
this->flags_.state_subscription = true;
@@ -334,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 {
@@ -385,10 +393,11 @@ class APIConnection final : public APIServerConnectionBase {
bool send_disconnect_response_();
bool send_ping_response_();
bool send_device_info_response_();
bool send_device_capabilities_response_();
#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
+2 -2
View File
@@ -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
+81 -1
View File
@@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif
return size;
}
#ifdef USE_BLUETOOTH_PROXY
uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address);
return pos;
}
uint32_t BluetoothProxyCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
size += 2 + this->mac_address.size();
return size;
}
#endif
#ifdef USE_VOICE_ASSISTANT
uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
return pos;
}
uint32_t VoiceAssistantCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
return size;
}
#endif
#ifdef USE_ZWAVE_PROXY
uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id);
return pos;
}
uint32_t ZWaveProxyCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
size += ProtoSize::calc_uint32(1, this->home_id);
return size;
}
#endif
uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
#ifdef USE_BLUETOOTH_PROXY
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy);
#endif
#ifdef USE_VOICE_ASSISTANT
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant);
#endif
#ifdef USE_ZWAVE_PROXY
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy);
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it);
}
#endif
return pos;
}
uint32_t DeviceCapabilitiesResponse::calculate_size() const {
uint32_t size = 0;
#ifdef USE_BLUETOOTH_PROXY
size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size());
#endif
#ifdef USE_VOICE_ASSISTANT
size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size());
#endif
#ifdef USE_ZWAVE_PROXY
size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size());
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(1, it.calculate_size());
}
#endif
return size;
}
#ifdef USE_BINARY_SENSOR
uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
@@ -2406,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:
@@ -2782,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));
@@ -4145,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:
+76 -2
View File
@@ -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,
@@ -600,6 +602,74 @@ class DeviceInfoResponse final : public ProtoMessage {
protected:
};
#ifdef USE_BLUETOOTH_PROXY
class BluetoothProxyCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
StringRef mac_address{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_VOICE_ASSISTANT
class VoiceAssistantCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_ZWAVE_PROXY
class ZWaveProxyCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
uint32_t home_id{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
class DeviceCapabilitiesResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 102;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
#endif
#ifdef USE_BLUETOOTH_PROXY
BluetoothProxyCapabilities bluetooth_proxy{};
#endif
#ifdef USE_VOICE_ASSISTANT
VoiceAssistantCapabilities voice_assistant{};
#endif
#ifdef USE_ZWAVE_PROXY
ZWaveProxyCapabilities zwave_proxy{};
#endif
#ifdef USE_SERIAL_PROXY
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
class ListEntitiesDoneResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 19;
@@ -1931,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;
@@ -2316,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;
@@ -3290,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;
+1 -1
View File
@@ -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
+57 -2
View File
@@ -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:
@@ -988,6 +990,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
return out.c_str();
}
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address);
return out.c_str();
}
#endif
#ifdef USE_VOICE_ASSISTANT
const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
return out.c_str();
}
#endif
#ifdef USE_ZWAVE_PROXY
const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("home_id"), this->home_id);
return out.c_str();
}
#endif
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
#ifdef USE_BLUETOOTH_PROXY
out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": ");
this->bluetooth_proxy.dump_to(out);
out.append("\n");
#endif
#ifdef USE_VOICE_ASSISTANT
out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": ");
this->voice_assistant.dump_to(out);
out.append("\n");
#endif
#ifdef USE_ZWAVE_PROXY
out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": ");
this->zwave_proxy.dump_to(out);
out.append("\n");
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": ");
it.dump_to(out);
out.append("\n");
}
#endif
return out.c_str();
}
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
return out.c_str();
@@ -1953,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);
@@ -2124,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));
@@ -2715,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);
+16 -9
View File
@@ -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);
@@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
case 149 /* DeviceCapabilitiesRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_device_capabilities_request"));
#endif
this->on_device_capabilities_request();
break;
}
default:
break;
}
+11 -9
View File
@@ -27,6 +27,8 @@ class APIServerConnectionBase {
void on_ping_response(){};
void on_device_info_request(){};
void on_device_capabilities_request(){};
void on_list_entities_request(){};
void on_subscribe_states_request(){};
@@ -113,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
@@ -233,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
};
+11 -4
View File
@@ -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");
}
}
});
}
+3 -1
View File
@@ -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)
+6 -6
View File
@@ -116,7 +116,7 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add
this->report_queue_.increment_dropped_count();
return;
}
memcpy(report->mac, mac, 6);
memcpy(report->mac, mac, MAC_ADDRESS_SIZE);
report->rssi = rssi;
report->addr_type = addr_type;
report->evt_type = evt_type;
@@ -230,7 +230,7 @@ void BK72xxBLE::loop() {
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
}
void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const {
void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
for (int i = 0; i < 6; i++)
out[i] = this->ble_mac_[i];
}
@@ -263,7 +263,7 @@ void BK72xxBLE::resolve_mac_() {
}
}
if (nonzero) {
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE);
return;
}
#endif
@@ -275,10 +275,10 @@ void BK72xxBLE::resolve_mac_() {
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it
// matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
// would carry differently.
uint8_t wifi_mac[6];
uint8_t wifi_mac[MAC_ADDRESS_SIZE];
get_mac_address_raw(wifi_mac); // MSB-first
const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
// Store LSB-first to match recv_adv_t adv_addr ordering.
for (int i = 0; i < 6; i++)
this->ble_mac_[i] = ble[5 - i];
+10 -10
View File
@@ -40,8 +40,8 @@ struct ScanParams {
/// One advertisement report from the controller.
struct BLEScanReport {
uint8_t mac[6]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t addr_type;
// GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type
// (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the
@@ -83,7 +83,7 @@ class BK72xxBLE final : public Component {
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
/// Controller BLE address, least-significant octet first (BLE convention).
void get_mac_lsb_first(uint8_t out[6]) const;
void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
/// Register a consumer for scan reports (delivered on the main task via loop()).
@@ -135,13 +135,13 @@ class BK72xxBLE final : public Component {
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
// Largest-to-smallest: padding only at the tail, absorbed by future byte fields.
uint32_t last_advance_ms_{0};
uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change
uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none
uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS
int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none
ScanParams requested_{}; // latched by scan_start()
ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention)
uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change
uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none
uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS
int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none
ScanParams requested_{}; // latched by scan_start()
ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts
uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention)
uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX};
bool scan_wanted_{false}; // the latched request is to scan (vs stopped)
bool release_warned_{false}; // gates the release WARN; widens the pump gate
@@ -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_)
@@ -116,8 +116,8 @@ class BK72xxBLETracker : public Component,
bool request_scan_mode(bool active);
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) {
uint8_t mac[6];
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
uint8_t mac[MAC_ADDRESS_SIZE];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
@@ -137,7 +137,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
// BLEScanResult's bda is most-significant octet first; the neutral ingest
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
// address_str_to() then produce exactly the historical esp32 values.
uint8_t mac_lsb_first[6];
uint8_t mac_lsb_first[MAC_ADDRESS_SIZE];
for (uint8_t i = 0; i < 6; i++)
mac_lsb_first[i] = scan_result.bda[5 - i];
this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv,
@@ -241,7 +241,7 @@ class ESPBTDevice {
// the 2-byte element header); every in-tree tracker scans legacy PDUs only.
static constexpr uint8_t MAX_ADV_NAME_LEN = 29;
uint8_t address_[6]{0};
uint8_t address_[MAC_ADDRESS_SIZE]{0};
uint8_t address_type_{0};
int rssi_{0};
// Fixed buffer instead of std::string: no per-advertisement heap churn on
@@ -2,6 +2,8 @@
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include "esphome/core/helpers.h"
#include <cstring>
namespace esphome::ble_device_base {
@@ -27,7 +29,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr
free_slot = &p;
continue;
}
if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) {
if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) {
// Same device advertised again before its scan response arrived — deliver
// the previous advertisement (its scan response is not coming) and reuse
// the slot, so no frame is ever lost.
@@ -47,7 +49,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr
}
slot->used = true;
this->pending_count_++;
memcpy(slot->mac, mac, 6);
memcpy(slot->mac, mac, MAC_ADDRESS_SIZE);
slot->addr_type = addr_type;
slot->rssi = rssi;
slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data);
@@ -61,7 +63,7 @@ void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_
// hottest caller.
if (this->pending_count_ != 0) {
for (auto &p : this->pending_adv_) {
if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) {
if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) {
// Append in place: the slot is released on delivery, so its 62-byte
// buffer (legacy adv + scan response) holds the merged frame directly.
const uint8_t room = sizeof(p.data) - p.data_len;
@@ -120,7 +120,7 @@ class ScanResponseMerger {
// as ESP-IDF delivers on ESP32.
struct PendingAdv {
bool used{false};
uint8_t mac[6];
uint8_t mac[MAC_ADDRESS_SIZE];
uint8_t addr_type;
int8_t rssi;
uint8_t data_len; // <= sizeof(data)
@@ -11,6 +11,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.components import rp2040_ble
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
@@ -39,11 +40,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"]
bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
DOMAIN = "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.
@@ -58,6 +60,21 @@ BluedroidGattClient = bluetooth_connection_ns.class_(
CONF_BACKEND_ID = "backend_id"
DOMAIN = "bluetooth_connection"
@dataclass
class _ConnectionData:
rp2_backend_count: int = 0
# GATT connection slots claimed this run, for the platform cap check.
slot_consumers: list[str] = field(default_factory=list)
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
@@ -66,8 +83,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)}
)
@@ -82,15 +97,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]
@@ -144,33 +173,23 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema:
)
@dataclass
class _SlotLedger:
"""GATT connection slots claimed this run, for the platform cap check."""
consumers: list[str] = field(default_factory=list)
def _ledger() -> _SlotLedger:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = _SlotLedger()
return CORE.data[DOMAIN]
def consume_gatt_slot(
consumer: str, count: int = 1
) -> Callable[[ConfigType], ConfigType]:
"""Validator claiming GATT connection slots - the one spelling for every
claimant. The neutral ledger feeds the hub-platform cap check in
FINAL_VALIDATE_SCHEMA; esp32 additionally charges the controller's
connection budget (its cap lives there, not in HUB_MAX_CONNECTIONS)."""
FINAL_VALIDATE_SCHEMA; esp32 and rp2 additionally charge their platform
stack's connection budget (esp32's cap lives there, not in
HUB_MAX_CONNECTIONS)."""
def validator(config: ConfigType) -> ConfigType:
_ledger().consumers.extend([consumer] * count)
_get_data().slot_consumers.extend([consumer] * count)
if CORE.is_esp32:
from esphome.components import esp32_ble
esp32_ble.consume_connection_slots(count, consumer)(config)
elif CORE.target_platform == PLATFORM_RP2:
rp2040_ble.consume_connection_slots(count, consumer)(config)
return config
return validator
@@ -196,7 +215,7 @@ def _validate_slot_totals(config: ConfigType) -> ConfigType:
"in HUB_MAX_CONNECTIONS"
)
return config
claimed = _ledger().consumers
claimed = _get_data().slot_consumers
if len(claimed) > cap:
raise cv.Invalid(
f"{CORE.target_platform} supports at most {cap} GATT client "
@@ -5,7 +5,7 @@
#include <esp_gattc_api.h>
#endif
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#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_SERVES_PROXY
#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,18 +16,14 @@
#include <esp_err.h>
#endif
// The proxy-serving surface is compiled: a proxy is present and a GATT
// backend is wired by codegen (one slot per connection). This is the single
// spelling of that predicate - the hub wrapper, the connection-aware API
// request handlers, and the Bluedroid in-place streamer all gate on it.
// Builds without a compiled backend get the clean-error handlers (a
// passive proxy alongside a backend consumer compiles the real ones);
// address-scoped maintenance (unpair, cache clear) still works there through
// the per-platform free functions below. Backend-only builds (a dedicated-backend
// consumer without bluetooth_proxy) compile none of this API surface.
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
#define BLUETOOTH_CONNECTION_SERVES_PROXY
#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;
@@ -69,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; }
@@ -83,6 +79,20 @@ inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
// send_service_ cursor states; >= 0 is the next service index to stream.
static constexpr int DONE_SENDING_SERVICES = -2;
static constexpr int INIT_SENDING_SERVICES = -3;
static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed
// Every sentinel must stay below the >= 0 streaming gate and clear of
// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused.
static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0);
static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED &&
SERVICES_DONE_PENDING != GATT_NOT_CONNECTED);
// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done
// 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 ----
@@ -142,7 +152,7 @@ inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uu
}
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#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.
@@ -154,6 +164,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND };
/// cannot drift.
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t &current_size, int16_t &send_service,
uint8_t connection_index, const char *address_str);
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
} // namespace esphome::bluetooth_connection
@@ -6,7 +6,7 @@
// The in-place streamer serves the proxy's service-discovery API; backend-only
// builds compile without the proxy headers or the streamer.
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -600,12 +600,14 @@ void BluedroidGattClient::deliver_pending_search_() {
this->listener_->on_service_discovery_done(this->search_status_);
}
#ifdef BLUETOOTH_CONNECTION_SERVES_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
@@ -616,20 +618,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
return;
}
if (conn.send_service_ >= this->service_total_) {
conn.send_service_ = DONE_SENDING_SERVICES;
conn.proxy_->send_gatt_services_done(conn.address_);
this->release_services();
conn.send_services_done_();
return;
}
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (a resubscribing client gets silence and its 30 s
// timeout, never an authoritative partial list).
// The subscriber vanished mid-stream.
auto *api_conn = conn.proxy_->get_api_connection();
if (api_conn == nullptr) {
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_);
conn.send_service_ = DONE_SENDING_SERVICES;
this->release_services();
conn.park_service_stream_();
return;
}
@@ -740,11 +738,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 // BLUETOOTH_CONNECTION_SERVES_PROXY
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
// ---- events ----
@@ -22,7 +22,7 @@
namespace esphome::bluetooth_connection {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothConnection;
#endif
@@ -86,7 +86,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
#endif
void release_services();
#ifdef BLUETOOTH_CONNECTION_SERVES_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_SERVES_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -16,12 +36,15 @@ 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';
return;
}
uint8_t mac[6];
uint8_t mac[MAC_ADDRESS_SIZE];
ble_device_base::uint64_to_mac_msb_first(address, mac);
format_mac_addr_upper(mac, this->address_str_);
}
@@ -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_,
@@ -299,25 +435,45 @@ conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval,
// ---- Service streaming ----
void BluetoothConnection::send_services_done_() {
if (this->proxy_->send_gatt_services_done(this->address_)) {
// Sent, or subscriber gone (park silently; its timeout arbitrates).
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (this->send_service_ != SERVICES_DONE_PENDING) {
// Warn on the transition only; retries stay silent.
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 {
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;
}
}
void BluetoothConnection::send_service_for_discovery_() {
auto table = this->backend_->get_service_table();
if (this->send_service_ >= table.service_count) {
this->send_service_ = DONE_SENDING_SERVICES;
this->proxy_->send_gatt_services_done(this->address_);
this->backend_->release_services();
this->send_services_done_();
return;
}
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (a resubscribing client gets silence and its 30 s
// timeout, never an authoritative partial list) and free the table; the
// api-gone sweep tears the connection down anyway.
// The subscriber vanished mid-stream; the api-gone sweep tears the
// connection down anyway.
auto *api_conn = this->proxy_->get_api_connection();
if (api_conn == nullptr) {
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_,
this->address_str_);
this->send_service_ = DONE_SENDING_SERVICES;
this->backend_->release_services();
this->park_service_stream_();
return;
}
@@ -399,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_SERVES_PROXY
#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_SERVES_PROXY
#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
@@ -126,7 +189,26 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
this->send_service_for_discovery_();
}
}
/// Park the stream without services-done and free any held table: an
/// 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;
} else if (this->send_service_ == SERVICES_DONE_PENDING) {
this->send_service_ = DONE_SENDING_SERVICES;
}
}
void send_service_for_discovery_();
/// Send services-done and settle the cursor: DONE when it lands (or no
/// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain
/// 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);
@@ -136,28 +218,57 @@ 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 first two tail bytes exactly.
ClientState state_ : 3 {ClientState::IDLE};
bool paired_ : 1 {false};
ConnectionType connection_type_ : 2 {ConnectionType::V1};
static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow");
uint8_t services_done_retries_ : 5 {0};
uint8_t connection_index_ : 4 {0};
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_SERVES_PROXY
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
@@ -5,6 +5,7 @@
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <BluetoothLock.h>
@@ -25,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;
@@ -53,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]) {
@@ -83,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
@@ -95,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)"); }
@@ -123,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);
}
@@ -392,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 &&
@@ -445,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_() &&
@@ -466,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_();
@@ -514,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;
}
@@ -538,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
@@ -563,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;
@@ -576,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;
@@ -595,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);
}
@@ -653,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();
@@ -759,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.
@@ -838,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() {
@@ -862,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
@@ -870,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;
@@ -880,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();
@@ -1098,7 +1259,7 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_
}
conn_err_t unpair_device(uint64_t address) {
uint8_t mac[6];
uint8_t mac[MAC_ADDRESS_SIZE];
ble_device_base::uint64_to_mac_msb_first(address, mac);
bool found = false;
BluetoothLock lock;
@@ -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
+15 -10
View File
@@ -157,8 +157,9 @@ 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:
@@ -166,14 +167,13 @@ def _rp2_config_schema() -> cv.All:
# their ids exist for codegen (the esp32 arm's `connections` pattern).
if not config[CONF_ACTIVE]:
return config
bluetooth_connection.consume_gatt_slot(
"bluetooth_proxy", config[CONF_CONNECTION_SLOTS]
)(config)
connection_slots: int = config[CONF_CONNECTION_SLOTS]
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
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]
@@ -191,8 +191,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}",
),
),
}
@@ -214,6 +214,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, service_table=False
@@ -40,7 +40,7 @@ static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STOPPED) ==
bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) {
if (this->api_connection_ == nullptr)
return false;
return true; // Nobody subscribed: nothing owed
api::BluetoothScannerStateResponse resp;
resp.state = static_cast<api::enums::BluetoothScannerState>(state);
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
@@ -51,7 +51,12 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState
return this->api_connection_->send_message(resp);
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) {
// False only on a refused frame, so the latch arms only when a retry is owed.
this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state);
}
#else
void BluetoothProxy::send_polled_scanner_state_() {
// One read feeds both the frame and the change detector; the detector only
// advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a
@@ -62,12 +67,13 @@ void BluetoothProxy::send_polled_scanner_state_() {
this->last_scan_running_ = running;
}
}
#endif // !USE_BLE_SCANNER_STATE_CALLBACK
#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();
@@ -78,7 +84,7 @@ void BluetoothProxy::setup() {
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// Only push hubs compile the slot; elsewhere loop() polls scan_running().
this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) {
static_cast<BluetoothProxy *>(self)->send_bluetooth_scanner_state_(state);
static_cast<BluetoothProxy *>(self)->send_scanner_state_(state);
}});
#endif
}
@@ -98,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) {
@@ -106,7 +112,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
}
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#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));
@@ -115,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_SERVES_PROXY
#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);
@@ -124,22 +143,32 @@ 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() {
// Print configured facts. dump_config runs right after setup, before the
// radio is up, so live scan state would always read "stopped" here — the
// loop's BluetoothScannerStateResponse carries the changing value instead.
char mac_str[18];
char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
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_SERVES_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Active: %s\n"
@@ -157,12 +186,9 @@ void BluetoothProxy::dump_config() {
#endif
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#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.
@@ -173,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"); }
@@ -190,8 +215,68 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v
ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value);
}
void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) {
// Match before free entry so one address never occupies two pool slots.
PendingReply *free_entry = nullptr;
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto &owed = this->pending_disconnections_[i];
if (owed.matches(address)) {
owed.set(address, error);
return;
}
if (free_entry == nullptr && owed.empty()) {
free_entry = &owed;
}
}
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.
this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), address);
this->pending_disconnections_[0].set(address, error);
}
void BluetoothProxy::clear_pending_disconnection_(uint64_t address) {
// A reconnect supersedes the owed disconnect; a late resend would shadow
// the new connection.
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::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) {
this->send_device_connection(connection->get_address(), false, 0, 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();
@@ -206,14 +291,20 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese
auto *connection = this->connections_[i];
uint64_t conn_addr = connection->get_address();
if (conn_addr == address)
if (conn_addr == address) {
// A connect request supersedes an owed disconnect.
if (reserve) {
this->clear_pending_disconnection_(address);
}
return connection;
}
if (free_slot == nullptr && conn_addr == 0)
free_slot = connection;
}
if (!reserve || free_slot == nullptr)
return nullptr;
this->clear_pending_disconnection_(address);
free_slot->send_service_ = INIT_SENDING_SERVICES;
free_slot->set_address(address);
// All connections must start at INIT
@@ -231,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()) {
@@ -269,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;
}
@@ -277,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;
@@ -321,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;
}
}
@@ -336,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);
}
}
@@ -349,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);
}
}
@@ -362,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);
}
}
@@ -375,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);
}
}
@@ -387,7 +478,30 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer
}
if (!connection->has_gatt_services()) {
ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str());
this->send_gatt_services_done(msg.address);
// Through the retrying sender: a drop must not leave discovery hanging.
// Re-entry does not depend on the cursor - this branch is gated on
// has_gatt_services() alone, so no restore is needed.
connection->send_services_done_();
return;
}
if (connection->send_service_ > 0) {
// A request mid-stream restarts from the top so the requester always
// gets the full list. No duplicate risk: the client accumulates batches
// per request, and a same-session re-request only happens after the
// previous request timed out and discarded its partial list.
ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(),
connection->address_str());
connection->send_service_ = 0;
return;
}
if (connection->send_service_ == SERVICES_DONE_PENDING) {
// A new request supersedes an owed done: the client accumulates batches
// per request, so its fresh, empty accumulator plus a bare done would
// cache as an empty database. The table is freed; the client's timeout
// arbitrates.
ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry",
connection->get_connection_index(), connection->address_str());
connection->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet
@@ -403,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;
@@ -421,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;
}
@@ -432,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_SERVES_PROXY
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
#ifdef USE_ESP32
@@ -478,7 +597,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
#endif // USE_ESP32
void BluetoothProxy::loop() {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#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++) {
@@ -492,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_SERVES_PROXY
#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++) {
@@ -515,7 +636,37 @@ void BluetoothProxy::loop() {
return;
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#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++) {
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())
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
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// Resend a dropped scanner-state push (see scanner_state_pending_).
if (this->scanner_state_pending_) {
this->send_scanner_state_(this->hub_->get_scanner_state());
}
#else
// This hub doesn't push scanner-state transitions; poll and report on
// change. A hub gaining push emits the define and drops this poll.
if (this->hub_->scan_running() != this->last_scan_running_) {
@@ -523,102 +674,79 @@ void BluetoothProxy::loop() {
}
#endif
this->flush_pending_advertisements_();
}
#ifndef BLUETOOTH_CONNECTION_SERVES_PROXY
// 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_SERVES_PROXY
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
// A previous subscriber still holds the slot. This is almost always a stale
// connection from a client that dropped without a clean disconnect and has
// not yet hit the keepalive timeout; rejecting the new subscriber would
// silently starve it of advertisements until it reconnects, so the newest
// subscriber wins instead.
char old_peername[socket::SOCKADDR_STR_LEN];
char new_peername[socket::SOCKADDR_STR_LEN];
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
this->api_connection_->get_peername_to(old_peername));
if (api_connection != this->api_connection_) {
if (this->api_connection_ != nullptr) {
// A previous subscriber still holds the slot. This is almost always a
// stale connection from a client that dropped without a clean disconnect
// and has not yet hit the keepalive timeout; rejecting the new
// subscriber would silently starve it of advertisements until it
// reconnects, so the newest subscriber wins instead.
char old_peername[socket::SOCKADDR_STR_LEN];
char new_peername[socket::SOCKADDR_STR_LEN];
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
this->api_connection_->get_peername_to(old_peername));
}
// Stale retry latches belong to the previous subscriber's session; a
// re-subscribe by the current one keeps what it is still owed.
this->reset_owed_replies_();
}
// A stale retry latch belongs to the previous subscriber's session.
this->connections_free_pending_ = false;
this->api_connection_ = api_connection;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// get_scanner_state() is part of the push-hub surface (see BLEHubContract).
this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state());
this->send_scanner_state_(this->hub_->get_scanner_state());
#else
this->send_polled_scanner_state_();
#endif
@@ -630,21 +758,10 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
return;
}
this->api_connection_ = nullptr;
this->connections_free_pending_ = false;
this->reset_owed_replies_();
}
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceConnectionResponse call;
call.address = address;
call.connected = connected;
call.mtu = mtu;
call.error = error;
// Fire and forget: a drop is covered by the client's own timeouts and the
// retried connections-free state.
this->api_connection_->send_message(call);
}
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void BluetoothProxy::send_connections_free() {
if (this->api_connection_ != nullptr) {
this->send_connections_free(this->api_connection_);
@@ -661,22 +778,33 @@ void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) {
}
}
void BluetoothProxy::send_gatt_services_done(uint64_t address) {
bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothGATTGetServicesDoneResponse call;
return true; // Nobody subscribed: nothing owed
api::BluetoothDeviceConnectionResponse call;
call.address = address;
this->api_connection_->send_message(call);
call.connected = connected;
call.mtu = mtu;
call.error = error;
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_services_done(uint64_t address) {
if (this->api_connection_ == nullptr)
return;
return true; // Nobody subscribed: nothing is owed, only a refused frame reports false
api::BluetoothGATTGetServicesDoneResponse call;
call.address = address;
return this->api_connection_->send_message(call);
}
bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
if (this->api_connection_ == nullptr)
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) {
@@ -687,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;
@@ -711,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)
@@ -10,6 +10,7 @@
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
@@ -24,9 +25,11 @@ namespace esphome::bluetooth_proxy {
using bluetooth_connection::CONN_OK;
using bluetooth_connection::conn_err_t;
using bluetooth_connection::GATT_NOT_CONNECTED;
using bluetooth_connection::DONE_SENDING_SERVICES;
using bluetooth_connection::INIT_SENDING_SERVICES;
using bluetooth_connection::SERVICES_DONE_PENDING;
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
using ClientState = ble_device_base::ClientState;
#endif
@@ -57,8 +60,45 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
#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
// must not corrupt the reason.
this->word_ = (address & ADDRESS_MASK) | (static_cast<uint64_t>(static_cast<uint16_t>(error)) << 48);
}
constexpr void clear() { this->word_ = 0; }
// 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); }
constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; }
constexpr conn_err_t error() const { return static_cast<int16_t>(this->word_ >> 48); }
private:
static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL;
uint64_t word_{0};
};
// 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_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_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_SERVES_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// Allow the connection to update connections_free_response_
friend bluetooth_connection::BluetoothConnection;
#endif
@@ -69,9 +109,9 @@ class BluetoothProxy final : public Component {
void setup() override;
void loop() override;
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void register_connection(BluetoothConnection *connection);
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#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
@@ -80,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);
@@ -88,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);
@@ -97,14 +139,23 @@ class BluetoothProxy final : public Component {
return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12);
}
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
/// False only when a subscriber refused the frame; true = delivered or
/// 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);
void send_gatt_services_done(uint64_t address);
void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
/// Same convention as send_device_connection: false only on a refused frame.
bool send_gatt_services_done(uint64_t address);
/// 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);
@@ -158,8 +209,8 @@ class BluetoothProxy final : public Component {
return flags;
}
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
uint8_t mac[6] = {};
void get_bluetooth_mac_address_pretty(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> output) {
uint8_t mac[MAC_ADDRESS_SIZE] = {};
this->hub_->get_adapter_mac(mac);
// Unavailable -> empty string: some hubs (rp2040's BTstack) only learn
// the address once the link layer is up, and report all-zero until then.
@@ -172,7 +223,9 @@ class BluetoothProxy final : public Component {
protected:
bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state);
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
void send_scanner_state_(ble_device_base::ScannerState state);
#else
void send_polled_scanner_state_();
#endif
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
@@ -181,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_SERVES_PROXY
#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_SERVES_PROXY
/// 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) {
@@ -221,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_();
@@ -231,15 +280,50 @@ class BluetoothProxy final : public Component {
/// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service
/// discovery and retry, rather than being told a partial list is complete.
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_SERVES_PROXY
#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<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
@@ -249,18 +333,32 @@ 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
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#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.
bool scanner_state_pending_{false};
#else
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
#endif
};
+45 -19
View File
@@ -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\":\""));
+2 -1
View File
@@ -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,
+5 -3
View File
@@ -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);
+34 -16
View File
@@ -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):
+1 -1
View File
@@ -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) &&
+5 -5
View File
@@ -674,21 +674,21 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat
}
#endif
void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const {
void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
// The running stack owns the address (on hosted controllers it lives in
// the remote chip's efuse); null before init becomes all-zero.
const uint8_t *mac = esp_bt_dev_get_address();
if (mac != nullptr) {
memcpy(out, mac, 6);
memcpy(out, mac, MAC_ADDRESS_SIZE);
} else {
memset(out, 0, 6);
memset(out, 0, MAC_ADDRESS_SIZE);
}
}
float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; }
void ESP32BLE::dump_config() {
uint8_t mac_address[6];
uint8_t mac_address[MAC_ADDRESS_SIZE];
this->get_mac_msb_first(mac_address);
if (mac_address_is_valid(mac_address)) {
const char *io_capability_s;
@@ -713,7 +713,7 @@ void ESP32BLE::dump_config() {
break;
}
char mac_s[18];
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(mac_address, mac_s);
ESP_LOGCONFIG(TAG,
"BLE:\n"
+1 -1
View File
@@ -109,7 +109,7 @@ class ESP32BLE final : public Component {
void loop() override;
void dump_config() override;
/// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up.
void get_mac_msb_first(uint8_t out[6]) const;
void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
float get_setup_priority() const override;
void set_name(const char *name) { this->name_ = name; }
@@ -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;
@@ -200,7 +200,7 @@ class ESP32BLETracker final : public Component,
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true,
/* scan_mode_switch = */ false};
}
void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); }
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); }
bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; }
bool scan_active() { return this->scan_active_; }
// The mode is driven through this tracker's own API (see get_capabilities);
@@ -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();
+59 -22
View File
@@ -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)
+185 -61
View File
@@ -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(
+20 -2
View File
@@ -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,33 @@
import esphome.codegen as cg
from esphome.components import modbus
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@zweckj"]
DEPENDENCIES = ["modbus"]
MULTI_CONF = True
CONF_HOERMANN_HCP_ID = "hoermann_hcp_id"
hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp")
HoermannHcp = hoermann_hcp_ns.class_(
"HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice
)
# The Hoermann UAP module answers on Modbus server address 2.
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)})
.extend(cv.polling_component_schema("500ms"))
.extend(modbus.modbus_device_schema(0x02, role="server"))
)
FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device(
"hoermann_hcp", role="server"
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_server_device(var, config)
@@ -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
@@ -0,0 +1,22 @@
import esphome.codegen as cg
from esphome.components import cover
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
DEPENDENCIES = ["hoermann_hcp"]
HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component)
CONFIG_SCHEMA = (
cover.cover_schema(HoermannHcpCover)
.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 cover.new_cover(config, parent)
await cg.register_component(var, config)
@@ -0,0 +1,87 @@
#include "hoermann_hcp_cover.h"
#include "esphome/core/log.h"
namespace esphome::hoermann_hcp {
static const char *const TAG = "hoermann_hcp.cover";
cover::CoverTraits HoermannHcpCover::get_traits() {
cover::CoverTraits traits;
traits.set_supports_position(true);
traits.set_supports_stop(true);
traits.set_supports_toggle(true);
return traits;
}
void HoermannHcpCover::setup() {
// Nothing is published before the bus controller is heard from, and the untouched position reads as fully
// open, so flag the entity until the first contact clears it again.
this->status_set_warning("waiting for the bus controller");
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
}
void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); }
void HoermannHcpCover::control(const cover::CoverCall &call) {
bool accepted = true;
if (call.get_stop())
accepted &= this->parent_->stop_door();
if (call.get_toggle().has_value())
accepted &= this->parent_->impulse_door();
if (const auto position = call.get_position())
accepted &= this->parent_->set_position(*position);
if (!accepted) {
// The command never reached the door, so publish the unchanged state over the one the caller assumed.
ESP_LOGW(TAG, "Command was not accepted by the door");
this->publish_state(false);
}
}
void HoermannHcpCover::update_from_state_() {
if (!this->parent_->is_valid()) {
this->status_set_warning();
// The door can now move unheard, so drop the baseline a direction would be inferred from and stop
// reporting motion instead of leaving the cover travelling until the controller returns.
this->previous_position_ = NAN;
if (this->current_operation != cover::COVER_OPERATION_IDLE) {
this->current_operation = cover::COVER_OPERATION_IDLE;
this->publish_state();
}
return;
}
this->status_clear_warning();
const auto previous_operation = this->current_operation;
const float current_position = this->parent_->get_current_position();
switch (this->parent_->get_door_state()) {
case DoorState::OPENING:
this->current_operation = cover::COVER_OPERATION_OPENING;
break;
case DoorState::CLOSING:
this->current_operation = cover::COVER_OPERATION_CLOSING;
break;
case DoorState::MOVE_VENTING:
case DoorState::MOVE_HALF:
// These states carry no direction, so keep the current one until the position actually moves.
if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) {
this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING
: cover::COVER_OPERATION_CLOSING;
}
break;
default:
this->current_operation = cover::COVER_OPERATION_IDLE;
break;
}
this->previous_position_ = current_position;
// Compare against the position last published, which starts at COVER_OPEN rather than at zero.
const bool changed = this->position != current_position || previous_operation != this->current_operation;
this->position = current_position;
if (changed) {
// The bus reports the position on every broadcast, so nothing here is worth restoring from flash.
this->publish_state(false);
}
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,27 @@
#pragma once
#include <cmath>
#include "esphome/components/cover/cover.h"
#include "esphome/core/component.h"
#include "../hoermann_hcp.h"
namespace esphome::hoermann_hcp {
class HoermannHcpCover : public cover::Cover, public Component {
public:
explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {}
void setup() override;
void dump_config() override;
cover::CoverTraits get_traits() override;
void control(const cover::CoverCall &call) override;
protected:
void update_from_state_();
HoermannHcp *const parent_;
// NAN until the first position is observed, so no direction is inferred from a baseline that never existed.
float previous_position_{NAN};
};
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,464 @@
#include "hoermann_hcp.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::hoermann_hcp {
static const char *const TAG = "hoermann_hcp";
// Hoermann HCP holding-register blocks.
static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller
static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller
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.
struct DoorStateMapping {
uint8_t code;
DoorState state;
};
static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = {
{0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF},
{0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN},
{0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN},
};
// The hub rejects a reply whose register count does not match the request, so an unrecognized block length
// is padded with zeros rather than answered with an exception that would fail the controller's whole poll.
static void push_zeros(modbus::RegisterValues &registers, uint16_t count) {
for (uint16_t i = 0; i < count; i++)
registers.push_back(0x0000);
}
// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving.
static bool is_moving(DoorState state) {
switch (state) {
case DoorState::OPENING:
case DoorState::CLOSING:
case DoorState::MOVE_HALF:
case DoorState::MOVE_VENTING:
return true;
default:
return false;
}
}
void HoermannHcp::update() {
const uint32_t now = millis();
// Time out the connection flag if the bus controller stopped polling.
if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_)
this->set_valid_(false);
// Status broadcasts alone keep the connection alive, so a command the controller never fetches would
// otherwise block every later one for as long as it keeps broadcasting.
if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
// Dropping after the press was presented leaves the door without its release value, which is worth saying
// apart from a command the controller never looked at.
if (this->command_written_at_ != 0) {
ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press",
this->next_command_->name);
} else {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
}
this->drop_command_();
// Children may have assumed the command would land, so let them re-derive from the door.
this->changed_ = true;
}
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) {
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();
}
}
void HoermannHcp::dump_config() {
ESP_LOGCONFIG(TAG,
"Hoermann HCP bridge:\n"
" Modbus server address: 0x%02X",
this->get_address());
}
modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
modbus::RegisterValues &registers) {
if (start_address != STATE_REG) {
ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address);
return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
this->record_response_();
// 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message
// counter (high byte) and command (low byte). The read length identifies which internal block is requested.
const uint16_t counter = this->command_reg_value_ & 0xFF00;
const uint16_t command = static_cast<uint16_t>((this->command_reg_value_ & 0x00FF) << 8);
switch (number_of_registers) {
case 8:
// Command request: return the internal state, injecting any pending command.
registers.push_back(counter);
registers.push_back(static_cast<uint16_t>(0x0001 | command));
this->push_command_registers_(registers);
push_zeros(registers, 4);
break;
case 2:
// Empty command request.
registers.push_back(static_cast<uint16_t>(0x0004 | counter));
registers.push_back(command);
break;
case 5:
// Bus scan (the bus controller discovering us, typically at startup).
ESP_LOGD(TAG, "Bus scan received from bus controller");
registers.push_back(counter);
registers.push_back(static_cast<uint16_t>(0x0005 | command));
registers.push_back(0x0430);
registers.push_back(0x10FF);
registers.push_back(0xA845);
break;
default:
ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers);
push_zeros(registers, number_of_registers);
break;
}
return {};
}
modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
const modbus::RegisterValues &registers) {
if (start_address == COMMAND_REG) {
// 0x17 write half: stash the command register so the following read half can echo its message counter and
// command byte back from STATE_REG. The hub always runs the write before the read within one request.
this->record_response_();
this->command_reg_value_ = registers[0];
return {};
}
if (start_address != BROADCAST_REG) {
// Every device sees every broadcast, so a frame meant for another node is ordinary traffic
ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address);
return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
this->record_response_();
// Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new
// position checks the target against the new state.
if (registers.size() > 2)
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 {};
}
void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
const HoermannHcpCommand *command = this->next_command_;
if (command == nullptr) {
push_zeros(registers, 2);
return;
}
if (this->command_written_at_ == 0) {
// First read after the command was queued: present the "key pressed" values.
this->command_written_at_ = millis();
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
registers.push_back(command->pressed_value);
registers.push_back(command->pressed_value_2);
return;
}
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
// Between the two events there is nothing to report, including in the second register.
push_zeros(registers, 2);
return;
}
// Enough time passed: present the "key released" values and clear the command.
ESP_LOGD(TAG, "Released '%s' command", command->name);
this->command_written_at_ = 0;
this->next_command_ = nullptr;
// A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left
// to wait for, so it must not re-arm the watchdog.
if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0)
this->light_toggle_released_at_ = millis();
registers.push_back(command->released_value);
registers.push_back(command->released_value_2);
}
void HoermannHcp::on_position_reg_(uint16_t value) {
// Low byte: current position.
const uint8_t position = static_cast<uint8_t>(value);
if (this->position_raw_ == position)
return;
this->position_raw_ = position;
this->update_current_position_();
// Until the door actually travels the way it was told to, its position says nothing about the target.
if (!this->has_target_() || !this->target_started_)
return;
// The door only knows "open" and "close", so a half-open target is reached by stopping it on the way.
const bool reached = this->target_direction_ == DoorState::OPENING
? this->current_position_ >= this->target_position_
: this->current_position_ <= this->target_position_;
if (reached)
this->stop_door();
}
void HoermannHcp::on_state_reg_(uint16_t value) {
// The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte.
const uint16_t previous = this->prev_state_reg_;
this->prev_state_reg_ = value;
if (previous == value)
return;
const uint8_t state = value >> 8;
if (state == 0x00) {
// Low byte 0x61 marks the door resting in the vent position, anything else a plain stop.
this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED);
return;
}
for (const auto &mapping : DOOR_STATE_MAPPINGS) {
if (mapping.code == state) {
this->set_door_state_(mapping.state);
return;
}
}
// The low byte can change on its own, so only report a state we cannot decode once.
if (state != (previous >> 8))
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.
ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name);
return false;
}
if (this->next_command_ != nullptr) {
ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller");
return false;
}
// A new command supersedes any half-open target the door was still travelling to.
if (command.clears_target)
this->clear_target_();
this->next_command_ = &command;
this->command_queued_at_ = millis();
return true;
}
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
bool HoermannHcp::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_)) {
this->clear_target_();
return true;
}
// On success queue_command_() clears the target; on refusal it stays armed so the next position retries.
return this->queue_command_(COMMAND_IMPULSE);
}
bool HoermannHcp::set_position(float position) {
// The first and last movement segments are inconsistent on some doors, so snap to fully open/closed.
if (position <= CLOSE_POSITION_THRESHOLD)
return this->close_door();
if (position >= OPEN_POSITION_THRESHOLD)
return this->open_door();
// Asking the door to travel to where it already is means stopping it.
if (position == this->current_position_)
return this->stop_door();
// The door itself has no notion of a target, so it is started in the right direction and stopped on the way.
const bool opening = position > this->current_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_;
return true;
}
void HoermannHcp::record_response_() {
this->last_response_ = millis();
this->set_valid_(true);
}
void HoermannHcp::set_valid_(bool valid) {
if (this->valid_ == valid)
return;
this->valid_ = valid;
this->changed_ = true;
if (valid) {
ESP_LOGI(TAG, "Bus controller connected");
return;
}
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;
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) {
if (this->door_state_ == state)
return;
this->door_state_ = state;
this->changed_ = true;
this->update_current_position_();
if (!this->has_target_())
return;
if (state == this->target_direction_) {
this->target_started_ = true;
} else if (this->target_started_ && !is_moving(state)) {
// The door came to rest without reaching the target, so the request it belonged to is over.
this->clear_target_();
}
}
void HoermannHcp::update_current_position_() {
// Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so
// trust the reported end stop over the raw count.
float position = static_cast<float>(this->position_raw_) / 200.0f;
if (this->door_state_ == DoorState::CLOSED) {
position = 0.0f;
} else if (this->door_state_ == DoorState::OPEN) {
position = 1.0f;
}
if (this->current_position_ != position) {
this->current_position_ = position;
this->changed_ = true;
}
}
void HoermannHcp::clear_target_() {
this->target_position_ = 0.0f;
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
@@ -0,0 +1,147 @@
#pragma once
#include <utility>
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::hoermann_hcp {
// Door state as reported by the Hoermann bus controller.
enum class DoorState : uint8_t {
OPEN,
OPENING,
CLOSED,
CLOSING,
HALF_OPEN,
MOVE_VENTING,
VENT,
MOVE_HALF,
STOPPED,
};
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
// short delay the released value. Each half also carries a second register, which 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 {
public:
void update() override;
void dump_config() override;
// Registered by child entities to be notified when the door state changes.
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
// Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write
// half first, storing the command register that the read half echoes back) and broadcasts status with 0x10.
modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues &registers) override;
modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
modbus::RegisterValues &registers) override;
// Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus
// controller cannot be asked right now, so the caller can react.
bool open_door();
bool close_door();
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 &registers);
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);
// Recomputes the reported position from position_raw_ and the current door state.
void update_current_position_();
bool has_target_() const { return this->target_position_ != 0.0f; }
void clear_target_();
void set_light_on_(bool on);
void set_light_seen_(bool seen);
CallbackManager<void()> state_callback_;
float current_position_{0.0f};
// Position the door was told to travel to; 0.0 means no target is armed.
float target_position_{0.0f};
// Pending command / key-press state machine.
const HoermannHcpCommand *next_command_{nullptr};
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};
// Drop the "connected" flag if the bus controller has not polled us for this long.
uint16_t connection_timeout_ms_{2000};
// The state starts on a value the bus controller never reports, so the first broadcast is decoded even when
// it reads 0x0000.
uint16_t prev_state_reg_{0xFFFF};
// 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message
// counter and low-byte command back from STATE_REG.
uint16_t command_reg_value_{0};
DoorState door_state_{DoorState::CLOSED};
// Direction the door was started in for the current target. A target armed while the door is still travelling
// the other way must not be judged by the reported direction until the door has turned around.
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
+1 -1
View File
@@ -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)) {
+3 -2
View File
@@ -8,6 +8,7 @@
#endif
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
namespace esphome::ld2410 {
@@ -178,7 +179,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui
}
void LD2410Component::dump_config() {
char mac_s[18];
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char version_s[20];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ld24xx::format_version_str(this->version_, version_s);
@@ -511,7 +512,7 @@ bool LD2410Component::handle_ack_data_() {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[18];
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ESP_LOGV(TAG, "MAC address: %s", mac_str);
#ifdef USE_TEXT_SENSOR
+1 -1
View File
@@ -121,7 +121,7 @@ class LD2410Component final : public Component, public uart::UARTDevice {
uint8_t out_pin_level_ = 0;
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
uint8_t buffer_data_[MAX_LINE_LENGTH];
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
bool bluetooth_on_{false};
#ifdef USE_NUMBER
+2 -2
View File
@@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui
}
void LD2412Component::dump_config() {
char mac_s[18];
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char version_s[20];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ld24xx::format_version_str(this->version_, version_s);
@@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[18];
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ESP_LOGV(TAG, "MAC address: %s", mac_str);
#ifdef USE_TEXT_SENSOR
+1 -1
View File
@@ -124,7 +124,7 @@ class LD2412Component final : public Component, public uart::UARTDevice {
uint8_t out_pin_level_ = 0;
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
uint8_t buffer_data_[MAX_LINE_LENGTH];
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
bool bluetooth_on_{false};
bool dynamic_background_correction_active_{false};
+2 -2
View File
@@ -184,7 +184,7 @@ void LD2450Component::setup() {
}
void LD2450Component::dump_config() {
char mac_s[18];
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char version_s[20];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ld24xx::format_version_str(this->version_, version_s);
@@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[18];
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
ESP_LOGV(TAG, "MAC address: %s", mac_str);
#ifdef USE_TEXT_SENSOR
+1 -1
View File
@@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice {
uint32_t moving_presence_millis_ = 0;
uint32_t timeout_ = 5;
uint8_t buffer_data_[MAX_LINE_LENGTH];
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer
uint8_t zone_type_ = 0;
+1 -2
View File
@@ -45,8 +45,7 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X";
// Helper function to format MAC address with stack allocation
// Returns pointer to UNKNOWN_MAC constant or formatted buffer
// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator)
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, 18> buffer) {
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
if (mac_address_is_valid(mac_address)) {
format_mac_addr_upper(mac_address, buffer.data());
return buffer.data();
+3 -3
View File
@@ -236,7 +236,7 @@ static void ble_scan_callback(void *param) {
// downstream the value is used exactly like on ESP32.
const int8_t raw = info->rssi;
memcpy(slot->mac, info->trans_addr, 6);
memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE);
slot->rssi = (raw > 20) ? static_cast<int8_t>(-raw) : raw;
slot->addr_type = info->trans_addr_type;
slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG;
@@ -407,7 +407,7 @@ void LN882HBLE::resolve_mac_() {
ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC");
}
if (!have_unique_addr) {
uint8_t wifi_mac[6] = {0};
uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0};
get_mac_address_raw(wifi_mac); // MSB-first
// Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment
// the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the
@@ -421,7 +421,7 @@ void LN882HBLE::resolve_mac_() {
ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored");
}
}
memcpy(this->ble_mac_, bt_addr.addr, 6);
memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE);
}
// ---------------------------------------------------------------------------
+3 -3
View File
@@ -23,8 +23,8 @@ enum class BLEComponentState : uint8_t {
/// One scan report from the controller, decoded from the SDK's rw-task event
/// (RSSI already sign-corrected).
struct BLEScanReport {
uint8_t mac[6]; // as the controller delivers it (LSB-first)
int8_t rssi; // signed dBm (-127..+20)
uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first)
int8_t rssi; // signed dBm (-127..+20)
uint8_t addr_type;
bool is_scan_response; // report is a scan response (active scan)
bool scannable; // advertisement may be followed by a scan response
@@ -138,7 +138,7 @@ class LN882HBLE final : public Component {
// Reports rejected by the legacy-only filter (rw-task producer, main-task
// consumer via exchange in loop()).
std::atomic<uint16_t> rejected_reports_{0};
uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it
uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it
BLEComponentState state_{BLEComponentState::STATE_OFF};
bool enable_on_boot_{false};
bool scanning_{false}; // controller scan running (re-entry guard for scan_start)
@@ -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_) {
@@ -90,8 +90,8 @@ class LN882HBLETracker : public Component,
}
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) {
uint8_t mac[6];
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
uint8_t mac[MAC_ADDRESS_SIZE];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
@@ -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)
+113 -3
View File
@@ -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
@@ -4,6 +4,7 @@
#include <array>
#include <cmath>
#include <numeric>
#include "mitsubishi_cn105_properties.h"
namespace esphome::mitsubishi_cn105 {
@@ -11,8 +12,6 @@ static const char *const TAG = "mitsubishi_cn105.driver";
static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000;
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10;
static constexpr size_t HEADER_LEN = 5;
static constexpr uint8_t PREAMBLE = 0xFC;
@@ -31,86 +30,6 @@ static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61;
template<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
static constexpr auto UNKNOWN_VALUE = Unknown;
const std::array<value_type, N> table;
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; }
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
static_assert(N <= std::numeric_limits<uint8_t>::max());
if (value == UNKNOWN_VALUE) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
if (this->table[i] == value) {
out = i;
return true;
}
}
return false;
}
constexpr bool is_valid(value_type value) const {
uint8_t raw;
return reverse_lookup(value, raw);
}
};
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{std::to_array(values)};
}
static constexpr auto PROTOCOL_MODE_MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
MitsubishiCN105::Mode::UNKNOWN, // 0x00
MitsubishiCN105::Mode::HEAT, // 0x01
MitsubishiCN105::Mode::DRY, // 0x02
MitsubishiCN105::Mode::COOL, // 0x03
MitsubishiCN105::Mode::UNKNOWN, // 0x04
MitsubishiCN105::Mode::UNKNOWN, // 0x05
MitsubishiCN105::Mode::UNKNOWN, // 0x06
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
MitsubishiCN105::Mode::AUTO // 0x08
});
static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
MitsubishiCN105::FanMode::AUTO, // 0x00
MitsubishiCN105::FanMode::QUIET, // 0x01
MitsubishiCN105::FanMode::SPEED_1, // 0x02
MitsubishiCN105::FanMode::SPEED_2, // 0x03
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
MitsubishiCN105::FanMode::SPEED_3, // 0x05
MitsubishiCN105::FanMode::SPEED_4 // 0x06
});
static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
MitsubishiCN105::VaneMode::AUTO, // 0x00
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
MitsubishiCN105::VaneMode::SWING // 0x07
});
static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
MitsubishiCN105::WideVaneMode::SWING // 0x0C
});
static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) {
return static_cast<uint8_t>(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0}));
}
@@ -124,10 +43,6 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
return packet;
}
static constexpr float decode_temperature(int temp_a, int temp_b, int delta) {
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
}
static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, CONNECT_REQUEST_PAYLOAD);
void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
@@ -277,14 +192,14 @@ bool MitsubishiCN105::should_request_telemetry_() const {
return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_;
}
void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) {
FrameParser::dump_buffer_vv("TX", packet, len);
this->device_.write_array(packet, len);
void MitsubishiCN105::send_packet_(std::span<const uint8_t> packet) {
FrameParser::dump_buffer_vv("TX", packet.data(), packet.size());
this->device_.write_array(packet.data(), packet.size());
this->operation_start_ms_ = get_loop_time_ms();
}
void MitsubishiCN105::update_status_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload = {this->current_status_msg_type_};
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{this->current_status_msg_type_};
this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload));
}
@@ -336,12 +251,22 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
}
bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) {
Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_};
switch (msg_type) {
case STATUS_MSG_SETTINGS:
return this->parse_status_settings_(payload, len);
if (!decoder.decode_settings(this->status_)) {
ESP_LOGVV(TAG, "RX settings payload too short");
return false;
}
return true;
case STATUS_MSG_TELEMETRY:
return this->parse_status_telemetry_(payload, len);
if (!decoder.decode_room_temperature(this->status_)) {
ESP_LOGVV(TAG, "RX telemetry payload too short");
return false;
}
this->last_telemetry_update_ms_ = get_loop_time_ms();
return true;
default:
ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type);
@@ -349,54 +274,6 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay
}
}
bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) {
if (len <= 10) {
ESP_LOGVV(TAG, "RX settings payload too short");
return false;
}
if (!this->pending_updates_.contains(UpdateFlag::POWER)) {
this->status_.power_on = payload[2] != 0;
}
this->use_temperature_encoding_b_ = payload[10] != 0;
if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
}
if (!this->pending_updates_.contains(UpdateFlag::MODE)) {
const bool i_see = payload[3] > 0x08;
this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0));
}
if (!this->pending_updates_.contains(UpdateFlag::FAN)) {
this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]);
}
if (!this->pending_updates_.contains(UpdateFlag::VANE)) {
this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]);
}
this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80;
if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) {
this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F);
}
return true;
}
bool MitsubishiCN105::parse_status_telemetry_(const uint8_t *payload, size_t len) {
if (len <= 5) {
ESP_LOGVV(TAG, "RX telemetry payload too short");
return false;
}
this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10);
this->last_telemetry_update_ms_ = get_loop_time_ms();
return true;
}
void MitsubishiCN105::set_remote_temperature(float temperature) {
if (std::isnan(temperature)) {
ESP_LOGD(TAG, "Ignoring NaN remote temperature");
@@ -415,12 +292,12 @@ void MitsubishiCN105::clear_remote_temperature() {
void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) {
this->remote_temperature_half_deg_ = temperature_half_deg;
this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE);
this->pending_updates_.set(Property::Temperature::Remote::ID);
}
void MitsubishiCN105::set_power(bool power_on) {
this->status_.power_on = power_on;
this->pending_updates_.set(UpdateFlag::POWER);
this->pending_updates_.set(Property::Power::ID);
}
void MitsubishiCN105::set_target_temperature(float target_temperature) {
@@ -429,101 +306,42 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
return;
}
this->status_.target_temperature = target_temperature;
this->pending_updates_.set(UpdateFlag::TEMPERATURE);
this->pending_updates_.set(Property::Temperature::Target::ID);
}
void MitsubishiCN105::set_mode(Mode mode) {
if (!PROTOCOL_MODE_MAP.is_valid(mode)) {
ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast<uint8_t>(mode));
return;
if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast<uint8_t>(mode));
}
this->status_.mode = mode;
this->pending_updates_.set(UpdateFlag::MODE);
}
void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) {
ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
return;
if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
}
this->status_.fan_mode = fan_mode;
this->pending_updates_.set(UpdateFlag::FAN);
}
void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) {
if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) {
ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
return;
if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
}
this->status_.vane_mode = vane_mode;
this->pending_updates_.set(UpdateFlag::VANE);
}
void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) {
if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) {
ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
return;
if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
}
this->status_.wide_vane_mode = wide_vane_mode;
this->pending_updates_.set(UpdateFlag::WIDE_VANE);
}
void MitsubishiCN105::apply_settings_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{};
Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_};
// Apply all other pending settings first; handle REMOTE_TEMPERATURE last
if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) {
payload[0] = 0x07;
if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) {
payload[3] = 0x80;
} else {
payload[1] = 0x01;
payload[2] = static_cast<uint8_t>(this->remote_temperature_half_deg_ - 16);
payload[3] = static_cast<uint8_t>(this->remote_temperature_half_deg_ + 128);
}
this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE);
if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) {
encoder.encode_remote_temperature(this->remote_temperature_half_deg_);
} else {
payload[0] = 0x01;
if (this->pending_updates_.contains(UpdateFlag::POWER)) {
payload[1] |= 0x01;
payload[3] = this->status_.power_on ? 0x01 : 0x00;
}
if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[5] =
static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
}
}
if (this->pending_updates_.contains(UpdateFlag::MODE) &&
PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) {
payload[1] |= 0x02;
}
if (this->pending_updates_.contains(UpdateFlag::FAN) &&
PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) {
payload[1] |= 0x08;
}
if (this->pending_updates_.contains(UpdateFlag::VANE) &&
PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) {
payload[1] |= 0x10;
}
if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) &&
PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) {
payload[2] |= 0x01;
if (this->set_wide_vane_high_bit_) {
payload[13] |= 0x80;
}
}
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN,
UpdateFlag::VANE, UpdateFlag::WIDE_VANE);
encoder.encode_settings(this->status_);
}
this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload));
@@ -5,6 +5,7 @@
#include <cmath>
#include <optional>
#include <span>
namespace esphome::mitsubishi_cn105 {
@@ -121,44 +122,47 @@ class MitsubishiCN105 {
uint8_t read_pos_{0};
};
enum class UpdateFlag : uint8_t {
enum class PropertyId : uint8_t {
TEMPERATURE = 0,
POWER = 1,
MODE = 2,
FAN = 3,
VANE = 4,
WIDE_VANE = 5,
REMOTE_TEMPERATURE = 6,
REMOTE_TEMPERATURE = 6
};
struct UpdateFlags {
template<typename... Flags> void set(Flags... flags) { (this->mask_.insert(flags), ...); }
template<typename... Flags> void clear(Flags... flags) { (this->mask_.erase(flags), ...); }
void set(PropertyId id) { this->mask_.insert(id); }
void clear(PropertyId id) { this->mask_.erase(id); }
bool any() const { return !this->mask_.empty(); }
bool contains(UpdateFlag flag) const { return this->mask_.count(flag); }
bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); }
bool contains(PropertyId id) const { return this->mask_.count(id); }
bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); }
protected:
using Mask =
FiniteSetMask<UpdateFlag, DefaultBitPolicy<UpdateFlag, static_cast<int>(UpdateFlag::REMOTE_TEMPERATURE) + 1>>;
FiniteSetMask<PropertyId, DefaultBitPolicy<PropertyId, static_cast<int>(PropertyId::REMOTE_TEMPERATURE) + 1>>;
Mask mask_;
};
struct PropertyContext {
bool use_temperature_encoding_b{false};
bool set_wide_vane_high_bit{false};
};
friend struct Property;
void set_state_(State new_state);
void did_transition_(State to);
bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len);
bool process_status_packet_(const uint8_t *payload, size_t len);
bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len);
bool parse_status_settings_(const uint8_t *payload, size_t len);
bool parse_status_telemetry_(const uint8_t *payload, size_t len);
void send_packet_(const uint8_t *packet, size_t len);
void send_packet_(std::span<const uint8_t> packet);
void update_status_();
bool should_request_telemetry_() const;
void apply_settings_();
bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); }
void set_remote_temperature_half_deg_(uint8_t temperature_half_deg);
template<typename T> void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); }
static bool should_transition(State from, State to);
static const LogString *state_to_string(State state);
@@ -175,8 +179,7 @@ class MitsubishiCN105 {
Status status_{};
State state_{State::NOT_CONNECTED};
UpdateFlags pending_updates_;
bool use_temperature_encoding_b_{false};
bool set_wide_vane_high_bit_{false};
PropertyContext property_context_;
FrameParser frame_parser_;
uint8_t current_status_msg_type_{0};
@@ -133,9 +133,7 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
}
}
if (this->parent_->is_status_initialized()) {
this->apply_values_();
}
this->parent_->publish_status();
}
void MitsubishiCN105Climate::apply_values_() {
@@ -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,9 +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->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
@@ -0,0 +1,302 @@
#pragma once
#include <array>
#include <cmath>
#include <cstdint>
#include <limits>
#include <type_traits>
#include <utility>
#include "mitsubishi_cn105.h"
namespace esphome::mitsubishi_cn105 {
template<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
const std::array<value_type, N> table;
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; }
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
static_assert(N <= std::numeric_limits<uint8_t>::max());
if (value == Unknown) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
if (this->table[i] == value) {
out = i;
return true;
}
}
return false;
}
};
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{std::to_array(values)};
}
struct Property {
using PropertyId = MitsubishiCN105::PropertyId;
using Status = MitsubishiCN105::Status;
using PropertyContext = MitsubishiCN105::PropertyContext;
struct Power {
static constexpr auto ID = PropertyId::POWER;
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
status.power_on = payload[2] != 0;
}
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
payload[1] |= 0x01;
payload[3] = status.power_on ? 0x01 : 0x00;
}
};
struct Temperature {
struct Target {
static constexpr auto ID = PropertyId::TEMPERATURE;
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {
ctx.use_temperature_encoding_b = payload[10] != 0;
}
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
}
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
payload[1] |= 0x04;
if (ctx.use_temperature_encoding_b) {
payload[14] = static_cast<uint8_t>(std::round(status.target_temperature * 2.0f) + 128);
} else {
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature));
}
}
};
struct Room {
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
status.room_temperature = Temperature::decode(payload[2], payload[5], 10);
}
};
struct Remote {
static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE;
static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) {
if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) {
payload[3] = 0x80;
} else {
payload[1] = 0x01;
payload[2] = static_cast<uint8_t>(remote_temperature_half_deg - 16);
payload[3] = static_cast<uint8_t>(remote_temperature_half_deg + 128);
}
}
};
protected:
static constexpr float decode(int temp_a, int temp_b, int delta) {
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
}
};
template<typename Derived, auto Field> struct Lookup {
using Value = std::remove_cvref_t<decltype(std::declval<Status>().*Field)>;
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {}
static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) {
status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx));
}
static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) {
uint8_t raw;
if (Derived::MAP.reverse_lookup(status.*Field, raw)) {
Derived::encode_raw(payload, raw, ctx);
}
}
template<typename Mask> static bool validate_and_set(Value value, Status &status, Mask &mask) {
uint8_t raw;
if (!Derived::MAP.reverse_lookup(value, raw)) {
return false;
}
status.*Field = value;
mask.set(Derived::ID);
return true;
}
private:
friend Derived;
constexpr Lookup() = default;
};
struct Mode : Lookup<Mode, &Status::mode> {
static constexpr auto ID = PropertyId::MODE;
static constexpr auto MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
MitsubishiCN105::Mode::UNKNOWN, // 0x00
MitsubishiCN105::Mode::HEAT, // 0x01
MitsubishiCN105::Mode::DRY, // 0x02
MitsubishiCN105::Mode::COOL, // 0x03
MitsubishiCN105::Mode::UNKNOWN, // 0x04
MitsubishiCN105::Mode::UNKNOWN, // 0x05
MitsubishiCN105::Mode::UNKNOWN, // 0x06
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
MitsubishiCN105::Mode::AUTO // 0x08
});
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) {
const bool i_see = payload[3] > 0x08;
return payload[3] - (i_see ? 0x08 : 0);
}
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
payload[1] |= 0x02;
payload[4] = raw;
}
};
struct FanMode : Lookup<FanMode, &Status::fan_mode> {
static constexpr auto ID = PropertyId::FAN;
static constexpr auto MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
MitsubishiCN105::FanMode::AUTO, // 0x00
MitsubishiCN105::FanMode::QUIET, // 0x01
MitsubishiCN105::FanMode::SPEED_1, // 0x02
MitsubishiCN105::FanMode::SPEED_2, // 0x03
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
MitsubishiCN105::FanMode::SPEED_3, // 0x05
MitsubishiCN105::FanMode::SPEED_4 // 0x06
});
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; }
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
payload[1] |= 0x08;
payload[6] = raw;
}
};
struct VaneMode : Lookup<VaneMode, &Status::vane_mode> {
static constexpr auto ID = PropertyId::VANE;
static constexpr auto MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
MitsubishiCN105::VaneMode::AUTO, // 0x00
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
MitsubishiCN105::VaneMode::SWING // 0x07
});
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; }
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) {
payload[1] |= 0x10;
payload[7] = raw;
}
};
struct WideVaneMode : Lookup<WideVaneMode, &Status::wide_vane_mode> {
static constexpr auto ID = PropertyId::WIDE_VANE;
static constexpr auto MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
MitsubishiCN105::WideVaneMode::SWING // 0x0C
});
static void decode_context(PropertyContext &ctx, const uint8_t *payload) {
ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80;
}
static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; }
static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) {
payload[2] |= 0x01;
payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw;
}
};
template<typename Mask> struct Decoder {
const std::span<const uint8_t> payload;
PropertyContext &context;
const Mask &pending_writes;
bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) {
if (this->payload.size() <= 10) {
return false;
}
this->decode_<Power, Temperature::Target, Mode, FanMode, VaneMode, WideVaneMode>(status);
return true;
}
bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) {
if (this->payload.size() <= 5) {
return false;
}
this->decode_<Temperature::Room>(status);
return true;
}
protected:
template<typename T, typename Out> ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) {
T::decode_context(this->context, this->payload.data());
if constexpr (requires { T::ID; }) {
if (this->pending_writes.contains(T::ID)) {
return;
}
}
T::decode(out, this->payload.data(), this->context);
}
template<typename... T, typename Out> void ESPHOME_ALWAYS_INLINE decode_(Out &out) {
(this->decode_one_<T>(out), ...);
}
};
template<typename Mask> struct Encoder {
uint8_t *payload;
const PropertyContext &context;
Mask &pending_writes;
void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) {
this->payload[0] = 0x01;
this->encode_and_clear_<Power, Temperature::Target, WideVaneMode, VaneMode, Mode, FanMode>(status);
}
void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) {
this->payload[0] = 0x07;
this->encode_and_clear_<Temperature::Remote>(remote_temperature_half_deg);
}
protected:
template<typename... T, typename In> void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) {
(this->encode_one_<T>(in), ...);
(this->pending_writes.clear(T::ID), ...);
}
template<typename T, typename In> void encode_one_(const In &in) {
if (this->pending_writes.contains(T::ID)) {
T::encode(this->payload, in, this->context);
}
}
};
};
} // namespace esphome::mitsubishi_cn105
@@ -0,0 +1,45 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import (
MITSUBISHI_CN105_DEVICE_SCHEMA,
VERTICAL_VANE_DIRECTIONS,
MitsubishiCN105Component,
mitsubishi_ns,
register_mitsubishi_cn105_device,
)
DEPENDENCIES = ["mitsubishi_cn105"]
CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction"
MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_(
"MitsubishiCN105VerticalVaneDirectionSelect",
select.Select,
cg.Component,
cg.Parented.template(MitsubishiCN105Component),
)
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema(
MitsubishiCN105VerticalVaneDirectionSelect,
icon="mdi:arrow-up-down",
),
}
).extend(MITSUBISHI_CN105_DEVICE_SCHEMA)
async def to_code(config: ConfigType) -> None:
if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION):
var = cg.new_Pvariable(vertical_vane_direction[CONF_ID])
await cg.register_component(var, vertical_vane_direction)
await select.register_select(
var,
vertical_vane_direction,
options=[direction.capitalize() for direction in VERTICAL_VANE_DIRECTIONS],
)
await register_mitsubishi_cn105_device(var, config)
@@ -0,0 +1,39 @@
#include "mitsubishi_cn105_vane_select_vertical.h"
#include <array>
namespace esphome::mitsubishi_cn105 {
// 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{
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2,
MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5,
MitsubishiCN105::VaneMode::SWING,
};
void MitsubishiCN105VerticalVaneDirectionSelect::setup() {
this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); });
if (this->parent_->is_status_initialized()) {
this->publish_vane_state(this->parent_->status().vane_mode);
}
}
void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) {
if (index < VALUES.size()) {
this->parent_->set_vane_mode(VALUES[index]);
this->parent_->publish_status();
}
}
void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) {
for (size_t i = 0; i < VALUES.size(); ++i) {
if (VALUES[i] == mode) {
this->publish_state(i);
return;
}
}
}
} // namespace esphome::mitsubishi_cn105
@@ -0,0 +1,21 @@
#pragma once
#include "../mitsubishi_cn105_component.h"
#include "esphome/components/select/select.h"
#include "esphome/core/component.h"
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select,
public Component,
public Parented<MitsubishiCN105Component> {
public:
void setup() override;
void publish_vane_state(MitsubishiCN105::VaneMode mode);
protected:
void control(size_t index) override;
};
} // namespace esphome::mitsubishi_cn105
+1
View File
@@ -28,6 +28,7 @@ MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000
MAX_NUM_OF_COILS_TO_WRITE = 1968
MAX_NUM_OF_REGISTERS_TO_READ = 125
MAX_NUM_OF_REGISTERS_TO_WRITE = 123
MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121
modbus_ns = cg.esphome_ns.namespace("modbus")
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
+44 -6
View File
@@ -382,7 +382,7 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) {
}
ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) {
if ((uint32_t) start_address + count > 0x10000u) {
if (!helpers::address_range_fits(start_address, count)) {
ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count);
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
@@ -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) {
@@ -1056,7 +1081,8 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
continue;
if (device == nullptr) {
// A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device).
const bool requeueable = !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read(pdu[0]);
const bool requeueable =
!helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]);
if (requeueable) {
ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]);
} else {
@@ -1236,7 +1262,14 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
switch (function_code) {
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: {
case FunctionCode::READ_INPUT_REGISTERS:
// FC 0x17 lands here too: its read start address and read quantity sit at the same request offsets as a
// plain read's (bytes 1..2 and 3..4), so start_address and count_or_value already hold the read block; its
// response carries only that read data, and the write half is confirmed by the response arriving at all.
// An exception routes here as well (the gate only validates the request when status is set), delivering
// empty registers with the error in status - so a 0x17 subclass handles success and failure in the one
// on_read_holding_registers() callback and never needs to also override on_error().
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
// Decode the big-endian register words into host byte order. The gate guarantees a success response
// carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the
// capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On
@@ -1248,10 +1281,15 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
}
}
std::span<const uint16_t> register_span(registers.data(), registers.size());
if (function_code == FunctionCode::READ_HOLDING_REGISTERS) {
if (function_code == FunctionCode::READ_INPUT_REGISTERS) {
this->on_read_input_registers(start_address, register_span, status);
} else if (function_code == FunctionCode::READ_HOLDING_REGISTERS ||
function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) {
this->on_read_holding_registers(start_address, register_span, status);
} else {
this->on_read_input_registers(start_address, register_span, status);
// Unreachable for the current case labels; match explicitly so a function code added to this group
// later is diverted to on_custom_response() rather than silently delivered as a holding read.
this->on_custom_response(request_pdu, response_pdu, status);
}
break;
}
+36 -15
View File
@@ -151,9 +151,7 @@ struct ModbusDeviceCommand {
static CommandPriority classify(uint8_t function_code) {
if (helpers::is_function_code_exception(function_code))
return CommandPriority::READ;
const auto code = static_cast<FunctionCode>(function_code);
if (helpers::is_function_code_write(function_code) || code == FunctionCode::MASK_WRITE_REGISTER ||
code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) {
if (helpers::is_function_code_write(function_code)) {
return CommandPriority::WRITE;
}
return CommandPriority::READ;
@@ -162,7 +160,7 @@ struct ModbusDeviceCommand {
// Requests this entry can serve: a standard read twice (run plus one re-run), everything else once.
uint8_t max_pending() const {
const uint8_t fc = this->frame.pdu()[0];
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read(fc);
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
return (requeueable && !this->continuous) ? 2 : 1;
}
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
@@ -173,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;
@@ -272,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,
@@ -413,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
@@ -533,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);
}
@@ -550,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,
@@ -594,6 +605,16 @@ class ModbusClientDevice {
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
}
/// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the
/// read registers, the same wire shape as a holding-register read). A device exception - typically a
/// rejected write half - arrives at that same on_read_holding_registers() with the error in its status,
/// exactly as success does, so a subclass overriding that one callback handles both outcomes and never
/// needs to also override on_error().
bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address,
std::span<const uint16_t> write_values) {
return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count,
write_start_address, write_values));
}
inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); }
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
+62 -27
View File
@@ -8,10 +8,11 @@ namespace esphome::modbus::helpers {
static const char *const TAG = "modbus_helpers";
// A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum,
// and the range [start_address, start_address + quantity) stays inside the 16-bit address space
// (the 32-bit promotion is the overflow guard - a 16-bit sum could wrap and pass).
// and the range [start_address, start_address + quantity) stays inside the 16-bit address space.
// Non-logging twin of register_block_in_range(): the same three predicates for the parser side, taking a
// uint16_t quantity. register_block_in_range() is the builder-side variant that also logs which half failed.
static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) {
return quantity != 0 && quantity <= max_quantity && uint32_t(start_address) + quantity <= 0x10000u;
return quantity != 0 && quantity <= max_quantity && address_range_fits(start_address, quantity);
}
// The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and
@@ -307,16 +308,20 @@ std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t cou
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
}
// Append a 16-bit value to a PDU in big-endian (wire) byte order.
template<size_t CAP> static void append_pdu_word(StaticVector<uint8_t, CAP> &pdu, uint16_t value) {
pdu.push_back(value >> 8);
pdu.push_back(value >> 0);
}
// Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit
// fields (start address + quantity for reads and multi-writes, address + value for single writes).
template<size_t CAP>
static void append_pdu_header(StaticVector<uint8_t, CAP> &pdu, FunctionCode function_code, uint16_t first,
uint16_t second) {
pdu.push_back(static_cast<uint8_t>(function_code));
pdu.push_back(first >> 8);
pdu.push_back(first >> 0);
pdu.push_back(second >> 8);
pdu.push_back(second >> 0);
append_pdu_word(pdu, first);
append_pdu_word(pdu, second);
}
// Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one
@@ -335,7 +340,7 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
return pdu;
}
if (uint32_t(start_address) + number_of_entities > 0x10000u) {
if (!address_range_fits(start_address, number_of_entities)) {
ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
start_address);
return pdu;
@@ -378,7 +383,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
// Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
// create_write_registers_pdu(), etc.) which bound their inputs per spec.
if (is_function_code_read(static_cast<uint8_t>(function_code))) {
if (is_function_code_read_only(static_cast<uint8_t>(function_code))) {
if (values != nullptr || values_len > 0) {
ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored",
static_cast<uint8_t>(function_code));
@@ -417,7 +422,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
static_cast<uint8_t>(function_code));
return pdu;
}
if (!is_single && uint32_t(start_address) + number_of_entities > 0x10000u) {
if (!is_single && !address_range_fits(start_address, number_of_entities)) {
ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
start_address);
return pdu;
@@ -460,29 +465,59 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
return pdu;
}
// Validate one register block for a client builder: a non-zero quantity within max_quantity that does not
// run past the 16-bit address space (register count × 2 stays within MAX_PDU_SIZE as a result). On failure
// it logs the reason and returns false, on which the caller returns an empty PDU. `role` names the block in
// the log ("Read"/"Write"). Logging twin of quantity_in_range(): the same three predicates, split so each
// failure names its reason, and taking size_t so an oversize span is caught before any narrowing.
static bool register_block_in_range(const LogString *role, uint16_t start_address, size_t quantity,
uint16_t max_quantity) {
if (quantity == 0 || quantity > max_quantity) {
ESP_LOGE(TAG, "%s count %zu out of range [1, %u], dropping request", LOG_STR_ARG(role), quantity, max_quantity);
return false;
}
if (!address_range_fits(start_address, quantity)) {
ESP_LOGE(TAG, "%s of %zu registers at %u runs past the 16-bit address space, dropping request", LOG_STR_ARG(role),
quantity, start_address);
return false;
}
return true;
}
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
if (values.empty()) {
ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request");
return pdu;
}
// Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE.
if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(),
MAX_NUM_OF_REGISTERS_TO_WRITE);
return pdu;
}
if (uint32_t(start_address) + values.size() > 0x10000u) {
ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(),
start_address);
if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) {
return pdu;
}
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size());
pdu.push_back(static_cast<uint8_t>(values.size() * 2)); // byte count
for (auto v : values) {
auto decoded_value = decode_value(v);
pdu.push_back(decoded_value[0]);
pdu.push_back(decoded_value[1]);
append_pdu_word(pdu, v);
}
return pdu;
}
PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count,
uint16_t write_start_address,
std::span<const uint16_t> write_values) {
PduBuffer pdu;
if (!register_block_in_range(LOG_STR("Read"), read_start_address, read_count, MAX_NUM_OF_REGISTERS_TO_READ)) {
return pdu;
}
if (!register_block_in_range(LOG_STR("Write"), write_start_address, write_values.size(),
MAX_NUM_OF_REGISTERS_TO_WRITE_RW)) {
return pdu;
}
// fc + read start(2) + read qty(2) + write start(2) + write qty(2) + write byte count(1) + write values.
const auto write_count = static_cast<uint16_t>(write_values.size());
pdu.push_back(static_cast<uint8_t>(FunctionCode::READ_WRITE_MULTIPLE_REGISTERS));
append_pdu_word(pdu, read_start_address);
append_pdu_word(pdu, read_count);
append_pdu_word(pdu, write_start_address);
append_pdu_word(pdu, write_count);
pdu.push_back(static_cast<uint8_t>(write_count * 2)); // byte count
for (auto v : write_values) {
append_pdu_word(pdu, v);
}
return pdu;
}
@@ -512,7 +547,7 @@ static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, Packed
ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE);
return;
}
if (uint32_t(start_address) + count > 0x10000u) {
if (!address_range_fits(start_address, count)) {
ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address);
return;
}
+35 -4
View File
@@ -11,7 +11,8 @@
namespace esphome::modbus::helpers {
inline bool is_function_code_read(uint8_t function_code) {
// Pure read codes (0x01-0x04): they only read, so they are idempotent and safe to retry.
inline bool is_function_code_read_only(uint8_t function_code) {
FunctionCode masked_function_code = static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK);
return masked_function_code == FunctionCode::READ_COILS ||
masked_function_code == FunctionCode::READ_DISCRETE_INPUTS ||
@@ -19,12 +20,27 @@ inline bool is_function_code_read(uint8_t function_code) {
masked_function_code == FunctionCode::READ_INPUT_REGISTERS;
}
// Codes whose response carries read-back data: the pure reads plus 0x17, which reads and writes at once.
inline bool is_function_code_read(uint8_t function_code) {
return is_function_code_read_only(function_code) ||
static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK) == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS;
}
// Codes that mutate registers or coils: the pure writes, 0x16 mask-write, and 0x17 read/write multiple.
inline bool is_function_code_write(uint8_t function_code) {
FunctionCode masked_function_code = static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK);
return masked_function_code == FunctionCode::WRITE_SINGLE_COIL ||
masked_function_code == FunctionCode::WRITE_SINGLE_REGISTER ||
masked_function_code == FunctionCode::WRITE_MULTIPLE_COILS ||
masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS;
masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS ||
masked_function_code == FunctionCode::MASK_WRITE_REGISTER ||
masked_function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS;
}
// True if [start_address, start_address + count) fits within the 16-bit Modbus address space. The 32-bit
// promotion is the overflow guard - a 16-bit sum could wrap and pass.
inline bool address_range_fits(uint16_t start_address, size_t count) {
return uint32_t(start_address) + count <= 0x10000u;
}
inline bool is_function_code_exception(uint8_t function_code) {
@@ -90,8 +106,8 @@ inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) {
}
/** Returns the payload portion of a server response PDU: the bytes after the function code, and for the
* standard read responses (0x01-0x04) also after the byte-count byte. Responses to 0x14/0x17 also carry a
* byte-count byte, but those codes are not implemented and their count byte is left in the payload. For
* read responses (0x01-0x04 and 0x17) also after the byte-count byte. Response 0x14 also carries a
* byte-count byte, but that code is not implemented and its count byte is left in the payload. For
* an exception PDU the payload is the exception code byte (the read check must not see the masked
* function code, or an exception-of-read would classify as a read and return an empty span). Returns an
* empty span if the PDU is too short.
@@ -432,6 +448,21 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
*/
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values);
/** Create modbus read/write multiple registers command
* Function 0x17 Read/Write Multiple Registers
* Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17);
* the response carries only the read registers.
* @param read_start_address modbus address of the first register to read back
* @param read_count number of registers to read (at most MAX_NUM_OF_REGISTERS_TO_READ)
* @param write_start_address modbus address of the first register to write
* @param write_values register values to write; the register count is write_values.size() (at most
* MAX_NUM_OF_REGISTERS_TO_WRITE_RW). Any contiguous uint16_t container converts.
* @return PDU (function code + data, no address, no CRC); an empty PDU on any out-of-range input
*/
PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count,
uint16_t write_start_address,
std::span<const uint16_t> write_values);
/** Create modbus write single register command
* Function 0x06 Write Single Register
* @param start_address modbus address of the register to write

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