Compare commits

..
Author SHA1 Message Date
kbx81 890f0408a5 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-08-06 23:27:44 -05:00
puddly 3e0f81bf1e Improve Zigbee/WiFi collision warning 2026-08-05 14:40:38 -04:00
puddly 6111791706 Have zigbee proxying piggyback off of serial proxying? 2026-08-05 14:40:38 -04:00
puddly 7a05749231 Classify UART traffic for arbitrary protocol passthrough 2026-08-05 14:40:38 -04:00
puddly 997e218376 Reset state more reliably 2026-08-05 14:40:38 -04:00
puddly 973da47da6 Simplify startup state machine by using direct NVRAM access 2026-08-05 14:40:38 -04:00
puddly e80aa9579b Handle more of the EZSP protocol and try to detect the bootloader 2026-08-05 14:40:38 -04:00
puddly a060db1251 Fix EZSP and ASH protocol parsing/forwarding 2026-08-05 14:39:01 -04:00
kbx81 9c4016a871 [zigbee_proxy] Drop usb_uart_id removal error, component is unreleased 2026-08-03 16:58:53 -05:00
kbx81 5846977cf6 [zigbee_proxy] Auto-detect USB UART channel from uart_id, drop usb_uart_id
The usb_uart_id key was redundant: uart_id already points at the channel.
A new usb_uart.is_usb_uart_channel() helper checks the config tree (use_id
resolution does not narrow the ID type), and zigbee_proxy uses it to enable
the RX callback fast path and USB timeout defaults automatically.
2026-08-03 16:55:28 -05:00
kbx81 ca42862742 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-08-03 16:32:34 -05:00
kbx81 e0a054dbcb [zigbee_proxy] Harden ASH sessions, fix UAF/boot-stall/overflows, reduce latency
- Unsubscribe on API disconnect (use-after-free) + loop() subscriber guard
- Bounds-checked frame building; cap forwarded RSTACK/ERROR payloads
- Explicit client ACKs, duplicate re-ACK, NAK on reject (both ASH sides)
- Client->NCP TX queue with NAK overflow; retry client frames on API backpressure
- Harvest EUI64 during boot; implement NETWORK_INFO request/response and push
- Proceed after boot timeout instead of stalling setup; periodic NCP recovery
- zwave-style inline UART fast path; process piggybacked ACKs before sequence check
- Wire up bootloader detection; heap-free hex logging
2026-07-22 23:20:51 -05:00
kbx81 5e822b828e Fix zigbee proxy handlers for new non-virtual dispatch, drop deprecated rp2040 platform key in test 2026-07-22 22:31:12 -05:00
kbx81 ef646a9303 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-07-22 22:20:25 -05:00
kbx81 f8bec0813d fix 2026-03-13 16:48:56 -05:00
kbx81 84762e6ae0 oops 2026-03-13 16:46:13 -05:00
kbx81 2edf313ee3 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-03-13 16:45:23 -05:00
kbx81 ae9c999052 fix 2026-02-28 23:21:30 -06:00
kbx81 7d2f6fbf55 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-28 23:12:31 -06:00
kbx81 608bef86cc Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-26 23:42:43 -06:00
kbx81 6514dc2fe1 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-26 20:55:50 -06:00
kbx81 240afd23b3 ... 2026-02-26 14:31:17 -06:00
kbx81 156c2a8cb0 optimize 2026-02-26 14:30:31 -06:00
kbx81 908c47bb5e preen, tune 2026-02-25 23:28:44 -06:00
kbx81 6df3a30740 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-25 17:33:27 -06:00
kbx81 0aaf59dbed Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-24 16:51:04 -06:00
kbx81 249c5bb724 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-23 18:01:56 -06:00
kbx81 54ea8dd207 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-19 18:31:15 -06:00
puddly 4cfb794b62 WIP 2026-02-19 18:22:03 -05:00
kbx81 917af8ff31 [zigbee_proxy] New component 2026-02-19 14:34:29 -06:00
420 changed files with 5636 additions and 11069 deletions
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
with:
category: "/language:${{matrix.language}}"
-1
View File
@@ -78,7 +78,6 @@ esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow
esphome/components/ble_device_base/* @Bl00d-B0b
esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_connection/* @bdraco @jesserockz
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
esphome/components/bm8563/* @abmantis
esphome/components/bme280_base/* @esphome/core
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.3
RUN \
platformio settings set enable_telemetry No \
+9 -12
View File
@@ -1,26 +1,23 @@
import esphome.codegen as cg
from esphome.components import ble_device_base
from esphome.components import esp32_ble_tracker
import esphome.config_validation as cv
from esphome.const import CONF_ID
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
CODEOWNERS = ["@jeromelaban"]
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
AirthingsListener = airthings_ble_ns.class_(
"AirthingsListener", ble_device_base.ESPBTDeviceListener
"AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener
)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("airthings_ble"),
cv.Schema(
{
cv.GenerateID(): cv.declare_id(AirthingsListener),
}
).extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(AirthingsListener),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await ble_device_base.register_ble_device(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
@@ -2,13 +2,15 @@
#include "esphome/core/log.h"
#include <cinttypes>
#ifdef USE_ESP32
namespace esphome::airthings_ble {
static const char *const TAG = "airthings_ble";
bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) {
bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
for (auto &it : device.get_manufacturer_datas()) {
if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) {
if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) {
if (it.data.size() < 4)
continue;
@@ -27,3 +29,5 @@ bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device)
}
} // namespace esphome::airthings_ble
#endif
@@ -1,13 +1,17 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/core/component.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
namespace esphome::airthings_ble {
class AirthingsListener final : public ble_device_base::ESPBTDeviceListener {
class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener {
public:
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
};
} // namespace esphome::airthings_ble
#endif
+39
View File
@@ -69,6 +69,8 @@ service APIConnection {
rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {}
rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {}
rpc zigbee_proxy_request(ZigbeeProxyRequest) returns (void) {}
rpc infrared_rf_transmit_raw_timings(InfraredRFTransmitRawTimingsRequest) returns (void) {}
rpc serial_proxy_configure(SerialProxyConfigureRequest) returns (void) {}
@@ -76,6 +78,7 @@ service APIConnection {
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
}
@@ -315,6 +318,10 @@ message DeviceInfoResponse {
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
// Indicates if Zigbee proxy support is available and features supported
uint32 zigbee_proxy_feature_flags = 27 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
uint64 zigbee_ieee_address = 28 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
}
message ListEntitiesRequest {
@@ -2731,6 +2738,23 @@ message SerialProxyRequestResponse {
string error_message = 4; // Additional detail on failure (optional)
}
// How a port treats the bytes passing through it. RAW is a plain byte pipe; EZSP_ASH lets
// a protocol-aware tap acknowledge NCP frames and read network metadata. A client that is
// about to flash firmware selects RAW first, which definitively disables that injection.
enum SerialProxyMode {
SERIAL_PROXY_MODE_RAW = 0;
SERIAL_PROXY_MODE_EZSP_ASH = 1;
}
message SerialProxySetModeRequest {
option (id) = 151;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SERIAL_PROXY";
uint32 instance = 1;
SerialProxyMode mode = 2;
}
// ==================== BLUETOOTH CONNECTION PARAMS ====================
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
@@ -2752,3 +2776,18 @@ message BluetoothSetConnectionParamsResponse {
uint64 address = 1;
int32 error = 2;
}
// ==================== ZIGBEE ====================
enum ZigbeeProxyRequestType {
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0;
}
message ZigbeeProxyRequest {
option (id) = 150;
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZIGBEE_PROXY";
ZigbeeProxyRequestType type = 1;
bytes data = 2;
}
+27
View File
@@ -47,6 +47,9 @@
#ifdef USE_ZWAVE_PROXY
#include "esphome/components/zwave_proxy/zwave_proxy.h"
#endif
#ifdef USE_ZIGBEE_PROXY
#include "esphome/components/zigbee_proxy/zigbee_proxy.h"
#endif
#ifdef USE_WATER_HEATER
#include "esphome/components/water_heater/water_heater.h"
#endif
@@ -1377,6 +1380,12 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#ifdef USE_ZIGBEE_PROXY
void APIConnection::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) {
zigbee_proxy::global_zigbee_proxy->zigbee_proxy_request(this, msg);
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) {
return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE,
@@ -1615,6 +1624,15 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
}
}
void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->set_mode(this, msg.mode);
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
#endif
@@ -1722,6 +1740,11 @@ void APIConnection::complete_authentication_() {
zwave_proxy::global_zwave_proxy->api_connection_authenticated(this);
}
#endif
#ifdef USE_ZIGBEE_PROXY
if (zigbee_proxy::global_zigbee_proxy != nullptr) {
zigbee_proxy::global_zigbee_proxy->api_connection_authenticated(this);
}
#endif
}
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
@@ -1871,6 +1894,10 @@ bool APIConnection::send_device_info_response_() {
info.port_type = proxy->get_port_type();
}
#endif
#ifdef USE_ZIGBEE_PROXY
resp.zigbee_proxy_feature_flags = zigbee_proxy::global_zigbee_proxy->get_feature_flags();
resp.zigbee_ieee_address = zigbee_proxy::global_zigbee_proxy->get_ieee_address();
#endif
#ifdef USE_API_NOISE
resp.api_encryption_supported = true;
#ifndef USE_API_NOISE_PSK_FROM_YAML
+5
View File
@@ -218,6 +218,10 @@ class APIConnection final : public APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg);
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
@@ -239,6 +243,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
void on_serial_proxy_request(const SerialProxyRequest &msg);
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
#endif
+61
View File
@@ -173,6 +173,12 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
#endif
#ifdef USE_API_NOISE
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 28, this->zigbee_ieee_address);
#endif
return pos;
}
@@ -238,6 +244,12 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
size += ProtoSize::calc_uint32(2, this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
size += ProtoSize::calc_uint64(2, this->zigbee_ieee_address);
#endif
return size;
}
@@ -4144,6 +4156,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
size += ProtoSize::calc_length(1, this->error_message.size());
return size;
}
bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->instance = value;
break;
case 2:
this->mode = static_cast<enums::SerialProxyMode>(value);
break;
default:
return false;
}
return true;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
@@ -4181,5 +4206,41 @@ uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const {
return size;
}
#endif
#ifdef USE_ZIGBEE_PROXY
bool ZigbeeProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->type = static_cast<enums::ZigbeeProxyRequestType>(value);
break;
default:
return false;
}
return true;
}
bool ZigbeeProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
case 2: {
this->data = value.data();
this->data_len = value.size();
break;
}
default:
return false;
}
return true;
}
uint8_t *ZigbeeProxyRequest::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->type));
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len);
return pos;
}
uint32_t ZigbeeProxyRequest::calculate_size() const {
uint32_t size = 0;
size += this->type ? 2 : 0;
size += ProtoSize::calc_length(1, this->data_len);
return size;
}
#endif
} // namespace esphome::api
+54 -1
View File
@@ -351,6 +351,15 @@ enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_TIMEOUT = 3,
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4,
};
enum SerialProxyMode : uint32_t {
SERIAL_PROXY_MODE_RAW = 0,
SERIAL_PROXY_MODE_EZSP_ASH = 1,
};
#endif
#ifdef USE_ZIGBEE_PROXY
enum ZigbeeProxyRequestType : uint32_t {
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0,
};
#endif
} // namespace enums
@@ -533,7 +542,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 312;
static constexpr uint16_t ESTIMATED_SIZE = 322;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -591,6 +600,12 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif
#ifdef USE_ZIGBEE_PROXY
uint32_t zigbee_proxy_feature_flags{0};
#endif
#ifdef USE_ZIGBEE_PROXY
uint64_t zigbee_ieee_address{0};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
@@ -3289,6 +3304,22 @@ class SerialProxyRequestResponse final : public ProtoMessage {
protected:
};
class SerialProxySetModeRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 151;
static constexpr uint8_t ESTIMATED_SIZE = 6;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); }
#endif
uint32_t instance{0};
enums::SerialProxyMode mode{};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
#endif
#ifdef USE_BLUETOOTH_PROXY
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
@@ -3328,5 +3359,27 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_ZIGBEE_PROXY
class ZigbeeProxyRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("zigbee_proxy_request"); }
#endif
enums::ZigbeeProxyRequestType type{};
const uint8_t *data{nullptr};
uint16_t data_len{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:
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
#endif
} // namespace esphome::api
-2
View File
@@ -3,10 +3,8 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLUETOOTH_PROXY
#ifndef USE_API_VARINT64
#define USE_API_VARINT64
#endif
#endif
namespace esphome::api {} // namespace esphome::api
+40
View File
@@ -856,6 +856,26 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::SerialProxyMode value) {
switch (value) {
case enums::SERIAL_PROXY_MODE_RAW:
return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW");
case enums::SERIAL_PROXY_MODE_EZSP_ASH:
return ESPHOME_PSTR("SERIAL_PROXY_MODE_EZSP_ASH");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
#ifdef USE_ZIGBEE_PROXY
template<> const char *proto_enum_to_string<enums::ZigbeeProxyRequestType>(enums::ZigbeeProxyRequestType value) {
switch (value) {
case enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO:
return ESPHOME_PSTR("ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
const char *HelloRequest::dump_to(DumpBuffer &out) const {
@@ -985,6 +1005,12 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
dump_field(out, ESPHOME_PSTR("zigbee_proxy_feature_flags"), this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
dump_field(out, ESPHOME_PSTR("zigbee_ieee_address"), this->zigbee_ieee_address);
#endif
return out.c_str();
}
@@ -2714,6 +2740,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("error_message"), this->error_message);
return out.c_str();
}
const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
@@ -2732,6 +2764,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
return out.c_str();
}
#endif
#ifdef USE_ZIGBEE_PROXY
const char *ZigbeeProxyRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZigbeeProxyRequest"));
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ZigbeeProxyRequestType>(this->type));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
#endif
} // namespace esphome::api
@@ -704,6 +704,28 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_bluetooth_set_connection_params_request(msg);
break;
}
#endif
#ifdef USE_ZIGBEE_PROXY
case ZigbeeProxyRequest::MESSAGE_TYPE: {
ZigbeeProxyRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_zigbee_proxy_request"), msg);
#endif
this->on_zigbee_proxy_request(msg);
break;
}
#endif
#ifdef USE_SERIAL_PROXY
case SerialProxySetModeRequest::MESSAGE_TYPE: {
SerialProxySetModeRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg);
#endif
this->on_serial_proxy_set_mode_request(msg);
break;
}
#endif
default:
break;
+7
View File
@@ -233,9 +233,16 @@ class APIServerConnectionBase {
void on_serial_proxy_request(const SerialProxyRequest &value){};
#endif
#ifdef USE_SERIAL_PROXY
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &value){};
#endif
};
} // namespace esphome::api
+8
View File
@@ -399,6 +399,14 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#ifdef USE_ZIGBEE_PROXY
void APIServer::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) {
// Very infrequent and small - send to all clients rather than tracking a subscription
for (auto &c : this->active_clients())
c->send_message(msg);
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
const std::vector<int32_t> *timings) {
+3
View File
@@ -186,6 +186,9 @@ class APIServer final : public Component,
#ifdef USE_ZWAVE_PROXY
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg);
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
#endif
@@ -1,6 +1,8 @@
#include "atc_mithermometer.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::atc_mithermometer {
static const char *const TAG = "atc_mithermometer";
@@ -13,7 +15,7 @@ void ATCMiThermometer::dump_config() {
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_);
}
bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) {
bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
if (device.address_uint64() != this->address_) {
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false;
@@ -50,7 +52,7 @@ bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device)
return success;
}
optional<ParseResult> ATCMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) {
optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) {
ParseResult result;
if (!service_data.uuid.contains(0x1A, 0x18)) {
ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes.");
@@ -130,3 +132,5 @@ bool ATCMiThermometer::report_results_(const optional<ParseResult> &result, cons
}
} // namespace esphome::atc_mithermometer
#endif
@@ -2,10 +2,12 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include <vector>
#ifdef USE_ESP32
namespace esphome::atc_mithermometer {
struct ParseResult {
@@ -16,11 +18,11 @@ struct ParseResult {
int raw_offset;
};
class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener {
class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { address_ = address; };
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
void dump_config() override;
void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; }
void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; }
@@ -38,9 +40,11 @@ class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDe
uint8_t last_frame_count_{0};
optional<ParseResult> parse_header_(const ble_device_base::ServiceData &service_data);
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
bool report_results_(const optional<ParseResult> &result, const char *address);
};
} // namespace esphome::atc_mithermometer
#endif
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import ble_device_base, sensor
from esphome.components import esp32_ble_tracker, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
@@ -24,15 +24,14 @@ from esphome.const import (
CODEOWNERS = ["@ahpohl"]
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer")
ATCMiThermometer = atc_mithermometer_ns.class_(
"ATCMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component
"ATCMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("atc_mithermometer"),
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ATCMiThermometer),
@@ -72,15 +71,15 @@ CONFIG_SCHEMA = cv.All(
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
+5 -1
View File
@@ -1,6 +1,8 @@
#include "b_parasite.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::b_parasite {
static const char *const TAG = "b_parasite";
@@ -14,7 +16,7 @@ void BParasite::dump_config() {
LOG_SENSOR(" ", "Illuminance", this->illuminance_);
}
bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) {
bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
if (device.address_uint64() != address_) {
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false;
@@ -111,3 +113,5 @@ bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) {
}
} // namespace esphome::b_parasite
#endif // USE_ESP32
+7 -3
View File
@@ -2,16 +2,18 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#ifdef USE_ESP32
namespace esphome::b_parasite {
class BParasite final : public Component, public ble_device_base::ESPBTDeviceListener {
class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { address_ = address; };
void set_bindkey(const std::string &bindkey);
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
void dump_config() override;
void set_battery_voltage(sensor::Sensor *battery_voltage) { battery_voltage_ = battery_voltage; }
@@ -33,3 +35,5 @@ class BParasite final : public Component, public ble_device_base::ESPBTDeviceLis
};
} // namespace esphome::b_parasite
#endif // USE_ESP32
+6 -7
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import ble_device_base, sensor
from esphome.components import esp32_ble_tracker, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_VOLTAGE,
@@ -23,15 +23,14 @@ from esphome.const import (
CODEOWNERS = ["@rbaron"]
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
b_parasite_ns = cg.esphome_ns.namespace("b_parasite")
BParasite = b_parasite_ns.class_(
"BParasite", ble_device_base.ESPBTDeviceListener, cg.Component
"BParasite", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("b_parasite"),
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BParasite),
@@ -69,15 +68,15 @@ CONFIG_SCHEMA = cv.All(
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
@@ -144,9 +144,6 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_BK72XX_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -159,7 +159,7 @@ void BK72xxBLETracker::dump_config() {
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
// Raw callback (the raw-advertisement path).
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac),
const ble_device_base::RawAdvertisement adv{.mac = report.mac,
.data = report.data,
.data_len = report.data_len,
.rssi = report.rssi,
@@ -45,6 +45,7 @@ namespace esphome::bk72xx_ble_tracker {
// ---------------------------------------------------------------------------
class BK72xxBLETracker : public Component,
public ble_device_base::BLEHub,
public bk72xx_ble::BLEScanListener,
public Parented<bk72xx_ble::BK72xxBLE>
#ifdef USE_OTA_STATE_LISTENER
@@ -92,15 +93,15 @@ class BK72xxBLETracker : public Component,
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
}
static constexpr ble_device_base::HubCapabilities get_capabilities() {
ble_device_base::HubCapabilities get_capabilities() const override {
// The Beken BDK exposes no active-scan path (passive scanning only), so the
// controller never solicits scan responses and never merges them; consumers
// relying on scan-response fields (device names) get them only where the
@@ -109,21 +110,21 @@ class BK72xxBLETracker : public Component,
// path there is no mode to switch to.
return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false};
}
bool request_scan_mode(bool active) {
bool request_scan_mode(bool active) override {
// Passive-only controller: a passive request is already honored, an active
// one cannot be.
return !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]) {
void get_adapter_mac(uint8_t out[6]) override {
uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
}
bool scan_running() { return this->scan_running_; }
bool scan_active() { return false; } // BK72xx scan is passive-only
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return false; } // BK72xx scan is passive-only
// ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
+12 -16
View File
@@ -3,22 +3,19 @@ ble_device_base — the platform-neutral BLE layer.
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h)
on every platform.
(BLEHub, in ble_hub.h) on every platform.
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
configuration declares via `cv.use_id(BLEHub)` ESPHome resolves any declared
subclass, so there is no Python platform table here and no dependency in
either direction (C++-side, the compile-time alias header ble_hub_impl.h and the
defines.h mirror are the deliberate exceptions). A sensor extends
BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a
declared key even on strict schemas) and calls register_ble_device() in
to_code; a tracker component declares BLEHub as its codegen-class parent and
MUST call register_hub_provider() at import time without it _require_hub
rejects configs that bind through the generated id (an explicit ble_hub_id:
bypasses the registry). Adding a new BLE chip requires a new in-tree tracker
component plus its alias arm and define (see above); out-of-tree BLE hubs
are not supported.
subclass, so there is no platform table here and no dependency in either
direction. A sensor extends BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an
explicit ble_hub_id: is a declared key even on strict schemas) and calls
register_ble_device() in to_code; a tracker component subclasses BLEHub (C++
and codegen class) and MUST call register_hub_provider() at import time
without it _require_hub rejects configs that bind through the generated id
(an explicit ble_hub_id: bypasses the registry). Adding a new BLE chip
requires only a new in-tree tracker component; out-of-tree BLE hubs are
not supported.
AES-CCM decryption for encrypted advertisements is provided portably in
ble_aes_ccm.h.
@@ -51,9 +48,8 @@ LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT"
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
# The neutral tracker contract. Every tracker's codegen class declares this as
# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python
# only: C++-side the name is a per-platform alias (ble_hub_impl.h).
# The neutral tracker contract. Every tracker's codegen class declares this as a
# parent, which is what lets cv.use_id(BLEHub) resolve any of them.
BLEHub = ble_device_base_ns.class_("BLEHub")
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
@@ -1,12 +1,11 @@
// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses
// registered on a BLEHub, exposed by each tracker under its own automation
// names. parse_device()'s return feeds the "Found device" suppression.
// Constructors are templated on the hub type so this header also builds with
// no tracker present (host unit tests).
#pragma once
#include "ble_device.h"
#include "ble_hub.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
@@ -19,7 +18,7 @@ namespace esphome::ble_device_base {
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
explicit ESPBTAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); }
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
@@ -40,7 +39,7 @@ class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public
// data for the given UUID. Optional single-MAC filter.
class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
explicit BLEServiceDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); }
void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
@@ -74,7 +73,7 @@ class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>,
// manufacturer data for the given ID. Optional single-MAC filter.
class BLEManufacturerDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
explicit BLEManufacturerDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); }
void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
@@ -109,7 +108,7 @@ class BLEManufacturerDataAdvertiseTrigger final : public Trigger<const adv_data_
// claims devices (parse_device always returns false).
class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit BLEEndOfScanTrigger(Hub *parent) { parent->register_listener(this); }
explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); }
bool parse_device(const ESPBTDevice &device) override { return false; }
void on_scan_end() override { this->trigger(); }
@@ -18,28 +18,6 @@ namespace esphome::ble_device_base {
static constexpr int GATT_ERR_NOT_CONNECTED = -1;
static constexpr int GATT_ERR_NO_MEMORY = -2;
/// Safety net shared by every GATT backend: force IDLE when the stack never
/// delivers its disconnect completion.
static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000;
// Preferred connection parameters shared by every platform's GATT client so
// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency
// 0). FAST covers connection setup and service discovery; MEDIUM is the
// steady state once established. Stack defaults (12.5-15 ms) are too slow for
// stable connections through WiFi-based BLE proxies, causing disconnections;
// MEDIUM balances responsiveness with bandwidth usage.
static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms
static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms
// The timeout value was increased from 6s to 8s to address stability issues observed
// in certain BLE devices when operating through WiFi-based BLE proxies. The longer
// timeout reduces the likelihood of disconnections during periods of high latency.
static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s
// Fastest connection parameters for devices with short discovery timeouts
static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum)
static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms
static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s
enum class ClientState : uint8_t {
// Connection is allocated
INIT,
@@ -154,9 +154,12 @@ class ESPBLEiBeacon {
};
/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks.
/// Trackers with LSB-native SDKs call this at the emit site before filling
/// RawAdvertisement::address; ESPBTDevice::address_uint64() is the equivalent
/// for an already parsed device, whose address is stored MSB-first.
///
/// The result is the printable-order value esp32 has always sent
/// (esp32_ble::ble_addr_to_uint64), so both proxy paths agree on the wire.
/// This takes the raw controller order delivered by BLEHub's raw-advertisement
/// callback; ESPBTDevice::address_uint64() is the equivalent for an already
/// parsed device, whose address is stored MSB-first.
inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
uint64_t addr = 0;
for (int i = 0; i < 6; i++)
@@ -2,17 +2,12 @@
//
// Platform-neutral GATT client connection contract.
//
// Exactly one GATT backend exists per build, so BLEGattConnection is a
// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract
// interface.
// A consumer - a streaming consumer that forwards the raw database (the hub
// BluetoothConnection wrapper) or a direct consumer owning a dedicated
// backend and resolving handles by UUID - drives it and receives
// completions through the GattClientListener interface (one build can hold
// several consumer types while the backend stays a single non-virtual
// class). All listener
// calls are delivered on the ESPHome main loop; borrowed data pointers are
// valid only for the duration of the call.
// A platform's GATT client backend (bluetooth_connection/esp32,
// bluetooth_connection/rp2) implements BLEGattConnection; consumers
// (bluetooth_proxy) drive it through this interface and receive
// completions through GattClientEventListener. All listener callbacks are
// delivered on the ESPHome main loop; borrowed data pointers are valid only
// for the duration of the call.
//
// Error domain (plain int, forwarded to the API without translation):
// 0 success
@@ -34,7 +29,6 @@
#include "ble_client_state.h"
#include "ble_device.h"
#include <concepts>
#include <cstdint>
namespace esphome::ble_device_base {
@@ -82,113 +76,65 @@ struct GattServiceTable {
uint16_t descriptor_count{0};
};
/// The event surface a backend delivers completions through. Genuine runtime
/// polymorphism lives here - one build can hold several consumer types (the
/// proxy's connection wrapper, dedicated-backend components) against the one
/// non-virtual backend class - so this is a plain virtual interface: every
/// method defaults to a no-op, consumers override what they consume (override
/// makes a misspelled name a compile error), and adding an event touches no
/// existing consumer. No destructor: components are never destroyed, and
/// nothing deletes through this base.
/// on_connection_state carries the negotiated MTU and an HCI
/// status/disconnect reason. Codegen wires the listener before setup(), so
/// backends may call without a null check.
class GattClientListener {
/// Completion/event sink for a GATT connection. Implemented by the consumer
/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop.
class GattClientEventListener {
public:
virtual void on_connection_state(bool connected, uint16_t mtu, int error) {}
virtual void on_service_discovery_done(int error) {}
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
virtual void on_write_result(uint16_t handle, int error) {}
virtual void on_notify_state(uint16_t handle, bool enabled, int error) {}
virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {}
virtual void on_pairing_result(int status) {}
virtual ~GattClientEventListener() = default;
/// Connected (with negotiated MTU) or disconnected/connect-failed
/// (error = HCI status or disconnect reason).
virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0;
/// Service discovery finished; on success the service table is populated.
virtual void on_service_discovery_done(int error) = 0;
/// Characteristic or descriptor read finished. data/len valid during the call.
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0;
/// Characteristic write-with-response or descriptor write finished.
virtual void on_write_result(uint16_t handle, int error) = 0;
/// Notification/indication registration state changed.
virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0;
/// Notification/indication data from the peer. data/len valid during the call.
virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0;
};
// The BLEGattConnection op surface, asserted where the alias binds
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
// through the listener) or a synchronous error (busy, not connected, stack
// rejection); one operation may be outstanding at a time. Semantics beyond
// the signatures:
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
// - disconnect: also cancels a connect in progress.
// - notify_characteristic: local registration only; the CCCD write is the
// API client's responsibility (a plain write_descriptor).
// - get_service_table/release_services: backend-owned transient storage,
// released after streaming (release is idempotent). A backend may
// additionally provide its own service streamer (stream_service_batch on
// the concrete type, detected by the consumer at compile time) for
// arbitrary-size databases; the table then materializes only for consumers
// that ask for it.
// - completions: connect and disconnect land in on_connection_state,
// discover_services in on_service_discovery_done, pair in
// on_pairing_result, reads in on_read_result, notify_characteristic in
// on_notify_state, characteristic writes with response and descriptor
// writes in on_write_result.
template<typename T>
concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) {
conn.set_listener(listener);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.disconnect() } -> std::same_as<int>;
{ conn.discover_services() } -> std::same_as<int>;
{ conn.read_characteristic(uint16_t{}) } -> std::same_as<int>;
{ conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as<int>;
{ conn.read_descriptor(uint16_t{}) } -> std::same_as<int>;
{ conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as<int>;
{ conn.notify_characteristic(uint16_t{}, true) } -> std::same_as<int>;
{ conn.pair() } -> std::same_as<int>;
{ conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as<int>;
{ conn.get_service_table() } -> std::same_as<GattServiceTable>;
{ conn.release_services() } -> std::same_as<void>;
// Connection-type hint for backends that tune parameters by it; others
// carry an inline no-op.
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
/// One GATT client connection slot. Operations return 0 when accepted
/// (completion arrives via the listener) or a synchronous error code
/// (busy, not connected, stack rejection). One operation may be outstanding
/// at a time; callers see a synchronous error otherwise.
class BLEGattConnection {
public:
virtual ~BLEGattConnection() = default;
void set_listener(GattClientEventListener *listener) { this->listener_ = listener; }
/// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant
/// (ble_device.h). Completion: on_connection_state().
virtual int connect(uint64_t address, uint8_t addr_type) = 0;
/// Disconnect (or cancel a connect in progress). Completion: on_connection_state().
virtual int disconnect() = 0;
/// Discover the peer's services/characteristics/descriptors into the
/// service table. Completion: on_service_discovery_done().
virtual int discover_services() = 0;
virtual int read_characteristic(uint16_t handle) = 0;
virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0;
virtual int read_descriptor(uint16_t handle) = 0;
virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0;
/// Enable/disable delivery of on_notify_data() for a characteristic value
/// handle. Local registration only — the CCCD write is the API client's
/// responsibility (it arrives as a plain write_descriptor).
virtual int notify_characteristic(uint16_t handle, bool enable) = 0;
virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) = 0;
/// Backend-owned service table (see GattServiceTable lifetime).
virtual GattServiceTable get_service_table() = 0;
/// Free the transient service table storage. Call after streaming.
virtual void release_services() = 0;
protected:
GattClientEventListener *listener_{nullptr};
};
// ---- service table lookup helpers ----
//
// Neutral, bounds-checked walks over a materialized GattServiceTable for
// direct consumers that resolve a known device's handles by UUID (streaming
// consumers forward the raw database and never need these). Linear search:
// the table exists only between discovery and release_services(), for one
// small known device.
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
static constexpr uint16_t CCCD_UUID = 0x2902;
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
for (uint16_t i = 0; i < table.service_count; i++) {
if (table.services[i].uuid == uuid)
return &table.services[i];
}
return nullptr;
}
inline const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid) {
uint16_t end = service.first_characteristic + service.characteristic_count;
if (end > table.characteristic_count)
return nullptr;
for (uint16_t i = service.first_characteristic; i < end; i++) {
if (table.characteristics[i].uuid == uuid)
return &table.characteristics[i];
}
return nullptr;
}
/// Handle of the characteristic's Client Characteristic Configuration
/// descriptor (0x2902), or 0 when it has none.
inline uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
uint16_t end = characteristic.first_descriptor + characteristic.descriptor_count;
if (end > table.descriptor_count)
return 0;
const ESPBTUUID cccd_uuid = ESPBTUUID::from_uint16(CCCD_UUID);
for (uint16_t i = characteristic.first_descriptor; i < end; i++) {
if (table.descriptors[i].uuid == cccd_uuid)
return table.descriptors[i].handle;
}
return 0;
}
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
+41 -62
View File
@@ -1,10 +1,13 @@
// ble_hub.h
//
// The platform-neutral BLE tracker contract: shared types plus the method
// surface every tracker provides (documented below). Exactly one tracker
// exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not
// an abstract interface — no vtable, every hub call inlinable. Consumers
// include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub).
// BLEHub — the platform-neutral BLE tracker contract.
//
// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker,
// ln882h_ble_tracker, future chips) implements this interface; every BLE
// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via
// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares.
// Adding a new BLE chip therefore requires only a new tracker component that
// implements BLEHub: no consumer, registry, or base changes.
//
// Chip differences are expressed as data (HubCapabilities), never as
// platform conditionals in consumers.
@@ -12,9 +15,7 @@
#pragma once
#include "ble_device.h"
#include "esphome/core/defines.h"
#include <concepts>
#include <cstdint>
namespace esphome::ble_device_base {
@@ -22,9 +23,8 @@ namespace esphome::ble_device_base {
/// One raw advertisement as delivered by the controller — a borrowed view,
/// valid only for the duration of the invoke() callback.
struct RawAdvertisement {
/// Producers convert their native byte order at the emit site, so no
/// byte-order convention crosses this contract.
uint64_t address;
/// Least-significant octet first (BLE controller convention).
const uint8_t *mac;
const uint8_t *data;
uint16_t data_len;
int8_t rssi; // signed dBm
@@ -48,27 +48,6 @@ struct RawAdvertisementCallback {
void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); }
};
/// Scanner lifecycle, wire-value aligned with the api enum so consumers cast
/// directly (pinned by static_asserts at the cast sites).
enum class ScannerState : uint8_t {
IDLE = 0,
STARTING = 1,
RUNNING = 2,
FAILED = 3,
STOPPING = 4,
STOPPED = 5,
};
/// Subscriber slot for scanner-state transitions; same shape as
/// RawAdvertisementCallback, delivered on the ESPHome main loop. Only hubs
/// that push provide the setter; consumers of the rest poll scan_running().
struct ScannerStateCallback {
void *instance{nullptr};
void (*fn)(void *instance, ScannerState state){nullptr};
bool is_set() const { return this->fn != nullptr; }
void invoke(ScannerState state) const { this->fn(this->instance, state); }
};
/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs.
struct HubCapabilities {
/// Controller can send scan requests (active scanning).
@@ -78,9 +57,8 @@ struct HubCapabilities {
/// may only see them where the receiver merges per address (Home Assistant does).
bool merges_scan_response;
/// GATT client connections are available: the platform has a
/// bluetooth_connection backend (rp2 binds the BLEGattConnection alias in
/// bluetooth_connection_gatt_backend.h; esp32 uses its Bluedroid client).
/// Today: esp32 and rp2.
/// bluetooth_connection backend implementing ble_device_base::BLEGattConnection
/// (ble_gatt_client.h). Today: esp32; rp2 follows with its BTstack backend.
bool gatt;
/// request_scan_mode() is honored at runtime. Distinct from active_scan:
/// a passive-only controller (bk72xx) can never switch, and a hub may
@@ -89,34 +67,35 @@ struct HubCapabilities {
bool scan_mode_switch;
};
// The BLEHub method surface, asserted where ble_hub_impl.h binds the alias.
// Semantics beyond the signatures:
// - register_listener: parsed-advertisement consumers (sensors, triggers).
// - set_raw_advertisement_callback: raw stream, one consumer at a time.
// - get_adapter_mac: printable order, out[0] = MSB.
// - scan_active: the current/configured mode sends scan requests.
// - request_scan_mode: false = cannot honor, state untouched (the caller
// reports the real state back); true = applied immediately, restarting a
// running scan. Honoring is advertised by HubCapabilities::scan_mode_switch.
// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback)
// and get_scanner_state() under USE_BLE_SCANNER_STATE_CALLBACK; the concept
// requires both exactly when that define is set. A push hub must emit a
// transition for every accepted or refused mode request - consumers skip
// their own mode report on push builds.
template<typename T>
concept BLEHubContract = requires(T hub, ESPBTDeviceListener *listener, RawAdvertisementCallback raw_callback,
uint8_t *mac) {
hub.register_listener(listener);
hub.set_raw_advertisement_callback(raw_callback);
{ T::get_capabilities() } -> std::same_as<HubCapabilities>;
hub.get_adapter_mac(mac);
{ hub.scan_running() } -> std::same_as<bool>;
{ hub.scan_active() } -> std::same_as<bool>;
{ hub.request_scan_mode(true) } -> std::same_as<bool>;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
hub.set_scanner_state_callback(ScannerStateCallback{});
{ hub.get_scanner_state() } -> std::same_as<ScannerState>;
#endif
class BLEHub {
public:
virtual ~BLEHub() = default;
/// Register a parsed-advertisement consumer (BLE sensors, automation triggers).
virtual void register_listener(ESPBTDeviceListener *listener) = 0;
/// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time.
virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0;
virtual HubCapabilities get_capabilities() const = 0;
/// Adapter MAC in printable (MSB-first) order, out[0] = MSB.
virtual void get_adapter_mac(uint8_t out[6]) = 0;
virtual bool scan_running() = 0;
/// True when the current/configured scan mode is active (scan requests sent).
virtual bool scan_active() = 0;
/// Request a scan-mode change (active = send scan requests). Returns false
/// when the hub cannot honor the request; the caller reports the real state
/// back to its subscriber. A hub that returns true applies the mode
/// immediately: a running scan is restarted with the new mode, an idle one
/// picks it up on its next start. The default cannot-change keeps hubs
/// without a mode switch (and out-of-tree trackers) building unchanged.
/// Independent of HubCapabilities::active_scan: that bit describes what the
/// CONTROLLER can do; whether this method honors requests is advertised by
/// HubCapabilities::scan_mode_switch, so consumers can gate features on the
/// switch without probing.
virtual bool request_scan_mode(bool active) { return false; }
};
} // namespace esphome::ble_device_base
@@ -1,35 +0,0 @@
// ble_hub_impl.h
//
// Binds ble_device_base::BLEHub to the build's one tracker; each tracker's
// codegen emits its USE_*_BLE_TRACKER define. Consumers include this header,
// trackers include ble_hub.h (the contract).
#pragma once
#include "ble_hub.h"
#include "esphome/core/defines.h"
#if defined(USE_ESP32_BLE_TRACKER)
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#define ESPHOME_BLE_HUB_TYPE esp32_ble_tracker::ESP32BLETracker
#elif defined(USE_RP2_BLE_TRACKER)
#include "esphome/components/rp2_ble_tracker/rp2_ble_tracker.h"
#define ESPHOME_BLE_HUB_TYPE rp2_ble_tracker::RP2BLETracker
#elif defined(USE_BK72XX_BLE_TRACKER)
#include "esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h"
#define ESPHOME_BLE_HUB_TYPE bk72xx_ble_tracker::BK72xxBLETracker
#elif defined(USE_LN882H_BLE_TRACKER)
#include "esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h"
#define ESPHOME_BLE_HUB_TYPE ln882h_ble_tracker::LN882HBLETracker
#endif
// No #else on purpose: builds without a tracker (host unit tests) get no BLEHub.
namespace esphome::ble_device_base {
#ifdef ESPHOME_BLE_HUB_TYPE
using BLEHub = ESPHOME_BLE_HUB_TYPE;
static_assert(BLEHubContract<BLEHub>, "The build's BLE tracker is missing part of the BLEHub surface (ble_hub.h)");
#undef ESPHOME_BLE_HUB_TYPE
#endif
} // namespace esphome::ble_device_base
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import binary_sensor, ble_device_base
from esphome.components import binary_sensor, esp32_ble_tracker
import esphome.config_validation as cv
from esphome.const import (
CONF_IBEACON_MAJOR,
@@ -13,14 +13,14 @@ from esphome.const import (
CONF_IRK = "irk"
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
ble_presence_ns = cg.esphome_ns.namespace("ble_presence")
BLEPresenceDevice = ble_presence_ns.class_(
"BLEPresenceDevice",
binary_sensor.BinarySensor,
cg.Component,
ble_device_base.ESPBTDeviceListener,
esp32_ble_tracker.ESPBTDeviceListener,
)
@@ -33,24 +33,23 @@ def _validate(config):
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_presence"),
binary_sensor.binary_sensor_schema(BLEPresenceDevice)
.extend(
{
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_IRK): cv.uuid,
cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid,
cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid,
cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid,
cv.Optional(CONF_TIMEOUT, default="5min"): cv.positive_time_period,
cv.Optional(CONF_MIN_RSSI): cv.All(
cv.decibel, cv.int_range(min=-100, max=-30)
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(
CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID
),
@@ -61,7 +60,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
cg.add(var.set_timeout(config[CONF_TIMEOUT].total_milliseconds))
if min_rssi := config.get(CONF_MIN_RSSI):
@@ -71,15 +70,20 @@ async def to_code(config):
cg.add(var.set_address(mac_address.as_hex))
if irk := config.get(CONF_IRK):
ble_device_base.request_irk_support()
irk = ble_device_base.as_hex_array(str(irk))
irk = esp32_ble_tracker.as_hex_array(str(irk))
cg.add(var.set_irk(irk))
if service_uuid := config.get(CONF_SERVICE_UUID):
ble_device_base.add_service_uuid(var, service_uuid)
if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid)))
elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format):
cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid)))
elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid)
cg.add(var.set_service_uuid128(uuid128))
if ibeacon_uuid := config.get(CONF_IBEACON_UUID):
ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid)
ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid)
cg.add(var.set_ibeacon_uuid(ibeacon_uuid))
if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None:
@@ -1,6 +1,8 @@
#include "ble_presence_device.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_presence {
static const char *const TAG = "ble_presence";
@@ -8,3 +10,5 @@ static const char *const TAG = "ble_presence";
void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); }
} // namespace esphome::ble_presence
#endif
@@ -1,17 +1,15 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
// No platform #ifdef: ble_device_base provides the BLE types on every platform,
// and this component is only compiled when configured — which requires a BLE
// hub — so it builds on any platform with a BLEHub tracker without a per-chip
// guard.
#ifdef USE_ESP32
namespace esphome::ble_presence {
class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
public ble_device_base::ESPBTDeviceListener,
public esp32_ble_tracker::ESPBTDeviceListener,
public Component {
public:
void set_address(uint64_t address) {
@@ -24,19 +22,19 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
}
void set_service_uuid16(uint16_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid);
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid);
}
void set_service_uuid32(uint32_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid);
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid);
}
void set_service_uuid128(uint8_t *uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid);
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid);
}
void set_ibeacon_uuid(uint8_t *uuid) {
this->match_by_ = MATCH_BY_IBEACON_UUID;
this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid);
this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid);
}
void set_ibeacon_major(uint16_t major) {
this->check_ibeacon_major_ = true;
@@ -51,7 +49,7 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
this->minimum_rssi_ = rssi;
}
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; }
bool parse_device(const ble_device_base::ESPBTDevice &device) override {
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
if (this->check_minimum_rssi_ && this->minimum_rssi_ > device.get_rssi()) {
return false;
}
@@ -121,9 +119,9 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
uint64_t address_;
uint8_t *irk_;
ble_device_base::ESPBTUUID uuid_;
esp32_ble_tracker::ESPBTUUID uuid_;
ble_device_base::ESPBTUUID ibeacon_uuid_;
esp32_ble_tracker::ESPBTUUID ibeacon_uuid_;
uint16_t ibeacon_major_{0};
uint16_t ibeacon_minor_{0};
@@ -139,3 +137,5 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
};
} // namespace esphome::ble_presence
#endif
@@ -1,6 +1,8 @@
#include "ble_rssi_sensor.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_rssi {
static const char *const TAG = "ble_rssi";
@@ -8,3 +10,5 @@ static const char *const TAG = "ble_rssi";
void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); }
} // namespace esphome::ble_rssi
#endif
+13 -13
View File
@@ -1,16 +1,14 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/sensor/sensor.h"
// No platform #ifdef: ble_device_base provides the BLE types on every platform,
// and this component is only compiled when configured — which requires a BLE
// hub — so it builds on any platform with a BLEHub tracker without a per-chip
// guard.
#ifdef USE_ESP32
namespace esphome::ble_rssi {
class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBTDeviceListener, public Component {
class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component {
public:
void set_address(uint64_t address) {
this->match_by_ = MATCH_BY_MAC_ADDRESS;
@@ -22,19 +20,19 @@ class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBT
}
void set_service_uuid16(uint16_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid);
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid);
}
void set_service_uuid32(uint32_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid);
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid);
}
void set_service_uuid128(uint8_t *uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid);
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid);
}
void set_ibeacon_uuid(uint8_t *uuid) {
this->match_by_ = MATCH_BY_IBEACON_UUID;
this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid);
this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid);
}
void set_ibeacon_major(uint16_t major) {
this->check_ibeacon_major_ = true;
@@ -49,7 +47,7 @@ class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBT
this->publish_state(NAN);
this->found_ = false;
}
bool parse_device(const ble_device_base::ESPBTDevice &device) override {
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
switch (this->match_by_) {
case MATCH_BY_MAC_ADDRESS:
if (device.address_uint64() == this->address_) {
@@ -111,9 +109,9 @@ class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBT
uint64_t address_;
uint8_t *irk_;
ble_device_base::ESPBTUUID uuid_;
esp32_ble_tracker::ESPBTUUID uuid_;
ble_device_base::ESPBTUUID ibeacon_uuid_;
esp32_ble_tracker::ESPBTUUID ibeacon_uuid_;
uint16_t ibeacon_major_;
uint16_t ibeacon_minor_;
@@ -122,3 +120,5 @@ class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBT
};
} // namespace esphome::ble_rssi
#endif
+17 -13
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import ble_device_base, sensor
from esphome.components import esp32_ble_tracker, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_IBEACON_MAJOR,
@@ -14,11 +14,11 @@ from esphome.const import (
CONF_IRK = "irk"
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
ble_rssi_ns = cg.esphome_ns.namespace("ble_rssi")
BLERSSISensor = ble_rssi_ns.class_(
"BLERSSISensor", sensor.Sensor, cg.Component, ble_device_base.ESPBTDeviceListener
"BLERSSISensor", sensor.Sensor, cg.Component, esp32_ble_tracker.ESPBTDeviceListener
)
@@ -31,7 +31,6 @@ def _validate(config):
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_rssi"),
sensor.sensor_schema(
BLERSSISensor,
unit_of_measurement=UNIT_DECIBEL_MILLIWATT,
@@ -43,14 +42,14 @@ CONFIG_SCHEMA = cv.All(
{
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_IRK): cv.uuid,
cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid,
cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid,
cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(
CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID
),
@@ -61,21 +60,26 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
if mac_address := config.get(CONF_MAC_ADDRESS):
cg.add(var.set_address(mac_address.as_hex))
if irk := config.get(CONF_IRK):
ble_device_base.request_irk_support()
irk = ble_device_base.as_hex_array(str(irk))
irk = esp32_ble_tracker.as_hex_array(str(irk))
cg.add(var.set_irk(irk))
if service_uuid := config.get(CONF_SERVICE_UUID):
ble_device_base.add_service_uuid(var, service_uuid)
if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid)))
elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format):
cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid)))
elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid)
cg.add(var.set_service_uuid128(uuid128))
if ibeacon_uuid := config.get(CONF_IBEACON_UUID):
ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid)
ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid)
cg.add(var.set_ibeacon_uuid(ibeacon_uuid))
if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None:
@@ -1,6 +1,8 @@
#include "ble_scanner.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_scanner {
static const char *const TAG = "ble_scanner";
@@ -8,3 +10,5 @@ static const char *const TAG = "ble_scanner";
void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); }
} // namespace esphome::ble_scanner
#endif
+9 -7
View File
@@ -7,18 +7,18 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/text_sensor/text_sensor.h"
// No platform #ifdef: ble_device_base provides the BLE types on every platform,
// and this component is only compiled when configured — which requires a BLE
// hub — so it builds on any platform with a BLEHub tracker without a per-chip
// guard.
#ifdef USE_ESP32
namespace esphome::ble_scanner {
class BLEScanner final : public text_sensor::TextSensor, public ble_device_base::ESPBTDeviceListener, public Component {
class BLEScanner final : public text_sensor::TextSensor,
public esp32_ble_tracker::ESPBTDeviceListener,
public Component {
public:
bool parse_device(const ble_device_base::ESPBTDevice &device) override {
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
// Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this
// sensor has always published.
@@ -35,3 +35,5 @@ class BLEScanner final : public text_sensor::TextSensor, public ble_device_base:
};
} // namespace esphome::ble_scanner
#endif
@@ -1,26 +1,25 @@
import esphome.codegen as cg
from esphome.components import ble_device_base, text_sensor
from esphome.components import esp32_ble_tracker, text_sensor
import esphome.config_validation as cv
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
ble_scanner_ns = cg.esphome_ns.namespace("ble_scanner")
BLEScanner = ble_scanner_ns.class_(
"BLEScanner",
text_sensor.TextSensor,
cg.Component,
ble_device_base.ESPBTDeviceListener,
esp32_ble_tracker.ESPBTDeviceListener,
)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_scanner"),
text_sensor.text_sensor_schema(BLEScanner)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
@@ -1,270 +0,0 @@
"""Per-platform GATT connection backends and the helpers to embed one.
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
consumer's codegen declares and registers the backend instances — the
Bluetooth proxy through its per-slot connection wrappers (a streaming
consumer), and direct consumers owning a dedicated backend through
gatt_client_config_schema() + new_gatt_backend().
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_MAC_ADDRESS,
PLATFORM_ESP32,
PLATFORM_RP2,
PlatformFramework,
)
from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
DOMAIN = "bluetooth_connection"
def AUTO_LOAD() -> list[str]:
"""ble_device_base plus the platform BLE stack the build's backend
registers with, so consumers stay platform-blind. The platform-less arm
serves tooling that resolves the manifest without a target."""
if CORE.is_esp32:
return ["ble_device_base", "esp32_ble_tracker"]
if CORE.is_rp2:
return ["ble_device_base", "rp2040_ble"]
if CORE.target_platform is None:
return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"]
return ["ble_device_base"]
CODEOWNERS = ["@bdraco", "@jesserockz"]
bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1;
# raising this needs an upstream change (the layer itself supports N).
RP2_MAX_CONNECTIONS = 1
# Hub platforms with a GATT backend, mapped to their slot limit — the single
# registry of which hub platforms run the connection-capable proxy.
HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS}
# The hub-platform wrapper and the backend codegen classes.
HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection")
RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component)
BluedroidGattClient = bluetooth_connection_ns.class_(
"BluedroidGattClient", cg.Component
)
CONF_BACKEND_ID = "backend_id"
CONF_ADDRESS_TYPE = "address_type"
# BLE_ADDR_TYPE_* code space shared with the API and the backends.
ADDRESS_TYPES = {"public": 0, "random": 1}
def _esp32_schema_fragment() -> cv.Schema:
from esphome.components import esp32_ble_tracker
return esp32_ble_tracker.ESP_BLE_DEVICE_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)}
)
async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None:
from esphome.components import esp32_ble_tracker
# The tracker's promote loop owns connect timing; the backend's
# tracker-facing shim registers as a raw client.
await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config)
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
from esphome.components import rp2040_ble
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."""
backend_class: cg.MockObjClass
schema_fragment: Callable[[], cv.Schema]
register: Callable[[cg.MockObj, ConfigType], Awaitable[None]]
# The single registry of platforms with a GATT client backend; a platform
# missing here fails loudly everywhere instead of falling into another
# platform's arm.
_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
PLATFORM_ESP32: _PlatformBackend(
BluedroidGattClient, _esp32_schema_fragment, _esp32_register
),
PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register),
}
# Gates dedicated-backend consumers (cv.only_on).
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
def _backend_entry(platform: str | None = None) -> _PlatformBackend:
key = platform if platform is not None else CORE.target_platform
if (entry := _PLATFORM_BACKENDS.get(key)) is None:
raise cv.Invalid(f"no GATT client backend is registered for {key}")
return entry
def gatt_client_schema(platform: str | None = None) -> cv.Schema:
"""Schema fragment for one GATT backend instance: its generated id plus
the platform-stack reference new_gatt_backend() resolves.
Defaults to the platform being validated; pass `platform` explicitly when
building a schema outside validation (the language-schema dumper calls
per-platform builders under arbitrary CORE platforms).
"""
entry = _backend_entry(platform)
return entry.schema_fragment().extend(
{cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)}
)
def hub_connection_schema(platform: str | None = None) -> cv.Schema:
"""Per-slot schema for the proxy's connection wrappers: the wrapper id on
top of the backend fragment. Same platform rules as gatt_client_schema()."""
return gatt_client_schema(platform).extend(
{cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}
)
@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):
"""Validator claiming GATT connection slots — the one spelling for every
claimant (the proxy per configured slot, dedicated backends once). The
neutral ledger feeds the platform cap check in FINAL_VALIDATE_SCHEMA;
esp32 additionally charges the controller's connection budget."""
def validator(config: ConfigType) -> ConfigType:
_ledger().consumers.extend([consumer] * count)
if CORE.is_esp32:
from esphome.components import esp32_ble
esp32_ble.consume_connection_slots(count, consumer)(config)
return config
return validator
def _validate_slot_totals(config: ConfigType) -> ConfigType:
# esp32 has its own controller budget (esp32_ble); the hub platforms cap
# at the prebuilt stack's client count, and nothing else counts claims
# across components (e.g. a proxy plus a radon_eye_rd200 on rp2).
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
return config
claimed = _ledger().consumers
if len(claimed) > cap:
raise cv.Invalid(
f"{CORE.target_platform} supports at most {cap} GATT client "
f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}"
)
return config
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
# The peer keys every dedicated-backend consumer shares: one target device.
_PEER_SCHEMA = cv.Schema(
{
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_ADDRESS_TYPE, default="public"): cv.enum(
ADDRESS_TYPES, lower=True
),
}
)
def gatt_client_config_schema(base_schema: cv.Schema, consumer: str):
"""Wrap a dedicated-backend consumer's schema so the consumer stays
platform-blind: gates on the platforms with a backend, folds in
gatt_client_schema() plus the peer keys (mac_address, address_type),
and claims the connection slot. `consumer` names the component in
slot-exhaustion errors."""
@schema_extractor("schema")
def apply(config: ConfigType) -> ConfigType:
if config is SCHEMA_EXTRACT:
# The language-schema dumper runs without a platform; expose the
# consumer's keys plus the platform-free peer keys.
return base_schema.extend(_PEER_SCHEMA)
cv.only_on(GATT_CLIENT_PLATFORMS)(config)
schema = base_schema.extend(_PEER_SCHEMA).extend(gatt_client_schema())
config = schema(config)
return consume_gatt_slot(consumer)(config)
return apply
async def new_gatt_backend(
config: ConfigType, *, service_table: bool = True
) -> cg.MockObj:
"""Instantiate the backend declared by gatt_client_schema() and register
it with its platform stack. The connection slot is claimed at validation
(gatt_client_config_schema / the proxy's slot validators), not here.
service_table compiles the on-demand service-table materializer into the
backend; direct consumers need it, the streaming proxy does not, so
proxy-only builds keep the smaller footprint.
"""
from esphome.components import ble_device_base
ble_device_base.request_gatt_client()
if service_table:
cg.add_define("USE_BLE_GATT_SERVICE_TABLE")
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
# The backend has no user-facing component options; an empty config keeps
# the consumer's own keys (update_interval, ...) off it.
await cg.register_component(backend, {})
await _backend_entry().register(backend, config)
return backend
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"bluetooth_connection_bluedroid.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
# Every hub platform the proxy admits (the file compiles empty where
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
# cannot hit a missing-symbol trap here.
"bluetooth_connection_hub.cpp": {
PlatformFramework.RP2_ARDUINO,
PlatformFramework.LN882X_ARDUINO,
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
"bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
}
)
@@ -1,69 +0,0 @@
#include "bluetooth_connection.h"
#ifdef USE_ESP32
#include <esp_bt_device.h>
#include <esp_gattc_api.h>
#endif
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/log.h"
namespace esphome::bluetooth_connection {
static const char *const TAG = "bluetooth_connection";
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t &current_size, int16_t &send_service,
uint8_t connection_index, const char *address_str) {
// Calculate the actual size of just this service (+1 for the field tag)
size_t service_size = resp.services.back().calculate_size() + 1;
if (current_size + service_size > MAX_PACKET_SIZE) {
if (resp.services.size() > 1) {
// We would go over -- pop the last service and retry it in the next batch
resp.services.pop_back();
ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %u + service: %u > %u), sending current batch",
connection_index, address_str, send_service, (unsigned) current_size, (unsigned) service_size,
(unsigned) MAX_PACKET_SIZE);
// Don't advance send_service -- the popped service goes into the next batch
} else {
// This single service is too large, but we have to send it anyway;
// advance so we don't get stuck
ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%u bytes) but sending anyway", connection_index, address_str,
send_service, (unsigned) service_size);
send_service++;
}
return BatchClose::SEND;
}
current_size += service_size;
send_service++;
return BatchClose::CONTINUE;
}
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32
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.
conn_err_t unpair_device(uint64_t address) {
esp_bd_addr_t bda;
ble_device_base::uint64_to_mac_msb_first(address, bda);
return esp_ble_remove_bond_device(bda);
}
conn_err_t clear_gatt_cache(uint64_t address) {
esp_bd_addr_t bda;
ble_device_base::uint64_to_mac_msb_first(address, bda);
return esp_ble_gattc_cache_clean(bda);
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32
@@ -1,158 +0,0 @@
// Shared types and helpers for the per-platform GATT connection backends and
// the Bluetooth proxy that drives them.
#pragma once
#include "esphome/core/defines.h"
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include <array>
#include <cstddef>
#include <cstdint>
#ifdef USE_ESP32
#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.
// Advertisement-only builds get the clean-error handlers; 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
namespace esphome::api {
class BluetoothGATTGetServicesResponse;
} // namespace esphome::api
namespace esphome::bluetooth_connection {
// Connection-owned error type for the API error fields, which are plain
// integers on the wire. Aliases esp_err_t on esp32 (where the values come from
// IDF calls); a bare int elsewhere. Owning the name instead of probing for
// esp_err_t keeps the header independent of how a platform's SDK spells its
// error type.
#ifdef USE_ESP32
using conn_err_t = esp_err_t;
static constexpr conn_err_t CONN_OK = ESP_OK;
#else
using conn_err_t = int;
static constexpr conn_err_t CONN_OK = 0;
#endif
// The ESPHome-private "not connected" wire value, shared with the neutral
// GATT contract so backend and wrapper cannot drift.
static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
// What the platform's connection backend supports beyond GATT operations;
// the proxy derives its feature flags and legacy version from these.
#if defined(USE_ESP32)
static constexpr bool SUPPORTS_PAIRING = true;
static constexpr bool SUPPORTS_CACHE_CLEARING = true;
#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
// The rp2 BTstack backend pairs (just works + bonding); it has no service
// cache to clear. Keyed on the backend, not the generic client define, so a
// future backend without pairing keeps the stub arm below.
static constexpr bool SUPPORTS_PAIRING = true;
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
#else
static constexpr bool SUPPORTS_PAIRING = false;
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))
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
conn_err_t clear_gatt_cache(uint64_t address);
#else
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
#endif
// 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;
// ---- Service-streaming size budget, shared by every platform's streamer ----
// Conservative MTU limit for API messages (accounts for WPA3 overhead)
static constexpr size_t MAX_PACKET_SIZE = 1360;
// Constants for size estimation
static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1)
static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4)
static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7)
static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1)
static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic
/// Estimate the wire size of a service (service overhead + its characteristics,
/// assuming 128-bit UUIDs and one 128-bit descriptor per characteristic to be
/// safe) before fetching/packing the full data.
inline size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) {
size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY;
return service_overhead + (CHAR_SIZE_128BIT + DESC_SIZE_128BIT * DESC_PER_CHAR) * char_count;
}
// ---- UUID wire packing, shared by every platform's streamer ----
// This function is allocation-free and directly packs UUIDs into the output
// array using precalculated constants for the Bluetooth base UUID. ESPBTUUID
// stores its 128-bit form little-endian (same as Bluedroid).
inline void fill_128bit_uuid_array(std::array<uint64_t, 2> &out, const ble_device_base::ESPBTUUID &uuid) {
using ble_device_base::ESPBTUUID;
if (uuid.type() == ESPBTUUID::Type::UUID128) {
const uint8_t *u = uuid.uuid128();
// out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian)
out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) |
((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]);
out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) |
((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]);
return;
}
// 16/32-bit UUID inserted into the Bluetooth base UUID:
// 00000000-0000-1000-8000-00805F9B34FB
uint32_t value = uuid.type() == ESPBTUUID::Type::UUID16 ? uuid.uuid16() : uuid.uuid32();
out[0] = ((uint64_t) value << 32) | 0x00001000ULL; // Base UUID bytes 8-11
out[1] = 0x800000805F9B34FBULL; // Base UUID bytes 0-7
}
/// Fill the UUID in the appropriate wire format based on client support and
/// UUID type (128-bit array for old clients or 128-bit UUIDs, short form
/// otherwise).
inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uuid,
const ble_device_base::ESPBTUUID &uuid, bool use_efficient_uuids) {
using ble_device_base::ESPBTUUID;
if (!use_efficient_uuids || uuid.type() == ESPBTUUID::Type::UUID128) {
fill_128bit_uuid_array(uuid_128, uuid);
} else if (uuid.type() == ESPBTUUID::Type::UUID16) {
short_uuid = uuid.uuid16();
} else {
short_uuid = uuid.uuid32();
}
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// 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.
enum class BatchClose : uint8_t { CONTINUE, SEND };
/// Close out the service just packed into resp (account its actual wire size,
/// advance the cursor) and decide whether the batch must be sent now. Shared
/// tail of both platform streamers so the budget logic and its log lines
/// 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
} // namespace esphome::bluetooth_connection
@@ -1,828 +0,0 @@
#include "bluetooth_connection_bluedroid.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
// 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
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
#endif
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <esp_gatt_common_api.h>
#include <cstring>
namespace esphome::bluetooth_connection {
static const char *const TAG = "bluetooth_connection.bluedroid";
using ble_device_base::FAST_CONN_TIMEOUT;
using ble_device_base::FAST_MAX_CONN_INTERVAL;
using ble_device_base::FAST_MIN_CONN_INTERVAL;
using ble_device_base::MEDIUM_CONN_TIMEOUT;
using ble_device_base::MEDIUM_MAX_CONN_INTERVAL;
using ble_device_base::MEDIUM_MIN_CONN_INTERVAL;
using esp32_ble_tracker::ClientState;
using esp32_ble_tracker::ConnectionType;
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
// ---- shim forwarders ----
bool BluedroidTrackerShim::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
return this->engine_->handle_gattc_event_(event, gattc_if, param);
}
void BluedroidTrackerShim::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
this->engine_->handle_gap_event_(event, param);
}
void BluedroidTrackerShim::connect() { this->engine_->tracker_connect_(); }
void BluedroidTrackerShim::disconnect() { this->engine_->disconnect(); }
// ---- component ----
void BluedroidGattClient::setup() {
static uint8_t connection_index = 0;
this->connection_index_ = connection_index++;
}
void BluedroidGattClient::loop() {
if (!esp32_ble::global_ble->is_active()) {
// Stack down: re-register the app on the next enable.
this->set_state_(ClientState::INIT);
return;
}
auto st = this->state_();
if (st == ClientState::INIT) {
auto ret = esp_ble_gattc_app_register(this->shim_.app_id);
if (ret) {
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->shim_.app_id, ret);
this->mark_failed();
}
// Do not wait for REG_EVT; a dropped event must not wedge the slot.
this->set_state_(ClientState::IDLE);
} else if (st == ClientState::IDLE) {
// The loop only drives the bootstrap and the disconnect safety timeout.
this->disable_loop();
} else if (st == ClientState::DISCONNECTING &&
millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGE(TAG, "[%d] Timeout waiting for CLOSE_EVT, forcing IDLE", this->connection_index_);
// Release before idling: unconditional disconnect does not release, and a
// lost CLOSE/DISCONNECT would otherwise leak the table and the cache.
this->release_services();
this->set_idle_();
this->report_connection_state_(false, ESP_GATT_CONN_TIMEOUT);
}
}
void BluedroidGattClient::dump_config() {
ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_);
if (this->is_failed()) {
ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots");
}
}
// ---- contract ops ----
int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
// Refuse anything but a fully idle slot. Clobbering DISCONNECTING with
// DISCOVERED would let the tracker open a new link while the old one is
// still closing - the stale CLOSE_EVT then tears the new attempt down.
if (this->state_() != ClientState::IDLE) {
ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_);
return ESP_GATT_BUSY;
}
ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_);
this->remote_addr_type_ = addr_type;
// Hand the request to the tracker's promote loop: it stops the scan, raises
// coex, and calls tracker_connect_() - the tracker owns connect timing here.
this->set_state_(ClientState::DISCOVERED);
return 0;
}
void BluedroidGattClient::tracker_connect_() {
auto st = this->state_();
if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) {
ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_);
return;
}
if (st == ClientState::DISCONNECTING) {
ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_);
return;
}
ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_);
this->services_released_ = false;
this->seen_mtu_ = false;
this->enable_loop();
this->set_state_(ClientState::CONNECTING);
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
// Fast params for the discovery phase; stepped down at SEARCH_CMPL.
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0,
FAST_CONN_TIMEOUT);
} else {
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0,
MEDIUM_CONN_TIMEOUT);
}
auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_,
static_cast<esp_ble_addr_type_t>(this->remote_addr_type_), true);
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_open", ret);
// CONNECT_EVT never fired, so conn_id_ is legitimately unset: plain IDLE.
this->set_state_(ClientState::IDLE);
this->report_connection_state_(false, ret);
}
}
int BluedroidGattClient::disconnect() {
auto st = this->state_();
if (st == ClientState::DISCONNECTING) {
return 0;
}
// Nothing was opened, so no completion event will follow: report
// not-connected and the hub frees the slot at once (rp2 convention).
if (st == ClientState::IDLE) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
if (st == ClientState::DISCOVERED) {
// Parked for the tracker promote loop, never opened.
this->set_state_(ClientState::IDLE);
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) {
ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_);
this->shim_.schedule_disconnect();
return 0;
}
this->unconditional_disconnect_();
return 0;
}
void BluedroidGattClient::unconditional_disconnect_() {
ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_);
if (this->conn_id_ == UNSET_CONN_ID) {
ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_);
return;
}
auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_);
if (err != ESP_OK) {
// The stack is now in an indeterminate state for this link.
ESP_LOGE(TAG, "[%d] esp_ble_gattc_close error: %d", this->connection_index_, err);
}
this->set_disconnecting_();
}
int BluedroidGattClient::discover_services() {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
return this->check_and_log_error_("esp_ble_gattc_search_service",
esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr));
}
int BluedroidGattClient::read_characteristic(uint16_t handle) {
return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_,
handle, ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
// The BTC layer copies the payload immediately, so the const_cast is safe.
return this->check_and_log_error_(
"esp_ble_gattc_write_char",
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::read_descriptor(uint16_t handle) {
return this->check_and_log_error_(
"esp_ble_gattc_read_char_descr",
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return this->check_and_log_error_(
"esp_ble_gattc_write_char_descr",
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) {
// Local registration only; the CCCD write is the API client's responsibility.
if (enable) {
return this->check_and_log_error_("esp_ble_gattc_register_for_notify",
esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle));
}
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify",
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
}
int BluedroidGattClient::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); }
int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) {
return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom");
}
void BluedroidGattClient::release_services() {
this->service_total_ = 0;
#ifdef USE_BLE_GATT_SERVICE_TABLE
this->free_service_table_();
#endif
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
// Only the cache clean makes the stack's database unsafe to walk.
this->services_released_ = true;
esp_ble_gattc_cache_clean(this->remote_bda_);
#endif
}
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
if (this->table_storage_ == nullptr &&
(this->services_released_ || this->service_total_ == 0 || !this->build_service_table_())) {
return {};
}
return this->table_view_();
}
// The view is carved from the storage block and the counts on each call
// (a cold path) rather than cached, saving a per-instance table member.
ble_device_base::GattServiceTable BluedroidGattClient::table_view_() const {
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = this->table_char_total_ * sizeof(ble_device_base::GattCharacteristic);
return {reinterpret_cast<const ble_device_base::GattService *>(this->table_storage_),
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes),
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes),
this->service_total_,
this->table_char_total_,
this->table_desc_total_};
}
void BluedroidGattClient::free_service_table_() {
if (this->table_storage_ == nullptr) {
return;
}
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
allocator.deallocate(this->table_storage_, 0);
this->table_storage_ = nullptr;
this->table_char_total_ = 0;
this->table_desc_total_ = 0;
}
template<typename ServiceFn, typename CharFn, typename DescFn>
bool BluedroidGattClient::walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
// Shared enumeration for both table-build passes: an identical walk order
// is what lets the counting pass size the block the filling pass fills.
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
for (uint16_t s = 0; s < this->service_total_; s++) {
esp_gattc_service_elem_t svc;
uint16_t svc_count = 1;
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s) != ESP_GATT_OK ||
svc_count == 0) {
return false;
}
if (!on_service(s, svc)) {
return false;
}
uint16_t svc_chars = 0;
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, svc.start_handle,
svc.end_handle, 0, &svc_chars) != ESP_GATT_OK) {
return false;
}
for (uint16_t c = 0; c < svc_chars; c++) {
esp_gattc_char_elem_t chr;
uint16_t char_count = 1;
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
if (status == ESP_GATT_INVALID_OFFSET || status == ESP_GATT_NOT_FOUND) {
break;
}
if (status != ESP_GATT_OK || char_count == 0) {
return false;
}
if (!on_char(svc, chr)) {
return false;
}
for (uint16_t d = 0;; d++) {
esp_gattc_descr_elem_t desc;
uint16_t desc_count = 1;
auto desc_status =
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || desc_count == 0) {
return false;
}
if (!on_desc(chr, desc)) {
return false;
}
}
}
}
return true;
}
bool BluedroidGattClient::build_service_table_() {
// Pass 1: count, so one exact-size block holds the whole table.
uint16_t char_total = 0;
uint16_t desc_total = 0;
bool counted = this->walk_database_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
char_total++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
desc_total++;
return true;
});
if (!counted) {
return false;
}
// The arrays share one block; carving stays aligned because each struct's
// strictest member is the UUID and array sizes are multiples of it.
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->table_storage_ = allocator.allocate(total_bytes);
if (this->table_storage_ == nullptr) {
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->connection_index_,
static_cast<unsigned>(total_bytes));
return false;
}
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->table_storage_);
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes);
auto *descriptors =
reinterpret_cast<ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes);
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
// means the cached database changed between the passes; fail the build
// rather than serve an inconsistent table (the consumer retries).
uint16_t char_index = 0;
uint16_t desc_index = 0;
ble_device_base::GattService *cur_service = nullptr;
ble_device_base::GattCharacteristic *cur_char = nullptr;
bool filled = this->walk_database_(
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
cur_service = &services[s];
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
cur_service->start_handle = svc.start_handle;
cur_service->end_handle = svc.end_handle;
cur_service->first_characteristic = char_index;
cur_service->characteristic_count = 0;
return true;
},
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
if (char_index >= char_total) {
return false;
}
cur_char = &characteristics[char_index++];
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
cur_char->value_handle = chr.char_handle;
// Bluedroid addresses descriptors by characteristic handle, so the
// table's end_handle only needs the service-bounded upper bound.
cur_char->end_handle = svc.end_handle;
cur_char->properties = chr.properties;
cur_char->first_descriptor = desc_index;
cur_char->descriptor_count = 0;
cur_service->characteristic_count++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
if (desc_index >= desc_total) {
return false;
}
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
descriptors[desc_index].handle = desc.handle;
desc_index++;
cur_char->descriptor_count++;
return true;
});
if (!filled || char_index != char_total || desc_index != desc_total) {
this->free_service_table_();
return false;
}
this->table_char_total_ = char_total;
this->table_desc_total_ = desc_total;
return true;
}
#endif // USE_BLE_GATT_SERVICE_TABLE
// ---- internals ----
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0;
}
void BluedroidGattClient::set_idle_() {
this->set_state_(ClientState::IDLE);
this->conn_id_ = UNSET_CONN_ID;
}
void BluedroidGattClient::set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state_(ClientState::DISCONNECTING);
// The loop may be disabled while idle; the safety timeout needs it.
this->enable_loop();
}
void BluedroidGattClient::report_connection_state_(bool connected, int error) {
this->listener_->on_connection_state(connected, this->mtu_, error);
}
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;
conn_params.latency = latency;
conn_params.timeout = timeout;
ESP_LOGD(TAG, "[%d] %s conn params", this->connection_index_, param_type);
return this->check_and_log_error_("esp_ble_gap_update_conn_params", esp_ble_gap_update_conn_params(&conn_params));
}
int BluedroidGattClient::check_and_log_error_(const char *operation, esp_err_t err) {
if (err != ESP_OK) {
this->log_gattc_warning_(operation, err);
}
return err;
}
void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->connection_index_, operation, code);
}
// ---- service streaming ----
void BluedroidGattClient::handle_search_cmpl_() {
// Step down from the fast discovery params.
this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
uint16_t primary = 0;
uint16_t secondary = 0;
auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE,
0x0001, 0xFFFF, 0, &primary);
auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE,
0x0001, 0xFFFF, 0, &secondary);
if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) {
// A failed count must not become an authoritative empty database - V3
// clients cache the streamed result permanently.
auto status = primary_status != ESP_GATT_OK ? primary_status : secondary_status;
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", status);
this->listener_->on_service_discovery_done(status);
return;
}
this->service_total_ = primary + secondary;
this->listener_->on_service_discovery_done(0);
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
if (this->services_released_ || conn.send_service_ >= this->service_total_) {
conn.send_service_ = DONE_SENDING_SERVICES;
conn.proxy_->send_gatt_services_done(conn.address_);
this->release_services();
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).
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();
return;
}
bool use_efficient_uuids = conn.proxy_->client_supports_efficient_uuids();
api::BluetoothGATTGetServicesResponse resp;
resp.address = conn.address_;
size_t current_size = resp.calculate_size();
int16_t batch_start = conn.send_service_;
while (conn.send_service_ < this->service_total_) {
esp_gattc_service_elem_t service_result;
uint16_t svc_count = 1;
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, &svc_count,
conn.send_service_) != ESP_GATT_OK ||
svc_count == 0) {
ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_,
conn.address_str_, conn.send_service_);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
uint16_t total_char_count = 0;
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
service_result.start_handle, service_result.end_handle, 0,
&total_char_count) != ESP_GATT_OK) {
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
// If this service likely won't fit, send the current batch first.
size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids);
if (!resp.services.empty() && current_size + estimated_size > MAX_PACKET_SIZE) {
break;
}
resp.services.emplace_back();
auto &service_resp = resp.services.back();
fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid,
ble_device_base::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids);
service_resp.handle = service_result.start_handle;
if (total_char_count > 0) {
service_resp.characteristics.init(total_char_count);
uint16_t char_offset = 0;
esp_gattc_char_elem_t char_result;
// Bounded by the count query: a misbehaving peripheral can make the
// enumeration return more entries than it reported.
while (char_offset < total_char_count) {
uint16_t cc = 1;
auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
service_result.end_handle, &char_result, &cc, char_offset);
if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) {
break;
}
if (char_status != ESP_GATT_OK || cc == 0) {
if (char_status != ESP_GATT_OK) {
this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
break;
}
service_resp.characteristics.emplace_back();
auto &characteristic_resp = service_resp.characteristics.back();
fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid,
ble_device_base::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids);
characteristic_resp.handle = char_result.char_handle;
characteristic_resp.properties = char_result.properties;
uint16_t total_desc_count = 0;
auto desc_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR,
0, 0, char_result.char_handle, &total_desc_count);
if (desc_count_status != ESP_GATT_OK) {
// Abort rather than stream the characteristic descriptor-less: a
// missing CCCD in a cached database breaks notifications for good.
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
if (total_desc_count > 0) {
characteristic_resp.descriptors.init(total_desc_count);
uint16_t desc_offset = 0;
esp_gattc_descr_elem_t desc_result;
while (desc_offset < total_desc_count) {
uint16_t dc = 1;
auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle,
&desc_result, &dc, desc_offset);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || dc == 0) {
if (desc_status != ESP_GATT_OK) {
this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
break;
}
characteristic_resp.descriptors.emplace_back();
auto &descriptor_resp = characteristic_resp.descriptors.back();
fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid,
ble_device_base::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids);
descriptor_resp.handle = desc_result.handle;
desc_offset++;
}
}
char_offset++;
}
}
if (close_service_batch(resp, current_size, conn.send_service_, conn.connection_index_, conn.address_str_) !=
BatchClose::CONTINUE) {
break;
}
}
// 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.send_service_ = batch_start;
}
}
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
// ---- events ----
void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
auto st = this->state_();
if (st == ClientState::IDLE) {
// IDF can deliver OPEN_EVT after esp_ble_gattc_open already returned an
// error and the slot went IDLE; do not resurrect it.
ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, param->open.status);
return;
}
if (st != ClientState::CONNECTING) {
ESP_LOGE(TAG, "[%d] OPEN_EVT in unexpected state", this->connection_index_);
}
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->log_gattc_warning_("Connection open", param->open.status);
// Never established, CLOSE_EVT may not follow.
this->set_idle_();
this->report_connection_state_(false, param->open.status);
return;
}
if (this->shim_.disconnect_pending()) {
// Earliest point conn_id_ exists; keep it set so CLOSE_EVT still matches.
this->unconditional_disconnect_();
return;
}
this->set_state_(ClientState::CONNECTED);
ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_);
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
this->set_state_(ClientState::ESTABLISHED);
// No discovery phase: report immediately; the MTU report below is
// suppressed by seen_mtu_ (HA tolerates a post-connect MTU of 23 here,
// matching the previous esp32 behavior).
this->seen_mtu_ = true;
this->report_connection_state_(true, 0);
// Settled: only the disconnect safety net needs the loop, and
// set_disconnecting_() re-enables it.
this->disable_loop();
}
}
void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) {
if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_() == ClientState::CONNECTED) {
ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_);
} else {
ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason);
}
if (this->state_() == ClientState::IDLE) {
// Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING.
return;
}
// Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting
// earlier makes the controller reject with 133 or assert) and before
// reporting - the wrapper frees the slot on the report, and a freed slot
// invites a reconnect into the still-closing link.
this->release_services();
this->set_disconnecting_();
}
bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
esp_ble_gattc_cb_param_t *param) {
if (event == ESP_GATTC_REG_EVT && this->shim_.app_id != param->reg.app_id)
return false;
if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_)
return false;
switch (event) {
case ESP_GATTC_REG_EVT: {
if (param->reg.status == ESP_GATT_OK) {
this->gattc_if_ = esp_gattc_if;
} else {
ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status);
this->mark_failed();
}
break;
}
case ESP_GATTC_CONNECT_EVT: {
if (!this->check_addr_(param->connect.remote_bda))
return false;
this->conn_id_ = param->connect.conn_id;
// MTU request here rather than OPEN_EVT, matching the IDF examples.
auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id);
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret);
}
break;
}
case ESP_GATTC_OPEN_EVT: {
if (!this->check_addr_(param->open.remote_bda))
return false;
this->handle_open_evt_(param);
break;
}
case ESP_GATTC_CFG_MTU_EVT: {
if (this->conn_id_ != param->cfg_mtu.conn_id)
return false;
if (param->cfg_mtu.status != ESP_GATT_OK) {
// Warn only; a disconnect will follow if the link is dead.
this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status);
} else {
this->mtu_ = param->cfg_mtu.mtu;
}
if (!this->seen_mtu_) {
this->seen_mtu_ = true;
// The connected report waited for the MTU so HA never sees 23.
this->report_connection_state_(true, 0);
}
break;
}
case ESP_GATTC_DISCONNECT_EVT: {
if (!this->check_addr_(param->disconnect.remote_bda))
return false;
this->handle_disconnect_evt_(param);
break;
}
case ESP_GATTC_CLOSE_EVT: {
if (this->conn_id_ != param->close.conn_id)
return false;
this->release_services();
this->set_idle_();
// The one connected=false report: the wrapper frees the slot on it,
// so it must not fire before the controller finished closing.
this->report_connection_state_(false, param->close.reason);
break;
}
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (this->conn_id_ != param->search_cmpl.conn_id)
return false;
ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_);
this->set_state_(ClientState::ESTABLISHED);
this->handle_search_cmpl_();
// Settled (see the V3_WITH_CACHE arm in handle_open_evt_).
this->disable_loop();
break;
}
case ESP_GATTC_READ_CHAR_EVT:
case ESP_GATTC_READ_DESCR_EVT: {
if (this->conn_id_ != param->read.conn_id)
return false;
bool ok = param->read.status == ESP_GATT_OK;
this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr,
ok ? param->read.value_len : 0, ok ? 0 : param->read.status);
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
case ESP_GATTC_WRITE_DESCR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
this->listener_->on_write_result(param->write.handle,
param->write.status == ESP_GATT_OK ? 0 : param->write.status);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
this->listener_->on_notify_state(param->reg_for_notify.handle, true,
param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status);
break;
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
this->listener_->on_notify_state(
param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (this->conn_id_ != param->notify.conn_id)
return false;
ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle);
this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
break;
}
default:
break;
}
return true;
}
void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
switch (event) {
case ESP_GAP_BLE_SEC_REQ_EVT: {
if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr))
break;
// Always accept a server-initiated security request.
esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true);
break;
}
case ESP_GAP_BLE_AUTH_CMPL_EVT: {
if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr))
break;
this->listener_->on_pairing_result(
param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason);
break;
}
default:
break;
}
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
@@ -1,166 +0,0 @@
// Bluedroid (esp32) GATT client backend: the esp32 arm of the
// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection
// wrapper. Not a BLEClientBase: the tracker's promote loop owns
// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only
// parks the address in DISCOVERED and the real esp_ble_gattc_open happens in
// the tracker-invoked shim connect(). The shim exists because the tracker's
// ESPBTClient::disconnect() returns void while the contract's returns int -
// one class cannot carry both.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
namespace esphome::bluetooth_connection {
class BluedroidGattClient;
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
class BluetoothConnection;
#endif
// The tracker-facing half: owns the ClientState the promote loop reads and
// forwards events/commands to the engine.
class BluedroidTrackerShim final : public esp32_ble_tracker::ESPBTClient {
public:
explicit BluedroidTrackerShim(BluedroidGattClient *engine) : engine_(engine) {}
bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
void connect() override;
void disconnect() override;
bool wants_parsed_advertisements() override { return false; }
void on_scan_end() override {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
void schedule_disconnect() { this->want_disconnect_ = true; }
protected:
BluedroidGattClient *engine_;
};
class BluedroidGattClient final : public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Wired by codegen before setup and invariant for the device lifetime.
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
esp32_ble_tracker::ESPBTClient *tracker_client() { return &this->shim_; }
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
int disconnect();
int discover_services();
int read_characteristic(uint16_t handle);
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
int read_descriptor(uint16_t handle);
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
int notify_characteristic(uint16_t handle, bool enable);
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
// Materialized on demand from Bluedroid's cached database for direct
// consumers that resolve handles by UUID. The streaming consumer (the
// proxy wrapper) never calls this - it uses stream_service_batch - so the
// materializer only compiles when codegen declares a direct consumer
// (USE_BLE_GATT_SERVICE_TABLE) and proxy-only builds keep the old
// footprint; a direct consumer's peak is bounded by its one known device.
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable get_service_table();
#else
ble_device_base::GattServiceTable get_service_table() { return {}; }
#endif
void release_services();
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// 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.
void stream_service_batch(BluetoothConnection &conn);
#endif
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
protected:
friend class BluedroidTrackerShim;
esp32_ble_tracker::ClientState state_() const { return this->shim_.state(); }
void set_state_(esp32_ble_tracker::ClientState st) { this->shim_.set_state(st); }
bool check_addr_(const esp_bd_addr_t &addr) const;
void tracker_connect_();
bool handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param);
void handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param);
void handle_open_evt_(esp_ble_gattc_cb_param_t *param);
void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param);
void handle_search_cmpl_();
void unconditional_disconnect_();
void set_idle_();
void set_disconnecting_();
void report_connection_state_(bool connected, int error);
esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type);
int check_and_log_error_(const char *operation, esp_err_t err);
void log_gattc_warning_(const char *operation, int code);
#ifdef USE_BLE_GATT_SERVICE_TABLE
template<typename ServiceFn, typename CharFn, typename DescFn>
bool walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
bool build_service_table_();
void free_service_table_();
ble_device_base::GattServiceTable table_view_() const;
#endif
// Group 1: pointers / composed objects
BluedroidTrackerShim shim_{this};
ble_device_base::GattClientListener *listener_{nullptr};
#ifdef USE_BLE_GATT_SERVICE_TABLE
// One exact-size block carved into the table's three arrays; owned here,
// freed by release_services(). Null when no table is materialized. The
// GattServiceTable view is rebuilt from this pointer and the counts on
// each (cold) get_service_table() call instead of being cached.
uint8_t *table_storage_{nullptr};
#endif
// Group 2: 4-byte types
int gattc_if_{ESP_GATT_IF_NONE};
uint32_t disconnecting_started_{0};
// Group 3: arrays
esp_bd_addr_t remote_bda_{};
// Group 4: 2-byte types
uint16_t conn_id_{0xFFFF};
uint16_t mtu_{23};
uint16_t service_total_{0};
#ifdef USE_BLE_GATT_SERVICE_TABLE
// Filled element counts of the materialized table (0 when none).
uint16_t table_char_total_{0};
uint16_t table_desc_total_{0};
#endif
// Group 5: 1-byte types
// Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call.
uint8_t remote_addr_type_{0};
esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE};
uint8_t connection_index_;
// Set only when release_services() cleans the stack's GATT cache, which no
// walk may then touch (Bluedroid asserts rather than erroring).
bool services_released_{false};
// The connected report waits for the MTU exchange; OPEN_EVT alone would
// hand HA the default 23.
bool seen_mtu_{false};
};
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
@@ -1,66 +0,0 @@
// bluetooth_connection_gatt_backend.h
//
// Binds ble_device_base::BLEGattConnection to the build's one GATT backend.
// Backend and consumer both live in this component, so the ladder does too;
// backends implement ble_gatt_client.h (the neutral contract).
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#if defined(USE_RP2040_BLE)
#include "bluetooth_connection_rp2.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
#elif defined(USE_ESP32_BLE)
#include "bluetooth_connection_bluedroid.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
// Emitted only by the host unit-test manifest: the tests compile the hub
// wrapper standalone, so bind a do-nothing backend. Every other backend-less
// build hits the #error below.
namespace esphome::bluetooth_connection {
class StubGattBackend {
public:
void set_listener(ble_device_base::GattClientListener *listener) {}
int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
int read_descriptor(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
int notify_characteristic(uint16_t handle, bool enable) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int pair() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
ble_device_base::GattServiceTable get_service_table() { return {}; }
void set_connection_type(ble_device_base::ConnectionType ct) {}
void release_services() {}
};
} // namespace esphome::bluetooth_connection
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::StubGattBackend
#else
#error "USE_BLE_GATT_CLIENT is set but this build has no GATT backend; add an alias arm here"
#endif
namespace esphome::ble_device_base {
using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE;
static_assert(BLEGattConnectionContract<BLEGattConnection>,
"The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)");
#undef ESPHOME_BLE_GATT_CONNECTION_TYPE
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -1,439 +0,0 @@
// The proxy's per-slot connection wrapper, shared by every platform.
#include "bluetooth_connection_hub.h"
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::bluetooth_connection {
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);
this->address_ = address;
if (address == 0) {
this->address_str_[0] = '\0';
return;
}
uint8_t mac[6];
ble_device_base::uint64_to_mac_msb_first(address, mac);
format_mac_addr_upper(mac, this->address_str_);
}
void BluetoothConnection::start_connect_() {
// No connect timeout here: the API client's own timeout or
// the api-gone sweep drives disconnect().
this->state_ = ClientState::CONNECTING;
int err = this->backend_->connect(this->address_, this->remote_addr_type_);
if (err != 0) {
ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err);
this->reset_connection_(err);
}
}
void BluetoothConnection::disconnect() {
// Idempotent: the proxy's teardown loop calls this
// every 100 ms while the API subscriber is gone, and a repeat call must not
// reach the backend (whose busy error would free the slot mid-teardown).
if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) {
return;
}
int err = this->backend_->disconnect();
if (err == GATT_NOT_CONNECTED) {
// Backend already idle: free the slot so the client is not stuck.
ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_);
this->reset_connection_(err);
return;
}
if (err != 0) {
// Transient refusal: stay DISCONNECTING and let the safety timeout
// arbitrate rather than freeing a slot whose teardown is unresolved.
// Latch the refusal unless a GATT cause is already recorded (first wins).
ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err);
if (this->pending_error_ == 0) {
this->pending_error_ = err;
}
}
this->state_ = ClientState::DISCONNECTING;
this->disconnecting_started_ = millis();
}
void BluetoothConnection::check_disconnect_timeout_() {
// Safety net: if the backend's disconnect completion is lost (or a refusal
// left the teardown unresolved), force the slot free instead of leaking it.
// The caller already gates on DISCONNECTING.
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_);
this->reset_connection_(GATT_NOT_CONNECTED);
}
}
void BluetoothConnection::on_pairing_result(int status) {
if (this->address_ == 0) {
// A drop before completion already answered: reset_connection_slot_ sends
// the connection response, which the client's pair watcher raises on.
return;
}
this->paired_ = status == 0;
this->proxy_->send_device_pairing(this->address_, status == 0, status);
}
void BluetoothConnection::reset_connection_(conn_err_t reason) {
if (this->pending_error_ != 0) {
reason = this->pending_error_;
this->pending_error_ = 0;
}
this->state_ = ClientState::IDLE;
this->services_discovered_ = false;
this->paired_ = false;
this->backend_->release_services();
this->proxy_->reset_connection_slot_(this, reason);
}
// ---- backend event listener ----
void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected && this->address_ == 0) {
// Late completion for a slot that was already freed: nothing to report,
// and the api-gone sweep or a new reservation owns the slot now.
int err = this->backend_->disconnect();
if (err != 0 && err != GATT_NOT_CONNECTED) {
// Log only: re-arming a freed slot could clobber a new reservation.
ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err);
}
return;
}
if (connected && this->state_ == ClientState::DISCONNECTING) {
// The link came up after a disconnect request won the race; finish the
// teardown instead of reporting a connection the client no longer wants.
int err = this->backend_->disconnect();
// Fresh teardown attempt: give it the full safety window.
this->disconnecting_started_ = millis();
if (err == GATT_NOT_CONNECTED) {
// Nothing left to tear down after all.
this->reset_connection_(err);
} else if (err != 0) {
// Transient refusal while the link is up: keep DISCONNECTING and let
// the safety timeout arbitrate (same policy as disconnect()).
ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err);
}
return;
}
if (connected) {
this->mtu_ = mtu;
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.
this->state_ = ClientState::ESTABLISHED;
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);
if (param_err != 0) {
// Survivable: the link just stays on the fast interval.
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->proxy_->send_connections_free();
return;
}
// V3_WITHOUT_CACHE: discover services first — the connected response is
// sent when discovery completes, mirroring the esp32 flow (MTU + services
// before the response).
this->state_ = ClientState::CONNECTED;
int err = this->backend_->discover_services();
if (err != 0) {
ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err);
// Latch the real cause for the disconnect report.
this->pending_error_ = err;
this->disconnect();
}
return;
}
// Disconnected, connect failed, or teardown complete
if (this->address_ == 0) {
return; // Slot already freed
}
ESP_LOGD(TAG, "[%d] [%s] Disconnected, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
error);
this->reset_connection_(error);
}
void BluetoothConnection::on_service_discovery_done(int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error);
// Carry the GATT error into the disconnection report so the client sees
// the real cause instead of a generic HCI reason.
this->pending_error_ = error;
this->disconnect();
return;
}
ESP_LOGD(TAG, "[%d] [%s] Discovery finished, sending connected (mtu=%u)", this->connection_index_, this->address_str_,
this->mtu_);
this->state_ = ClientState::ESTABLISHED;
this->services_discovered_ = true;
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
this->proxy_->send_connections_free();
}
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::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);
return;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return;
api::BluetoothGATTReadResponse resp;
resp.address = this->address_;
resp.handle = handle;
resp.set_data(data, len);
if (!api_connection->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_);
}
}
void BluetoothConnection::on_write_result(uint16_t handle, int error) {
if (this->address_ == 0)
return;
if (error != 0) {
this->log_gatt_operation_error_("writing char/descriptor", handle, error);
this->proxy_->send_gatt_error(this->address_, 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_);
}
}
void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) {
if (this->address_ == 0)
return;
if (error != 0) {
this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle,
error);
this->proxy_->send_gatt_error(this->address_, 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_);
}
}
void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
if (this->address_ == 0)
return;
ESP_LOGV(TAG, "[%d] [%s] Notify: handle=0x%2X", this->connection_index_, this->address_str_, handle);
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return;
api::BluetoothGATTNotifyDataResponse resp;
resp.address = this->address_;
resp.handle = handle;
resp.set_data(data, len);
if (!api_connection->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_);
}
}
// ---- GATT operations ----
conn_err_t BluetoothConnection::check_connected_op_(const char *action, const char *type) const {
if (this->connected()) {
return CONN_OK;
}
ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str_, action,
type);
return GATT_NOT_CONNECTED;
}
conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
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);
return this->backend_->read_characteristic(handle);
}
conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length,
bool response) {
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);
return this->backend_->write_characteristic(handle, data, static_cast<uint16_t>(length), response);
}
conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
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);
return this->backend_->read_descriptor(handle);
}
// The neutral backend contract performs descriptor writes acknowledged, so
// 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*/) {
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);
return this->backend_->write_descriptor(handle, data, static_cast<uint16_t>(length));
}
conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) {
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_,
enable ? "Registering for" : "Unregistering for", handle);
return this->backend_->notify_characteristic(handle, enable);
}
conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) {
if (conn_err_t err = this->check_connected_op_("update params of", "connection"); err != CONN_OK)
return err;
return this->backend_->update_connection_params(min_interval, max_interval, latency, timeout);
}
// ---- Service streaming ----
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();
return;
}
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (esp32 parity — 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.
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();
return;
}
// Check if client supports efficient UUIDs
bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids();
// Prepare response
api::BluetoothGATTGetServicesResponse resp;
resp.address = this->address_;
// Dynamic batching based on actual size, same contract as the esp32 streamer
size_t current_size = resp.calculate_size();
int16_t batch_start = this->send_service_;
while (this->send_service_ < table.service_count) {
const auto &service = table.services[this->send_service_];
// If this service likely won't fit, send current batch (unless it's the first)
size_t estimated_size = estimate_service_size(service.characteristic_count, use_efficient_uuids);
if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) {
break;
}
resp.services.emplace_back();
auto &service_resp = resp.services.back();
fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service.uuid, use_efficient_uuids);
service_resp.handle = service.start_handle;
// Bounds-check the backend's index ranges against the table totals rather
// than trusting its discovery bookkeeping blindly. A miscounted non-empty
// range must not stream a truncated database as authoritative (V3 clients
// cache it permanently): abort and tear the connection down; the client
// times out and retries. Empty ranges are tolerated regardless of index.
uint16_t char_count = service.characteristic_count;
if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) {
ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream",
this->connection_index_, this->address_str_, this->send_service_);
this->send_service_ = DONE_SENDING_SERVICES;
this->disconnect();
return;
}
if (char_count > 0) {
service_resp.characteristics.init(char_count);
for (uint16_t ci = 0; ci < char_count; ci++) {
const auto &chr = table.characteristics[service.first_characteristic + ci];
service_resp.characteristics.emplace_back();
auto &characteristic_resp = service_resp.characteristics.back();
fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, chr.uuid, use_efficient_uuids);
characteristic_resp.handle = chr.value_handle;
characteristic_resp.properties = chr.properties;
uint16_t desc_count = chr.descriptor_count;
if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) {
ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream",
this->connection_index_, this->address_str_, this->send_service_);
this->send_service_ = DONE_SENDING_SERVICES;
this->disconnect();
return;
}
if (desc_count == 0) {
continue;
}
characteristic_resp.descriptors.init(desc_count);
for (uint16_t di = 0; di < desc_count; di++) {
const auto &desc = table.descriptors[chr.first_descriptor + di];
characteristic_resp.descriptors.emplace_back();
auto &descriptor_resp = characteristic_resp.descriptors.back();
fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc.uuid, use_efficient_uuids);
descriptor_resp.handle = desc.handle;
}
}
}
if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str_) !=
BatchClose::CONTINUE) {
break;
}
}
// Send the message with dynamically batched services; on a failed send,
// rewind the cursor so the batch is retried instead of silently skipped
// (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->send_service_ = batch_start;
}
}
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
@@ -1,150 +0,0 @@
// BluetoothConnection: drives the build's GATT backend (the
// ble_device_base::BLEGattConnection alias) and translates its events into
// the proxy's API messages. One wrapper for every platform; per-backend
// differences live behind the alias and the streamer cut-through.
#pragma once
#include "bluetooth_connection.h"
// 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
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "bluetooth_connection_gatt_backend.h"
#include "esphome/core/helpers.h"
namespace esphome::bluetooth_proxy {
class BluetoothProxy;
} // namespace esphome::bluetooth_proxy
namespace esphome::bluetooth_connection {
using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
class BluetoothConnection final : public ble_device_base::GattClientListener {
public:
/// Wire the platform backend. Called from codegen before setup.
void set_backend(ble_device_base::BLEGattConnection *backend) {
this->backend_ = backend;
backend->set_listener(this);
}
// ---- proxy dispatch surface ----
conn_err_t read_characteristic(uint16_t handle);
conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response);
conn_err_t read_descriptor(uint16_t handle);
conn_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response);
conn_err_t notify_characteristic(uint16_t handle, bool enable);
conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
/// Start connecting: record the API address type (BLE_ADDR_TYPE_* code
/// space) and open the connection through the backend. Failures report
/// through the same reset path a failed open takes on esp32.
void initiate_connection(uint8_t address_type) {
this->remote_addr_type_ = address_type;
this->start_connect_();
}
void disconnect();
bool is_paired() const { return this->paired_; }
void set_unpaired() { this->paired_ = false; }
conn_err_t pair() { return this->backend_->pair(); }
void set_address(uint64_t address);
uint64_t get_address() const { return this->address_; }
const char *address_str() const { return this->address_str_; }
uint8_t get_connection_index() const { return this->connection_index_; }
ClientState state() const { return this->state_; }
void set_state(ClientState st) { this->state_ = st; }
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.
this->backend_->set_connection_type(ct);
}
// Latched at discovery completion rather than read from the backend table:
// streaming frees the table, and this must stay true for the connection's
// lifetime (a repeat GetServices is silently ignored, never answered with
// an authoritative empty database).
bool has_gatt_services() const { return this->services_discovered_; }
/// Stream any pending service-discovery batch and police the disconnect
/// safety timeout. Called from the proxy's loop — the wrapper has no
/// Component loop of its own.
void process_pending_services() {
if (this->send_service_ >= 0) {
this->stream_pending_(this->backend_);
}
// Inline state gate: this runs per loop iteration for every slot, and the
// 10 s safety net only matters while DISCONNECTING.
if (this->state_ == ClientState::DISCONNECTING) {
this->check_disconnect_timeout_();
}
}
// ---- backend event listener (called directly by the backend, main loop) ----
void on_connection_state(bool connected, uint16_t mtu, int error) override;
void on_service_discovery_done(int error) override;
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
void on_write_result(uint16_t handle, int error) override;
void on_notify_state(uint16_t handle, bool enabled, int error) override;
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_pairing_result(int status) override;
protected:
friend class bluetooth_proxy::BluetoothProxy;
// The Bluedroid backend streams services in place from its stack cache.
friend class BluedroidGattClient;
void start_connect_();
// 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
// that lack the method.
template<typename Backend> void stream_pending_(Backend *backend) {
if constexpr (requires { backend->stream_service_batch(*this); }) {
backend->stream_service_batch(*this);
} else {
this->send_service_for_discovery_();
}
}
void send_service_for_discovery_();
void check_disconnect_timeout_();
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);
// Memory optimized layout for 32-bit systems
// Group 1: Pointers (4 bytes each, naturally aligned)
bluetooth_proxy::BluetoothProxy *proxy_{nullptr};
ble_device_base::BLEGattConnection *backend_{nullptr};
// Group 2: 2-byte types
int16_t send_service_{INIT_SENDING_SERVICES};
uint16_t mtu_{23};
// Group 3: 8-byte and 4-byte types
uint64_t address_{0};
uint32_t disconnecting_started_{0};
conn_err_t pending_error_{0};
// Group 4: Arrays
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
// Group 5: 1-byte types
ClientState state_{ClientState::IDLE};
bool paired_{false};
ConnectionType connection_type_{ConnectionType::V1};
uint8_t remote_addr_type_{0};
uint8_t connection_index_{0};
bool services_discovered_{false};
};
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
File diff suppressed because it is too large Load Diff
@@ -1,218 +0,0 @@
// RP2 (Pico W / Pico 2 W) GATT client backend over BTstack.
//
// The build's ble_device_base::BLEGattConnection backend (bound by alias in
// bluetooth_connection_gatt_backend.h) for the hub BluetoothConnection wrapper. BTstack packet handlers run in the
// CYW43 async-context low-priority IRQ (or on the main-loop stack during BluetoothLock release), so handlers only copy
// into per-instance lock-free queues/storage; loop() drains them and drives the state machine. Every BTstack call
// issued from the main loop is wrapped in BluetoothLock.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/rp2040_ble/rp2040_ble.h"
#include "esphome/core/component.h"
#include "esphome/core/event_pool.h"
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h"
#include <btstack.h>
#include <array>
#include <cstdint>
namespace esphome::bluetooth_connection {
// Caps for the transient service table. Sized generously for real devices
// (typical peripherals expose < 8 services / < 30 characteristics); a peer
// exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than
// streaming an incomplete database a V3 client would cache permanently.
static constexpr uint16_t RP2_GATT_MAX_SERVICES = 16;
static constexpr uint16_t RP2_GATT_MAX_CHARACTERISTICS = 96;
static constexpr uint16_t RP2_GATT_MAX_DESCRIPTORS = 96;
// Concurrent notify subscriptions per connection (enable fails with
// GATT_ERR_NO_MEMORY when exceeded; real clients subscribe to a handful).
static constexpr uint8_t RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS = 16;
// ATT spec maximum attribute value length; bounds the op buffer and
// notification payloads.
static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512;
// Control events from the BTstack handlers to loop().
struct RP2GattEvent {
enum Type : uint8_t {
CONNECTED, // status + con_handle (value)
DISCONNECTED, // status = HCI reason
MTU_EXCHANGED, // value = negotiated MTU
QUERY_COMPLETE, // status = ATT status of the finished query
WRITE_NO_RSP_DONE, // status = result of the deferred write
PAIRING_RESULT, // status = SM pairing status (0 = bonded)
};
Type type;
uint8_t status;
uint16_t value;
void release() {}
};
// One notification/indication from the peer.
struct RP2GattNotifyEvent {
uint16_t handle;
uint16_t len;
uint8_t data[RP2_GATT_MAX_ATTR_LEN];
void release() {}
};
static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8;
// Depth 4: the queue is drained every main-loop iteration and each slot is a
// 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> {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override;
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
int disconnect();
int discover_services();
int read_characteristic(uint16_t handle);
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
int read_descriptor(uint16_t handle);
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
int notify_characteristic(uint16_t handle, bool enable);
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) {}
void release_services();
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
DISCONNECTING,
};
enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS };
enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, WRITE_CHAR_NO_RSP, READ_DESC, WRITE_DESC };
// The whole table in one transient allocation (RAMAllocator, checked),
// freed after streaming.
struct ServiceArena {
ble_device_base::GattService services[RP2_GATT_MAX_SERVICES];
ble_device_base::GattCharacteristic characteristics[RP2_GATT_MAX_CHARACTERISTICS];
ble_device_base::GattDescriptor descriptors[RP2_GATT_MAX_DESCRIPTORS];
};
// BTstack packet handlers (IRQ context: copy-and-enqueue only).
static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size);
static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size);
static void sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size);
static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle);
void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet);
void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value);
void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len);
void assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len);
// Main-loop state machine.
void handle_event_(const RP2GattEvent &event);
void handle_connected_(uint8_t status, uint16_t con_handle);
void handle_disconnected_(uint8_t reason);
void handle_query_complete_(uint8_t att_status);
void advance_discovery_(uint8_t att_status);
int issue_characteristic_query_(uint16_t service_index);
int issue_descriptor_query_(uint16_t char_index);
void finish_discovery_(int error);
void fail_connection_(uint8_t reason);
void cleanup_link_state_();
bool notify_subscribed_(uint16_t handle) const;
static void can_write_no_rsp_trampoline(void *context);
void finish_write_no_rsp_(uint8_t status);
void release_scan_inhibit_();
bool op_in_flight_() const {
return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE;
}
// Group 1: containers / large storage
ble_device_base::GattClientListener *listener_{nullptr};
ServiceArena *arena_{nullptr};
esphome::LockFreeQueue<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
esphome::LockFreeQueue<RP2GattNotifyEvent, RP2_GATT_NOTIFY_QUEUE_SIZE> notify_queue_;
esphome::EventPool<RP2GattNotifyEvent, RP2_GATT_NOTIFY_QUEUE_SIZE - 1> notify_pool_;
// Shared buffer for the single outstanding GATT op: write payloads (BTstack
// keeps the caller's pointer until the request is sent) and read results
// (written from the handler, read after QUERY_COMPLETE is drained).
uint8_t op_buffer_[RP2_GATT_MAX_ATTR_LEN];
// BTstack registrations
gatt_client_notification_t notification_registration_{};
btstack_context_callback_registration_t can_write_registration_{};
// Group 3: 4-byte types
uint32_t connect_started_{0};
uint32_t disconnecting_started_{0};
uint32_t write_no_rsp_started_{0};
// Group 4: 2-byte types (table counters written from the handler during
// discovery, read from the main loop after the phase's QUERY_COMPLETE)
hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID};
uint16_t mtu_{23};
uint16_t op_handle_{0};
uint16_t op_len_{0};
uint16_t service_count_{0};
uint16_t char_count_{0};
uint16_t desc_count_{0};
uint16_t disc_service_cursor_{0};
uint16_t disc_char_cursor_{0};
// Group 5: arrays / 1-byte types
// Subscribed notify handles; the loop() drain filters the wildcard
// 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};
EngineState state_{EngineState::IDLE};
DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE};
OpType op_type_{OpType::NONE};
bool truncated_{false};
// One cancel attempt per connect: the second timeout escalates to failure.
bool connect_cancel_attempted_{false};
// A disconnect request raced an in-flight connect; finish teardown on link-up.
bool cancel_requested_{false};
// This engine's own hold on the shared scan inhibit, so the pairing stays
// one-to-one per connection even with multiple slots.
bool holds_scan_inhibit_{false};
// Instance registry for routing BTstack events (IRQ context) to engines.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static RP2GattClient *instances[ESPHOME_BLE_GATT_CLIENT_COUNT];
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static uint8_t instance_count;
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
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;
};
} // namespace esphome::bluetooth_connection
#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT
+66 -163
View File
@@ -2,25 +2,19 @@ import functools
import logging
import esphome.codegen as cg
from esphome.components import ble_device_base, bluetooth_connection
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTIVE,
CONF_ID,
PLATFORM_ESP32,
PLATFORM_LN882X,
PLATFORM_RP2,
)
from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2
from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily
# inside _esp32_config_schema()/_to_code_esp32(): importing those modules
# registers esp32-only automations (ble.enable, ble.disable, ...) as a side
# effect, and a module-scope import would leak them into every platform's
# registry the moment a config declares `bluetooth_proxy:` — degrading
# "Unable to find action" config errors into C++ compile failures.
# The esp32 BLE stack (esp32_ble, esp32_ble_client, esp32_ble_tracker) is
# imported lazily inside _esp32_config_schema()/_to_code_esp32(): importing
# those modules registers esp32-only automations (ble.enable, ble.disable, ...)
# as a side effect, and a module-scope import would leak them into every
# platform's registry the moment a config declares `bluetooth_proxy:` —
# degrading "Unable to find action" config errors into C++ compile failures.
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
@@ -33,17 +27,13 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
target platform set, so it takes one of the concrete branches.
"""
if CORE.is_esp32:
return ["bluetooth_connection", "esp32_ble_tracker"]
return ["esp32_ble_client", "esp32_ble_tracker"]
if CORE.target_platform in _HUB_PLATFORMS:
return ["ble_device_base", "bluetooth_connection"]
return ["ble_device_base"]
# No target platform, or one this component does not support: tooling
# resolving the manifest (including the host-pinned dependency resolver) —
# expose every arm so the closure keeps the esp32 BLE stack.
return [
"ble_device_base",
"bluetooth_connection",
"esp32_ble_tracker",
]
return ["ble_device_base", "esp32_ble_client", "esp32_ble_tracker"]
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
@@ -52,8 +42,6 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
# proxy would be misdriven — bk72xx follows once the API carries a feature
# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs).
# Coupled to bluetooth_connection: platforms with a GATT backend are also
# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry.
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
DEPENDENCIES = ["api"]
@@ -70,17 +58,16 @@ bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component)
# Mirrors esp32_ble.IDF_MAX_CONNECTIONS (the loosest platform cap): the esp32
# schema builder asserts the two agree, tests/component_tests/bluetooth_proxy/
# pins them together, and the outer walkable schema uses it as the
# connection_slots bound (per-platform schemas tighten it).
# Mirrors esp32_ble.IDF_MAX_CONNECTIONS as a literal so the statically walkable
# CONFIG_SCHEMA below can state the connection_slots range without importing the
# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together.
_IDF_MAX_CONNECTIONS = 9
@functools.cache
def _esp32_config_schema() -> cv.All:
"""Build the esp32 schema, importing the esp32 BLE stack only when used."""
from esphome.components import esp32_ble, esp32_ble_tracker
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS:
raise cv.Invalid(
@@ -90,7 +77,14 @@ def _esp32_config_schema() -> cv.All:
f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py"
)
CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
BluetoothConnection = bluetooth_proxy_ns.class_(
"BluetoothConnection", esp32_ble_client.BLEClientBase
)
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BluetoothConnection),
}
).extend(cv.COMPONENT_SCHEMA)
def validate_connections(config):
if CONF_CONNECTIONS in config:
@@ -100,7 +94,7 @@ def _esp32_config_schema() -> cv.All:
)
elif config[CONF_ACTIVE]:
connection_slots: int = config[CONF_CONNECTION_SLOTS]
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
config
)
@@ -148,97 +142,31 @@ def _validate_no_active(config: ConfigType) -> ConfigType:
return config
@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."""
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
def populate_connections(config: ConfigType) -> ConfigType:
# One wrapper + backend pair per slot, declared during validation so
# their ids exist for codegen (the esp32 arm's `connections` pattern).
if not config[CONF_ACTIVE]:
return config
bluetooth_connection.consume_gatt_slot(
"bluetooth_proxy", config[CONF_CONNECTION_SLOTS]
)(config)
return {
**config,
CONF_CONNECTIONS: [
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
],
}
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
schema = (
cv.Schema(
{
**_COMMON_SCHEMA_KEYS,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
cv.Optional(
CONF_CONNECTION_SLOTS,
default=min(DEFAULT_CONNECTION_SLOTS, max_conn),
): cv.All(
cv.positive_int,
cv.Range(
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}",
),
),
}
)
.extend(
# ble_hub_id with the friendly no-tracker-configured guard.
ble_device_base.BLE_DEVICE_SCHEMA
)
.extend(cv.COMPONENT_SCHEMA)
)
return cv.All(schema, populate_connections)
async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
"""One wrapper + backend pair per slot; the platform-specific backend
registration lives in bluetooth_connection.new_gatt_backend()."""
for connection_conf in config.get(CONF_CONNECTIONS, []):
backend = await bluetooth_connection.new_gatt_backend(
connection_conf, service_table=False
)
connection = cg.new_Pvariable(connection_conf[CONF_ID])
cg.add(connection.set_backend(backend))
cg.add(var.register_connection(connection))
# Per-platform schema builders; every key of
# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by
# tests/component_tests/bluetooth_proxy/). Connection codegen is shared.
_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema}
# Keys every platform arm declares identically; each arm spreads this dict so
# the shared surface cannot drift. CONF_ACTIVE stays per-arm: its default
# differs (esp32 True, rp2 True, advertisement-only False).
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
# callback feeds the same API batching. GATT/active connections are excluded at
# compile time — only the esp32 build compiles the connection stack; nothing
# reads HubCapabilities::gatt at runtime for this today.
# Keys both platform schemas must declare identically; each arm spreads this
# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays
# per-arm: its default differs (esp32 True, hub arms False — no GATT).
_COMMON_SCHEMA_KEYS = {
cv.GenerateID(): cv.declare_id(BluetoothProxy),
}
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
# callback feeds the same API batching, no connection stack compiled.
_BLE_HUB_CONFIG_SCHEMA = cv.All(
cv.Schema(
{
**_COMMON_SCHEMA_KEYS,
# Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator
# after a strict schema rejects an explicit `ble_hub_id` before it
# runs, and that key is the documented way to disambiguate once a
# platform has two trackers.
cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id(
ble_device_base.BLEHub
),
cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
}
)
.extend(
# ble_hub_id with the friendly no-tracker-configured guard.
ble_device_base.BLE_DEVICE_SCHEMA
)
.extend(cv.COMPONENT_SCHEMA),
).extend(cv.COMPONENT_SCHEMA),
_validate_no_active,
)
@@ -247,10 +175,9 @@ _BLE_HUB_CONFIG_SCHEMA = cv.All(
def _validate_platform(config: ConfigType) -> ConfigType:
"""Apply the schema for the platform actually being compiled.
Three-way dispatch: esp32 gets the full GATT proxy, HUB_MAX_CONNECTIONS
platforms get their _GATT_HUB_SCHEMAS arm, the remaining hub platforms get
the advertisement-only shape; unsupported keys were already rejected by
name in _reject_unsupported_connection_keys.
esp32 keeps the full GATT proxy; every other platform gets the
advertisement-only shape, which rejects the connection-oriented options
above because its schema does not define them.
"""
if config is SCHEMA_EXTRACT:
# The language-schema dumper runs without a platform. Expose the esp32
@@ -266,21 +193,18 @@ def _validate_platform(config: ConfigType) -> ConfigType:
raise cv.Invalid(
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
"active-scan-capable BLE tracker hub is available for this "
"platform. It runs on esp32 and rp2 (full proxy) and the ln882x "
"family (advertisement-only)."
"platform. It runs on esp32 (full proxy), and the ln882x and rp2 "
"families (advertisement-only)."
)
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config)
return _BLE_HUB_CONFIG_SCHEMA(config)
def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType:
"""Reject connection options a platform does not support, by name.
def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
"""Reject connection-oriented options by name on hub-only platforms.
GATT hub platforms keep connection_slots but reject the esp32-only keys;
advertisement-only hubs reject all three. Runs before the walkable schema
below so the user gets "this option does not exist here" instead of a
value-range error implying the option works.
Runs before the walkable schema below so the user gets "this option does
not exist here" instead of the option's esp32 value range (which would
imply a smaller number is accepted).
"""
if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None:
return config
@@ -289,28 +213,14 @@ def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType:
# reports "not supported on {platform}" instead of a key-level message
# implying an advertisement-only proxy is available.
return config
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
# Full proxy: connection_slots is real here; the per-connection list
# exists internally but carries no user options, and the Bluedroid
# NVS service cache is esp32-only.
rejected = {
CONF_CONNECTIONS: (
"has no per-connection options on this platform; use "
"'connection_slots' to set the count"
),
CONF_CACHE_SERVICES: "is esp32-only (Bluedroid NVS service cache)",
}
else:
reason = (
"requires active connection support; this platform runs the "
"advertisement-only proxy and has no such option"
)
rejected = dict.fromkeys(
(CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS), reason
)
for key, reason in rejected.items():
for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS):
if key in config:
raise cv.Invalid(f"'{key}' {reason}", path=[key])
raise cv.Invalid(
f"'{key}' requires active connection support, which needs the "
"esp32 GATT stack; this platform runs the advertisement-only "
"proxy and has no such option",
path=[key],
)
return config
@@ -328,14 +238,11 @@ def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType:
# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched
# for _ESP32_CONFIG_SCHEMA to validate exactly once.
CONFIG_SCHEMA = cv.All(
_reject_unsupported_connection_keys,
_reject_connection_keys_off_esp32,
cv.Schema(
{
cv.Optional(CONF_ACTIVE): cv.boolean,
cv.Optional(CONF_CACHE_SERVICES): cv.boolean,
# Bounded by the loosest platform cap so range walkers (the
# device-builder field-range sync) see a real Range; the
# per-platform schemas tighten it (1 on rp2) with their own error.
cv.Optional(CONF_CONNECTION_SLOTS): cv.All(
cv.positive_int,
cv.Range(min=1, max=_IDF_MAX_CONNECTIONS),
@@ -359,18 +266,18 @@ async def _to_code_esp32(config: ConfigType) -> None:
await cg.register_component(var, config)
cg.add(var.set_active(config[CONF_ACTIVE]))
tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID])
cg.add(var.set_ble_hub(tracker))
# Compiles the scanner-state push slot into the tracker and the matching
# registration into the proxy; the other hubs are polled instead.
cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK")
await esp32_ble_tracker.register_raw_ble_device(var, config)
await esp32_ble_tracker.register_scanner_state_listener(var, config)
# Define max connections for protobuf fixed array
connection_count = len(config.get(CONF_CONNECTIONS, []))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
await _connections_to_code(var, config)
for connection_conf in config.get(CONF_CONNECTIONS, []):
connection_var = cg.new_Pvariable(connection_conf[CONF_ID])
await cg.register_component(connection_var, connection_conf)
cg.add(var.register_connection(connection_var))
await esp32_ble_tracker.register_raw_client(connection_var, connection_conf)
if config.get(CONF_CACHE_SERVICES):
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True)
@@ -385,12 +292,8 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
cg.add(var.set_ble_hub(hub))
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
# this define whenever a proxy is present. Zero on advertisement-only hubs.
# Sized from the instantiated connections so the define can never diverge
# from the loop below (the define sizes fixed storage in the proxy).
slots = len(config.get(CONF_CONNECTIONS, ()))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots)
await _connections_to_code(var, config)
# this define whenever a proxy is present; no connections off-esp32.
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0)
async def to_code(config: ConfigType) -> None:
@@ -0,0 +1,597 @@
#include "bluetooth_connection.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include "bluetooth_proxy.h"
namespace esphome::bluetooth_proxy {
static const char *const TAG = "bluetooth_proxy.connection";
// This function is allocation-free and directly packs UUIDs into the output array
// using precalculated constants for the Bluetooth base UUID
static void fill_128bit_uuid_array(std::array<uint64_t, 2> &out, esp_bt_uuid_t uuid_source) {
// Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB
// out[0] = bytes 8-15 (big-endian)
// - For 128-bit UUIDs: use bytes 8-15 as-is
// - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11
out[0] = uuid_source.len == ESP_UUID_LEN_128
? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) |
((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) |
((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) |
((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8]))
: (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32)
<< 32) |
0x00001000ULL); // Base UUID bytes 8-11
// out[1] = bytes 0-7 (big-endian)
// - For 128-bit UUIDs: use bytes 0-7 as-is
// - For 16/32-bit UUIDs: use precalculated base UUID constant
out[1] = uuid_source.len == ESP_UUID_LEN_128
? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) |
((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) |
((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) |
((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0])
: 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB
}
// Helper to fill UUID in the appropriate format based on client support and UUID type
static void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid,
bool use_efficient_uuids) {
if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) {
// Use 128-bit format for old clients or when UUID is already 128-bit
fill_128bit_uuid_array(uuid_128, uuid);
} else if (uuid.len == ESP_UUID_LEN_16) {
short_uuid = uuid.uuid.uuid16;
} else if (uuid.len == ESP_UUID_LEN_32) {
short_uuid = uuid.uuid.uuid32;
}
}
// Constants for size estimation
static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1)
static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4)
static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7)
static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1)
static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4)
static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic
// Helper to estimate service size before fetching all data
/**
* Estimate the size of a Bluetooth service based on the number of characteristics and UUID format.
*
* @param char_count The number of characteristics in the service.
* @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions.
* @return The estimated size of the service in bytes.
*
* This function calculates the size of a Bluetooth service by considering:
* - A service overhead, which depends on whether efficient UUIDs are used.
* - The size of each characteristic, assuming 128-bit UUIDs for safety.
* - The size of descriptors, assuming one 128-bit descriptor per characteristic.
*/
static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) {
size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY;
// Always assume 128-bit UUIDs for characteristics to be safe
size_t char_size = CHAR_SIZE_128BIT;
// Assume one 128-bit descriptor per characteristic
size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR;
return service_overhead + (char_size + desc_size) * char_count;
}
bool BluetoothConnection::supports_efficient_uuids_() const {
auto *api_conn = this->proxy_->get_api_connection();
return api_conn && api_conn->client_supports_api_version(1, 12);
}
void BluetoothConnection::dump_config() {
ESP_LOGCONFIG(TAG, "BLE Connection:");
BLEClientBase::dump_config();
}
void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) {
auto &allocated = this->proxy_->connections_free_response_.allocated;
for (auto &slot : allocated) {
if (slot == find_value) {
slot = set_value;
return;
}
}
}
void BluetoothConnection::set_address(uint64_t address) {
// If we're clearing an address (disconnecting), update the pre-allocated message
if (address == 0 && this->address_ != 0) {
this->proxy_->connections_free_response_.free++;
this->update_allocated_slot_(this->address_, 0);
}
// If we're setting a new address (connecting), update the pre-allocated message
else if (address != 0 && this->address_ == 0) {
this->proxy_->connections_free_response_.free--;
this->update_allocated_slot_(0, address);
}
// Call parent implementation to actually set the address
BLEClientBase::set_address(address);
}
void BluetoothConnection::loop() {
BLEClientBase::loop();
// Early return if no active connection
if (this->address_ == 0) {
return;
}
// Handle service discovery if in valid range
if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) {
this->send_service_for_discovery_();
}
// Check if we should disable the loop
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
// Important: If we were in the middle of sending services, we do NOT send
// send_gatt_services_done() here. This ensures the client knows that
// the service discovery was interrupted and can retry. The client
// (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT)
// to detect incomplete service discovery rather than relying on us to
// tell them about a partial list.
this->set_address(0);
this->send_service_ = INIT_SENDING_SERVICES;
this->proxy_->send_connections_free();
}
void BluetoothConnection::send_service_for_discovery_() {
if (this->send_service_ >= this->service_count_) {
this->send_service_ = DONE_SENDING_SERVICES;
this->proxy_->send_gatt_services_done(this->address_);
this->release_services();
return;
}
// Early return if no API connection
auto *api_conn = this->proxy_->get_api_connection();
if (api_conn == nullptr) {
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
// Check if client supports efficient UUIDs
bool use_efficient_uuids = this->supports_efficient_uuids_();
// Prepare response
api::BluetoothGATTGetServicesResponse resp;
resp.address = this->address_;
// Dynamic batching based on actual size
// Conservative MTU limit for API messages (accounts for WPA3 overhead)
static constexpr size_t MAX_PACKET_SIZE = 1360;
// Keep running total of actual message size
size_t current_size = resp.calculate_size();
while (this->send_service_ < this->service_count_) {
esp_gattc_service_elem_t service_result;
uint16_t service_count = 1;
esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr,
&service_result, &service_count, this->send_service_);
if (service_status != ESP_GATT_OK || service_count == 0) {
ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d",
this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing",
service_status, service_count, this->send_service_);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
// Get the number of characteristics BEFORE adding to response
uint16_t total_char_count = 0;
esp_gatt_status_t char_count_status =
esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
service_result.start_handle, service_result.end_handle, 0, &total_char_count);
if (char_count_status != ESP_GATT_OK) {
this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
// If this service likely won't fit, send current batch (unless it's the first)
size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids);
if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) {
// This service likely won't fit, send current batch
break;
}
// Now add the service since we know it will likely fit
resp.services.emplace_back();
auto &service_resp = resp.services.back();
fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids);
service_resp.handle = service_result.start_handle;
if (total_char_count > 0) {
// Initialize FixedVector with exact count and process characteristics
service_resp.characteristics.init(total_char_count);
uint16_t char_offset = 0;
esp_gattc_char_elem_t char_result;
// Bound by total_char_count: the vector is sized for it, and a malicious peripheral
// can make enumeration return more entries than the count query reported
while (char_offset < total_char_count) { // characteristics
uint16_t char_count = 1;
esp_gatt_status_t char_status =
esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
service_result.end_handle, &char_result, &char_count, char_offset);
if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) {
break;
}
if (char_status != ESP_GATT_OK) {
this->log_connection_error_("esp_ble_gattc_get_all_char", char_status);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (char_count == 0) {
break;
}
service_resp.characteristics.emplace_back();
auto &characteristic_resp = service_resp.characteristics.back();
fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids);
characteristic_resp.handle = char_result.char_handle;
characteristic_resp.properties = char_result.properties;
char_offset++;
// Get the number of descriptors directly with one call
uint16_t total_desc_count = 0;
esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count(
this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count);
if (desc_count_status != ESP_GATT_OK) {
this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (total_desc_count == 0) {
continue;
}
// Initialize FixedVector with exact count and process descriptors
characteristic_resp.descriptors.init(total_desc_count);
uint16_t desc_offset = 0;
esp_gattc_descr_elem_t desc_result;
while (desc_offset < total_desc_count) { // descriptors
uint16_t desc_count = 1;
esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr(
this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK) {
this->log_connection_error_("esp_ble_gattc_get_all_descr", desc_status);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (desc_count == 0) {
break; // No more descriptors
}
characteristic_resp.descriptors.emplace_back();
auto &descriptor_resp = characteristic_resp.descriptors.back();
fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids);
descriptor_resp.handle = desc_result.handle;
desc_offset++;
}
}
} // end if (total_char_count > 0)
// Calculate the actual size of just this service
size_t service_size = service_resp.calculate_size() + 1; // +1 for field tag
// Check if adding this service would exceed the limit
if (current_size + service_size > MAX_PACKET_SIZE) {
// We would go over - pop the last service if we have more than one
if (resp.services.size() > 1) {
resp.services.pop_back();
ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch",
this->connection_index_, this->address_str(), this->send_service_, current_size, service_size,
MAX_PACKET_SIZE);
// Don't increment send_service_ - we'll retry this service in next batch
} else {
// This single service is too large, but we have to send it anyway
ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_,
this->address_str(), this->send_service_, service_size);
// Increment so we don't get stuck
this->send_service_++;
}
// Send what we have
break;
}
// Now we know we're keeping this service, add its size
current_size += service_size;
// Successfully added this service, increment counter
this->send_service_++;
}
// Send the message with dynamically batched services
api_conn->send_message(resp);
}
void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) {
ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status);
}
void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) {
ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err);
}
void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) {
ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action,
type);
}
void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) {
ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(),
operation, handle, status);
}
esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) {
if (err != ESP_OK) {
this->log_connection_warning_(operation, err);
return err;
}
return ESP_OK;
}
bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
if (!BLEClientBase::gattc_event_handler(event, gattc_if, param))
return false;
switch (event) {
case ESP_GATTC_DISCONNECT_EVT: {
// Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources
// This prevents race condition where we mark slot as free before controller cleanup is complete
ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_,
param->disconnect.reason);
// Send disconnection notification but don't free the slot yet
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
} else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) {
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
this->proxy_->send_connections_free();
}
this->seen_mtu_or_services_ = false;
break;
}
case ESP_GATTC_CFG_MTU_EVT:
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (!this->seen_mtu_or_services_) {
// We don't know if we will get the MTU or the services first, so
// only send the device connection true if we have already received
// the services.
this->seen_mtu_or_services_ = true;
break;
}
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
this->proxy_->send_connections_free();
break;
}
case ESP_GATTC_READ_DESCR_EVT:
case ESP_GATTC_READ_CHAR_EVT: {
if (param->read.status != ESP_GATT_OK) {
this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status);
this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTReadResponse resp;
resp.address = this->address_;
resp.handle = param->read.handle;
resp.set_data(param->read.value, param->read.value_len);
api_connection->send_message(resp);
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
case ESP_GATTC_WRITE_DESCR_EVT: {
if (param->write.status != ESP_GATT_OK) {
this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status);
this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTWriteResponse resp;
resp.address = this->address_;
resp.handle = param->write.handle;
api_connection->send_message(resp);
break;
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
if (param->unreg_for_notify.status != ESP_GATT_OK) {
this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle,
param->unreg_for_notify.status);
this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyResponse resp;
resp.address = this->address_;
resp.handle = param->unreg_for_notify.handle;
api_connection->send_message(resp);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
if (param->reg_for_notify.status != ESP_GATT_OK) {
this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle,
param->reg_for_notify.status);
this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyResponse resp;
resp.address = this->address_;
resp.handle = param->reg_for_notify.handle;
api_connection->send_message(resp);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_,
param->notify.handle);
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyDataResponse resp;
resp.address = this->address_;
resp.handle = param->notify.handle;
resp.set_data(param->notify.value, param->notify.value_len);
api_connection->send_message(resp);
break;
}
default:
break;
}
return true;
}
void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
BLEClientBase::gap_event_handler(event, param);
switch (event) {
case ESP_GAP_BLE_AUTH_CMPL_EVT:
if (memcmp(param->ble_security.auth_cmpl.bd_addr, this->remote_bda_, 6) != 0)
break;
if (param->ble_security.auth_cmpl.success) {
this->proxy_->send_device_pairing(this->address_, true);
} else {
this->proxy_->send_device_pairing(this->address_, false, param->ble_security.auth_cmpl.fail_reason);
}
break;
default:
break;
}
}
esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
if (!this->connected()) {
this->log_gatt_not_connected_("read", "characteristic");
return ESP_GATT_NOT_CONNECTED;
}
ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE);
return this->check_and_log_error_("esp_ble_gattc_read_char", err);
}
esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length,
bool response) {
if (!this->connected()) {
this->log_gatt_not_connected_("write", "characteristic");
return ESP_GATT_NOT_CONNECTED;
}
ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
// ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data
// The BTC layer immediately copies the data to its own buffer (see btc_gattc.c)
// const_cast is safe here and was previously hidden by a C-style cast
esp_err_t err =
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
return this->check_and_log_error_("esp_ble_gattc_write_char", err);
}
esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
if (!this->connected()) {
this->log_gatt_not_connected_("read", "descriptor");
return ESP_GATT_NOT_CONNECTED;
}
ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE);
return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err);
}
esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) {
if (!this->connected()) {
this->log_gatt_not_connected_("write", "descriptor");
return ESP_GATT_NOT_CONNECTED;
}
ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
// ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data
// The BTC layer immediately copies the data to its own buffer (see btc_gattc.c)
// const_cast is safe here and was previously hidden by a C-style cast
esp_err_t err = esp_ble_gattc_write_char_descr(
this->gattc_if_, this->conn_id_, handle, length, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err);
}
esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) {
if (!this->connected()) {
this->log_gatt_not_connected_("notify", "characteristic");
return ESP_GATT_NOT_CONNECTED;
}
if (enable) {
ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_,
this->address_str_, handle);
esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle);
return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err);
}
ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_,
this->address_str_, handle);
esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle);
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err);
}
esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() {
return this->proxy_->get_advertisement_parser_type();
}
} // namespace esphome::bluetooth_proxy
#endif // USE_ESP32
@@ -0,0 +1,62 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/components/esp32_ble_client/ble_client_base.h"
namespace esphome::bluetooth_proxy {
class BluetoothProxy;
class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
public:
void dump_config() override;
void loop() override;
bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
esp_err_t read_characteristic(uint16_t handle);
esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response);
esp_err_t read_descriptor(uint16_t handle);
esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response);
esp_err_t notify_characteristic(uint16_t handle, bool enable);
esp_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom");
}
void set_address(uint64_t address) override;
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
void update_allocated_slot_(uint64_t find_value, uint64_t set_value);
void log_connection_error_(const char *operation, esp_gatt_status_t status);
void log_connection_warning_(const char *operation, esp_err_t err);
void log_gatt_not_connected_(const char *action, const char *type);
void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status);
esp_err_t check_and_log_error_(const char *operation, esp_err_t err);
// Memory optimized layout for 32-bit systems
// Group 1: Pointers (4 bytes each, naturally aligned)
BluetoothProxy *proxy_;
// Group 2: 2-byte types
int16_t send_service_{-3}; // -3 = INIT_SENDING_SERVICES, -2 = DONE_SENDING_SERVICES, >=0 = service index
// Group 3: 1-byte types
bool seen_mtu_or_services_{false};
// 1 byte used, 1 byte padding
};
} // namespace esphome::bluetooth_proxy
#endif // USE_ESP32
@@ -8,7 +8,6 @@
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
#include <algorithm>
#include <cinttypes>
#include <cstring>
#include <limits>
@@ -25,71 +24,57 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62
BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; }
// The neutral enum's values are the wire values.
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::IDLE) == api::enums::BLUETOOTH_SCANNER_STATE_IDLE);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STARTING) ==
api::enums::BLUETOOTH_SCANNER_STATE_STARTING);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::RUNNING) ==
api::enums::BLUETOOTH_SCANNER_STATE_RUNNING);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::FAILED) ==
api::enums::BLUETOOTH_SCANNER_STATE_FAILED);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STOPPING) ==
api::enums::BLUETOOTH_SCANNER_STATE_STOPPING);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STOPPED) ==
api::enums::BLUETOOTH_SCANNER_STATE_STOPPED);
bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) {
if (this->api_connection_ == nullptr)
return false;
api::BluetoothScannerStateResponse resp;
resp.state = static_cast<api::enums::BluetoothScannerState>(state);
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
resp.configured_mode = this->configured_scan_active_
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
return this->api_connection_->send_message(resp);
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
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
// full TX buffer) is retried from loop() instead of leaving a stale state.
const bool running = this->hub_->scan_running();
if (this->send_bluetooth_scanner_state_(running ? ble_device_base::ScannerState::RUNNING
: ble_device_base::ScannerState::IDLE)) {
this->last_scan_running_ = running;
}
}
#endif // !USE_BLE_SCANNER_STATE_CALLBACK
#ifdef USE_ESP32
void BluetoothProxy::setup() {
// BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy.
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->hub_->scan_active();
this->configured_scan_active_ = this->parent_->get_scan_active();
}
void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) {
if (this->api_connection_ != nullptr) {
this->send_bluetooth_scanner_state_(state);
}
}
void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) {
api::BluetoothScannerStateResponse resp;
resp.state = static_cast<api::enums::BluetoothScannerState>(state);
resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
resp.configured_mode = this->configured_scan_active_
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
this->api_connection_->send_message(resp);
}
#else // !USE_ESP32
void BluetoothProxy::setup() {
this->connections_free_response_.limit = 0;
this->connections_free_response_.free = 0;
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->hub_->scan_active();
this->last_scan_running_ = this->hub_->scan_running();
// The hub delivers raw advertisements on the ESPHome main loop:
// mac is least-significant octet first (BLE controller convention).
this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
static_cast<BluetoothProxy *>(self)->on_raw_advertisement_(adv);
}});
#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);
}});
#endif
}
// The hub delivers raw advertisements on the ESPHome main loop.
void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) {
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
adv.address = raw.address;
// raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends.
adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac);
adv.rssi = raw.rssi;
adv.address_type = raw.addr_type;
uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast<uint8_t>(raw.data_len);
@@ -98,7 +83,8 @@ 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_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4],
raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi);
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
@@ -106,16 +92,30 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
}
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) {
void BluetoothProxy::send_bluetooth_scanner_state_() {
api::BluetoothScannerStateResponse resp;
resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING
: api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE;
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
resp.configured_mode = this->configured_scan_active_
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
this->api_connection_->send_message(resp);
}
#endif // USE_ESP32
#ifdef USE_ESP32
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::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));
connection->address_str(), espbt::client_state_to_string(state));
}
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_ESP32
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
@@ -124,77 +124,104 @@ 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);
this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED);
}
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_DEVICE
bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// This method should never be called since bluetooth_proxy always uses raw advertisements
// but we need to provide an implementation to satisfy the virtual method requirement
return false;
}
#endif
bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) {
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return false;
auto &advertisements = this->response_.advertisements;
for (size_t i = 0; i < count; i++) {
auto &result = scan_results[i];
uint8_t length = result.adv_data_len + result.scan_rsp_len;
// Fill in the data directly at current position
auto &adv = advertisements[this->response_.advertisements_len];
adv.address = esp32_ble::ble_addr_to_uint64(result.bda);
adv.rssi = result.rssi;
adv.address_type = result.ble_addr_type;
adv.data_len = length;
std::memcpy(adv.data, result.ble_adv, length);
this->response_.advertisements_len++;
ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0],
result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi);
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
this->flush_pending_advertisements_();
}
}
return true;
}
#endif // USE_ESP32
void BluetoothProxy::log_advertisement_flush_() {
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", 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];
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_ESP32
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Active: %s\n"
" Connections: %d\n"
" Configured scan: %s\n"
" Adapter MAC: %s",
YESNO(this->active_), this->connection_count_, scan_mode, mac_out);
" Connections: %d",
YESNO(this->active_), this->connection_count_);
#else
// Advertisement-only: 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];
this->get_bluetooth_mac_address_pretty(mac_str);
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Mode: advertisement-only (no GATT connections)\n"
" Configured scan: %s\n"
" Adapter MAC: %s",
scan_mode, mac_out);
this->configured_scan_active_ ? "active" : "passive",
mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)");
#endif
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32
// 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
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.
ESP_LOGE(TAG, "Connection registry full, dropping registration");
void BluetoothProxy::loop() {
// Run advertisement flush / connection cleanup every 100ms
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_advertisement_flush_time_ < 100)
return;
this->last_advertisement_flush_time_ = now;
if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) {
this->flush_pending_advertisements_();
return;
}
// The hub wrapper has no Component lifecycle, so the index is assigned here.
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"); }
void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_value) {
for (auto &slot : this->connections_free_response_.allocated) {
if (slot == find_value) {
slot = set_value;
return;
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->get_address() != 0 && !connection->disconnect_pending()) {
connection->disconnect();
}
}
// The accounting arrays are only mutated here and sized to the slot count,
// so a miss means the bookkeeping already drifted — say so.
ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value);
}
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
this->send_device_connection(connection->get_address(), false, 0, reason);
connection->set_address(0);
connection->send_service_ = INIT_SENDING_SERVICES;
this->send_connections_free();
esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() {
return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS;
}
BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) {
@@ -212,7 +239,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese
// We only set the state if we allocate the connection
// to avoid a race where multiple connection attempts
// are made.
connection->set_state(ClientState::INIT);
connection->set_state(espbt::ClientState::INIT);
return connection;
}
}
@@ -235,25 +262,34 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
this->send_device_connection(msg.address, false);
return;
}
if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) {
if (connection->state() == espbt::ClientState::CONNECTED ||
connection->state() == espbt::ClientState::ESTABLISHED) {
this->log_connection_request_ignored_(connection, connection->state());
this->send_device_connection(msg.address, true);
this->send_connections_free();
return;
} else if (connection->state() != ClientState::INIT) {
// Covers CONNECTING too: a repeat request during a connect attempt is
// ignored the same way.
} else if (connection->state() == espbt::ClientState::CONNECTING) {
if (connection->disconnect_pending()) {
ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect",
connection->get_connection_index(), connection->address_str());
connection->cancel_pending_disconnect();
return;
}
this->log_connection_request_ignored_(connection, connection->state());
return;
} else if (connection->state() != espbt::ClientState::INIT) {
this->log_connection_request_ignored_(connection, connection->state());
return;
}
if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) {
connection->set_connection_type(ble_device_base::ConnectionType::V3_WITH_CACHE);
connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE);
this->log_connection_info_(connection, "v3 with cache");
} else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE
connection->set_connection_type(ble_device_base::ConnectionType::V3_WITHOUT_CACHE);
connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE);
this->log_connection_info_(connection, "v3 without cache");
}
connection->initiate_connection(static_cast<uint8_t>(msg.address_type));
connection->set_remote_addr_type(static_cast<esp_ble_addr_type_t>(msg.address_type));
connection->set_state(espbt::ClientState::DISCOVERED);
this->send_connections_free();
break;
}
@@ -264,7 +300,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
this->send_connections_free();
return;
}
if (connection->state() != ClientState::IDLE) {
if (connection->state() != espbt::ClientState::IDLE) {
connection->disconnect();
} else {
connection->set_address(0);
@@ -274,40 +310,32 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: {
// The connection wrapper exposes the pairing surface; success is
// reported when the platform's pairing completion arrives.
auto *connection = this->get_connection_(msg.address, false);
if (connection != nullptr) {
if (!connection->is_paired()) {
auto err = connection->pair();
if (err != CONN_OK) {
if (err != ESP_OK) {
this->send_device_pairing(msg.address, false, err);
}
} else {
this->send_device_pairing(msg.address, true);
}
} else {
// Answer instead of leaving the client to time out.
this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED);
}
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: {
conn_err_t ret = bluetooth_connection::unpair_device(msg.address);
if (ret == CONN_OK) {
// The bond is gone; a live connection must not short-circuit the
// next PAIR as already paired.
auto *connection = this->get_connection_(msg.address, false);
if (connection != nullptr) {
connection->set_unpaired();
}
}
this->send_device_unpairing(msg.address, ret == CONN_OK, ret);
esp_bd_addr_t address;
uint64_to_bd_addr(msg.address, address);
esp_err_t ret = esp_ble_remove_bond_device(address);
this->send_device_unpairing(msg.address, ret == ESP_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);
esp_bd_addr_t address;
uint64_to_bd_addr(msg.address, address);
esp_err_t ret = esp_ble_gattc_cache_clean(address);
// Shares the sender with the neutral path, which also null-checks api_connection_.
this->send_device_clear_cache(msg.address, ret == ESP_OK, ret);
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
@@ -326,7 +354,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms
}
auto err = connection->read_characteristic(msg.handle);
if (err != CONN_OK) {
if (err != ESP_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -339,7 +367,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) {
if (err != ESP_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -352,7 +380,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead
}
auto err = connection->read_descriptor(msg.handle);
if (err != CONN_OK) {
if (err != ESP_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -365,7 +393,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) {
if (err != ESP_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -376,8 +404,8 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
return;
}
if (!connection->has_gatt_services()) {
ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str());
if (!connection->service_count_) {
ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str());
this->send_gatt_services_done(msg.address);
return;
}
@@ -393,7 +421,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
}
auto err = connection->notify_characteristic(msg.handle, msg.enable);
if (err != CONN_OK) {
if (err != ESP_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -401,7 +429,6 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
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.
auto *connection = this->get_connection_(msg.address, false);
api::BluetoothSetConnectionParamsResponse resp;
@@ -409,9 +436,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
if (connection == nullptr || !connection->connected()) {
ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected",
connection ? static_cast<int>(connection->get_connection_index()) : -1,
connection ? static_cast<int>(connection->connection_index_) : -1,
connection ? connection->address_str() : "unknown");
resp.error = GATT_NOT_CONNECTED;
resp.error = ESP_GATT_NOT_CONNECTED;
this->api_connection_->send_message(resp);
return;
}
@@ -426,102 +453,52 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
this->api_connection_->send_message(resp);
}
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
// esp32 only: BLEHub is the concrete tracker here, so these calls reach
// tracker-native methods beyond the neutral contract.
if (this->hub_->get_scan_active() == active) {
if (this->parent_->get_scan_active() == active) {
return;
}
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
this->hub_->set_scan_active(active);
this->hub_->stop_scan();
this->hub_->set_scan_continuous(
this->parent_->set_scan_active(active);
this->parent_->stop_scan();
this->parent_->set_scan_continuous(
true); // Set this to true to automatically start scanning again when it has cleaned up.
}
#else // !USE_ESP32
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->hub_->scan_active() != active) {
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
if (!this->hub_->request_scan_mode(active)) {
// Passive-only controller asked for active scanning; the state report
// below carries the real, unchanged mode so the subscriber does not
// assume the change happened.
ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive");
}
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
if (this->api_connection_ != nullptr) {
// Reports the mode change; the sender also refreshes last_scan_running_, so
// a failed restart (scan_running_ dropped by the tracker) is not reported
// again by loop() on the next tick. A push hub reports the restart's
// transitions (mode rides along) instead.
this->send_polled_scanner_state_();
}
#endif
}
#endif // USE_ESP32
// Advertisement-only proxy. GATT client connections are excluded at compile
// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults
// HubCapabilities at runtime today — so every connection-oriented request is
// answered with a clean error instead of silence, and Home Assistant treats
// the proxy as passive.
void BluetoothProxy::loop() {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// 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++) {
this->connections_[i]->process_pending_services();
}
#endif
// Run advertisement flush / scanner-state poll every 100ms
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_advertisement_flush_time_ < 100)
return;
this->last_advertisement_flush_time_ = now;
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// 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++) {
auto *connection = this->connections_[i];
if (connection->get_address() != 0) {
connection->disconnect();
}
}
#endif
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
// 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_) {
this->send_polled_scanner_state_();
// The hub has no scanner-state listener interface; poll and report on change.
bool running = this->hub_->scan_running();
if (running != this->last_scan_running_) {
this->last_scan_running_ = running;
this->send_bluetooth_scanner_state_();
}
#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);
this->send_device_connection(msg.address, false, 0, ESP_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.
@@ -529,20 +506,14 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
this->send_connections_free();
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED);
this->send_device_pairing(msg.address, false, ESP_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);
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR:
this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
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);
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE:
this->send_device_clear_cache(msg.address, false, ESP_GATT_NOT_CONNECTED);
break;
}
}
}
@@ -573,14 +544,32 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
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;
resp.error = ESP_GATT_NOT_CONNECTED;
this->api_connection_->send_message(resp);
}
#endif // !BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->hub_->scan_active() != active) {
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
if (!this->hub_->request_scan_mode(active)) {
// Passive-only controller asked for active scanning; the state report
// below carries the real, unchanged mode so the subscriber does not
// assume the change happened.
ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive");
}
}
if (this->api_connection_ != nullptr) {
// Keep loop()'s change detector in step with the state sent here, so a
// failed restart (scan_running_ dropped by the tracker) is not reported
// twice — once now and again on the next tick.
this->last_scan_running_ = this->hub_->scan_running();
this->send_bluetooth_scanner_state_();
}
}
#endif // USE_ESP32
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
@@ -596,11 +585,12 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
this->api_connection_->get_peername_to(old_peername));
}
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());
#ifdef USE_ESP32
this->parent_->recalculate_advertisement_parser_types();
this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state());
#else
this->send_polled_scanner_state_();
this->last_scan_running_ = this->hub_->scan_running();
this->send_bluetooth_scanner_state_();
#endif
}
@@ -610,9 +600,12 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
return;
}
this->api_connection_ = nullptr;
#ifdef USE_ESP32
this->parent_->recalculate_advertisement_parser_types();
#endif
}
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceConnectionResponse call;
@@ -640,7 +633,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) {
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothGATTErrorResponse call;
@@ -650,7 +643,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) {
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDevicePairingResponse call;
@@ -661,7 +654,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) {
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceUnpairingResponse call;
@@ -674,7 +667,7 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_
// 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.
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) {
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceClearCacheResponse call;
@@ -5,30 +5,50 @@
#ifdef USE_BLUETOOTH_PROXY
#include <array>
#include <map>
#include <vector>
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/components/ble_device_base/ble_hub_impl.h"
#ifdef USE_ESP32
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h"
#include "bluetooth_connection.h"
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#include <esp_bt.h>
#endif
#include <esp_bt_device.h>
#else
#include "esphome/components/ble_device_base/ble_hub.h"
#endif // USE_ESP32
namespace esphome::bluetooth_proxy {
// The connection-domain types live in the bluetooth_connection component;
// re-exported here so the proxy code reads unqualified.
using bluetooth_connection::CONN_OK;
using bluetooth_connection::conn_err_t;
using bluetooth_connection::GATT_NOT_CONNECTED;
using bluetooth_connection::INIT_SENDING_SERVICES;
// Proxy-owned error type for the API error fields, which are plain integers on
// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls);
// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps
// the header independent of how a hub platform's SDK spells its error type.
#ifdef USE_ESP32
using proxy_err_t = esp_err_t;
static constexpr proxy_err_t PROXY_OK = ESP_OK;
#else
using proxy_err_t = int;
static constexpr proxy_err_t PROXY_OK = 0;
#endif
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
using ClientState = ble_device_base::ClientState;
static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
static constexpr int DONE_SENDING_SERVICES = -2;
static constexpr int INIT_SENDING_SERVICES = -3;
#ifdef USE_ESP32
using namespace esp32_ble_client;
#endif
// Legacy versions:
@@ -38,8 +58,6 @@ using ClientState = ble_device_base::ClientState;
// Version 4: Pairing support
// Version 5: Cache clear support
static constexpr uint32_t LEGACY_ACTIVE_CONNECTIONS_VERSION = 5;
static constexpr uint32_t LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION = 4;
static constexpr uint32_t LEGACY_ACTIVE_NO_PAIRING_VERSION = 3;
static constexpr uint32_t LEGACY_PASSIVE_ONLY_VERSION = 1;
enum BluetoothProxyFeature : uint32_t {
@@ -57,28 +75,46 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
#ifdef USE_ESP32
class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
public esp32_ble_tracker::BLEScannerStateListener,
public Component {
friend class BluetoothConnection; // Allow connection to update connections_free_response_
#else
class BluetoothProxy final : public Component {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Allow the connection to update connections_free_response_
friend bluetooth_connection::BluetoothConnection;
#endif
public:
BluetoothProxy();
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_DEVICE
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
#endif
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
#endif // USE_ESP32
void dump_config() override;
void setup() override;
void loop() override;
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void register_connection(BluetoothConnection *connection);
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifndef USE_ESP32
#ifdef USE_ESP32
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
void 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
if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
this->connections_[this->connection_count_++] = connection;
connection->proxy_ = this;
}
#endif
}
#else
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
// snapshots scan_active()/scan_running() and installs the raw callback, and
// the BLEHub contract does not promise those are settled any earlier than
// the hub's own setup().
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
#endif // !USE_ESP32
#endif // USE_ESP32
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
@@ -92,37 +128,42 @@ class BluetoothProxy final : public Component {
void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags);
void unsubscribe_api_connection(api::APIConnection *api_connection);
api::APIConnection *get_api_connection() { return this->api_connection_; }
/// Whether the subscribed API client understands 16/32-bit UUID fields.
bool client_supports_efficient_uuids() const {
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);
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, proxy_err_t error = PROXY_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);
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);
void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK);
void send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error);
void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK);
void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
void bluetooth_scanner_set_mode(bool active);
#ifdef USE_ESP32
static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) {
bd_addr[0] = (address >> 40) & 0xff;
bd_addr[1] = (address >> 32) & 0xff;
bd_addr[2] = (address >> 24) & 0xff;
bd_addr[3] = (address >> 16) & 0xff;
bd_addr[4] = (address >> 8) & 0xff;
bd_addr[5] = (address >> 0) & 0xff;
}
#endif
void set_active(bool active) { this->active_ = active; }
bool has_active() { return this->active_; }
#ifdef USE_ESP32
/// BLEScannerStateListener interface
void on_scanner_state(esp32_ble_tracker::ScannerState state) override;
#endif
uint32_t get_legacy_version() const {
if (!this->active_) {
return LEGACY_PASSIVE_ONLY_VERSION;
}
// Legacy clients (which predate the feature flags) map versions to
// capability sets: 5 adds cache clearing, 4 adds pairing, 3 is active
// connections only.
if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) {
if (this->active_) {
return LEGACY_ACTIVE_CONNECTIONS_VERSION;
}
return bluetooth_connection::SUPPORTS_PAIRING ? LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION
: LEGACY_ACTIVE_NO_PAIRING_VERSION;
return LEGACY_PASSIVE_ONLY_VERSION;
}
uint32_t get_feature_flags() const {
@@ -136,46 +177,53 @@ class BluetoothProxy final : public Component {
// scan_mode_switch is the capability bit for exactly that (#18079) —
// active_scan alone is not enough, a hub may support active scanning yet
// refuse the runtime switch.
if (ble_device_base::BLEHub::get_capabilities().scan_mode_switch) {
if (this->hub_->get_capabilities().scan_mode_switch) {
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
}
#endif
if (this->active_) {
// REMOTE_CACHING is mandatory for active connections: API clients
// refuse to connect without it (it selects which V3 connect request
// they send, not device-side caching).
flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS;
flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING;
flags |= BluetoothProxyFeature::FEATURE_PAIRING;
flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING;
flags |= BluetoothProxyFeature::FEATURE_CONNECTION_PARAMS_SETTING;
if (bluetooth_connection::SUPPORTS_PAIRING) {
flags |= BluetoothProxyFeature::FEATURE_PAIRING;
}
if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) {
flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING;
}
}
return flags;
}
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
uint8_t mac[6] = {};
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.
if (mac_address_is_valid(mac)) {
#ifdef USE_ESP32
const uint8_t *mac = esp_bt_dev_get_address();
if (mac != nullptr) {
format_mac_addr_upper(mac, output.data());
} else {
output[0] = '\0';
}
#else
uint8_t mac[6] = {};
this->hub_->get_adapter_mac(mac);
// Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs
// (rp2040's BTstack) only learn the address once the link layer is up, and
// report all-zero until then.
bool nonzero = false;
for (uint8_t b : mac)
nonzero |= b != 0;
if (nonzero) {
format_mac_addr_upper(mac, output.data());
} else {
output[0] = '\0';
}
#endif
}
protected:
bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state);
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
void send_polled_scanner_state_();
#endif
#ifdef USE_ESP32
void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state);
#else
void send_bluetooth_scanner_state_();
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
#endif
/// Caller must ensure api_connection_ is non-null and API server is connected.
void flush_pending_advertisements_() {
@@ -189,59 +237,24 @@ class BluetoothProxy final : public Component {
}
void log_advertisement_flush_();
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state);
void log_connection_request_ignored_(BluetoothConnection *connection, espbt::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
auto &resp = this->connections_free_response_;
if (new_address == 0 && old_address != 0) {
if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
resp.free++;
} else {
this->log_slot_accounting_mismatch_();
}
this->replace_allocated_slot_(old_address, 0);
} else if (new_address != 0 && old_address == 0) {
if (resp.free > 0) {
resp.free--;
} else {
this->log_slot_accounting_mismatch_();
}
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_();
/// Free a connection slot after teardown: notify the API client and reset
/// the streaming cursor. Important: does NOT send send_gatt_services_done()
/// when service streaming was interrupted -- the client (aioesphomeapi) has
/// 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);
#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_ESP32
// Group 2: Fixed-size array of connection pointers
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
#endif
#else
ble_device_base::BLEHub *hub_{nullptr};
#endif
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
@@ -256,7 +269,7 @@ class BluetoothProxy final : public Component {
bool active_;
uint8_t connection_count_{0};
bool configured_scan_active_{false}; // Configured scan mode from YAML
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#ifndef USE_ESP32
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
#endif
};
@@ -1,24 +1,24 @@
import esphome.codegen as cg
from esphome.components import ble_device_base
from esphome.components import esp32_ble_tracker
import esphome.config_validation as cv
from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS
from esphome.core import HexInt
CODEOWNERS = ["@nagyrobi"]
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
BLE_DEVICE_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA
bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer")
BTHomeMiThermometer = bthome_mithermometer_ns.class_(
"BTHomeMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component
"BTHomeMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
def bthome_mithermometer_base_schema(extra_schema=None):
if extra_schema is None:
extra_schema = {}
return cv.All(
ble_device_base.rename_legacy_hub_id("bthome_mithermometer"),
return (
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer),
@@ -26,15 +26,15 @@ def bthome_mithermometer_base_schema(extra_schema=None):
cv.Optional(CONF_BINDKEY): cv.bind_key,
}
)
.extend(BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(extra_schema)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def setup_bthome_mithermometer(var, config):
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
if bindkey := config.get(CONF_BINDKEY):
bindkey_bytes = [
@@ -8,20 +8,13 @@
#include <cstring>
#include <span>
// AES-CCM backend for encrypted-advertisement (bindkey) decryption:
// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed.
// - every other platform -> the portable software AES-CCM in ble_device_base, so
// decryption never depends on the SDK exposing mbedtls/PSA to application code
// (e.g. LibreTiny beken-72xx keeps its mbedtls internal). Works on any BLE platform.
#ifdef USE_ESP32
#include <esp_idf_version.h>
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#include <psa/crypto.h>
#define BTHOME_CRYPTO_PSA
#endif
#endif
#ifndef BTHOME_CRYPTO_PSA
#include "esphome/components/ble_device_base/ble_aes_ccm.h"
#else
#include "mbedtls/ccm.h"
#endif
namespace esphome::bthome_mithermometer {
@@ -164,7 +157,7 @@ void BTHomeMiThermometer::dump_config() {
LOG_SENSOR(" ", "Signal Strength", this->signal_strength_);
}
bool BTHomeMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) {
bool BTHomeMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool matched = false;
for (auto &service_data : device.get_service_datas()) {
if (this->handle_service_data_(service_data, device)) {
@@ -211,7 +204,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector<uint8_t> &da
const uint8_t *ciphertext = data.data() + 1;
const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE;
#if defined(BTHOME_CRYPTO_PSA)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
// PSA AEAD expects ciphertext + tag concatenated
// BLE advertisement max payload is 31 bytes, so this is always sufficient
static constexpr size_t MAX_CT_WITH_TAG = 32;
@@ -243,18 +236,29 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector<uint8_t> &da
return false;
}
#else
// Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency.
if (!ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce.data(), nonce.size(), nullptr, 0, ciphertext,
ciphertext_size, payload.data(), mic, BTHOME_MIC_SIZE)) {
ESP_LOGVV(TAG, "BTHome decryption failed.");
mbedtls_ccm_context ctx;
mbedtls_ccm_init(&ctx);
int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, this->bindkey_, BTHOME_BINDKEY_SIZE * 8);
if (ret) {
ESP_LOGVV(TAG, "mbedtls_ccm_setkey() failed.");
mbedtls_ccm_free(&ctx);
return false;
}
ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_size, nonce.data(), nonce.size(), nullptr, 0, ciphertext,
payload.data(), mic, BTHOME_MIC_SIZE);
mbedtls_ccm_free(&ctx);
if (ret) {
ESP_LOGVV(TAG, "BTHome decryption failed (ret=%d).", ret);
return false;
}
#endif
return true;
}
bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceData &service_data,
const ble_device_base::ESPBTDevice &device) {
bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceData &service_data,
const esp32_ble_tracker::ESPBTDevice &device) {
if (!service_data.uuid.contains(0xD2, 0xFC)) {
return false;
}
@@ -435,3 +439,5 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat
}
} // namespace esphome::bthome_mithermometer
#endif
@@ -1,6 +1,6 @@
#pragma once
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
@@ -8,12 +8,11 @@
#include <initializer_list>
#include <vector>
// No platform #ifdef: ble_device_base provides the BLE types on every platform; this
// component is only compiled when configured (which requires a BLE hub). bindkey (AES-CCM)
// decryption availability is selected per platform in the .cpp.
#ifdef USE_ESP32
namespace esphome::bthome_mithermometer {
class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, public Component {
class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component {
public:
void set_address(uint64_t address) { this->address_ = address; }
void set_bindkey(std::initializer_list<uint8_t> bindkey);
@@ -25,11 +24,11 @@ class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, p
void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; }
void dump_config() override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
protected:
bool handle_service_data_(const ble_device_base::ServiceData &service_data,
const ble_device_base::ESPBTDevice &device);
bool handle_service_data_(const esp32_ble_tracker::ServiceData &service_data,
const esp32_ble_tracker::ESPBTDevice &device);
bool decrypt_bthome_payload_(const std::vector<uint8_t> &data, uint64_t source_address,
std::vector<uint8_t> &payload) const;
@@ -46,3 +45,5 @@ class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, p
};
} // namespace esphome::bthome_mithermometer
#endif
@@ -23,10 +23,10 @@ from esphome.const import (
from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer
AUTO_LOAD = ["ble_device_base"]
CODEOWNERS = ["@nagyrobi"]
DEPENDENCIES = ["esp32_ble_tracker"]
CONFIG_SCHEMA = bthome_mithermometer_base_schema(
{
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
+52 -66
View File
@@ -2248,62 +2248,6 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_vfs_fatfs_sdkconfig(
disable_vfs_termios: bool,
disable_vfs_select: bool,
disable_vfs_dir: bool,
disable_fatfs: bool,
) -> None:
"""Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win."""
opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
def set_opt(name: str, value: SdkconfigValueType) -> None:
# User sdkconfig_options (applied during to_code) win.
if name not in opts:
add_idf_sdkconfig_option(name, value)
# USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off.
if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_TERMIOS", True)
else:
set_opt("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios)
# VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread);
# sockets use lwip_select() either way. ~2.7KB flash when off.
if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_SELECT", True)
else:
set_opt("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select)
# Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off.
if CORE.data.get(KEY_VFS_DIR_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_DIR", True)
else:
set_opt("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir)
# FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only;
# sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set
# any member, leave the group alone. LFN_HEAP allocates per LFN op; LFN_STACK uses stack.
lfn_keys = (
"CONFIG_FATFS_LFN_NONE",
"CONFIG_FATFS_LFN_HEAP",
"CONFIG_FATFS_LFN_STACK",
)
user_picked_lfn = any(k in opts for k in lfn_keys)
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
if not user_picked_lfn:
set_opt("CONFIG_FATFS_LFN_NONE", False)
set_opt("CONFIG_FATFS_LFN_HEAP", True)
set_opt("CONFIG_FATFS_MAX_LFN", 255)
set_opt("CONFIG_FATFS_VOLUME_COUNT", 4)
elif disable_fatfs:
if not user_picked_lfn:
set_opt("CONFIG_FATFS_LFN_NONE", True)
# Kconfig range is [1,10]; 0 gets clamped to the default.
set_opt("CONFIG_FATFS_VOLUME_COUNT", 1)
@coroutine_with_priority(CoroPriority.FINAL - 1)
async def _finalize_arduino_aware_flags():
"""Build flags that depend on whether arduino-esp32 is linked in.
@@ -2659,6 +2603,47 @@ async def to_code(config):
if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]:
add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False)
# Disable VFS support for termios (terminal I/O functions)
# USB Serial JTAG VFS functions require termios support.
# Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected
# as the logger output) call require_vfs_termios().
# Saves approximately 1.8KB of flash when disabled (default).
if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False):
# Component requires VFS termios - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS]
)
# Disable VFS support for select() with file descriptors
# ESPHome only uses select() with sockets via lwip_select(), which still works.
# VFS select is only needed for UART/eventfd file descriptors.
# Components that need it (e.g., openthread) call require_vfs_select().
# Saves approximately 2.7KB of flash when disabled (default).
if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False):
# Component requires VFS select - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT]
)
# Disable VFS support for directory functions (opendir, readdir, mkdir, etc.)
# ESPHome doesn't use directory functions on ESP32.
# Components that need it (e.g., storage components) call require_vfs_dir().
# Saves approximately 0.5KB+ of flash when disabled (default).
if CORE.data.get(KEY_VFS_DIR_REQUIRED, False):
# Component requires VFS directory support - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR]
)
if use_platformio:
cg.add_platformio_option("board_build.partitions", "partitions.csv")
if CONF_PARTITIONS in config:
@@ -2893,16 +2878,6 @@ async def to_code(config):
# FINAL priority: runs after every network/coexistence request_*() call
CORE.add_job(_reconcile_network_sdkconfig)
# FINAL: require_*() calls can come from to_code at or below this priority, so an
# inline read would be iteration-order-dependent; reconcile once after every job ran.
CORE.add_job(
_reconcile_vfs_fatfs_sdkconfig,
advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS],
advanced[CONF_DISABLE_VFS_SUPPORT_SELECT],
advanced[CONF_DISABLE_VFS_SUPPORT_DIR],
advanced[CONF_DISABLE_FATFS],
)
# Disable regi2c control functions in IRAM
# Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:
@@ -2918,6 +2893,17 @@ async def to_code(config):
):
add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True)
# Disable FATFS support
# Components that need FATFS (SD card, etc.) can call require_fatfs()
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
# Component called require_fatfs() - enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False)
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
elif advanced[CONF_DISABLE_FATFS]:
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
# Kconfig range is [1,10]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
+2 -14
View File
@@ -674,23 +674,11 @@ 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 {
// 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);
} else {
memset(out, 0, 6);
}
}
float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; }
void ESP32BLE::dump_config() {
uint8_t mac_address[6];
this->get_mac_msb_first(mac_address);
if (mac_address_is_valid(mac_address)) {
const uint8_t *mac_address = esp_bt_dev_get_address();
if (mac_address) {
const char *io_capability_s;
switch (this->io_cap_) {
case ESP_IO_CAP_OUT:
-2
View File
@@ -108,8 +108,6 @@ class ESP32BLE final : public Component {
void setup() override;
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;
float get_setup_priority() const override;
void set_name(const char *name) { this->name_ = name; }
@@ -13,14 +13,20 @@ namespace esphome::esp32_ble_client {
static const char *const TAG = "esp32_ble_client";
// Connection parameters are shared with the other GATT client backends
// (ble_device_base/ble_client_state.h) so the platforms cannot drift.
using ble_device_base::FAST_CONN_TIMEOUT;
using ble_device_base::FAST_MAX_CONN_INTERVAL;
using ble_device_base::FAST_MIN_CONN_INTERVAL;
using ble_device_base::MEDIUM_CONN_TIMEOUT;
using ble_device_base::MEDIUM_MAX_CONN_INTERVAL;
using ble_device_base::MEDIUM_MIN_CONN_INTERVAL;
// Intermediate connection parameters for standard operation
// ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies,
// causing disconnections. These medium parameters balance responsiveness with bandwidth usage.
static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms
static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms
// The timeout value was increased from 6s to 8s to address stability issues observed
// in certain BLE devices when operating through WiFi-based BLE proxies. The longer
// timeout reduces the likelihood of disconnections during periods of high latency.
static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s
// Fastest connection parameters for devices with short discovery timeouts
static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum)
static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms
static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s
static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s
static const esp_bt_uuid_t NOTIFY_DESC_UUID = {
.len = ESP_UUID_LEN_16,
@@ -92,8 +92,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
uint16_t get_conn_id() const { return this->conn_id_; }
uint64_t get_address() const { return this->address_; }
bool is_paired() const { return this->paired_; }
// The proxy clears this when a bond is removed while the link is up.
void set_unpaired() { this->paired_ = false; }
uint8_t get_connection_index() const { return this->connection_index_; }
@@ -66,9 +66,12 @@ def _get_required_features() -> set[BLEFeatures]:
# Slot counters sizing the tracker's StaticVector storage; one request per
# registered listener or client.
# registered listener, client, or scanner state listener.
_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT")
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
_request_scanner_state_listener_slot = cg.slot_counter(
"ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT"
)
def register_ble_features(features: set[BLEFeatures]) -> None:
@@ -205,9 +208,6 @@ async def to_code(config):
# available on esp32 (sensors with irk: worked without opting in).
ble_device_base.request_irk_support()
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_ESP32_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -374,6 +374,20 @@ async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExp
return var
async def register_raw_ble_device(
var: cg.SafeExpType, config: ConfigType
) -> cg.SafeExpType:
"""Register a BLE device listener that only needs raw advertisement data.
This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice
will not be compiled in if this is the only registration method used.
"""
_request_listener_slot()
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.register_listener(var))
return var
async def register_raw_client(
var: cg.SafeExpType, config: ConfigType
) -> cg.SafeExpType:
@@ -386,3 +400,17 @@ async def register_raw_client(
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.register_client(var))
return var
async def register_scanner_state_listener(
var: cg.SafeExpType, config: ConfigType
) -> cg.SafeExpType:
"""Register a listener for scanner state changes.
The slot request here is what sizes the tracker's listener storage; a
build with no registrations compiles the storage out entirely.
"""
_request_scanner_state_listener_slot()
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.add_scanner_state_listener(var))
return var
@@ -271,9 +271,7 @@ void ESP32BLETracker::register_client(ESPBTClient *client) {
// Safe because ESP32BLETracker (singleton) outlives all registered clients.
client->set_tracker_state_version(&this->state_version_);
this->clients_.push_back(client);
// Registration is add-only, so the flag is a monotonic OR.
if (client->wants_parsed_advertisements())
this->parse_advertisements_ = true;
this->recalculate_advertisement_parser_types();
#endif
}
@@ -285,11 +283,48 @@ void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *li
#endif
}
void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) {
get_mac_address_raw(out); // WiFi base MAC, MSB-first
// BT MAC = base MAC + 2 on the last octet only, wrapping without carry —
// exactly ESP-IDF's esp_read_mac(ESP_MAC_BT): mac[5] += MAC_ADDR_UNIVERSE_BT_OFFSET.
out[5] += 2;
}
void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
listener->set_parent(this);
this->listeners_.push_back(listener);
this->parse_advertisements_ = true;
this->recalculate_advertisement_parser_types();
#endif
}
void ESP32BLETracker::recalculate_advertisement_parser_types() {
this->raw_advertisements_ = false;
this->parse_advertisements_ = false;
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in
// listeners_; without this, any later esp32-path registration (e.g. the proxy's
// GATT clients) would recompute the flags and silently drop parsed dispatch.
if (!this->neutral_listeners_.empty())
this->parse_advertisements_ = true;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_) {
if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) {
this->parse_advertisements_ = true;
} else {
this->raw_advertisements_ = true;
}
}
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
if (client->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) {
this->parse_advertisements_ = true;
} else {
this->raw_advertisements_ = true;
}
}
#endif
}
@@ -387,9 +422,9 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i
void ESP32BLETracker::set_scanner_state_(ScannerState state) {
this->scanner_state_ = state;
this->state_version_++;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
if (this->scanner_state_callback_.is_set()) {
this->scanner_state_callback_.invoke(state);
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
for (auto *listener : this->scanner_state_listeners_) {
listener->on_scanner_state(state);
}
#endif
}
@@ -427,15 +462,18 @@ void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) {
#endif // USE_ESP32_BLE_DEVICE
void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) {
// Neutral raw-advertisement subscriber (the bluetooth_proxy path).
if (this->raw_advertisement_callback_.is_set()) {
ble_device_base::RawAdvertisement adv;
adv.address = esp32_ble::ble_addr_to_uint64(scan_result.bda);
adv.data = scan_result.ble_adv;
adv.data_len = static_cast<uint16_t>(scan_result.adv_data_len) + scan_result.scan_rsp_len;
adv.rssi = scan_result.rssi;
adv.addr_type = scan_result.ble_addr_type;
this->raw_advertisement_callback_.invoke(adv);
// Process raw advertisements
if (this->raw_advertisements_) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_) {
listener->parse_devices(&scan_result, 1);
}
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->parse_devices(&scan_result, 1);
}
#endif
}
// Process parsed advertisements
@@ -35,6 +35,11 @@ using namespace esp32_ble;
using adv_data_t = ble_device_base::adv_data_t;
enum AdvertisementParserType {
PARSED_ADVERTISEMENTS,
RAW_ADVERTISEMENTS,
};
#ifdef USE_ESP32_BLE_UUID
using ServiceData = ble_device_base::ServiceData;
#endif
@@ -58,6 +63,10 @@ class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener {
// Raw-only build: no parsed-device support is compiled in.
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
#endif
virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; };
virtual AdvertisementParserType get_advertisement_parser_type() {
return AdvertisementParserType::PARSED_ADVERTISEMENTS;
};
void set_parent(ESP32BLETracker *parent) { parent_ = parent; }
protected:
@@ -86,8 +95,28 @@ using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
using ble_device_base::client_state_to_string;
// Neutral scanner lifecycle re-exported for backward compatibility.
using ScannerState = ble_device_base::ScannerState;
enum class ScannerState {
// Scanner is idle, init state
IDLE,
// Scanner is starting
STARTING,
// Scanner is running
RUNNING,
// Scanner failed to start
FAILED,
// Scanner is stopping
STOPPING,
};
/** Listener interface for BLE scanner state changes.
*
* Components can implement this interface to receive scanner state updates
* without the overhead of std::function callbacks.
*/
class BLEScannerStateListener {
public:
virtual void on_scanner_state(ScannerState state) = 0;
};
/// Base class for BLE GATT clients that connect to remote devices.
///
@@ -104,10 +133,6 @@ using ScannerState = ble_device_base::ScannerState;
/// The pointer may be null if the client is not registered with a tracker.
class ESPBTClient : public ESPBTDeviceListener {
public:
/// False keeps the tracker from building parsed ESPBTDevice objects on
/// this client's account (raw consumers use the hub callback).
virtual bool wants_parsed_advertisements() { return true; }
virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) = 0;
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0;
@@ -161,6 +186,7 @@ class ESPBTClient : public ESPBTDeviceListener {
};
class ESP32BLETracker final : public Component,
public ble_device_base::BLEHub,
#ifdef USE_OTA_STATE_LISTENER
public ota::OTAGlobalStateListener,
#endif
@@ -183,29 +209,22 @@ class ESP32BLETracker final : public Component,
// esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer).
void register_listener(ESPBTDeviceListener *listener);
void register_client(ESPBTClient *client);
void recalculate_advertisement_parser_types();
// ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener);
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override;
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
}
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) {
this->scanner_state_callback_ = callback;
}
#endif
static constexpr ble_device_base::HubCapabilities get_capabilities() {
ble_device_base::HubCapabilities get_capabilities() const override {
// scan_mode_switch is false: the mode is driven through this tracker's own
// API (set_scan_active + restart), not the neutral request_scan_mode().
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); }
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);
// the neutral request refuses without changing any state.
bool request_scan_mode(bool active) { return false; }
void get_adapter_mac(uint8_t out[6]) override;
bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; }
bool scan_active() override { return this->scan_active_; }
#ifdef USE_ESP32_BLE_DEVICE
void print_bt_device_info(const ESPBTDevice &device);
@@ -223,6 +242,15 @@ class ESP32BLETracker final : public Component,
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
/// Add a listener for scanner state changes. Only compiled when a consumer
/// requested a slot in codegen: register through
/// esp32_ble_tracker.register_scanner_state_listener() in your component's
/// to_code, which requests the slot and emits this call.
void add_scanner_state_listener(BLEScannerStateListener *listener) {
this->scanner_state_listeners_.push_back(listener);
}
#endif
ScannerState get_scanner_state() const { return this->scanner_state_; }
protected:
@@ -288,6 +316,10 @@ class ESP32BLETracker final : public Component,
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
StaticVector<ESPBTClient *, ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT> clients_;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
StaticVector<BLEScannerStateListener *, ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT>
scanner_state_listeners_;
#endif
// Parsed listeners registered through the neutral BLEHub contract (migrated
// sensors); dispatched alongside listeners_.
@@ -295,9 +327,6 @@ class ESP32BLETracker final : public Component,
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> neutral_listeners_;
#endif
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
ble_device_base::ScannerStateCallback scanner_state_callback_{};
#endif
#ifdef USE_ESP32_BLE_DEVICE
/// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl)
ble_device_base::DiscoveredDeviceLog discovered_log_;
@@ -336,6 +365,7 @@ class ESP32BLETracker final : public Component,
bool scan_continuous_before_ota_{false};
#endif
bool ble_was_disabled_{true};
bool raw_advertisements_{false};
bool parse_advertisements_{false};
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
bool coex_prefer_ble_{false};
@@ -1,59 +1,33 @@
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base
from esphome.components import esp32_ble_tracker
import esphome.config_validation as cv
from esphome.const import CONF_TRIGGER_ID
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
CODEOWNERS = ["@OttoWinter"]
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications")
ExposureNotification = exposure_notifications_ns.struct("ExposureNotification")
ExposureNotificationTrigger = exposure_notifications_ns.class_(
"ExposureNotificationTrigger",
ble_device_base.ESPBTDeviceListener,
esp32_ble_tracker.ESPBTDeviceListener,
automation.Trigger.template(ExposureNotification),
)
CONF_ON_EXPOSURE_NOTIFICATION = "on_exposure_notification"
_RENAME_HUB_ID = ble_device_base.rename_legacy_hub_id("exposure_notifications")
_VALIDATE_AUTOMATION = automation.validate_automation(
cv.Schema(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ExposureNotificationTrigger),
}
# The trigger is the BLE listener, so the hub id lives on it.
).extend(ble_device_base.BLE_DEVICE_SCHEMA)
)
# validate_automation() needs a dict-based schema, so the rename cannot go
# inside it and has to run on the option value first. That value may also be a
# list of automations or malformed, and rename_legacy_hub_id() is dict-only, so
# map over lists and let validate_automation() report anything else.
# schema_extractor keeps the key typed as a trigger in the generated editor
# schema; build_language_schema.py recurses into cv.All but not into a plain
# function.
@schema_extractor("automation")
def _validate_on_exposure_notification(value: Any) -> list[ConfigType]:
if value is SCHEMA_EXTRACT:
return _VALIDATE_AUTOMATION(value)
if isinstance(value, dict):
value = _RENAME_HUB_ID(value)
elif isinstance(value, list):
value = [_RENAME_HUB_ID(v) if isinstance(v, dict) else v for v in value]
return _VALIDATE_AUTOMATION(value)
CONFIG_SCHEMA = cv.Schema(
{
cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): _validate_on_exposure_notification,
cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): automation.validate_automation(
cv.Schema(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ExposureNotificationTrigger
),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
),
}
)
@@ -62,4 +36,4 @@ async def to_code(config):
for conf in config.get(CONF_ON_EXPOSURE_NOTIFICATION, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(ExposureNotification, "x")], conf)
await ble_device_base.register_ble_device(trigger, conf)
await esp32_ble_tracker.register_ble_device(trigger, conf)
@@ -2,9 +2,11 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::exposure_notifications {
using namespace ble_device_base;
using namespace esp32_ble_tracker;
static const char *const TAG = "exposure_notifications";
@@ -41,3 +43,5 @@ bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) {
}
} // namespace esphome::exposure_notifications
#endif
@@ -2,9 +2,11 @@
#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include <array>
#ifdef USE_ESP32
namespace esphome::exposure_notifications {
struct ExposureNotification {
@@ -15,9 +17,11 @@ struct ExposureNotification {
};
class ExposureNotificationTrigger final : public Trigger<ExposureNotification>,
public ble_device_base::ESPBTDeviceListener {
public esp32_ble_tracker::ESPBTDeviceListener {
public:
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
};
} // namespace esphome::exposure_notifications
#endif
@@ -1,6 +1,8 @@
#include "inkbird_ibsth1_mini.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::inkbird_ibsth1_mini {
static const char *const TAG = "inkbird_ibsth1_mini";
@@ -13,7 +15,7 @@ void InkbirdIbstH1Mini::dump_config() {
LOG_SENSOR(" ", "Battery Level", this->battery_level_);
}
bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device) {
bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// The below is based on my research and reverse engineering of a single device
// It is entirely possible that some of that may be inaccurate or incomplete
@@ -30,7 +32,7 @@ bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device)
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false;
}
if (device.get_address_type() != ble_device_base::BLE_ADDR_TYPE_PUBLIC) {
if (device.get_address_type() != BLE_ADDR_TYPE_PUBLIC) {
ESP_LOGVV(TAG, "parse_device(): address is not public");
return false;
}
@@ -44,7 +46,7 @@ bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device)
return false;
}
const auto &mnf_data = mnf_datas[0];
if (mnf_data.uuid.type() != ble_device_base::ESPBTUUID::Type::UUID16) {
if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) {
ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16");
return false;
}
@@ -69,7 +71,7 @@ bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device)
auto external_temperature = NAN;
// Read bluetooth data into variable
auto measured_temperature = ((int16_t) mnf_data.uuid.uuid16()) / 100.0f;
auto measured_temperature = ((int16_t) mnf_data.uuid.get_uuid().uuid.uuid16) / 100.0f;
// Set temperature or external_temperature based on which sensor is in use
if (mnf_data.data[2] == 0) {
@@ -102,3 +104,5 @@ bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device)
}
} // namespace esphome::inkbird_ibsth1_mini
#endif
@@ -2,15 +2,17 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#ifdef USE_ESP32
namespace esphome::inkbird_ibsth1_mini {
class InkbirdIbstH1Mini final : public Component, public ble_device_base::ESPBTDeviceListener {
class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { address_ = address; }
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
void dump_config() override;
void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; }
@@ -27,3 +29,5 @@ class InkbirdIbstH1Mini final : public Component, public ble_device_base::ESPBTD
};
} // namespace esphome::inkbird_ibsth1_mini
#endif
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import ble_device_base, sensor
from esphome.components import esp32_ble_tracker, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
@@ -18,15 +18,14 @@ from esphome.const import (
)
CODEOWNERS = ["@fkirill"]
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
inkbird_ibsth1_mini_ns = cg.esphome_ns.namespace("inkbird_ibsth1_mini")
InkbirdIbstH1Mini = inkbird_ibsth1_mini_ns.class_(
"InkbirdIbstH1Mini", ble_device_base.ESPBTDeviceListener, cg.Component
"InkbirdIbstH1Mini", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("inkbird_ibsth1_mini"),
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(InkbirdIbstH1Mini),
@@ -58,15 +57,15 @@ CONFIG_SCHEMA = cv.All(
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
@@ -1,101 +0,0 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_WAKEUP_PIN,
ENTITY_CATEGORY_CONFIG,
ENTITY_CATEGORY_DIAGNOSTIC,
)
import esphome.final_validate as fv
from .. import LD6002BComponent, ld6002b_ns
from ..const import (
CONF_GET_DELAY,
CONF_GET_INSTALLATION,
CONF_GET_LOW_POWER_MODE,
CONF_GET_LOW_POWER_SLEEP_TIME,
CONF_GET_SENSITIVITY,
CONF_GET_TRIGGER_SPEED,
CONF_GET_Z_RANGE,
CONF_LD6002B_ID,
CONF_RESET_UNATTENDED,
CONF_WAKE,
)
DEPENDENCIES = ["ld6002b"]
LD6002BButton = ld6002b_ns.class_("LD6002BButton", button.Button)
ButtonType = ld6002b_ns.enum("ButtonType", is_class=True)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_GET_DELAY): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_SENSITIVITY): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_TRIGGER_SPEED): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_Z_RANGE): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_INSTALLATION): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_LOW_POWER_MODE): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_LOW_POWER_SLEEP_TIME): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_RESET_UNATTENDED): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_WAKE): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
}
)
def final_validate(config):
full_config = fv.full_config.get()
hub_id = config[CONF_LD6002B_ID]
if config.get(CONF_WAKE):
hub_path = full_config.get_path_for_id(hub_id)
hub_config = full_config.get_config_for_path(hub_path[:-1])
if hub_config.get(CONF_WAKEUP_PIN) is None:
raise cv.Invalid(
f"{CONF_WAKE} requires {CONF_WAKEUP_PIN} on the parent ld6002b component",
path=[CONF_WAKE],
)
return config
FINAL_VALIDATE_SCHEMA = final_validate
BUTTON_MAP = {
CONF_GET_DELAY: ButtonType.GET_DELAY,
CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY,
CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED,
CONF_GET_Z_RANGE: ButtonType.GET_Z_RANGE,
CONF_GET_INSTALLATION: ButtonType.GET_INSTALLATION,
CONF_GET_LOW_POWER_MODE: ButtonType.GET_LOW_POWER_MODE,
CONF_GET_LOW_POWER_SLEEP_TIME: ButtonType.GET_LOW_POWER_SLEEP_TIME,
CONF_RESET_UNATTENDED: ButtonType.RESET_UNATTENDED,
CONF_WAKE: ButtonType.WAKE,
}
async def to_code(config):
for key, button_type in BUTTON_MAP.items():
if button_config := config.get(key):
b = cg.new_Pvariable(button_config[CONF_ID], button_type)
await button.register_button(b, button_config)
await cg.register_parented(b, config[CONF_LD6002B_ID])
@@ -1,7 +0,0 @@
#include "ld6002b_button.h"
namespace esphome::ld6002b {
void LD6002BButton::press_action() { this->parent_->press_button(this->type_); }
} // namespace esphome::ld6002b
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/components/button/button.h"
#include "../ld6002b.h"
namespace esphome::ld6002b {
class LD6002BButton : public button::Button, public Parented<LD6002BComponent> {
public:
explicit LD6002BButton(ButtonType type) : type_(type) {}
protected:
void press_action() override;
ButtonType type_;
};
} // namespace esphome::ld6002b
-21
View File
@@ -1,29 +1,8 @@
CONF_AUTO_WAKE = "auto_wake"
CONF_CLUSTER_ID = "cluster_id"
CONF_DOPPLER_INDEX = "doppler_index"
CONF_GET_DELAY = "get_delay"
CONF_GET_INSTALLATION = "get_installation"
CONF_GET_LOW_POWER_MODE = "get_low_power_mode"
CONF_GET_LOW_POWER_SLEEP_TIME = "get_low_power_sleep_time"
CONF_GET_SENSITIVITY = "get_sensitivity"
CONF_GET_TRIGGER_SPEED = "get_trigger_speed"
CONF_GET_Z_RANGE = "get_z_range"
CONF_HOLD_DELAY = "hold_delay"
CONF_INSTALLATION_MODE = "installation_mode"
CONF_LD6002B_ID = "ld6002b_id"
CONF_LOW_POWER = "low_power"
CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time"
CONF_OTA_VERSION = "ota_version"
CONF_POINT_CLOUD = "point_cloud"
CONF_POINT_COUNT = "point_count"
CONF_RESET_UNATTENDED = "reset_unattended"
CONF_TARGET_DISPLAY = "target_display"
CONF_TRIGGER_SPEED = "trigger_speed"
CONF_WAKE = "wake"
CONF_WAKEUP_PULSE = "wakeup_pulse"
CONF_WORK_MODE = "work_mode"
CONF_Z = "z"
CONF_Z_MAX = "z_max"
CONF_Z_MIN = "z_min"
MAX_TARGETS = 3
+27 -754
View File
@@ -3,7 +3,6 @@
#include <algorithm>
#include <cinttypes>
#include <cmath>
#include <cstdio>
#include <cstring>
namespace esphome::ld6002b {
@@ -15,55 +14,20 @@ static constexpr uint32_t SETUP_DELAY_MS = 100;
// Command/message types
static constexpr uint16_t TYPE_CONTROL = 0x0201;
static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203;
static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204;
static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205;
static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04;
static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08;
static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D;
static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E;
static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F;
static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10;
static constexpr uint16_t TYPE_REPORT_INSTALLATION = 0x0A11;
static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12;
static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13;
static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14;
static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF;
// Control command values for TYPE_CONTROL
static constexpr uint32_t CMD_GET_DELAY = 0x05;
static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06;
static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07;
static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08;
static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09;
static constexpr uint32_t CMD_SENSITIVITY_LOW = 0x0A;
static constexpr uint32_t CMD_SENSITIVITY_MEDIUM = 0x0B;
static constexpr uint32_t CMD_SENSITIVITY_HIGH = 0x0C;
static constexpr uint32_t CMD_GET_SENSITIVITY = 0x0D;
static constexpr uint32_t CMD_TRIGGER_SLOW = 0x0E;
static constexpr uint32_t CMD_TRIGGER_MEDIUM = 0x0F;
static constexpr uint32_t CMD_TRIGGER_FAST = 0x10;
static constexpr uint32_t CMD_GET_TRIGGER = 0x11;
static constexpr uint32_t CMD_GET_Z_RANGE = 0x12;
static constexpr uint32_t CMD_INSTALL_TOP = 0x13;
static constexpr uint32_t CMD_INSTALL_SIDE = 0x14;
static constexpr uint32_t CMD_GET_INSTALLATION = 0x15;
static constexpr uint32_t CMD_LOW_POWER_ON = 0x16;
static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17;
static constexpr uint32_t CMD_GET_LOW_POWER = 0x18;
static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19;
static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A;
static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id
static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00};
#ifdef ESPHOME_LOG_HAS_VERBOSE
static const char *control_command_name(uint32_t command) {
switch (command) {
case CMD_GET_DELAY:
return "get_delay";
case CMD_POINT_CLOUD_ON:
return "point_cloud_on";
case CMD_POINT_CLOUD_OFF:
@@ -72,104 +36,10 @@ static const char *control_command_name(uint32_t command) {
return "target_display_on";
case CMD_TARGET_DISPLAY_OFF:
return "target_display_off";
case CMD_SENSITIVITY_LOW:
return "sensitivity_low";
case CMD_SENSITIVITY_MEDIUM:
return "sensitivity_medium";
case CMD_SENSITIVITY_HIGH:
return "sensitivity_high";
case CMD_GET_SENSITIVITY:
return "get_sensitivity";
case CMD_TRIGGER_SLOW:
return "trigger_slow";
case CMD_TRIGGER_MEDIUM:
return "trigger_medium";
case CMD_TRIGGER_FAST:
return "trigger_fast";
case CMD_GET_TRIGGER:
return "get_trigger";
case CMD_GET_Z_RANGE:
return "get_z_range";
case CMD_INSTALL_TOP:
return "install_top";
case CMD_INSTALL_SIDE:
return "install_side";
case CMD_GET_INSTALLATION:
return "get_installation";
case CMD_LOW_POWER_ON:
return "low_power_on";
case CMD_LOW_POWER_OFF:
return "low_power_off";
case CMD_GET_LOW_POWER:
return "get_low_power";
case CMD_GET_LOW_POWER_SLEEP:
return "get_low_power_sleep";
case CMD_RESET_UNATTENDED:
return "reset_unattended";
default:
return "unknown";
}
}
static const char *frame_type_name(uint16_t type) {
switch (type) {
case TYPE_CONTROL:
return "control";
case TYPE_SET_HOLD_DELAY:
return "set_hold_delay";
case TYPE_SET_Z_RANGE:
return "set_z_range";
case TYPE_SET_LOW_POWER_SLEEP:
return "set_low_power_sleep";
case TYPE_REPORT_TARGET:
return "report_target";
case TYPE_REPORT_POINT_CLOUD:
return "report_point_cloud";
case TYPE_REPORT_DELAY:
return "report_delay";
case TYPE_REPORT_SENSITIVITY:
return "report_sensitivity";
case TYPE_REPORT_TRIGGER:
return "report_trigger";
case TYPE_REPORT_Z_RANGE:
return "report_z_range";
case TYPE_REPORT_INSTALLATION:
return "report_installation";
case TYPE_REPORT_LOW_POWER:
return "report_low_power";
case TYPE_REPORT_LOW_POWER_SLEEP:
return "report_low_power_sleep";
case TYPE_REPORT_WORK_MODE:
return "report_work_mode";
case TYPE_QUERY_VERSION:
return "query_version";
default:
return "unknown";
}
}
static bool is_expected_control_report(uint32_t command, uint16_t type) {
switch (command) {
case CMD_GET_DELAY:
return type == TYPE_REPORT_DELAY;
case CMD_GET_SENSITIVITY:
return type == TYPE_REPORT_SENSITIVITY;
case CMD_GET_TRIGGER:
return type == TYPE_REPORT_TRIGGER;
case CMD_GET_Z_RANGE:
return type == TYPE_REPORT_Z_RANGE;
case CMD_GET_INSTALLATION:
return type == TYPE_REPORT_INSTALLATION;
case CMD_GET_LOW_POWER:
case CMD_LOW_POWER_ON:
case CMD_LOW_POWER_OFF:
return type == TYPE_REPORT_LOW_POWER;
case CMD_GET_LOW_POWER_SLEEP:
return type == TYPE_REPORT_LOW_POWER_SLEEP;
default:
return false;
}
}
#endif
uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast<uint16_t>(data[0]) << 8) | data[1]; }
@@ -200,25 +70,10 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) {
data[3] = (value >> 24) & 0xFF;
}
void LD6002BComponent::write_f32_le(uint8_t *data, float value) {
uint32_t raw;
std::memcpy(&raw, &value, sizeof(raw));
write_u32_le(data, raw);
}
void LD6002BComponent::setup() {
// Only the point cloud stream needs the larger frame; nothing resizes the buffer after setup.
bool point_cloud_configured = false;
#ifdef USE_SENSOR
point_cloud_configured = point_cloud_configured || this->point_count_sensor_ != nullptr;
#endif
#ifdef USE_SWITCH
point_cloud_configured = point_cloud_configured || this->point_cloud_switch_ != nullptr;
#endif
this->max_data_len_ = point_cloud_configured ? DEFAULT_MAX_DATA_LEN_POINT_CLOUD : DEFAULT_MAX_DATA_LEN;
// One allocation for the component lifetime; the parser reuses it for the header and every payload.
RAMAllocator<uint8_t> allocator;
this->data_buf_ = allocator.allocate(this->max_data_len_);
this->data_buf_ = allocator.allocate(DEFAULT_MAX_DATA_LEN);
if (this->data_buf_ == nullptr) {
this->mark_failed(LOG_STR("Failed to allocate frame buffer"));
return;
@@ -253,132 +108,25 @@ void LD6002BComponent::setup() {
}
}
#endif
#ifdef USE_TEXT_SENSOR
// The work mode fallback reads presence off this stream, so it counts as a
// consumer of it here. This only feeds the automatic branch below: with a
// target_display switch configured that switch still decides, and the
// fallback weighs no presence at all while the stream is off.
want_target_stream = want_target_stream || this->work_mode_text_sensor_ != nullptr;
#endif
bool target_display_controlled = false;
#ifdef USE_SWITCH
if (this->target_display_switch_ != nullptr) {
target_display_controlled = true;
// Nothing reports this switch back, so its restored state is the only state
// there is. Restoring through the switch keeps its inversion in the path:
// the restored value is logical, and turn_on()/turn_off() are what turn it
// into the raw command, the published state and the stream flag.
const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true);
if (state) {
this->target_display_switch_->turn_on();
} else {
this->target_display_switch_->turn_off();
}
}
#endif
if (!target_display_controlled) {
// No switch: the stream follows its consumers. With none, nothing is sent
// and the module's own default stands -- but the reports are gated out
// regardless, because there is nothing configured for them to feed.
this->target_display_enabled_ = want_target_stream;
if (want_target_stream) {
this->send_control_command_(CMD_TARGET_DISPLAY_ON);
}
if (want_target_stream) {
this->send_control_command_(CMD_TARGET_DISPLAY_ON);
}
bool point_cloud_controlled = false;
#ifdef USE_SWITCH
if (this->point_cloud_switch_ != nullptr) {
point_cloud_controlled = true;
// The switch owns the stream, so it is also what applies the restored state:
// driving it rather than the module keeps the entity's inversion in the path.
const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false);
if (state) {
this->point_cloud_switch_->turn_on();
} else {
this->point_cloud_switch_->turn_off();
}
}
#endif
if (!point_cloud_controlled) {
// No switch: the stream follows the sensor that reads it, which is also what
// the frame buffer above was sized for.
bool want_point_cloud = false;
#ifdef USE_SENSOR
want_point_cloud = this->point_count_sensor_ != nullptr;
#endif
this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF);
this->point_cloud_enabled_ = want_point_cloud;
}
#ifdef USE_SELECT
if (this->sensitivity_select_ != nullptr) {
this->send_control_command_(CMD_GET_SENSITIVITY);
}
if (this->trigger_speed_select_ != nullptr) {
this->send_control_command_(CMD_GET_TRIGGER);
}
if (this->installation_select_ != nullptr) {
this->send_control_command_(CMD_GET_INSTALLATION);
}
#endif
#ifdef USE_NUMBER
if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) {
this->send_control_command_(CMD_GET_Z_RANGE);
}
if (this->low_power_sleep_number_ != nullptr) {
this->send_control_command_(CMD_GET_LOW_POWER_SLEEP);
}
if (this->hold_delay_number_ != nullptr) {
this->send_control_command_(CMD_GET_DELAY);
}
#endif
#ifdef USE_SWITCH
bool want_low_power = this->low_power_switch_ != nullptr;
if (want_low_power) {
// The module reports this one back, so the query below confirms what it took.
// Driving the switch applies its inversion; it also marks the restored value
// as reported, so the work mode fallback runs on that until the query lands.
const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false);
if (state) {
this->low_power_switch_->turn_on();
} else {
this->low_power_switch_->turn_off();
}
}
#else
bool want_low_power = false;
#endif
#ifdef USE_TEXT_SENSOR
want_low_power = want_low_power || this->work_mode_text_sensor_ != nullptr;
#endif
if (want_low_power) {
this->send_control_command_(CMD_GET_LOW_POWER);
}
this->init_version_pref_();
#ifdef USE_TEXT_SENSOR
if (this->ota_version_text_sensor_ != nullptr) {
this->queue_command_(TYPE_QUERY_VERSION, VERSION_QUERY_DATA, sizeof(VERSION_QUERY_DATA));
}
#endif
this->send_control_command_(CMD_POINT_CLOUD_OFF);
});
}
void LD6002BComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"HLK-LD6002B:\n"
" Auto wake: %s\n"
" Max data length: %u",
this->auto_wake_ ? "true" : "false", static_cast<unsigned>(this->max_data_len_));
" Auto wake: %s",
this->auto_wake_ ? "true" : "false");
if (this->wakeup_pin_ != nullptr) {
LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_);
ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_);
}
#ifdef USE_SENSOR
LOG_SENSOR(" ", "Target Count", this->target_count_sensor_);
LOG_SENSOR(" ", "Point Count", this->point_count_sensor_);
for (auto &target : this->targets_) {
LOG_SENSOR(" ", "Target X", target.x);
LOG_SENSOR(" ", "Target Y", target.y);
@@ -393,26 +141,6 @@ void LD6002BComponent::dump_config() {
LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]);
}
#endif
#ifdef USE_TEXT_SENSOR
LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_);
LOG_TEXT_SENSOR(" ", "OTA Version", this->ota_version_text_sensor_);
#endif
#ifdef USE_NUMBER
LOG_NUMBER(" ", "Hold Delay", this->hold_delay_number_);
LOG_NUMBER(" ", "Z Min", this->z_min_number_);
LOG_NUMBER(" ", "Z Max", this->z_max_number_);
LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_);
#endif
#ifdef USE_SWITCH
LOG_SWITCH(" ", "Low Power", this->low_power_switch_);
LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_);
LOG_SWITCH(" ", "Target Display", this->target_display_switch_);
#endif
#ifdef USE_SELECT
LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_);
LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_);
LOG_SELECT(" ", "Installation Mode", this->installation_select_);
#endif
}
void LD6002BComponent::loop() {
@@ -464,7 +192,7 @@ void LD6002BComponent::parse_byte_(uint8_t byte) {
this->frame_type_ = read_u16_be(this->data_buf_ + 4);
// The length is only trustworthy once the header checksum has been verified, so just
// remember that the frame is oversized and let the HCK state act on it.
this->frame_oversize_ = this->data_len_ > this->max_data_len_;
this->frame_oversize_ = this->data_len_ > DEFAULT_MAX_DATA_LEN;
this->parse_state_ = ParseState::HCK;
}
}
@@ -539,63 +267,16 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_
return;
}
#ifdef ESPHOME_LOG_HAS_VERBOSE
const uint32_t active_control_command =
(this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4)
? read_u32_le(this->active_command_.data.data())
: 0;
if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) {
ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type,
control_command_name(active_control_command), active_control_command);
}
#endif
switch (type) {
case TYPE_REPORT_TARGET:
this->handle_target_report_(data, len);
break;
case TYPE_REPORT_POINT_CLOUD:
this->handle_point_cloud_(data, len);
break;
case TYPE_REPORT_DELAY:
this->handle_delay_report_(data, len);
break;
case TYPE_REPORT_SENSITIVITY:
this->handle_sensitivity_report_(data, len);
break;
case TYPE_REPORT_TRIGGER:
this->handle_trigger_speed_report_(data, len);
break;
case TYPE_REPORT_Z_RANGE:
this->handle_z_range_report_(data, len);
break;
case TYPE_REPORT_INSTALLATION:
this->handle_installation_report_(data, len);
break;
case TYPE_REPORT_LOW_POWER:
this->handle_low_power_report_(data, len);
break;
case TYPE_REPORT_LOW_POWER_SLEEP:
this->handle_low_power_sleep_report_(data, len);
break;
case TYPE_REPORT_WORK_MODE:
this->handle_work_mode_report_(data, len);
break;
case TYPE_QUERY_VERSION:
this->handle_version_report_(data, len);
break;
default:
break;
}
}
void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) {
// The module stops streaming when it acts on the command, not when the command
// is queued, so trailing frames after an off must not repopulate what
// set_switch_state just cleared.
if (!this->target_display_enabled_) {
return;
}
if (len < 4)
return;
@@ -658,7 +339,6 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len)
this->presence_binary_sensor_->publish_state(this->target_presence_any_);
}
#endif
this->update_work_mode_fallback_();
for (uint8_t i = 0; i < MAX_TARGETS; i++) {
bool has_target = this->slot_occupied_[i];
@@ -693,7 +373,26 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len)
#endif
} else {
#ifdef USE_SENSOR
this->clear_target_slot_(i);
TargetSensors &target = this->targets_[i];
if (this->last_target_presence_[i]) {
if (target.x != nullptr) {
target.x->publish_state(NAN);
}
if (target.y != nullptr) {
target.y->publish_state(NAN);
}
if (target.z != nullptr) {
target.z->publish_state(NAN);
}
if (target.dop_idx != nullptr) {
target.dop_idx->publish_state(NAN);
}
if (target.cluster_id != nullptr) {
target.cluster_id->publish_state(NAN);
}
// The slot is free: the next person's id is new even when it repeats this one.
this->last_cluster_id_valid_[i] = false;
}
#endif
}
#ifdef USE_BINARY_SENSOR
@@ -708,189 +407,6 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len)
}
}
void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) {
// Same window as the target stream: a frame already in flight must not put the
// count back after the switch cleared it.
if (!this->point_cloud_enabled_) {
return;
}
if (len < 4)
return;
#ifdef USE_SENSOR
uint32_t point_num = read_u32_le(data);
if (this->point_count_sensor_ != nullptr) {
if (point_num != this->last_point_count_) {
this->point_count_sensor_->publish_state(point_num);
this->last_point_count_ = point_num;
}
}
#endif
}
void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) {
if (len < 4)
return;
#ifdef USE_NUMBER
uint32_t delay = read_u32_le(data);
this->publish_number_clamped_(this->hold_delay_number_, delay);
#endif
}
void LD6002BComponent::handle_sensitivity_report_(const uint8_t *data, uint16_t len) {
if (len < 1)
return;
#ifdef USE_SELECT
if (this->sensitivity_select_ == nullptr)
return;
uint8_t value = data[0];
if (value <= 2) {
this->sensitivity_select_->publish_state(value);
}
#endif
}
void LD6002BComponent::handle_trigger_speed_report_(const uint8_t *data, uint16_t len) {
if (len < 1)
return;
#ifdef USE_SELECT
if (this->trigger_speed_select_ == nullptr)
return;
uint8_t value = data[0];
if (value <= 2) {
this->trigger_speed_select_->publish_state(value);
}
#endif
}
void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) {
if (len < 8)
return;
float z_min = read_f32_le(data);
float z_max = read_f32_le(data + 4);
this->z_min_ = z_min;
this->z_max_ = z_max;
#ifdef USE_NUMBER
this->publish_number_clamped_(this->z_min_number_, z_min);
this->publish_number_clamped_(this->z_max_number_, z_max);
#endif
}
void LD6002BComponent::handle_installation_report_(const uint8_t *data, uint16_t len) {
if (len < 1)
return;
#ifdef USE_SELECT
if (this->installation_select_ == nullptr)
return;
uint8_t value = data[0];
if (value <= 1) {
this->installation_select_->publish_state(value);
}
#endif
}
void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t len) {
if (len < 1)
return;
bool enabled = data[0] != 0;
this->low_power_enabled_ = enabled;
this->low_power_reported_ = true;
#ifdef USE_SWITCH
if (this->low_power_switch_ != nullptr) {
this->low_power_switch_->publish_state(enabled);
}
#endif
this->update_work_mode_fallback_();
}
void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint16_t len) {
if (len < 4)
return;
#ifdef USE_NUMBER
uint32_t sleep_ms = read_u32_le(data);
this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms);
#endif
}
void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) {
if (len < 1)
return;
#ifdef USE_TEXT_SENSOR
const bool low_power = (data[0] == 0);
if (this->work_mode_text_sensor_ != nullptr) {
this->work_mode_reported_ = true;
this->publish_work_mode_(low_power);
}
#endif
}
void LD6002BComponent::update_work_mode_fallback_() {
#ifdef USE_TEXT_SENSOR
if (this->work_mode_text_sensor_ == nullptr || this->work_mode_reported_) {
return;
}
if (!this->low_power_reported_) {
return;
}
// Presence is only meaningful while the stream that maintains it runs; with it
// off there is nothing to weigh and low power alone decides.
const bool presence = this->target_display_enabled_ && this->target_presence_any_;
this->publish_work_mode_(this->low_power_enabled_ && !presence);
#endif
}
void LD6002BComponent::publish_work_mode_(bool low_power) {
#ifdef USE_TEXT_SENSOR
if (this->work_mode_text_sensor_ == nullptr) {
return;
}
if (this->last_work_mode_valid_ && this->last_work_mode_low_power_ == low_power) {
return;
}
this->work_mode_text_sensor_->publish_state(low_power ? "low_power" : "normal");
this->last_work_mode_valid_ = true;
this->last_work_mode_low_power_ = low_power;
#endif
}
#ifdef USE_NUMBER
void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) {
if (number == nullptr)
return;
const float min_value = number->traits.get_min_value();
const float max_value = number->traits.get_max_value();
// Outside the declared range the user cannot write the value back, so publish
// what they can reach and say what the module actually sent.
if (value < min_value || value > max_value) {
ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", number->get_name().c_str(), value, min_value,
max_value);
value = std::clamp(value, min_value, max_value);
}
number->publish_state(value);
}
#endif
void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) {
if (len < 4)
return;
#ifdef USE_TEXT_SENSOR
if (this->ota_version_text_sensor_ == nullptr)
return;
uint8_t project = data[0];
uint8_t major = data[1];
uint8_t minor = data[2];
uint8_t patch = data[3];
char buf[32];
if (project == 0) {
std::snprintf(buf, sizeof(buf), "%u.%u.%u", major, minor, patch);
} else {
std::snprintf(buf, sizeof(buf), "p%u %u.%u.%u", project, major, minor, patch);
}
this->ota_version_text_sensor_->publish_state(buf);
this->save_version_pref_(buf);
#endif
}
void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) {
if (len > CMD_MAX_DATA_LEN) {
ESP_LOGW(TAG, "Command data too large: %u", len);
@@ -1005,8 +521,6 @@ void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data
if (len > 0 && data != nullptr) {
std::memcpy(this->wake_scratch_.data(), data, len);
}
// A button pulse must not raise the pin in the middle of this one.
this->cancel_timeout(WAKE_BUTTON_TIMEOUT);
this->wake_pulse_pending_ = true;
this->wakeup_pin_->digital_write(false);
const uint8_t generation = this->send_generation_;
@@ -1073,245 +587,4 @@ void LD6002BComponent::send_control_command_(uint32_t command) {
this->queue_command_(TYPE_CONTROL, data, sizeof(data));
}
void LD6002BComponent::send_z_range_() {
// One frame carries both bounds, so half a range cannot be written.
if (std::isnan(this->z_min_) || std::isnan(this->z_max_)) {
ESP_LOGW(TAG, "Z range not written, other bound unknown");
return;
}
// Both bounds are known and crossed; the frame has no way to say that.
if (this->z_min_ > this->z_max_) {
ESP_LOGW(TAG, "Z range not written, min above max");
return;
}
uint8_t data[8];
write_f32_le(data, this->z_min_);
write_f32_le(data + 4, this->z_max_);
this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data));
}
void LD6002BComponent::wake_() {
// A command's own pulse raises the pin and writes after it, so ride along instead of
// claiming the flag: claiming it would send that command down the immediate-write path
// with the pin still low.
if (this->wakeup_pin_ == nullptr || this->wake_pulse_pending_)
return;
this->wakeup_pin_->digital_write(false);
this->set_timeout(WAKE_BUTTON_TIMEOUT, this->wakeup_pulse_ms_, [this]() { this->wakeup_pin_->digital_write(true); });
}
void LD6002BComponent::set_number_value(NumberType type, float value) {
switch (type) {
case NumberType::HOLD_DELAY: {
uint32_t delay = static_cast<uint32_t>(value);
uint8_t data[4];
write_u32_le(data, delay);
this->queue_command_(TYPE_SET_HOLD_DELAY, data, sizeof(data));
break;
}
case NumberType::Z_MIN:
this->z_min_ = value;
this->send_z_range_();
break;
case NumberType::Z_MAX:
this->z_max_ = value;
this->send_z_range_();
break;
case NumberType::LOW_POWER_SLEEP: {
uint32_t sleep_ms = static_cast<uint32_t>(value);
uint8_t data[4];
write_u32_le(data, sleep_ms);
this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data));
break;
}
}
}
void LD6002BComponent::set_select_value(SelectType type, size_t index) {
switch (type) {
case SelectType::SENSITIVITY:
if (index == 0) {
this->send_control_command_(CMD_SENSITIVITY_LOW);
} else if (index == 1) {
this->send_control_command_(CMD_SENSITIVITY_MEDIUM);
} else if (index == 2) {
this->send_control_command_(CMD_SENSITIVITY_HIGH);
}
break;
case SelectType::TRIGGER_SPEED:
if (index == 0) {
this->send_control_command_(CMD_TRIGGER_SLOW);
} else if (index == 1) {
this->send_control_command_(CMD_TRIGGER_MEDIUM);
} else if (index == 2) {
this->send_control_command_(CMD_TRIGGER_FAST);
}
break;
case SelectType::INSTALLATION_MODE:
if (index == 0) {
this->send_control_command_(CMD_INSTALL_TOP);
} else if (index == 1) {
this->send_control_command_(CMD_INSTALL_SIDE);
}
break;
}
}
void LD6002BComponent::init_version_pref_() {
#ifdef USE_TEXT_SENSOR
if (this->ota_version_text_sensor_ == nullptr) {
return;
}
this->version_pref_ = this->ota_version_text_sensor_->make_entity_preference<VersionPref>();
this->version_pref_initialized_ = true;
VersionPref pref{};
if (this->version_pref_.load(&pref) && pref.value[0] != '\0') {
pref.value[sizeof(pref.value) - 1] = '\0';
this->ota_version_text_sensor_->publish_state(pref.value);
}
#endif
}
void LD6002BComponent::save_version_pref_(const char *value) {
#ifdef USE_TEXT_SENSOR
if (!this->version_pref_initialized_) {
return;
}
VersionPref pref{};
std::strncpy(pref.value, value, sizeof(pref.value) - 1);
pref.value[sizeof(pref.value) - 1] = '\0';
this->version_pref_.save(&pref);
#endif
}
#ifdef USE_SENSOR
void LD6002BComponent::clear_target_slot_(uint8_t index) {
if (!this->last_target_presence_[index]) {
return;
}
TargetSensors &target = this->targets_[index];
if (target.x != nullptr) {
target.x->publish_state(NAN);
}
if (target.y != nullptr) {
target.y->publish_state(NAN);
}
if (target.z != nullptr) {
target.z->publish_state(NAN);
}
if (target.dop_idx != nullptr) {
target.dop_idx->publish_state(NAN);
}
if (target.cluster_id != nullptr) {
target.cluster_id->publish_state(NAN);
}
// The slot is free: the next person's id is new even when it repeats this one.
this->last_cluster_id_valid_[index] = false;
}
#endif
void LD6002BComponent::clear_target_state_() {
// Nothing corrects any of this until the stream comes back. The slot table goes
// with it: slots key on cluster ids, which only track a person while reports are
// arriving, and the room can empty and refill across the gap -- so the next
// report starts from an empty table and fills slots in wire order, rather than
// handing one back to whoever last held that id.
for (uint8_t i = 0; i < MAX_TARGETS; i++) {
#ifdef USE_SENSOR
this->clear_target_slot_(i);
this->last_target_presence_[i] = false;
#endif
if (this->slot_occupied_[i]) {
this->slot_occupied_[i] = false;
#ifdef USE_BINARY_SENSOR
if (this->target_presence_[i] != nullptr) {
this->target_presence_[i]->publish_state(false);
}
#endif
}
}
#ifdef USE_SENSOR
if (this->last_target_count_ != 0xFFFFFFFF) {
if (this->target_count_sensor_ != nullptr) {
this->target_count_sensor_->publish_state(NAN);
}
this->last_target_count_ = 0xFFFFFFFF;
}
#endif
if (this->target_presence_any_) {
this->target_presence_any_ = false;
#ifdef USE_BINARY_SENSOR
if (this->presence_binary_sensor_ != nullptr) {
this->presence_binary_sensor_->publish_state(this->target_presence_any_);
}
#endif
this->update_work_mode_fallback_();
}
}
void LD6002BComponent::set_switch_state(SwitchType type, bool state) {
switch (type) {
case SwitchType::LOW_POWER:
this->low_power_enabled_ = state;
this->low_power_reported_ = true;
this->send_control_command_(state ? CMD_LOW_POWER_ON : CMD_LOW_POWER_OFF);
this->update_work_mode_fallback_();
break;
case SwitchType::POINT_CLOUD:
this->point_cloud_enabled_ = state;
this->send_control_command_(state ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF);
#ifdef USE_SENSOR
// The count only moves while the stream runs, so the last one would stand as
// a live reading. The dedup sentinel is cleared with it: the same count is
// new again when the stream comes back.
if (!state && this->point_count_sensor_ != nullptr && this->last_point_count_ != 0xFFFFFFFF) {
this->point_count_sensor_->publish_state(NAN);
this->last_point_count_ = 0xFFFFFFFF;
}
#endif
break;
case SwitchType::TARGET_DISPLAY:
this->target_display_enabled_ = state;
this->send_control_command_(state ? CMD_TARGET_DISPLAY_ON : CMD_TARGET_DISPLAY_OFF);
if (!state) {
// Every target entity is fed by the reports this just stopped.
this->clear_target_state_();
}
break;
}
}
void LD6002BComponent::press_button(ButtonType type) {
switch (type) {
case ButtonType::GET_DELAY:
this->send_control_command_(CMD_GET_DELAY);
break;
case ButtonType::GET_SENSITIVITY:
this->send_control_command_(CMD_GET_SENSITIVITY);
break;
case ButtonType::GET_TRIGGER_SPEED:
this->send_control_command_(CMD_GET_TRIGGER);
break;
case ButtonType::GET_Z_RANGE:
this->send_control_command_(CMD_GET_Z_RANGE);
break;
case ButtonType::GET_INSTALLATION:
this->send_control_command_(CMD_GET_INSTALLATION);
break;
case ButtonType::GET_LOW_POWER_MODE:
this->send_control_command_(CMD_GET_LOW_POWER);
break;
case ButtonType::GET_LOW_POWER_SLEEP_TIME:
this->send_control_command_(CMD_GET_LOW_POWER_SLEEP);
break;
case ButtonType::RESET_UNATTENDED:
this->send_control_command_(CMD_RESET_UNATTENDED);
break;
case ButtonType::WAKE:
this->wake_();
break;
}
}
} // namespace esphome::ld6002b
-146
View File
@@ -3,7 +3,6 @@
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/core/gpio.h"
#include "esphome/components/uart/uart.h"
#ifdef USE_SENSOR
@@ -12,61 +11,16 @@
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#ifdef USE_NUMBER
#include "esphome/components/number/number.h"
#endif
#ifdef USE_SELECT
#include "esphome/components/select/select.h"
#endif
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
#include <array>
#include <cmath>
namespace esphome::ld6002b {
static constexpr uint8_t MAX_TARGETS = 3;
static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024;
static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096;
// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes.
static constexpr size_t CMD_MAX_DATA_LEN = 28;
enum class NumberType : uint8_t {
HOLD_DELAY,
Z_MIN,
Z_MAX,
LOW_POWER_SLEEP,
};
enum class SelectType : uint8_t {
SENSITIVITY,
TRIGGER_SPEED,
INSTALLATION_MODE,
};
enum class SwitchType : uint8_t {
LOW_POWER,
POINT_CLOUD,
TARGET_DISPLAY,
};
enum class ButtonType : uint8_t {
GET_DELAY,
GET_SENSITIVITY,
GET_TRIGGER_SPEED,
GET_Z_RANGE,
GET_INSTALLATION,
GET_LOW_POWER_MODE,
GET_LOW_POWER_SLEEP_TIME,
RESET_UNATTENDED,
WAKE,
};
#ifdef USE_SENSOR
struct TargetSensors {
sensor::Sensor *x{nullptr};
@@ -78,10 +32,6 @@ struct TargetSensors {
#endif
struct VersionPref {
char value[20];
};
class LD6002BComponent : public Component, public uart::UARTDevice {
public:
void setup() override;
@@ -95,7 +45,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
#ifdef USE_SENSOR
void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; }
void set_point_count_sensor(sensor::Sensor *sensor) { this->point_count_sensor_ = sensor; }
void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) {
if (target >= MAX_TARGETS)
@@ -133,35 +82,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
}
#endif
#ifdef USE_TEXT_SENSOR
void set_work_mode_text_sensor(text_sensor::TextSensor *sensor) { this->work_mode_text_sensor_ = sensor; }
void set_ota_version_text_sensor(text_sensor::TextSensor *sensor) { this->ota_version_text_sensor_ = sensor; }
#endif
#ifdef USE_NUMBER
void set_hold_delay_number(number::Number *number) { this->hold_delay_number_ = number; }
void set_z_min_number(number::Number *number) { this->z_min_number_ = number; }
void set_z_max_number(number::Number *number) { this->z_max_number_ = number; }
void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; }
#endif
#ifdef USE_SELECT
void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; }
void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; }
void set_installation_select(select::Select *select) { this->installation_select_ = select; }
#endif
#ifdef USE_SWITCH
void set_low_power_switch(switch_::Switch *sw) { this->low_power_switch_ = sw; }
void set_point_cloud_switch(switch_::Switch *sw) { this->point_cloud_switch_ = sw; }
void set_target_display_switch(switch_::Switch *sw) { this->target_display_switch_ = sw; }
#endif
void set_number_value(NumberType type, float value);
void set_select_value(SelectType type, size_t index);
void set_switch_state(SwitchType type, bool state);
void press_button(ButtonType type);
protected:
enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD };
@@ -175,28 +95,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
void reset_parser_();
void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len);
void handle_target_report_(const uint8_t *data, uint16_t len);
void handle_point_cloud_(const uint8_t *data, uint16_t len);
void handle_delay_report_(const uint8_t *data, uint16_t len);
void handle_sensitivity_report_(const uint8_t *data, uint16_t len);
void handle_trigger_speed_report_(const uint8_t *data, uint16_t len);
void handle_z_range_report_(const uint8_t *data, uint16_t len);
void handle_installation_report_(const uint8_t *data, uint16_t len);
void handle_low_power_report_(const uint8_t *data, uint16_t len);
void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len);
void handle_work_mode_report_(const uint8_t *data, uint16_t len);
void handle_version_report_(const uint8_t *data, uint16_t len);
void update_work_mode_fallback_();
void publish_work_mode_(bool low_power);
// Drops every target-derived reading and the slot table they are indexed by.
void clear_target_state_();
#ifdef USE_SENSOR
void clear_target_slot_(uint8_t index);
#endif
#ifdef USE_NUMBER
void publish_number_clamped_(number::Number *number, float value);
#endif
void init_version_pref_();
void save_version_pref_(const char *value);
void queue_command_(uint16_t type, const uint8_t *data, uint8_t len);
void process_command_queue_();
@@ -204,47 +102,21 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track);
void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track);
void send_control_command_(uint32_t command);
void send_z_range_();
void wake_();
static uint16_t read_u16_be(const uint8_t *data);
static uint32_t read_u32_le(const uint8_t *data);
static int32_t read_int32_le(const uint8_t *data);
static float read_f32_le(const uint8_t *data);
static void write_u32_le(uint8_t *data, uint32_t value);
static void write_f32_le(uint8_t *data, float value);
#ifdef USE_SENSOR
std::array<TargetSensors, MAX_TARGETS> targets_{};
sensor::Sensor *target_count_sensor_{nullptr};
sensor::Sensor *point_count_sensor_{nullptr};
#endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *presence_binary_sensor_{nullptr};
std::array<binary_sensor::BinarySensor *, MAX_TARGETS> target_presence_{};
#endif
#ifdef USE_TEXT_SENSOR
text_sensor::TextSensor *work_mode_text_sensor_{nullptr};
text_sensor::TextSensor *ota_version_text_sensor_{nullptr};
ESPPreferenceObject version_pref_{};
bool version_pref_initialized_{false};
#endif
#ifdef USE_NUMBER
number::Number *hold_delay_number_{nullptr};
number::Number *z_min_number_{nullptr};
number::Number *z_max_number_{nullptr};
number::Number *low_power_sleep_number_{nullptr};
#endif
#ifdef USE_SELECT
select::Select *sensitivity_select_{nullptr};
select::Select *trigger_speed_select_{nullptr};
select::Select *installation_select_{nullptr};
#endif
#ifdef USE_SWITCH
switch_::Switch *low_power_switch_{nullptr};
switch_::Switch *point_cloud_switch_{nullptr};
switch_::Switch *target_display_switch_{nullptr};
#endif
GPIOPin *wakeup_pin_{nullptr};
uint32_t wakeup_pulse_ms_{50};
@@ -260,7 +132,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
uint8_t data_xor_{0};
uint32_t discard_remaining_{0};
bool frame_oversize_{false};
size_t max_data_len_{0};
uint8_t *data_buf_{nullptr};
uint16_t next_frame_id_{0};
@@ -273,9 +144,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
// How long the module stays awake after any frame, and so still answers the next one.
static constexpr uint32_t MODULE_AWAKE_MS = 10000;
static constexpr uint8_t CMD_MAX_RETRIES = 3;
// Named so a repeated press replaces its own pending timeout instead of stacking
// another, and so the command path can cancel it when it takes the pin over.
static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button";
// A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms.
static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000;
@@ -305,24 +173,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
// Bumped whenever the active command changes, so a deferred send can tell it was retired.
uint8_t send_generation_{0};
float z_min_{NAN};
float z_max_{NAN};
// Which person owns each target_N slot, so a slot survives the module re-sorting its array.
std::array<int32_t, MAX_TARGETS> slot_cluster_{};
std::array<bool, MAX_TARGETS> slot_occupied_{};
bool target_presence_any_{false};
// What the switches and setup asked the module for, which is not the same as
// what it is doing yet: a stream keeps sending until it acts on the command.
// The report handlers read these and drop anything a stopped stream still emits.
bool target_display_enabled_{false};
bool point_cloud_enabled_{false};
bool work_mode_reported_{false};
bool low_power_enabled_{false};
bool low_power_reported_{false};
bool last_work_mode_valid_{false};
bool last_work_mode_low_power_{false};
#ifdef USE_SENSOR
std::array<bool, MAX_TARGETS> last_target_presence_{}; // one-shot NAN clear for target sensors
@@ -330,7 +185,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice {
std::array<int32_t, MAX_TARGETS> last_cluster_id_{};
std::array<bool, MAX_TARGETS> last_cluster_id_valid_{};
uint32_t last_target_count_{0xFFFFFFFF};
uint32_t last_point_count_{0xFFFFFFFF};
#endif
};
@@ -1,82 +0,0 @@
import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_DISTANCE,
DEVICE_CLASS_DURATION,
ENTITY_CATEGORY_CONFIG,
UNIT_METER,
UNIT_MILLISECOND,
UNIT_SECOND,
)
from .. import LD6002BComponent, ld6002b_ns
from ..const import (
CONF_HOLD_DELAY,
CONF_LD6002B_ID,
CONF_LOW_POWER_SLEEP_TIME,
CONF_Z_MAX,
CONF_Z_MIN,
)
DEPENDENCIES = ["ld6002b"]
LD6002BNumber = ld6002b_ns.class_("LD6002BNumber", number.Number)
NumberType = ld6002b_ns.enum("NumberType", is_class=True)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_HOLD_DELAY): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_SECOND,
device_class=DEVICE_CLASS_DURATION,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_Z_MIN): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_Z_MAX): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_LOW_POWER_SLEEP_TIME): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_MILLISECOND,
device_class=DEVICE_CLASS_DURATION,
entity_category=ENTITY_CATEGORY_CONFIG,
),
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
for key, number_type, setter, min_value, max_value, step in (
(CONF_HOLD_DELAY, NumberType.HOLD_DELAY, "set_hold_delay_number", 0, 65535, 1),
(CONF_Z_MIN, NumberType.Z_MIN, "set_z_min_number", -10, 10, 0.1),
(CONF_Z_MAX, NumberType.Z_MAX, "set_z_max_number", -10, 10, 0.1),
# 0x0205 carries a uint32 of milliseconds; the vendor documents 500 ms as
# the default and no upper bound, so the range ends at a minute rather
# than at a default the module is free to be sleeping past.
(
CONF_LOW_POWER_SLEEP_TIME,
NumberType.LOW_POWER_SLEEP,
"set_low_power_sleep_number",
0,
60000,
100,
),
):
if conf := config.get(key):
n = await number.new_number(
conf, number_type, min_value=min_value, max_value=max_value, step=step
)
await cg.register_parented(n, config[CONF_LD6002B_ID])
cg.add(getattr(hub, setter)(n))
@@ -1,10 +0,0 @@
#include "ld6002b_number.h"
namespace esphome::ld6002b {
void LD6002BNumber::control(float value) {
this->publish_state(value);
this->parent_->set_number_value(this->type_, value);
}
} // namespace esphome::ld6002b
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/components/number/number.h"
#include "../ld6002b.h"
namespace esphome::ld6002b {
class LD6002BNumber : public number::Number, public Parented<LD6002BComponent> {
public:
explicit LD6002BNumber(NumberType type) : type_(type) {}
protected:
void control(float value) override;
NumberType type_;
};
} // namespace esphome::ld6002b
@@ -1,60 +0,0 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG
from .. import LD6002BComponent, ld6002b_ns
from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED
DEPENDENCIES = ["ld6002b"]
LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select)
SelectType = ld6002b_ns.enum("SelectType", is_class=True)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_SENSITIVITY): select.select_schema(
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_TRIGGER_SPEED): select.select_schema(
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_INSTALLATION_MODE): select.select_schema(
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
),
}
)
SELECT_MAP = (
(
CONF_SENSITIVITY,
SelectType.SENSITIVITY,
"set_sensitivity_select",
["low", "medium", "high"],
),
(
CONF_TRIGGER_SPEED,
SelectType.TRIGGER_SPEED,
"set_trigger_speed_select",
["slow", "medium", "fast"],
),
(
CONF_INSTALLATION_MODE,
SelectType.INSTALLATION_MODE,
"set_installation_select",
["top", "side"],
),
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
for key, select_type, setter, options in SELECT_MAP:
if conf := config.get(key):
s = await select.new_select(conf, select_type, options=options)
await cg.register_parented(s, config[CONF_LD6002B_ID])
cg.add(getattr(hub, setter)(s))
@@ -1,10 +0,0 @@
#include "ld6002b_select.h"
namespace esphome::ld6002b {
void LD6002BSelect::control(size_t index) {
this->publish_state(index);
this->parent_->set_select_value(this->type_, index);
}
} // namespace esphome::ld6002b
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/components/select/select.h"
#include "../ld6002b.h"
namespace esphome::ld6002b {
class LD6002BSelect : public select::Select, public Parented<LD6002BComponent> {
public:
explicit LD6002BSelect(SelectType type) : type_(type) {}
protected:
void control(size_t index) override;
SelectType type_;
};
} // namespace esphome::ld6002b
-9
View File
@@ -15,7 +15,6 @@ from .const import (
CONF_CLUSTER_ID,
CONF_DOPPLER_INDEX,
CONF_LD6002B_ID,
CONF_POINT_COUNT,
CONF_Z,
MAX_TARGETS,
)
@@ -76,10 +75,6 @@ CONFIG_SCHEMA = cv.Schema(
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)})
@@ -91,10 +86,6 @@ async def to_code(config):
sens = await sensor.new_sensor(target_count_config)
cg.add(hub.set_target_count_sensor(sens))
if point_count_config := config.get(CONF_POINT_COUNT):
sens = await sensor.new_sensor(point_count_config)
cg.add(hub.set_point_count_sensor(sens))
for i in range(MAX_TARGETS):
if target_config := config.get(f"target_{i + 1}"):
if x_config := target_config.get(CONF_X):
@@ -1,60 +0,0 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG
from .. import LD6002BComponent, ld6002b_ns
from ..const import (
CONF_LD6002B_ID,
CONF_LOW_POWER,
CONF_POINT_CLOUD,
CONF_TARGET_DISPLAY,
)
DEPENDENCIES = ["ld6002b"]
LD6002BSwitch = ld6002b_ns.class_("LD6002BSwitch", switch.Switch)
SwitchType = ld6002b_ns.enum("SwitchType", is_class=True)
# None of these three carry an inversion. They name what the module is doing, not
# how something is wired to it, so an inverted one would only report the opposite
# of the truth -- and the boot restore, which applies a state nothing reports back,
# is where that would be hardest to spot.
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_LOW_POWER): switch.switch_schema(
LD6002BSwitch,
block_inverted=True,
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_POINT_CLOUD): switch.switch_schema(
LD6002BSwitch,
block_inverted=True,
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_TARGET_DISPLAY): switch.switch_schema(
LD6002BSwitch,
block_inverted=True,
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
default_restore_mode="RESTORE_DEFAULT_ON",
),
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
for key, switch_type, setter in (
(CONF_LOW_POWER, SwitchType.LOW_POWER, "set_low_power_switch"),
(CONF_POINT_CLOUD, SwitchType.POINT_CLOUD, "set_point_cloud_switch"),
(CONF_TARGET_DISPLAY, SwitchType.TARGET_DISPLAY, "set_target_display_switch"),
):
if conf := config.get(key):
s = await switch.new_switch(conf, switch_type)
await cg.register_parented(s, config[CONF_LD6002B_ID])
cg.add(getattr(hub, setter)(s))
@@ -1,10 +0,0 @@
#include "ld6002b_switch.h"
namespace esphome::ld6002b {
void LD6002BSwitch::write_state(bool state) {
this->parent_->set_switch_state(this->type_, state);
this->publish_state(state);
}
} // namespace esphome::ld6002b
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/components/switch/switch.h"
#include "../ld6002b.h"
namespace esphome::ld6002b {
class LD6002BSwitch : public switch_::Switch, public Parented<LD6002BComponent> {
public:
explicit LD6002BSwitch(SwitchType type) : type_(type) {}
protected:
void write_state(bool state) override;
SwitchType type_;
};
} // namespace esphome::ld6002b
-31
View File
@@ -1,31 +0,0 @@
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC
from . import LD6002BComponent
from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE
DEPENDENCIES = ["ld6002b"]
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_WORK_MODE): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_OTA_VERSION): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
if work_mode_config := config.get(CONF_WORK_MODE):
sens = await text_sensor.new_text_sensor(work_mode_config)
cg.add(hub.set_work_mode_text_sensor(sens))
if ota_config := config.get(CONF_OTA_VERSION):
sens = await text_sensor.new_text_sensor(ota_config)
cg.add(hub.set_ota_version_text_sensor(sens))
@@ -127,9 +127,6 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_LN882H_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -240,11 +240,8 @@ void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t add
// Raw callback (the raw-advertisement path). Both full advertisements and
// unmatched scan responses (raw_only) are forwarded.
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(mac),
.data = data,
.data_len = data_len,
.rssi = rssi,
.addr_type = addr_type};
const ble_device_base::RawAdvertisement adv{
.mac = mac, .data = data, .data_len = data_len, .rssi = rssi, .addr_type = addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
@@ -26,6 +26,7 @@ namespace esphome::ln882h_ble_tracker {
// ---------------------------------------------------------------------------
class LN882HBLETracker : public Component,
public ble_device_base::BLEHub,
public Parented<ln882h_ble::LN882HBLE>,
public ln882h_ble::BLEScanListener
#ifdef USE_OTA_STATE_LISTENER
@@ -74,15 +75,15 @@ class LN882HBLETracker : public Component,
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
}
static constexpr ble_device_base::HubCapabilities get_capabilities() {
ble_device_base::HubCapabilities get_capabilities() const override {
// The LN882H controller supports active scanning; adv + scan response arrive
// as separate reports and are merged by this tracker (Bluedroid semantics).
// The SDK's GATT client is not exposed.
@@ -91,15 +92,15 @@ 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]) {
void get_adapter_mac(uint8_t out[6]) override {
uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
}
bool scan_running() { return this->scan_running_; }
bool scan_active() { return this->scan_active_; }
bool request_scan_mode(bool active);
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return this->scan_active_; }
bool request_scan_mode(bool active) override;
// ---- ln882h_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
+3 -24
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from typing import Any, Literal
from typing import Literal
from esphome import pins
import esphome.codegen as cg
@@ -21,14 +21,6 @@ AUTO_LOAD = ["modbus_client"]
# Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC.
MAX_PDU_SIZE = 253
# Mirror the per-function entity count limits from modbus_definitions.h. Keep these in step with the
# C++ constants of the same name; the spec sets a different ceiling for each function code.
MAX_NUM_OF_COILS_TO_READ = 2000
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
modbus_ns = cg.esphome_ns.namespace("modbus")
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus)
@@ -99,28 +91,15 @@ async def to_code(config):
cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME]))
def _validate_server_address(value: Any) -> int:
address = cv.hex_uint8_t(value)
# The broadcast address (0) is delivered to every device and is never answered (Modbus 4.1),
# so it cannot identify an individual server device.
if address == 0:
raise cv.Invalid(
"Address 0 is the Modbus broadcast address and cannot be used as a "
"server device address. Assign a unique unit address instead."
)
return address
def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"):
hub_type = ModbusClient if role == "client" else ModbusServer
address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t
schema = {
cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type),
}
if default_address is None:
schema[cv.Required(CONF_ADDRESS)] = address_validator
schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t
else:
schema[cv.Optional(CONF_ADDRESS, default=default_address)] = address_validator
schema[cv.Optional(CONF_ADDRESS, default=default_address)] = cv.hex_uint8_t
return cv.Schema(schema)
+53 -215
View File
@@ -15,9 +15,6 @@ static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11;
// Milliseconds per second
static constexpr uint32_t MS_PER_SEC = 1000;
// Shortest gap between two "no device accepted broadcast" warnings
static constexpr uint32_t UNACCEPTED_BROADCAST_WARN_INTERVAL_MS = 60 * MS_PER_SEC;
void Modbus::setup() {
if (this->flow_control_pin_ != nullptr) {
this->flow_control_pin_->setup();
@@ -186,10 +183,6 @@ void ModbusServerHub::parse_modbus_frames() {
size_t size = this->rx_buffer_.size();
ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size);
bool retry_as_client = false;
// A broadcast is a client request, never a peer response; clear any stale expectation (RTU is half-duplex).
const bool is_broadcast = this->rx_buffer_[0] == BROADCAST_ADDRESS;
if (is_broadcast)
this->expecting_peer_response_ = 0;
if (this->expecting_peer_response_ != 0) {
if (!this->parse_modbus_server_frame_()) {
ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse",
@@ -284,17 +277,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
// This requires copying the frame data to a local buffer beforehand.
uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
uint16_t data_len = frame_length - 2 - data_offset;
uint8_t data_buffer[MAX_FRAME_SIZE] = {};
std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len);
std::span<const uint8_t> data(data_buffer, data_len);
uint8_t data[MAX_FRAME_SIZE] = {};
std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len);
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
if (address == BROADCAST_ADDRESS) {
// Keep the unicast response buffers out of the broadcast call chain.
this->process_broadcast_frame_(function_code, data);
} else {
this->process_modbus_client_frame_(address, function_code, data);
}
this->process_modbus_client_frame_(address, function_code, data);
return true;
}
@@ -378,148 +365,18 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) {
return nullptr;
}
ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) {
bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address,
uint16_t number_of_registers) {
if ((uint32_t) start_address + number_of_registers > 0x10000u) {
ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address,
number_of_registers);
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
return std::nullopt;
}
// Write PDU layout after the function code: start address(2) [+ quantity(2) + byte count(1)] + register values.
// The value subspans taken at these offsets stay in range because client_pdu_length() clamps the byte count to the
// same maximum the callers' number_of_registers * 2 == number_of_bytes guard enforces.
static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2;
static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5;
// FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1).
static constexpr size_t READ_WRITE_VALUES_OFFSET = 9;
ResponseStatus ModbusServerHub::parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers) {
start_address = helpers::get_data<uint16_t>(data.data(), 0);
// No range check needed: one register can never push start_address + 1 past the address space.
this->assemble_registers_(data.subspan(WRITE_SINGLE_VALUES_OFFSET, sizeof(uint16_t)), registers);
return std::nullopt;
}
ResponseStatus ModbusServerHub::parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers) {
start_address = helpers::get_data<uint16_t>(data.data(), 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data.data(), 4);
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE ||
number_of_registers * 2 != number_of_bytes) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes);
return ExceptionCode::ILLEGAL_DATA_VALUE;
}
if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) {
return status;
}
this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers);
return std::nullopt;
}
void ModbusServerHub::assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers) {
for (size_t offset = 0; offset + 1 < values.size(); offset += 2) {
registers.push_back(helpers::get_data<uint16_t>(values.data(), offset));
}
}
void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data) {
// Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an
// unsupported function code or a validation failure is silently dropped instead of replying with an exception.
// Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet.
uint16_t start_address;
RegisterValues registers;
ResponseStatus status;
switch (static_cast<FunctionCode>(function_code)) {
case FunctionCode::WRITE_SINGLE_REGISTER:
status = this->parse_write_single_(data, start_address, registers);
break;
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
status = this->parse_write_multiple_(data, start_address, registers);
break;
default:
// Reads and read/write require a reply, so they are not valid as broadcasts.
ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code);
return;
}
if (status.has_value()) {
return;
}
// A broadcast is never answered, so a rejecting device has no other feedback channel: report the
// per-device outcome at V, and warn if the write reached nobody at all.
bool accepted = false;
for (auto *device : this->devices_) {
if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers);
device_status.has_value()) {
ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(),
static_cast<uint8_t>(device_status.value()));
} else {
accepted = true;
}
}
if (!accepted && !this->devices_.empty()) {
// Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes
// repeats forever, so warning per frame would flood the log.
const uint32_t now = millis();
if (this->last_unaccepted_broadcast_warn_ == 0 ||
now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) {
this->last_unaccepted_broadcast_warn_ = now;
ESP_LOGW(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address);
} else {
ESP_LOGV(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address);
}
}
}
bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status,
uint16_t number_of_registers, const RegisterValues &registers,
std::span<uint8_t> response_buffer, uint16_t &response_len) {
// A handler that returns an exception leaves registers partially filled, so check the exception
// first and forward it before validating the register count on the success path.
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_ADDRESS);
return false;
}
if (registers.size() != number_of_registers) {
ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size());
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return false;
}
// The byte count is a single byte, so the count must stay within the protocol read limit; above it the
// static_cast<uint8_t>(number_of_registers * 2) below would silently truncate the byte count.
if (number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGE(TAG, "Read response of %" PRIu16 " registers exceeds the limit of %" PRIu16, number_of_registers,
MAX_NUM_OF_REGISTERS_TO_READ);
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return false;
}
// Byte count(1) + two bytes per register. Checked here rather than at the call sites so the bound travels with
// the write itself: a future caller starting at a non-zero response_len, or passing a smaller buffer, is
// rejected instead of overrunning it before send_response_'s size guard can fire.
const size_t required = static_cast<size_t>(response_len) + 1 + static_cast<size_t>(number_of_registers) * 2;
if (required > response_buffer.size()) {
ESP_LOGE(TAG, "Read response needs %zu bytes but only %zu are available", required, response_buffer.size());
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return false;
}
response_buffer[response_len++] = static_cast<uint8_t>(number_of_registers * 2); // actual byte count
for (auto r : registers) {
auto register_bytes = decode_value(r);
response_buffer[response_len++] = register_bytes[0];
response_buffer[response_len++] = register_bytes[1];
}
return true;
}
void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code,
std::span<const uint8_t> data) {
void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) {
ModbusServerDevice *device = this->find_device_(address);
if (device == nullptr) {
this->expecting_peer_response_ = address;
@@ -536,16 +393,14 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: {
// PDU data: start address(2) + quantity(2).
uint16_t start_address = helpers::get_data<uint16_t>(data.data(), 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
uint16_t start_address = helpers::get_data<uint16_t>(data, 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data, 2);
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
status = this->check_register_range_(start_address, number_of_registers);
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) {
return;
}
RegisterValues registers;
@@ -555,78 +410,61 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
status = device->on_read_input_registers(start_address, number_of_registers, registers);
}
if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers,
response_buffer, response_len)) {
// A handler that returns an exception leaves registers partially filled, so check the exception
// first and forward it before validating the register count on the success path.
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
if (registers.size() != number_of_registers) {
ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size());
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return;
}
response_buffer[response_len++] = static_cast<uint8_t>(number_of_registers * 2); // actual byte count
for (auto r : registers) {
auto register_bytes = decode_value(r);
response_buffer[response_len++] = register_bytes[0];
response_buffer[response_len++] = register_bytes[1];
}
break;
}
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
// Parse and validate the write PDU into host-order register values; reply with an exception on failure.
uint16_t start_address;
RegisterValues registers;
if (static_cast<FunctionCode>(function_code) == FunctionCode::WRITE_SINGLE_REGISTER) {
status = this->parse_write_single_(data, start_address, registers);
} else {
status = this->parse_write_multiple_(data, start_address, registers);
// PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values.
// A single-register write always targets one register; for a multiple-register write the
// quantity is in the frame and its byte count must equal quantity * 2. The register values are
// assembled into registers below so the handler doesn't have to know the request framing.
uint16_t start_address = helpers::get_data<uint16_t>(data, 0);
uint16_t number_of_registers = 1;
uint16_t values_offset = 2; // single write: values follow the 2-byte start address
if (static_cast<FunctionCode>(function_code) == FunctionCode::WRITE_MULTIPLE_REGISTERS) {
number_of_registers = helpers::get_data<uint16_t>(data, 2);
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data, 4);
values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1)
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE ||
number_of_registers * 2 != number_of_bytes) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers,
number_of_bytes);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) {
return;
}
}
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
// Assemble the register values (host byte order) so the handler never sees wire framing.
RegisterValues registers;
for (uint16_t i = 0; i < number_of_registers; i++) {
registers.push_back(helpers::get_data<uint16_t>(data, values_offset + i * 2));
}
status = device->on_write_registers(start_address, registers);
response_data = data.data(); // echo the request header per Modbus 6.6, 6.12
response_data = data; // echo the request header per Modbus 6.6, 6.12
response_len = 4;
break;
}
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
// PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) +
// write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read.
uint16_t read_start_address = helpers::get_data<uint16_t>(data.data(), 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
uint16_t write_start_address = helpers::get_data<uint16_t>(data.data(), 4);
uint16_t number_of_write_registers = helpers::get_data<uint16_t>(data.data(), 6);
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data.data(), 8);
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ ||
number_of_write_registers == 0 || number_of_write_registers > MAX_NUM_OF_REGISTERS_TO_WRITE_RW ||
number_of_write_registers * 2 != number_of_bytes) {
ESP_LOGW(TAG, "Invalid number of registers (read %" PRIu16 ", write %" PRIu16 ") or bytes %" PRIu8,
number_of_registers, number_of_write_registers, number_of_bytes);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
status = this->check_register_range_(read_start_address, number_of_registers);
if (!status.has_value()) {
status = this->check_register_range_(write_start_address, number_of_write_registers);
}
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
// Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read
// values are allocated, keeping only one RegisterValues buffer live at a time.
{
RegisterValues write_registers;
this->assemble_registers_(data.subspan(READ_WRITE_VALUES_OFFSET, number_of_bytes), write_registers);
// Dispatch to the standalone write and read handlers so any device implementing those supports 0x17
// without a dedicated handler; a device that maps registers by address reconstructs the read response
// from the values it just stored.
status = device->on_write_registers(write_start_address, write_registers);
}
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
RegisterValues registers;
status = device->on_read_holding_registers(read_start_address, number_of_registers, registers);
if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers,
response_buffer, response_len)) {
return;
}
break;
}
default:
ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION);
+13 -45
View File
@@ -312,14 +312,6 @@ class ModbusClientHub : public Modbus {
std::deque<ModbusDeviceCommand> tx_buffer_;
};
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>;
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type.
using RegisterValues = StaticVector<uint16_t, MAX_NUM_OF_REGISTERS_TO_READ>;
class ModbusServerHub : public Modbus {
public:
ModbusServerHub() = default;
@@ -330,47 +322,27 @@ class ModbusServerHub : public Modbus {
void parse_modbus_frames() override;
bool parse_modbus_client_frame_();
void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) override;
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span<const uint8_t> data);
// Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered.
void process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data);
// Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register
// values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus
// exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast
// writes (which silently drop invalid frames).
ResponseStatus parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address, RegisterValues &registers);
ResponseStatus parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers);
// Appends the big-endian register values in values to registers, in host byte order.
void assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers);
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data);
ModbusServerDevice *find_device_(uint8_t address);
// Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space,
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required.
ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers);
// Builds the body of a register read response (byte count followed by the big-endian register values) into
// response_buffer. Shared by every function code that answers with register values, so the read reply stays
// identical across them. Returns false once an exception has been sent: the one the handler reported via
// status, or SERVICE_DEVICE_FAILURE if it returned the wrong number of registers, the count exceeds the
// protocol read limit, or the body does not fit.
bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status,
uint16_t number_of_registers, const RegisterValues &registers,
std::span<uint8_t> response_buffer, uint16_t &response_len);
// Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space.
// On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client.
bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address,
uint16_t number_of_registers);
void send_raw_(const uint8_t *payload, uint16_t len);
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
uint8_t expecting_peer_response_{0};
std::vector<ModbusServerDevice *> devices_;
// Stamp of the last "broadcast reached no device" warning, 0 until the first one is logged. Rate limiting
// on time rather than on address keeps the log bounded no matter how many addresses a shared bus carries.
uint32_t last_unaccepted_broadcast_warn_{0};
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
// Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation.
std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_;
uint16_t deferred_payload_len_{0};
};
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>;
/// 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 send_pdu() (false return)
@@ -591,6 +563,11 @@ class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_e
}
};
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type.
using RegisterValues = StaticVector<uint16_t, MAX_NUM_OF_REGISTERS_TO_READ>;
class ModbusServerDevice {
public:
virtual ~ModbusServerDevice() = default;
@@ -617,18 +594,9 @@ class ModbusServerDevice {
virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues &registers) {
return ExceptionCode::ILLEGAL_FUNCTION;
};
// Hub entry point for broadcast (address 0) writes, which are never answered.
ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues &registers) {
this->broadcast_write_ = true;
ResponseStatus status = this->on_write_registers(start_address, registers);
this->broadcast_write_ = false;
return status;
}
protected:
uint8_t address_{0};
// Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence.
bool broadcast_write_{false};
};
} // namespace esphome::modbus
+6 -10
View File
@@ -33,12 +33,12 @@ enum class FunctionCode : uint8_t {
GET_COMM_EVENT_LOG = 0x0C, // not implemented
WRITE_MULTIPLE_COILS = 0x0F,
WRITE_MULTIPLE_REGISTERS = 0x10,
REPORT_SERVER_ID = 0x11, // not implemented
READ_FILE_RECORD = 0x14, // not implemented
WRITE_FILE_RECORD = 0x15, // not implemented
MASK_WRITE_REGISTER = 0x16, // not implemented
READ_WRITE_MULTIPLE_REGISTERS = 0x17,
READ_FIFO_QUEUE = 0x18, // not implemented
REPORT_SERVER_ID = 0x11, // not implemented
READ_FILE_RECORD = 0x14, // not implemented
WRITE_FILE_RECORD = 0x15, // not implemented
MASK_WRITE_REGISTER = 0x16, // not implemented
READ_WRITE_MULTIPLE_REGISTERS = 0x17, // not implemented
READ_FIFO_QUEUE = 0x18, // not implemented
};
// Remove before 2027.2.0
@@ -116,10 +116,6 @@ static constexpr uint16_t READ_PDU_SIZE = 5;
// A single-write PDU is always function code(1) + address(2) + value(2)
static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5;
static constexpr uint16_t MAX_FRAME_SIZE = 256;
// 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered.
static constexpr uint8_t BROADCAST_ADDRESS = 0;
// Both send paths bound their payload so the framed result lands exactly on the RTU limit: a client
// PDU gains an address byte and a CRC, a raw server frame gains a CRC. send_frame_() therefore never
// has to check the framed size - it cannot be exceeded.
+7 -17
View File
@@ -534,33 +534,23 @@ PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) {
return pdu;
}
// Shared by the two bool-container overloads: both index the same way, so the packing is written once.
template<typename BoolContainer>
static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const BoolContainer &values) {
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
const size_t count = values.size();
// Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest.
if (count > MAX_NUM_OF_COILS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", count,
if (values.size() > MAX_NUM_OF_COILS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", values.size(),
MAX_NUM_OF_COILS_TO_WRITE);
return pdu;
}
CoilPackBuffer packed;
for (size_t i = 0; i != count; i++) {
StaticVector<uint8_t, packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE)> packed;
for (size_t i = 0; i != values.size(); i++) {
if (i % 8 == 0)
packed.push_back(0);
if (values[i])
packed[i / 8] |= (1 << (i % 8));
}
build_write_coils_pdu(pdu, start_address, PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), count));
build_write_coils_pdu(pdu, start_address,
PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), values.size()));
return pdu;
}
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values) {
return create_write_coils_pdu_from_bools(start_address, values);
}
PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector<bool> &values) {
return create_write_coils_pdu_from_bools(start_address, values);
}
} // namespace esphome::modbus::helpers

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