From ab12e5490f680ab337236fd149ab59e96e1b373b Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 9 Aug 2026 15:00:44 -0700 Subject: [PATCH 01/11] [modbus] Rename send_pdu() to queue_pdu() (#18196) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 14 +- esphome/components/modbus/modbus.h | 99 +++--- .../components/modbus_client/modbus_client.h | 29 +- .../modbus_controller/modbus_controller.cpp | 15 +- esphome/components/pzemac/pzemac.cpp | 2 +- esphome/components/pzemdc/pzemdc.cpp | 2 +- tests/components/modbus/heap_probe_test.cpp | 8 +- .../modbus/modbus_client_hub_test.cpp | 286 ++++++++++-------- 8 files changed, 260 insertions(+), 195 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index c9e443cd87..9f2527d9fb 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -883,8 +883,8 @@ void ModbusClientHub::sweep_() { } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. -bool ModbusClientHub::send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, - CommandOptions options) { +bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, + CommandOptions options) { // Requests refused here never enter the machine and get no callback - the false return is it. if (pdu.empty()) { ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address); @@ -995,7 +995,7 @@ void ModbusClientHub::send_raw(const std::vector &payload, ModbusClient ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused"); return; } - this->send_pdu(payload[0], std::span(payload).subspan(1), device); + this->queue_pdu(payload[0], std::span(payload).subspan(1), device); } // Send raw command for server replies immediately. Except CRC everything must be contained in payload @@ -1077,7 +1077,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // - On failure (status engaged) the response is empty by design (see on_error()), so only the request // is validated. bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size()); - if (!custom && !status.has_value()) { + if (!custom && succeeded(status)) { custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size()); if (!custom && helpers::is_function_code_read(static_cast(function_code))) { const bool bits = @@ -1104,7 +1104,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On // failure the registers span is empty. RegisterValues registers; - if (!status.has_value()) { + if (succeeded(status)) { for (size_t i = 0; i != count_or_value; i++) { registers.push_back(helpers::get_data(response_pdu.data(), 2 + 2 * i)); } @@ -1124,7 +1124,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them. std::span packed_bytes; uint16_t count = 0; - if (!status.has_value()) { + if (succeeded(status)) { packed_bytes = response_pdu.subspan(2); count = count_or_value; } @@ -1141,7 +1141,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // copy. On an exception the response has no value and the request copy is the only one. case FunctionCode::WRITE_SINGLE_REGISTER: case FunctionCode::WRITE_SINGLE_COIL: { - const uint16_t value = (!status.has_value() && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) + const uint16_t value = (succeeded(status) && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) ? helpers::get_data(response_pdu.data(), 3) : count_or_value; if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) { diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 274b10f9b4..3b6028e90a 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -262,19 +262,31 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->send_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); + this->queue_pdu(address, + helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, + payload_len), + device); }; - // Queue a request; true once it is a live entry (resolving in one terminal), false if it never - // entered the machine (empty/oversize PDU, full queue, anonymous or over-cap duplicate) - no callback. - bool send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, - CommandOptions options = {}); - ESPDEPRECATED("Use send_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") + /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and + /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in + /// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means + /// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap + /// duplicate) and no callback of any kind will follow; the false return is the whole story. + bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, + CommandOptions options = {}); + // Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions: + // the bool return and the options argument arrived after that release, so nothing external can be + // relying on them under this name. Callers who want the queued/refused answer move to queue_pdu(). + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { + this->queue_pdu(address, pdu, device); + } + ESPDEPRECATED("Use queue_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); // Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the // wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently. @@ -315,6 +327,12 @@ class ModbusClientHub : public Modbus { // Transaction status: std::nullopt on success, otherwise a Modbus exception code using ResponseStatus = std::optional; +/// True when a transaction carried no exception. The optional holds the exception, so has_value() means +/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the +/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code +/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer. +inline bool succeeded(ResponseStatus status) { return !status.has_value(); } + // 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. @@ -373,7 +391,7 @@ class ModbusServerHub : public Modbus { /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by -/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return) +/// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return) /// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request. /// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all /// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from @@ -383,7 +401,7 @@ class ModbusServerHub : public Modbus { /// merges into it). /// /// Invariants: -/// - Public entry points (send_pdu/clear_tx_queue_*) only append to the queue or mutate an existing +/// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing /// entry through its callback-free transition methods. /// - Public entry points can never trigger a callback synchronously. /// - Callbacks are delivered only from within loop(). @@ -485,66 +503,75 @@ class ModbusClientDevice { /// to handle custom traffic (which also silences the warning). virtual void on_custom_response(std::span request_pdu, std::span response_pdu, ResponseStatus status); - ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0") + ESPDEPRECATED("Use the typed read_*/write_* helpers or queue_pdu() instead. Removed in 2027.2.0", "2026.8.0") void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { - this->parent_->send_pdu( + this->parent_->queue_pdu( this->address_, helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), this); } - /// See ModbusClientHub::send_pdu(): true = accepted (a terminal callback will follow), - /// false = refused at the door (no callback). - bool send_pdu(std::span pdu, CommandOptions options = {}) { - return this->parent_->send_pdu(this->address_, pdu, this, options); + /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will + /// follow, false = refused at the door and nothing further happens. Neither means the frame is on + /// the wire; on_sent() reports that. + bool queue_pdu(std::span pdu, CommandOptions options = {}) { + return this->parent_->queue_pdu(this->address_, pdu, this, options); } - ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") - bool send_raw(const std::vector &payload) { + // Remove before 2027.2.0. As on the hub, this is the signature 2026.7.4 shipped: void, no options. + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(std::span pdu) { this->queue_pdu(pdu); } + ESPDEPRECATED("Use queue_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") + void send_raw(const std::vector &payload) { if (payload.empty()) - return false; // too short to contain a PDU; refused at the door like any invalid send - return this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); + return; // too short to contain a PDU; refused at the door like any invalid send + this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); } + // The typed request builders below all queue through queue_pdu(), so they share its contract: true + // means the request is queued and will resolve in exactly one terminal callback, false means it was + // refused outright with no callback. Neither says the frame has been transmitted - on_sent() does. // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which - // create_read_pdu() rejects into an empty PDU and send_pdu() refuses with a false return. + // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, - number_of_entities), - options); + return this->queue_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, + number_of_entities), + options); } bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { - return this->send_pdu( + return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options); } bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { - return this->send_pdu( + return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options); } bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); + return this->queue_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); } bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), - options); + return this->queue_pdu( + helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } bool write_single_register(uint16_t start_address, uint16_t value) { - return this->send_pdu(helpers::create_write_single_register_pdu(start_address, value)); + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); } bool write_single_coil(uint16_t address, bool value) { - return this->send_pdu(helpers::create_write_single_coil_pdu(address, value)); + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); } bool write_multiple_registers(uint16_t start_address, std::span values) { - return this->send_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->send_pdu(helpers::create_write_coils_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->send_pdu(helpers::create_write_coils_pdu(start_address, bits)); + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); } inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 599be85cb8..f9a00d65f6 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -33,7 +33,11 @@ template class ClientActionBase : public Action, public m /// The frame was written to the wire: fires once per transmission, before any reply, and never for a /// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data). void on_sent(std::span request_pdu) override { this->sent_trigger_.trigger(request_pdu); } - /// Never reached the wire (tx queue full, cleared, or a duplicate write dropped by the hub's dedup). + /// Never reached the wire, from either of two sources. The hub calls this for a request it accepted + /// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus + /// device going offline, say. Everything the hub refuses at the door instead returns false from + /// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same + /// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder. void on_not_sent(std::span request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); } /// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing: /// register_client_action() wires on_error for all of them, so a derived class must not have to @@ -64,7 +68,7 @@ template class ClientActionBase : public Action, public m /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. void send_or_resolve_(std::span pdu) { - if (!this->send_pdu(pdu)) + if (!this->queue_pdu(pdu)) this->on_not_sent(pdu); } @@ -107,7 +111,9 @@ template class ModbusClientSendAction : public ClientActionBase< /// valid for the duration of the trigger. (For a typed-built request the gate can only divert on the /// response, never with an exception status - real device exceptions arrive via on_error, which /// ClientActionBase already routes straight to its trigger, so the typed callbacks below only ever see a -/// success status.) +/// success status.) Each typed callback still checks succeeded() before firing its trigger: that branch +/// is unreachable today, and is kept so a future change to that interception cannot silently deliver an +/// exception as a successful reply. template class TypedClientActionBase : public ClientActionBase { public: Trigger, std::span> *get_custom_response_trigger() { @@ -127,11 +133,6 @@ template class TypedClientActionBase : public ClientActionBase, std::span> custom_response_trigger_; bool custom_response_handled_{false}; }; @@ -154,7 +155,7 @@ template class ReadRegistersAction : public TypedClientActionBas } void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(registers); } @@ -181,7 +182,7 @@ template class ReadBitsAction : public TypedClientActionBaseis_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(bits); } @@ -204,7 +205,7 @@ template class WriteSingleRegisterAction : public TypedClientAct modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -226,7 +227,7 @@ template class WriteSingleCoilAction : public TypedClientActionB modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -270,7 +271,7 @@ template class WriteMultipleRegistersAction : public TypedClient } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -318,7 +319,7 @@ template class WriteMultipleCoilsAction : public TypedClientActi } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c4161d454f..da9d29887e 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -160,10 +160,11 @@ void ModbusController::queue_command(ModbusCommandItem command) { } void ModbusController::unqueue_command(const ModbusCommandItem *command) { - // Called as the last action of the command's own callback, and from send() after send_pdu (which may - // synchronously call on_not_sent). Destroying `command` here would leave send() and the hub touching a - // freed object, so we only FLAG it; sweep_completed_one_shots_() erases it later at a safe point. No-op - // for polling commands (they persist and are not in the one-shot list). + // Called as the last action of the command's own callback (on_response/on_error/on_not_sent/ + // on_no_response), which the hub runs from inside its sweep while this entry is still live. + // Destroying `command` here would leave the hub touching a freed object, so we only FLAG it; + // sweep_completed_one_shots_() erases it later at a safe point. No-op for polling commands + // (they persist and are not in the one-shot list). for (auto &item : this->one_shot_command_items_) { if (item.get() == command) { item->pending_removal = true; @@ -494,13 +495,13 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( bool ModbusCommandItem::send() { bool accepted; if (this->function_code_ != FunctionCode::CUSTOM) { - accepted = this->send_pdu(modbus::helpers::create_client_pdu( + accepted = this->queue_pdu(modbus::helpers::create_client_pdu( this->function_code_, this->start_address_, this->register_count_, this->payload.empty() ? nullptr : this->payload.data(), this->payload.size())); } else { // Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own // address (which may differ from this controller's); the hub appends the CRC and routes the response - // back to this item by pointer. (send_raw() is deprecated, so send_pdu() is called with the extracted + // back to this item by pointer. (send_raw() is deprecated, so queue_pdu() is called with the extracted // address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.) std::span frame = this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; @@ -508,7 +509,7 @@ bool ModbusCommandItem::send() { ESP_LOGW(TAG, "Empty custom command frame, not sent"); accepted = false; } else { - accepted = this->parent_->send_pdu(frame[0], frame.subspan(1), this); + accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this); } } // The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire. diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 5651e07af0..d817888922 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -77,7 +77,7 @@ void PZEMAC::dump_config() { void PZEMAC::reset_energy_() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; - this->send_pdu(pdu); + this->queue_pdu(pdu); } } // namespace esphome::pzemac diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 5e505cde0c..926ad83f09 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -65,7 +65,7 @@ void PZEMDC::dump_config() { void PZEMDC::reset_energy() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; - this->send_pdu(pdu); + this->queue_pdu(pdu); } } // namespace esphome::pzemdc diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp index ddf905a8df..869b280b0d 100644 --- a/tests/components/modbus/heap_probe_test.cpp +++ b/tests/components/modbus/heap_probe_test.cpp @@ -134,7 +134,7 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { size_t total = 0; for (int i = 0; i != n; i++) { req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue - total += sample([&] { device.send_pdu(req); }).count; + total += sample([&] { device.queue_pdu(req); }).count; } printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); EXPECT_EQ(total, 0u); @@ -151,11 +151,11 @@ TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) { req.assign(read_pdu, read_pdu + sizeof(read_pdu)); for (int i = 0; i != 3; i++) { req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue - device.send_pdu(req); + device.queue_pdu(req); } const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - Sample append = sample([&] { device.send_pdu(write_pdu); }); + Sample append = sample([&] { device.queue_pdu(write_pdu); }); printf("HEAPPROBE write_append count=%zu bytes=%zu\n", append.count, append.bytes); EXPECT_EQ(append.count, 0u); } @@ -180,7 +180,7 @@ TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { const uint8_t small_resp[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; auto round_trip = [&](std::span response_pdu) { - device.send_pdu(req); + device.queue_pdu(req); hub.loop(); // transmit; the tx queue is empty during the measured receive below uart.inject_frame(0x02, response_pdu); return sample([&] { hub.loop(); }); // receive + parse + match + dispatch diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 4d5b4e7ee8..c2a36c0da7 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -116,7 +116,7 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); hub.force_send_next(); ASSERT_EQ(hub.queued_frames(), 0u); @@ -141,7 +141,7 @@ TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -157,7 +157,7 @@ TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { NoResponseProbeHub hub; { RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // device destructor clears its queue entries, including the waiting frame's device pointer } @@ -177,7 +177,7 @@ TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. @@ -204,7 +204,7 @@ TEST(ModbusClientHubNoResponse, InterruptedShellDeclinedRetryRetiresOnRelease) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; @@ -229,7 +229,7 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { NoResponseProbeHub hub; ClearingRetryDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -246,9 +246,9 @@ TEST(ModbusClientHubPriority, WritesSendBeforeQueuedReads) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(read_a); - device.send_pdu(read_b); - device.send_pdu(write_pdu); + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(write_pdu); ASSERT_EQ(hub.queued_frames(), 3u); hub.force_send_next(); @@ -266,8 +266,8 @@ TEST(ModbusClientHubPriority, DuplicateQueuedFrameAbsorbedNotDuplicated) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 2u); // one entry standing for two accepted requests @@ -280,9 +280,9 @@ TEST(ModbusClientHubPriority, InFlightDuplicateRunsOnceMore) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // duplicate of the waiting frame + device.queue_pdu(read_pdu()); // duplicate of the waiting frame EXPECT_EQ(hub.queued_frames(), 0u); // not queued twice EXPECT_EQ(hub.waiting_command().pending, 2u); @@ -303,9 +303,9 @@ TEST(ModbusClientHubPriority, AbsorbedRequestSurvivesDeviceRetry) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed + device.queue_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed ASSERT_EQ(hub.waiting_command().pending, 2u); hub.timeout_waiting(); // no response; the device requests a retry @@ -459,8 +459,8 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) { device.read_holding_registers(0x100, 2, {.continuous = true}); const uint8_t one_shot[] = {0x03, 0x02, 0x00, 0x00, 0x01}; const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(one_shot); - device.send_pdu(write_pdu); + device.queue_pdu(one_shot); + device.queue_pdu(write_pdu); ASSERT_EQ(hub.queued_frames(), 3u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::CONTINUOUS); EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); @@ -482,7 +482,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) { RetryingDevice device(&hub, 0x02, /*retry=*/false); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu, {.continuous = true}); + device.queue_pdu(write_pdu, {.continuous = true}); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); EXPECT_FALSE(hub.queued(0).continuous); @@ -530,8 +530,8 @@ TEST(ModbusClientHubPriority, DuplicateQueuedWriteRefused) { SentCountingDevice device(&hub, 0x02); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); // duplicate write: refused + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); // duplicate write: refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -547,8 +547,8 @@ TEST(ModbusClientHubPriority, DuplicateCustomFunctionCodeRefused) { SentCountingDevice device(&hub, 0x02); const uint8_t custom_pdu[] = {0x41, 0x01, 0x02}; // user-defined function code - EXPECT_TRUE(device.send_pdu(custom_pdu)); - EXPECT_FALSE(device.send_pdu(custom_pdu)); // duplicate custom command: refused + EXPECT_TRUE(device.queue_pdu(custom_pdu)); + EXPECT_FALSE(device.queue_pdu(custom_pdu)); // duplicate custom command: refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -562,8 +562,8 @@ TEST(ModbusClientHubPriority, AnonymousDuplicateDroppedNotPromoted) { NoResponseProbeHub hub; const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; - hub.send_pdu(0x02, read); - hub.send_pdu(0x02, read); // anonymous duplicate: dropped + hub.queue_pdu(0x02, read); + hub.queue_pdu(0x02, read); // anonymous duplicate: dropped ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 1u); // never absorbed for a null owner @@ -575,13 +575,13 @@ TEST(ModbusClientHubPriority, RetriedReadGoesBehindFreshReads) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); - hub.force_send_next(); // the frame that will time out and retry - device.send_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry + device.queue_pdu(read_pdu()); + hub.force_send_next(); // the frame that will time out and retry + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry const uint8_t fresh_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t fresh_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - device.send_pdu(fresh_a); - device.send_pdu(fresh_b); + device.queue_pdu(fresh_a); + device.queue_pdu(fresh_b); ASSERT_EQ(hub.queued_frames(), 2u); hub.timeout_waiting(); // device retries; the entry returns to READY behind the fresh reads @@ -608,9 +608,9 @@ TEST(ModbusClientHubPriority, AbsorbedDuplicateKeepsPlaceInLine) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - device.send_pdu(read_a); - device.send_pdu(read_b); - device.send_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged ASSERT_EQ(hub.queued_frames(), 2u); const ModbusDeviceCommand *next = hub.next_ready(); @@ -626,14 +626,14 @@ TEST(ModbusClientHubPriority, RetriedWriteKeepsWritePriorityAndStaysNonRequeueab RetryingDevice device(&hub, 0x02, /*retry=*/true); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu); + device.queue_pdu(write_pdu); hub.force_send_next(); hub.timeout_waiting(); // no response -> device requests retry -> back to READY ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); // retry preserves the WRITE class - device.send_pdu(write_pdu); // duplicate of the retried write + device.queue_pdu(write_pdu); // duplicate of the retried write ASSERT_EQ(hub.queued_frames(), 1u); // still not queued twice... EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); hub.sweep_for_test(); @@ -655,7 +655,7 @@ TEST(ModbusClientHubSent, BlockedHubDefersInsteadOfFailing) { AlwaysBlockedHub hub; SentCountingDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); hub.send_next_for_test(); EXPECT_EQ(device.sent_count_, 0); @@ -673,7 +673,7 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { hub.setup(); // frame timing derives from the baud rate SentCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); EXPECT_EQ(device.sent_count_, 0); // queued only - nothing sent yet hub.send_next_for_test(); @@ -703,7 +703,7 @@ TEST(ModbusClientHubSent, SendRejectedAfterDelayLeavesFrameReady) { hub.setup(); SentCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); // gate passes, send_frame_ rejects on the post-delay re-check EXPECT_EQ(device.sent_count_, 0); // nothing transmitted @@ -766,7 +766,7 @@ TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); drain_with_responses(hub, OK_RESPONSE); EXPECT_EQ(device.data_count_, 1); @@ -781,8 +781,8 @@ TEST(ModbusClientHubCallbackCount, DuplicateReadExactlyTwoCallbacks) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); int cycles = drain_with_responses(hub, OK_RESPONSE); EXPECT_EQ(cycles, 2); @@ -812,8 +812,8 @@ TEST(ModbusClientHubCallbackCount, ClearFromResponseResolvesDuplicateWithNotSent NoResponseProbeHub hub; ClearOnFirstResponseDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, pending 2 + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, pending 2 hub.force_send_next(); hub.receive_frame_for_test(0x02, OK_RESPONSE); // response -> on_response -> clear, then sweep @@ -829,9 +829,9 @@ TEST(ModbusClientHubCallbackCount, TripleReadRefusesTheThird) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_FALSE(device.send_pdu(read_pdu())); // the entry is already at its cap + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_FALSE(device.queue_pdu(read_pdu())); // the entry is already at its cap hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); // refused synchronously, nothing owed int cycles = drain_with_responses(hub, OK_RESPONSE); @@ -849,8 +849,8 @@ TEST(ModbusClientHubCallbackCount, DuplicateWriteRefusedWithoutLifecycle) { DataCountingDevice device(&hub, 0x02); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); hub.sweep_for_test(); EXPECT_EQ(device.terminals(), 0); // the accepted write has not resolved; the other never existed @@ -867,7 +867,7 @@ TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) { hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); const uint8_t exception_response[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, exception_response); @@ -886,7 +886,7 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); hub.timeout_waiting(); EXPECT_EQ(device.no_response_count_, 1); @@ -896,8 +896,8 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) // Unabsorbable duplicate: the second identical write is refused at the door - no lifecycle, no // terminal, nothing sent. const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); EXPECT_EQ(device.terminals(), 1); // still just the read's timeout @@ -920,7 +920,7 @@ TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) { DataCountingDevice device(&hub, 0x02); device.retries_ = 1; // ask for exactly one retry - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued) ASSERT_EQ(hub.queued_frames(), 1u); @@ -946,13 +946,13 @@ TEST(ModbusClientHubCallbackCount, RetryIsNeverRefusedByFullQueue) { device.retries_ = 1; SentCountingDevice filler(&hub, 0x05); - device.send_pdu(read_pdu()); - hub.force_send_next(); // waiting - device.send_pdu(read_pdu()); // absorbed: two requests pending + device.queue_pdu(read_pdu()); + hub.force_send_next(); // waiting + device.queue_pdu(read_pdu()); // absorbed: two requests pending // Fill the remaining live capacity with distinct frames. for (uint16_t i = 0; hub.entries() < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); + filler.queue_pdu(fill); } hub.timeout_waiting(); // retry requested; the entry flips back to READY regardless of capacity @@ -991,10 +991,10 @@ TEST(ModbusClientHubQueue, SendRawTooShortIsRefusedAtTheDoor) { NotSentCountingRawDevice device(&hub, 0x02); #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - EXPECT_FALSE(device.send_raw({})); // too short to contain a PDU + device.send_raw({}); // too short to contain a PDU; the deprecated void spelling cannot report it #pragma GCC diagnostic pop - EXPECT_EQ(device.not_sent_count_, 0); // refusals are returned, never delivered - EXPECT_TRUE(hub.tx_buffer_empty()); + EXPECT_EQ(device.not_sent_count_, 0); // refused at the door: no callback delivered + EXPECT_TRUE(hub.tx_buffer_empty()); // the only evidence of the refusal is that nothing queued } // A continuous read: every wire transmission pairs one sent with one terminal, ending on the error. @@ -1040,7 +1040,7 @@ class ChainOnSentDevice : public ModbusClientDevice { if (!this->chained_) { this->chained_ = true; const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1 - this->send_pdu(follow); + this->queue_pdu(follow); } } bool chained_{false}; @@ -1059,9 +1059,9 @@ TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02}; - controller_like.send_pdu(read_a); - bystander_same.send_pdu(read_b); - bystander_other.send_pdu(read_c); + controller_like.queue_pdu(read_a); + bystander_same.queue_pdu(read_b); + bystander_other.queue_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); controller_like.clear_tx_queue_for_address(); @@ -1083,8 +1083,8 @@ TEST(ModbusClientHubQueue, ClearAddressDeliversOneTerminalPerAcceptedRequest) { SentCountingDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; - device.send_pdu(read); - device.send_pdu(read); // duplicate: absorbed into the queued entry + device.queue_pdu(read); + device.queue_pdu(read); // duplicate: absorbed into the queued entry ASSERT_EQ(hub.queued_frames(), 1u); ASSERT_EQ(hub.queued(0).pending, 2u); @@ -1103,8 +1103,8 @@ TEST(ModbusClientHubQueue, ClearSentOnInFlightDuplicateStillNotifiesTheDuplicate NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 ASSERT_EQ(hub.queued(0).pending, 2u); hub.force_send_next(); // the frame is sent (WAITING); pending still 2 ASSERT_TRUE(hub.waiting()); @@ -1122,7 +1122,7 @@ TEST(ModbusClientHubQueue, ClearWhileInFlightStillDeliversTheResponse) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // sent, now WAITING ASSERT_TRUE(hub.waiting()); @@ -1151,7 +1151,7 @@ class ResendOnNotSentDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; // a write: ranked first at selection, not by position - this->send_pdu(again); + this->queue_pdu(again); } } int not_sent_count_{0}; @@ -1165,7 +1165,7 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) { ResendOnNotSentDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); ASSERT_EQ(hub.queued_frames(), 1u); hub.clear_tx_queue_for_address(0x02); @@ -1187,8 +1187,8 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendNotSwept) { const uint8_t read_victim[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - resender.send_pdu(read_victim); - bystander_other.send_pdu(read_other); + resender.queue_pdu(read_victim); + bystander_other.queue_pdu(read_other); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); @@ -1212,13 +1212,14 @@ class AlwaysResendDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01}; - this->send_pdu(again); + this->queue_pdu(again); } int not_sent_count_{0}; }; -// From inside on_not_sent, clears ANOTHER address - those victims must still be notified (the per-device -// guard suppresses deliveries only to a device already inside its own on_not_sent()). +// From inside on_not_sent, clears ANOTHER address - those victims must still be notified. Nothing +// suppresses that: a re-entrant clear only flips states, retire() is a no-op on an already-retired +// entry, and each entry still owes one notification per un-run request until pending reaches zero. class ClearOtherOnNotSentDevice : public ModbusClientDevice { public: ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} @@ -1238,9 +1239,9 @@ TEST(ModbusClientHubQueue, PendingNeverExceedsTheServableCap) { AlwaysResendDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x50, 0x00, 0x01}; - EXPECT_TRUE(device.send_pdu(read)); - EXPECT_TRUE(device.send_pdu(read)); - EXPECT_FALSE(device.send_pdu(read)); // at the cap: refused + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_FALSE(device.queue_pdu(read)); // at the cap: refused ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 2u); @@ -1260,12 +1261,12 @@ TEST(ModbusClientHubQueue, FullQueueRefusesWithoutCallbacks) { // Fill the queue with distinct frames (distinct start addresses keep the dedup from absorbing them). for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); + filler.queue_pdu(fill); } ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - EXPECT_FALSE(device.send_pdu(read)); // refused synchronously + EXPECT_FALSE(device.queue_pdu(read)); // refused synchronously hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); // nothing was accepted, so nothing is owed @@ -1298,13 +1299,13 @@ TEST(ModbusClientHubQueue, SelfClearFromNotSentResolvesEveryRequest) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01}; - clearer.send_pdu(read_a); - clearer.send_pdu(read_b); - bystander.send_pdu(read_c); + clearer.queue_pdu(read_a); + clearer.queue_pdu(read_b); + bystander.queue_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); - EXPECT_FALSE(clearer.send_pdu(std::span{})); // empty: refused, no callback - clearer.clear_tx_queue_for_address(); // the clear the handler used to make + EXPECT_FALSE(clearer.queue_pdu(std::span{})); // empty: refused, no callback + clearer.clear_tx_queue_for_address(); // the clear the handler used to make hub.sweep_for_test(); @@ -1323,8 +1324,8 @@ TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - clearer.send_pdu(read_a); - victim.send_pdu(read_b); + clearer.queue_pdu(read_a); + victim.queue_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); // clearer's on_not_sent clears address 0x03 in turn @@ -1345,7 +1346,7 @@ class ResendSecondFrameDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t same_as_r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; - this->send_pdu(same_as_r2); + this->queue_pdu(same_as_r2); } } int not_sent_count_{0}; @@ -1360,8 +1361,8 @@ TEST(ModbusClientHubQueue, SweepDedupSkipsDeletedFrames) { const uint8_t r1[] = {0x03, 0x00, 0x21, 0x00, 0x01}; const uint8_t r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; - device.send_pdu(r1); - device.send_pdu(r2); + device.queue_pdu(r1); + device.queue_pdu(r2); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); @@ -1383,7 +1384,7 @@ class ResendAndClearOnNotSentDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x70, 0x00, 0x01}; - this->send_pdu(again); + this->queue_pdu(again); this->clear_tx_queue_for_address(); } int not_sent_count_{0}; @@ -1397,7 +1398,7 @@ TEST(ModbusClientHubQueue, ResendAndClearFromNotSentCannotExtendTheSweep) { ResendAndClearOnNotSentDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x70, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); hub.clear_tx_queue_for_address(0x02); hub.sweep_for_test(); @@ -1421,8 +1422,8 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; - device.send_pdu(read_a); - device.send_pdu(read_b); + device.queue_pdu(read_a); + device.queue_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); device.clear_tx_queue_for_device(); @@ -1431,7 +1432,7 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback } -// A send_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending +// A queue_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending // immediately or corrupting the waiting transaction. TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { NullUART uart; @@ -1440,7 +1441,7 @@ TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { hub.setup(); ChainOnSentDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); // first frame is sent -> on_sent chains a follow-up EXPECT_TRUE(hub.waiting()); // first frame is waiting @@ -1507,34 +1508,69 @@ class LegacyNameDevice : public ModbusClientDevice { #pragma GCC diagnostic pop } // namespace +// send_pdu() was renamed queue_pdu() because the call queues a request rather than transmitting one. +// The old spelling stays for the deprecation window with the signature 2026.7.4 shipped - void, no +// CommandOptions - so a component built against a real release still compiles and still queues. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +TEST(ModbusClientHubCompat, DeprecatedSendPduStillQueues) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(read); // deprecated device spelling: void, as 2026.7.4 shipped it + EXPECT_EQ(hub.queued_frames(), 1u); + + // A refusal is invisible to this spelling - no return value and no callback - so the only evidence + // is that nothing was queued. Reporting the refusal is exactly what moving to queue_pdu() buys. + device.send_pdu(std::span()); + EXPECT_EQ(hub.queued_frames(), 1u); + + // The deprecated hub spelling queues the same way, addressed explicitly. + const uint8_t other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + hub.send_pdu(0x03, other, &device); + EXPECT_EQ(hub.queued_frames(), 2u); + + // Both frames resolve to the same owner. Drain them in turn: the device-spelling frame first (FIFO), + // then the hub-spelling frame - addressed to 0x03 yet owned by &device, so reaching device's + // on_no_response proves the request routes by owner pointer, not by address. + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); // device-spelling frame (address 0x02) + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 2); // hub-spelling frame (address 0x03, &device routing) +} +#pragma GCC diagnostic pop + TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); hub.force_send_next(); hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response EXPECT_EQ(device.legacy_no_response_, 1); // A refused send returns false with no callback, so exercise the forward through an accepted // request instead: a cleared queue entry delivers on_not_sent(), which forwards to the old name. - EXPECT_FALSE(device.send_pdu(std::span())); // empty PDU: refused at the door + EXPECT_FALSE(device.queue_pdu(std::span())); // empty PDU: refused at the door EXPECT_EQ(device.legacy_not_sent_, 0); const uint8_t queued[] = {0x03, 0x00, 0x11, 0x00, 0x01}; - EXPECT_TRUE(device.send_pdu(queued)); + EXPECT_TRUE(device.queue_pdu(queued)); hub.clear_tx_queue_for_address(0x02); hub.sweep_for_test(); EXPECT_EQ(device.legacy_not_sent_, 1); } -// The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU +// The queue_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU // 256-byte limit, so it is refused up front - false at the call site, no entry, no callback. TEST(ModbusClientHub, OversizedPduIsRefusedAtTheDoor) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); std::vector big(MAX_PDU_SIZE + 1, 0x41); - EXPECT_FALSE(device.send_pdu(big)); + EXPECT_FALSE(device.queue_pdu(big)); EXPECT_EQ(device.legacy_not_sent_, 0); // refusals are returned, never delivered EXPECT_TRUE(hub.tx_buffer_empty()); EXPECT_EQ(hub.entries(), 0u); @@ -1568,7 +1604,7 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Read response: on_modbus_data() historically received the payload after the function code and // the byte-count byte, as an owning vector. const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02}; - device.send_pdu(read_req); + device.queue_pdu(read_req); hub.force_send_next(); const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x02, response); @@ -1577,14 +1613,14 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Write echo: no byte-count byte, so the payload is everything after the function code. const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; - device.send_pdu(write_req); + device.queue_pdu(write_req); hub.force_send_next(); hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request const std::vector expected_echo{0x00, 0x10, 0x00, 0x2A}; EXPECT_EQ(device.last_data_, expected_echo); // Exception response: on_modbus_error() received the masked function code and the exception code. - device.send_pdu(read_req); + device.queue_pdu(read_req); hub.force_send_next(); const uint8_t error[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, error); @@ -1675,9 +1711,9 @@ class ResendOnDataDevice : public ModbusClientDevice { public: ResendOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} void on_response(std::span request_pdu, std::span response_pdu) override { - this->send_pdu(std::vector(request_pdu.begin(), request_pdu.end())); + this->queue_pdu(std::vector(request_pdu.begin(), request_pdu.end())); } - void send_pdu(const std::vector &pdu) { ModbusClientDevice::send_pdu(pdu); } + void queue_pdu(const std::vector &pdu) { ModbusClientDevice::queue_pdu(pdu); } }; } // namespace @@ -1704,8 +1740,8 @@ TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { SentCountingDevice device(&hub, 0x02); const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged - EXPECT_TRUE(device.send_pdu(weird)); - EXPECT_FALSE(device.send_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused + EXPECT_TRUE(device.queue_pdu(weird)); + EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -1715,7 +1751,7 @@ TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { // The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class // ordering either: exception-flagged codes are excluded from the mutates classification. const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(weird_write); + device.queue_pdu(weird_write); ASSERT_EQ(hub.queued_frames(), 2u); EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE const ModbusDeviceCommand *next = hub.next_ready(); @@ -1732,7 +1768,7 @@ class ResendInFlightOnNotSentDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t same_as_waiting[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // == READ_PDU - this->send_pdu(same_as_waiting); + this->queue_pdu(same_as_waiting); } } int not_sent_count_{0}; @@ -1746,10 +1782,10 @@ TEST(ModbusClientHubQueue, SweepResendAfterClearQueuesFreshNotAbsorbedIntoShell) NoResponseProbeHub hub; ResendInFlightOnNotSentDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // READ_PDU now waiting const uint8_t queued_read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(queued_read); // a queued frame for the sweep to notify + device.queue_pdu(queued_read); // a queued frame for the sweep to notify ASSERT_EQ(hub.queued_frames(), 1u); hub.clear_tx_queue_for_address(0x02); @@ -1787,7 +1823,7 @@ TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseDoesNotDoubleResolve) { NoResponseProbeHub hub; ClearAddressOnNoResponseDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -1802,8 +1838,8 @@ TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseResolvesTheAbsorbedReques NoResponseProbeHub hub; ClearAddressOnNoResponseDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, two requests + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, two requests hub.force_send_next(); hub.timeout_waiting(); @@ -1818,7 +1854,7 @@ TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnLateResponse) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1836,7 +1872,7 @@ TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnTimeout) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1856,7 +1892,7 @@ TEST(ModbusClientHubQueue, ClearInterruptedFrameGetsNoResponseAtTimeout) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); // declines the retry (retries_ == 0) - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction @@ -1887,7 +1923,7 @@ TEST(ModbusClientHubQueue, InterruptAfterClearStillDistrusts) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1912,8 +1948,8 @@ TEST(ModbusClientHubQueue, ClearedInFlightDuplicateTimesOutWithoutRerunning) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 ASSERT_EQ(hub.queued(0).pending, 2u); hub.force_send_next(); // sent, pending still 2 hub.clear_tx_queue_for_address(0x02); @@ -1936,9 +1972,9 @@ TEST(ModbusClientHubCallbackCount, AbsorbedRequestRunsAfterErrorResponse) { hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // waiting duplicate: absorbed + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed const uint8_t exception_response[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, exception_response); // error terminal for request 1 @@ -1954,8 +1990,8 @@ TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t mask_write[] = {0x16, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x01}; - device.send_pdu(read); - device.send_pdu(mask_write); + device.queue_pdu(read); + device.queue_pdu(mask_write); ASSERT_EQ(hub.queued_frames(), 2u); const ModbusDeviceCommand *next = hub.next_ready(); From 989dbd755007a7cd231721913ff50a4976f874c3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:10:38 +1200 Subject: [PATCH 02/11] [ci] Name release runs after the version or dev tag they build (#18110) --- .github/workflows/release-nightly.yml | 38 +++++++++++++++++++++++++++ .github/workflows/release.yml | 36 ++++++++++++++++++++----- 2 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release-nightly.yml diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 0000000000..cd3b7207b7 --- /dev/null +++ b/.github/workflows/release-nightly.yml @@ -0,0 +1,38 @@ +--- +name: Nightly Dev Release + +# Works out the dated dev tag and starts the release workflow with it, so that +# the release run is named after the tag it builds. A workflow run name is +# fixed when the run starts and cannot read a file or the current date. + +on: + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read # actions/checkout to read the version from esphome/const.py + +jobs: + trigger: + name: Start release build + if: github.repository == 'esphome/esphome' + runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout to read the version from esphome/const.py + actions: write # gh workflow run starts release.yml + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Start the release workflow + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py) + if [[ -z "$VERSION" ]]; then + echo "::error::Could not read __version__ from esphome/const.py" + exit 1 + fi + TAG="${VERSION}$(date --utc '+%Y%m%d')" + echo "Starting release build for ${TAG}" + gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 839b805237..10b28ace38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,23 @@ --- name: Publish Release +# Releases (production and beta) are named after the version they publish. +# Dev builds are named after the dated dev tag, which is passed in by the +# nightly workflow because a run name cannot compute it itself. +run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }} + on: workflow_dispatch: + inputs: + tag: + description: >- + Tag to build. Only supported on dev, where the nightly workflow + uses it. Leave empty to build the version from esphome/const.py + with today's date appended. + required: false + default: "" release: types: [published] - schedule: - - cron: "0 2 * * *" permissions: contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write @@ -23,6 +34,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get tag id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} # yamllint disable rule:line-length run: | if [[ "${{ github.event_name }}" = "release" ]]; then @@ -34,12 +47,23 @@ jobs: ENVIRONMENT="production" fi else - TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") - today="$(date --utc '+%Y%m%d')" - TAG="${TAG}${today}" BRANCH=${GITHUB_REF#refs/heads/} + # The nightly workflow passes the finished tag so that the run name + # matches what is built. Without it, work it out here. + TAG="${INPUT_TAG}" + if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then + echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images." + exit 1 + fi + if [[ -z "$TAG" ]]; then + TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") + today="$(date --utc '+%Y%m%d')" + TAG="${TAG}${today}" + if [[ "$BRANCH" != "dev" ]]; then + TAG="${TAG}-${BRANCH}" + fi + fi if [[ "$BRANCH" != "dev" ]]; then - TAG="${TAG}-${BRANCH}" BRANCH_BUILD="true" ENVIRONMENT="" else From 3de8c7f95c46bfb935dc62c5434e25b2056e0474 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:49:54 +0000 Subject: [PATCH 03/11] Bump bundled esphome-device-builder to 1.9.5 (#18223) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c0f7222bca..a4f5d3c3a6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 RUN \ platformio settings set enable_telemetry No \ From 0f59ef36a9ed67fcc28fe8f943241f2bed71d15f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 00:26:41 -0500 Subject: [PATCH 04/11] [core] Store the validated-config cache as JSON to drop YAML off the upload fast path (#18106) --- esphome/compiled_config.py | 118 ++++-- esphome/core/__init__.py | 12 +- esphome/yaml_util.py | 2 + .../python/test_compiled_config_bench.py | 2 +- .../lazy_imports/upload_command_fast_path.py | 46 ++- tests/unit_tests/test_compiled_config.py | 339 +++++++++++++++--- tests/unit_tests/test_core.py | 27 ++ tests/unit_tests/test_lazy_imports.py | 18 +- 8 files changed, 459 insertions(+), 105 deletions(-) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 1bcd567b84..303af99e66 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -1,48 +1,69 @@ """Validated-config cache for the upload/logs fast path. -compile dumps the validated config to /storage/.validated.yaml; +compile dumps the validated config to /storage/.validated.json; the next upload/logs for that YAML reuses it instead of running the full -read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps -!lambda/!include/IDs/paths intact; mtime gates staleness. +read_config pipeline. The cache is deliberately lossy: only ``!lambda`` +bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses, +paths, UUIDs and enums store the same string form the YAML dumper +produced for them. JSON additionally coerces non-str dict keys to +strings; validated configs only use string keys (every schema key +validator is ``cv.string``). mtime gates staleness. """ from __future__ import annotations +import json import logging from pathlib import Path +from typing import Any -from esphome.core import CORE +from esphome.const import __version__ as ESPHOME_VERSION +from esphome.core import CORE, Lambda from esphome.helpers import write_file from esphome.storage_json import StorageJSON, ext_storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) +# Bump when the on-disk shape changes; a mismatched version falls back +# to read_config. The envelope also stamps the writing esphome version: +# after an upgrade the cache holds the previous release's validation, so +# it falls back once and the re-save self-heals. +_CACHE_VERSION = 1 +_LAMBDA_KEY = "__esphome_lambda__" + def compiled_config_path(config_filename: str) -> Path: """Path to the cached validated config alongside the storage sidecar.""" - return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" - - -def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: - """True iff the cache file exists and isn't older than the source.""" - try: - return cache_path.stat().st_mtime >= source_path.stat().st_mtime - except OSError: - return False + return CORE.data_dir / "storage" / f"{config_filename}.validated.json" def save_compiled_config(config: ConfigType) -> None: """Write the validated-config cache. Always-write so mtime stays fresh. - Mode 0600 because show_secrets=True resolves !secret inline. + Mode 0600 because config validation resolved !secret inline. Failures are non-fatal: the fast path falls back to read_config. """ - from esphome import yaml_util - try: - rendered = yaml_util.dump(config, show_secrets=True) + # The legacy YAML cache holds inline-resolved secrets and nothing + # reads it anymore; drop it even when the write below fails. A + # failed removal leaves resolved secrets on disk, so it warns. + try: + _legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True) + except OSError as err: + _LOGGER.warning( + "Could not remove the legacy validated-config cache: %s", err + ) + rendered = json.dumps( + {"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config}, + separators=(",", ":"), + default=_json_default, + ) write_file(compiled_config_path(CORE.config_filename), rendered, private=True) + except TypeError as err: + # Structural, not transient: this config can never cache (e.g. a + # non-basic dict key), so every upload/logs pays the slow path. + _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Skipping compiled config cache write: %s", err) @@ -51,25 +72,29 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: """Load the cached validated config and apply storage metadata to CORE. Returns None (caller falls back to read_config) when the cache is - missing, older than the source YAML, unparseable, or the sidecar - is incomplete. + missing, older than the source YAML, unparseable, a different cache + version, or the sidecar is incomplete. The loaded config carries no + source ranges; callers must not feed it into read_config/write_cpp. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): return None - from esphome import yaml_util - try: - # Fast path never validates or generates code - no source ranges - # needed (see load_yaml). Callers must not feed this config into - # read_config/write_cpp: the esp_range consumers in config.py and - # cpp_generator.py are isinstance-guarded and would degrade - # silently (wrong error/lambda locations) instead of raising. - config = yaml_util.load_yaml( - cache_path, clear_secrets=False, track_document_range=False + envelope = json.loads( + cache_path.read_text(encoding="utf-8"), object_hook=_decode_object ) - except Exception: # noqa: BLE001 # pylint: disable=broad-except + except (OSError, ValueError) as err: + _LOGGER.debug("Ignoring unreadable compiled config cache: %s", err) + return None + + if ( + not isinstance(envelope, dict) + or envelope.get("v") != _CACHE_VERSION + or envelope.get("esphome") != ESPHOME_VERSION + or not isinstance(config := envelope.get("config"), dict) + ): + _LOGGER.debug("Ignoring compiled config cache with a foreign envelope") return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) @@ -81,3 +106,38 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage.apply_to_core() return config + + +# Remove before 2027.8: by then every maintained install has saved the +# JSON cache at least once and dropped its legacy YAML file. +def _legacy_compiled_config_path(config_filename: str) -> Path: + """Path of the pre-JSON YAML cache; only ever removed.""" + return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" + + +def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: + """True iff the cache file exists and isn't older than the source.""" + try: + return cache_path.stat().st_mtime >= source_path.stat().st_mtime + except OSError: + return False + + +def _json_default(value: Any) -> Any: + """Mirror ESPHomeDumper's representers: Lambda stays typed, the rest + stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums). + + IncludeFile/Extend/Remove have no JSON mirror and would stringify + wrong, but none survive validation (config.py's packages merge and + the substitution pass consume them) so no guard is spent on them. + """ + if isinstance(value, Lambda): + return {_LAMBDA_KEY: value.value} + return str(value) + + +def _decode_object(obj: dict[str, Any]) -> Any: + """Revive the Lambda sentinel; every other mapping passes through.""" + if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str): + return Lambda(value) + return obj diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e5b3ebb84d..1a5f4f2cf5 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -321,14 +321,18 @@ LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") class Lambda: def __init__(self, value): - from esphome.cpp_generator import Expression, statement - # pylint: disable=protected-access if isinstance(value, Lambda): self._value = value._value - elif isinstance(value, Expression): - self._value = str(statement(value)) + elif isinstance(value, str): + # The validated-config cache revives Lambdas from strings on the + # upload/logs fast path; keep codegen off that path. + self._value = value else: + from esphome.cpp_generator import Expression, statement + + if isinstance(value, Expression): + value = str(statement(value)) self._value = value self._parts = None self._requires_ids = None diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 981e508d5d..d3c6caf60b 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1349,6 +1349,8 @@ class ESPHomeDumper(yaml.SafeDumper): return super().increase_indent(flow, False) +# Mirrored by compiled_config._json_default: a new representer that keeps a +# type round-trippable (like Lambda's) needs a sentinel there too. ESPHomeDumper.add_multi_representer( dict, lambda dumper, value: dumper.represent_mapping("tag:yaml.org,2002:map", value) ) diff --git a/tests/benchmarks/python/test_compiled_config_bench.py b/tests/benchmarks/python/test_compiled_config_bench.py index 5c8892f8d0..4d7821f704 100644 --- a/tests/benchmarks/python/test_compiled_config_bench.py +++ b/tests/benchmarks/python/test_compiled_config_bench.py @@ -52,7 +52,7 @@ def _prime_cache(yaml_path: Path) -> None: Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``, then ``update_storage_json`` writes both the StorageJSON sidecar and - the ``.validated.yaml`` compiled-config cache. + the ``.validated.json`` compiled-config cache. """ CORE.config_path = yaml_path config = read_config({}, skip_external_update=True) diff --git a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py index f0df08aa4e..f70a3f85ac 100644 --- a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py +++ b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py @@ -2,12 +2,13 @@ Executed as a subprocess by test_lazy_imports.py: heavy module names come in on argv, the ones found in sys.modules afterwards go out on stdout. -Covers both fast-path claims: the bundle suffix check in run_esphome reads -BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, and -the real validated-config cache parse, include resolution included, stays -voluptuous free. +Covers three fast-path claims: the bundle suffix check in run_esphome reads +BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, the +validated-config cache parse stays voluptuous free, and the JSON cache +(lambda sentinel included) resolves without pyyaml or esphome.yaml_util. """ +import json import os from pathlib import Path import sys @@ -16,7 +17,6 @@ from unittest.mock import patch from _leak_report import print_leaked_modules from _storage import make_storage -import yaml # Everything imported past this point is the code under test; the pop # below must only drop what the setup itself preloaded, or it would @@ -24,8 +24,10 @@ import yaml _FIXTURE_PRELOADED = frozenset(sys.modules) from esphome import __main__ as main_mod # noqa: E402 +from esphome.const import __version__ as ESPHOME_VERSION # noqa: E402 CONFIG_TEXT = "esphome:\n name: t\n" +LAMBDA_BODY = 'ESP_LOGD("t", "x");' # An ambient data-dir override would relocate the storage tree away # from the tmp config dir this fixture builds. @@ -39,13 +41,23 @@ with tempfile.TemporaryDirectory() as _td: storage_dir = tmp / ".esphome" / "storage" storage_dir.mkdir(parents=True) - # The cache is a top-level !include so loading it resolves an - # IncludeFile for real on the fast path. The sidecar is written to the - # layout ext_storage_path resolves once run_esphome sets - # CORE.config_path; going through CORE here would be circular. - (storage_dir / "inc.yaml").write_text(CONFIG_TEXT) - cache_path = storage_dir / "test.yaml.validated.yaml" - cache_path.write_text("!include inc.yaml\n") + # The cache carries a lambda sentinel so loading revives a real Lambda + # on the fast path. The sidecar is written to the layout + # ext_storage_path resolves once run_esphome sets CORE.config_path; + # going through CORE here would be circular. + cache_path = storage_dir / "test.yaml.validated.json" + cache_path.write_text( + json.dumps( + { + "v": 1, + "esphome": ESPHOME_VERSION, + "config": { + "esphome": {"name": "t"}, + "script": [{"lambda": {"__esphome_lambda__": LAMBDA_BODY}}], + }, + } + ) + ) os.utime(cache_path) # keep the cache at least as fresh as the source make_storage().save(storage_dir / "test.yaml.json") @@ -76,7 +88,13 @@ with tempfile.TemporaryDirectory() as _td: # asserts so PYTHONOPTIMIZE in the ambient environment can't strip them. if exit_code != 0: sys.exit(f"run_esphome exited {exit_code} before dispatching upload") - if dispatched.get("config") != yaml.safe_load(CONFIG_TEXT): - sys.exit(f"cache include did not resolve through the fast path: {dispatched!r}") + config = dispatched.get("config") + if config is None or config.get("esphome") != {"name": "t"}: + sys.exit(f"cache did not resolve through the fast path: {dispatched!r}") + from esphome.core import Lambda + + revived = config["script"][0]["lambda"] + if not isinstance(revived, Lambda) or revived.value != LAMBDA_BODY: + sys.exit(f"lambda sentinel did not revive: {revived!r}") print_leaked_modules() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b852d2d596..b3c2170c3f 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,15 +2,20 @@ from __future__ import annotations +from ipaddress import IPv4Address, IPv4Network import json import os from pathlib import Path +from typing import Any from unittest.mock import patch +from uuid import UUID import pytest +from esphome import const, yaml_util from esphome.__main__ import run_esphome from esphome.compiled_config import ( + _LAMBDA_KEY, compiled_config_path, load_compiled_config, save_compiled_config, @@ -24,30 +29,26 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, KEY_VARIANT, + Toolchain, ) -from esphome.core import CORE -from esphome.yaml_util import ESPHomeDataBase +from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.util import OrderedDict -_VALIDATED_CONFIG_YAML = """\ -esphome: - name: lite_test - friendly_name: Lite Test Device -esp32: - board: nodemcu-32s -logger: - baud_rate: 115200 -api: - port: 6053 - encryption: - key: 6dGhpcyBpcyBhIHRlc3Q= -ota: - - platform: esphome - port: 3232 - password: secret -wifi: - ssid: ssid - use_address: 192.168.1.42 -""" +_VALIDATED_CONFIG = { + "esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"}, + "esp32": {"board": "nodemcu-32s"}, + "logger": {"baud_rate": 115200}, + "api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}}, + "ota": [{"platform": "esphome", "port": 3232, "password": "secret"}], + "wifi": {"ssid": "ssid", "use_address": "192.168.1.42"}, +} + + +def _cache_body(config: dict | None = None) -> str: + """Render the JSON envelope the production save writes.""" + return json.dumps( + {"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG} + ) def _write_storage( @@ -79,10 +80,10 @@ def _write_storage( storage_path.write_text(json.dumps(data), encoding="utf-8") -def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: +def _write_cache(cache_path: Path, body: str | None = None) -> Path: """Write the cache file and return it.""" cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(body, encoding="utf-8") + cache_path.write_text(body if body is not None else _cache_body(), encoding="utf-8") return cache_path @@ -96,24 +97,28 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: @pytest.fixture -def fresh_cache_files(tmp_path: Path) -> Path: - """YAML + StorageJSON + cache, all consistent and fresh.""" +def primed_storage(tmp_path: Path) -> Path: + """YAML + StorageJSON sidecar, no cache yet.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path - - storage_dir = tmp_path / ".esphome" / "storage" - _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") - _set_cache_mtime(cache, yaml_path, offset=5) - + _write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json") return yaml_path +@pytest.fixture +def fresh_cache_files(primed_storage: Path) -> Path: + """YAML + StorageJSON + cache, all consistent and fresh.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") + _set_cache_mtime(cache, primed_storage, offset=5) + return primed_storage + + def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: """The cache file shape is predictable from the YAML filename.""" path = compiled_config_path("device.yaml") - assert path.name == "device.yaml.validated.yaml" + assert path.name == "device.yaml.validated.json" assert path.parent.name == "storage" @@ -126,9 +131,8 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" assert config["ota"][0]["password"] == "secret" - # The fast path loads without per-node source ranges (the full - # contract lives in test_yaml_util; this checks the flag is wired up). - assert not isinstance(config[CONF_ESPHOME][CONF_NAME], ESPHomeDataBase) + # The fast path loads plain scalars; no per-node source ranges exist. + assert type(config[CONF_ESPHOME][CONF_NAME]) is str # apply_to_core populated exactly what upload/logs read off CORE. assert CORE.name == "lite_test" @@ -147,7 +151,7 @@ def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -168,7 +172,7 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms( esp_platform="ESP8266", core_platform="esp8266", ) - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -185,7 +189,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path storage_dir = tmp_path / ".esphome" / "storage" - cache_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path = storage_dir / "lite_test.yaml.validated.json" sidecar_path = storage_dir / "lite_test.yaml.json" if scenario == "missing_cache": @@ -196,7 +200,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: elif scenario == "corrupt_cache": _write_storage(sidecar_path) _set_cache_mtime( - _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 + _write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5 ) elif scenario == "missing_sidecar": # Cache fresh + parseable, but no StorageJSON → can't populate CORE. @@ -205,6 +209,108 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: assert load_compiled_config(yaml_path) is None +@pytest.mark.parametrize( + "body", + [ + pytest.param( + json.dumps( + {"v": 999, "esphome": const.__version__, "config": {"esphome": {}}} + ), + id="wrong_version", + ), + pytest.param( + json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}), + id="missing_version", + ), + pytest.param( + json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}), + id="other_esphome_version", + ), + pytest.param( + json.dumps({"v": 1, "config": {"esphome": {}}}), + id="missing_esphome_version", + ), + pytest.param( + json.dumps( + { + "v": 1, + "esphome": const.__version__, + "config": ["not", "a", "dict"], + } + ), + id="non_dict_config", + ), + pytest.param( + json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config" + ), + pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"), + ], +) +def test_load_compiled_config_rejects_bad_envelope( + primed_storage: Path, body: str +) -> None: + """A foreign or future cache shape falls back instead of half-loading.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body) + _set_cache_mtime(cache, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None: + """A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + legacy = _write_cache( + storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n name: lite_test\n" + ) + _set_cache_mtime(legacy, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None: + """A successful save leaves only the JSON cache behind.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert compiled_config_path("lite_test.yaml").is_file() + assert not legacy.exists() + + +def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None: + """The secret-bearing legacy cache goes away regardless of write outcome.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert not legacy.exists() + assert not compiled_config_path("lite_test.yaml").exists() + + +def test_save_warns_when_legacy_cache_unremovable( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A secret-bearing legacy file that won't unlink warns; the write proceeds.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.mkdir() # unlink() on a directory raises OSError + + with caplog.at_level("WARNING", logger="esphome.compiled_config"): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert "legacy validated-config cache" in caplog.text + assert compiled_config_path("lite_test.yaml").is_file() + + @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_use_cache_when_fresh( command: str, @@ -258,7 +364,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( ) -> None: """Without a StorageJSON sidecar (no compile has run), the fallback skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) YAML would be inert and + so writing the rendered (secret-resolved) config would be inert and leak secrets to disk for nothing.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") @@ -293,7 +399,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=-60) # stale fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} @@ -386,28 +492,161 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None def test_save_compiled_config_writes_cache(tmp_path: Path) -> None: - """`save_compiled_config` writes the dumped YAML next to the sidecar.""" + """`save_compiled_config` writes the JSON envelope next to the sidecar.""" CORE.config_path = tmp_path / "lite_test.yaml" save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}}) cache_path = compiled_config_path("lite_test.yaml") assert cache_path.is_file() - body = cache_path.read_text() - assert "name: lite_test" in body - assert "logger:" in body + envelope = json.loads(cache_path.read_text()) + assert envelope["v"] == 1 + assert envelope["esphome"] == const.__version__ + assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}} -def test_save_compiled_config_swallows_dump_errors( +def test_save_compiled_config_swallows_write_errors( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Failures during the dump are non-fatal -- a bad cache just means + """Failures during the write are non-fatal -- a bad cache just means the next fast path falls back to read_config().""" CORE.config_path = tmp_path / "lite_test.yaml" - with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")): + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): save_compiled_config({"esphome": {"name": "lite_test"}}) assert not compiled_config_path("lite_test.yaml").exists() +def test_save_stringifies_unknown_values(tmp_path: Path) -> None: + """A type with no dedicated encoding stores its string form.""" + + class Weird: + def __str__(self) -> str: + return "weird-str" + + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}}) + envelope = json.loads(compiled_config_path("lite_test.yaml").read_text()) + assert envelope["config"]["esphome"]["weird"] == "weird-str" + + +def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None: + """A non-basic dict key aborts the write; the fast path falls back.""" + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {("a", "b"): "lite_test"}}) + assert not compiled_config_path("lite_test.yaml").exists() + + +def _normalize(value: Any) -> Any: + """Make Lambda comparable; everything else compares by value already.""" + if isinstance(value, Lambda): + return ("__lambda__", value.value) + if isinstance(value, dict): + return {k: _normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize(v) for v in value] + return value + + +def _round_trip_config() -> OrderedDict: + """A post-validation shaped config exercising every representer type.""" + return OrderedDict( + { + "esphome": OrderedDict( + { + "name": "lite_test", + "build_path": Path("/build/lite_test"), + "on_boot": [ + OrderedDict( + { + "trigger_id": ID("trigger_1", type="Trigger"), + "then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}], + } + ) + ], + } + ), + "wifi": OrderedDict( + { + "id": ID("wifi_id", type="WiFiComponent"), + "reboot_timeout": TimePeriodMilliseconds(milliseconds=900000), + "use_address": IPv4Address("192.168.1.42"), + "subnet": IPv4Network("192.168.1.0/24"), + "mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01), + } + ), + "misc": OrderedDict( + { + "uuid": UUID("12345678-1234-5678-1234-567812345678"), + "toolchain": Toolchain.PLATFORMIO, + "hex": HexInt(0x1234), + "levels": (1, 2.5, True, None), + "empty": {}, + } + ), + } + ) + + +def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None: + """The JSON cache loads the same tree the YAML cache used to.""" + config = _round_trip_config() + save_compiled_config(config) + from_json = load_compiled_config(primed_storage) + assert from_json is not None + + yaml_cache = primed_storage.parent / "dumped.yaml" + yaml_cache.write_text(yaml_util.dump(config, show_secrets=True)) + from_yaml = yaml_util.load_yaml( + yaml_cache, clear_secrets=False, track_document_range=False + ) + + assert _normalize(from_json) == _normalize(from_yaml) + + +def test_lambda_sentinel_round_trips(primed_storage: Path) -> None: + """A !lambda body comes back as a Lambda with the same source.""" + body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";' + save_compiled_config( + { + "esphome": {"name": "lite_test"}, + "script": [{"then": [{"lambda": Lambda(body)}]}], + } + ) + + config = load_compiled_config(primed_storage) + assert config is not None + revived = config["script"][0]["then"][0]["lambda"] + assert isinstance(revived, Lambda) + assert revived.value == body + + +def test_object_hook_requires_exact_shape(primed_storage: Path) -> None: + """Only the exact one-key string-valued sentinel revives a Lambda.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + config = { + "esphome": {"name": "lite_test"}, + "extra_key": {_LAMBDA_KEY: "x", "y": 1}, + "non_str": {_LAMBDA_KEY: 5}, + } + cache = _write_cache( + storage_dir / "lite_test.yaml.validated.json", _cache_body(config) + ) + _set_cache_mtime(cache, primed_storage, offset=5) + + loaded = load_compiled_config(primed_storage) + assert loaded is not None + assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1} + assert loaded["non_str"] == {_LAMBDA_KEY: 5} + + +def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: + """Non-str basic keys stringify; validated configs only use string keys.""" + save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}}) + + config = load_compiled_config(primed_storage) + assert config is not None + assert config["table"] == {"1": "a", "2": "b"} + + def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: """A wizard-only sidecar (no compile -- no core_platform / target_platform) can't drive upload/logs, so the fast path falls back.""" @@ -426,7 +665,7 @@ def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> Non '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' '"framework": null, "core_platform": null}' ) - cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) assert load_compiled_config(yaml_path) is None diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 0cb0c1f62d..7f00d00ef7 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -1,5 +1,7 @@ import os from pathlib import Path +import subprocess +import sys from unittest.mock import patch from hypothesis import given @@ -213,6 +215,31 @@ class TestLambda: assert str(target) is value.value + def test_init__expression_initializer(self): + from esphome.cpp_generator import RawExpression + + target = core.Lambda(RawExpression("foo()")) + + assert target.value == "foo();" + + def test_init__other_initializer(self): + target = core.Lambda(123) + + assert target.value == 123 + + def test_init_from_str_does_not_import_codegen(self): + """The validated-config cache revives Lambdas on the upload fast path.""" + # sys.exit rather than assert so ambient PYTHONOPTIMIZE can't strip it. + check = ( + "import sys; from esphome.core import Lambda; " + "Lambda('return 1;'); " + "sys.exit('codegen leaked' if 'esphome.cpp_generator' in sys.modules else 0)" + ) + result = subprocess.run( + [sys.executable, "-c", check], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + def test_parts(self): target = core.Lambda(SAMPLE_LAMBDA.strip()) diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 8358f4b781..2e09c4a945 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -46,6 +46,11 @@ API_HEAVY_MODULES = ("aioesphomeapi",) # never pays for the bundle machinery and its tarfile chain. BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile") +# Heavy only for a cache-hit upload/logs run: the JSON cache parse must +# not resolve pyyaml or the yaml_util chain (the read_config fallback +# still uses both). +CACHE_HIT_HEAVY_MODULES = ("esphome.yaml_util", "yaml") + # Stdlib modules deferred out of the dispatch fast path: a cache-hit # upload/logs run never writes a file (tempfile), spawns a process # (subprocess), parses a URL (urllib.parse), or prints a serial @@ -56,8 +61,6 @@ STDLIB_FAST_PATH_MODULES = ( "tempfile", "subprocess", "getpass", - # Pins the module-level contract only: PyYAML's constructor loads - # datetime during the cache parse until the JSON cache lands. "datetime", *(("urllib.parse",) if sys.version_info >= (3, 13) else ()), ) @@ -108,6 +111,7 @@ def test_watched_heavy_modules_exist() -> None: FAST_PATH_HEAVY_MODULES + API_HEAVY_MODULES + BUNDLE_HEAVY_MODULES + + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES ): assert importlib.util.find_spec(module) is not None, ( @@ -270,13 +274,13 @@ def test_upload_command_path_does_not_import_heavy_modules( leaked = _leaked_from_fixture( fixture_path, "upload_command_fast_path.py", - extra=BUNDLE_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, + extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, ) assert not leaked, ( f"the upload dispatch path pulls in heavy modules: {leaked}. " "An ordinary run only needs the bundle suffix constant, and the " - "cache parse must not resolve voluptuous; keep the esphome.bundle " - "import inside the branch that extracts one, the Invalid import " - "inside the branch that raises it, and the deferred stdlib " - "imports inside the write/spawn/serial helpers that use them." + "JSON cache parse must not resolve voluptuous or pyyaml; keep the " + "esphome.bundle import inside the branch that extracts one, the " + "yaml_util imports inside the read_config fallback, and the " + "deferred stdlib imports inside the write/spawn/serial helpers." ) From 25cb4400059e6d8dca4b5cfb5049830d6a815c31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 00:29:00 -0500 Subject: [PATCH 05/11] [core] Use Happy Eyeballs for remote file downloads (#18050) --- .../components/dashboard_import/__init__.py | 2 + esphome/components/esp32/__init__.py | 14 +- esphome/components/font/__init__.py | 2 + esphome/components/shelly_dimmer/light.py | 2 + esphome/external_files.py | 4 + esphome/framework_helpers.py | 5 + esphome/happy_eyeballs.py | 136 ++++++++ requirements.txt | 1 + tests/unit_tests/test_happy_eyeballs.py | 325 ++++++++++++++++++ 9 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 esphome/happy_eyeballs.py create mode 100644 tests/unit_tests/test_happy_eyeballs.py diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 000db307b9..31559a514c 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -12,6 +12,7 @@ from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -109,6 +110,7 @@ def import_config( if git_file.query and "full_config" in git_file.query: url = git_file.raw_url try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d16e8ae03c..2e72c78974 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3286,9 +3286,19 @@ def copy_files(): if str(path).startswith("http"): import requests + from esphome.happy_eyeballs import ensure_happy_eyeballs + + ensure_happy_eyeballs() + + try: + req = requests.get(path, timeout=30) + req.raise_for_status() + except requests.exceptions.RequestException as e: + raise EsphomeError( + f"Could not download extra build file {path}: {e}" + ) from e CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - content = requests.get(path, timeout=30).content - CORE.relative_build_path(name).write_bytes(content) + CORE.relative_build_path(name).write_bytes(req.content) else: copy_file_if_changed(path, CORE.relative_build_path(name)) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7510f2f8b6..5872b607f1 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -36,6 +36,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -319,6 +320,7 @@ def download_gfont(value): if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) try: + ensure_happy_eyeballs() req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index f2ab5a4bc1..cd6d858067 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -29,6 +29,7 @@ from esphome.const import ( UNIT_WATT, ) from esphome.core import CORE, HexInt +from esphome.happy_eyeballs import ensure_happy_eyeballs DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -81,6 +82,7 @@ def get_firmware(value): def dl(url): try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/external_files.py b/esphome/external_files.py index 69423d3999..160a2b6c29 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -14,6 +14,7 @@ import requests import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import write_file from esphome.types import ConfigType @@ -92,6 +93,7 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: @@ -158,6 +160,7 @@ def compute_local_file_dir(domain: str) -> Path: def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: + ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) return path.read_bytes() @@ -231,6 +234,7 @@ def download_content_many( seen: dict[Path, str] = {path: url for url, path in items} if not seen: return + ensure_happy_eyeballs() _LOGGER.info("Checking %d %s for updates", len(seen), description) if len(seen) == 1: path, url = next(iter(seen.items())) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 202d4a2bfb..6ed608b171 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -13,6 +13,7 @@ import sys import time from typing import IO, TYPE_CHECKING +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import ProgressBar, rmtree if TYPE_CHECKING: @@ -755,6 +756,8 @@ def download_with_resume( from esphome.core import EsphomeError + ensure_happy_eyeballs() + dest = Path(dest) part = dest.with_name(dest.name + ".part") meta = part.with_name(part.name + ".meta") @@ -922,6 +925,8 @@ def download_from_mirrors( from esphome.core import EsphomeError + ensure_happy_eyeballs() + # 1. Classify the target: filesystem path or open file object path_target: Path | None = None f: IO[bytes] | None = None diff --git a/esphome/happy_eyeballs.py b/esphome/happy_eyeballs.py new file mode 100644 index 0000000000..ebfb94f1f9 --- /dev/null +++ b/esphome/happy_eyeballs.py @@ -0,0 +1,136 @@ +"""Happy Eyeballs (RFC 8305) connection support for requests/urllib3. + +urllib3 tries each resolved address in sequence with the full connect +timeout, so a network advertising IPv6 DNS without IPv6 connectivity stalls +every download for the whole timeout before IPv4 is tried. +``ensure_happy_eyeballs()`` swaps urllib3's ``create_connection`` for one +that races address families with a short stagger via aiohappyeyeballs, run +on a daemon-thread event loop so callers stay synchronous. +""" + +from __future__ import annotations + +import logging +import socket +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +_LOGGER = logging.getLogger(__name__) + +# RFC 8305 recommended delay between staggered connection attempts. +HAPPY_EYEBALLS_DELAY = 0.25 + +# Extra seconds the connect thread gets beyond the connect timeout before +# the caller gives up waiting for it. +_THREAD_WAIT_BUFFER = 5.0 + + +def ensure_happy_eyeballs() -> None: + """Make urllib3 (and therefore requests) connect with Happy Eyeballs. + + Idempotent; call before performing requests-based downloads. + """ + stock: Callable[..., socket.socket] | None = None + try: + import urllib3.util.connection + + stock = urllib3.util.connection.create_connection + if getattr(stock, "_esphome_patched", False): + return + + urllib3.util.connection.create_connection = _make_create_connection() + except (ImportError, AttributeError) as err: # urllib3 internals moved + # WARNING: degraded mode brings back the stalls this module prevents. + _LOGGER.warning( + "Happy Eyeballs unavailable (%s); downloads use the slower stock " + "urllib3 connect", + err, + ) + _LOGGER.debug("Happy Eyeballs fallback traceback", exc_info=True) + if stock is not None: + # Latch so the warning fires once, not per download. + stock._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + + +def _make_create_connection() -> Callable[..., socket.socket]: + """Build a drop-in replacement for urllib3's ``create_connection``.""" + # Deferred so runs that never download skip the ~30 ms asyncio import. + import asyncio + + from aiohappyeyeballs import start_connection + from urllib3.exceptions import LocationParseError + from urllib3.util.connection import ( # noqa: PLC2701 + _set_socket_options, + allowed_gai_family, + ) + from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701 + + from esphome import async_thread + + def create_connection( + address: tuple[str, int], + timeout: Any = _DEFAULT_TIMEOUT, + source_address: tuple[str, int] | None = None, + socket_options: Any = None, + ) -> socket.socket: + host, port = address + if host.startswith("["): + host = host.strip("[]") + try: + host.encode("idna") + except UnicodeError: + raise LocationParseError(f"'{host}', label empty or too long") from None + + addr_infos = socket.getaddrinfo( + host, port, allowed_gai_family(), socket.SOCK_STREAM + ) + if not addr_infos: + # Same error as stock urllib3. + raise OSError("getaddrinfo returns an empty list") + connect_timeout = ( + socket.getdefaulttimeout() if timeout is _DEFAULT_TIMEOUT else timeout + ) + + def socket_factory(addr_info: Any) -> socket.socket: + family, type_, proto, _, _ = addr_info + sock = socket.socket(family, type_, proto) + try: + _set_socket_options(sock, socket_options) + if source_address: + sock.bind(source_address) + except BaseException: + sock.close() + raise + return sock + + async def connect() -> socket.socket: + return await asyncio.wait_for( + start_connection( + addr_infos, + happy_eyeballs_delay=HAPPY_EYEBALLS_DELAY, + interleave=1, + socket_factory=socket_factory, + ), + connect_timeout, + ) + + wait = ( + None if connect_timeout is None else connect_timeout + _THREAD_WAIT_BUFFER + ) + # on_orphan closes a socket won after the timeout so it cannot leak. + sock = async_thread.run_async( + connect, timeout=wait, on_orphan=socket.socket.close + ) + # aiohappyeyeballs leaves the winning socket non-blocking; restore the + # blocking-with-timeout behavior urllib3 callers expect. + try: + sock.settimeout(connect_timeout) + except BaseException: + sock.close() + raise + return sock + + create_connection._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + return create_connection diff --git a/requirements.txt b/requirements.txt index d56a8daec1..4501b733a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ platformio==6.1.19 esptool==5.3.1 click==8.3.3 aioesphomeapi==45.7.0 +aiohappyeyeballs==2.6.2 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import diff --git a/tests/unit_tests/test_happy_eyeballs.py b/tests/unit_tests/test_happy_eyeballs.py new file mode 100644 index 0000000000..3335a8a3e3 --- /dev/null +++ b/tests/unit_tests/test_happy_eyeballs.py @@ -0,0 +1,325 @@ +"""Tests for the Happy Eyeballs urllib3 shim.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +import socket +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from esphome.happy_eyeballs import _make_create_connection, ensure_happy_eyeballs + + +def _addr_info(host: str, port: int) -> tuple[Any, ...]: + """Build a getaddrinfo-style result tuple for an IPv4 address.""" + return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (host, port)) + + +@pytest.fixture +def create_connection() -> Any: + """A freshly built Happy Eyeballs create_connection replacement.""" + return _make_create_connection() + + +@pytest.fixture +def listener() -> Generator[tuple[str, int]]: + """A listening TCP socket on localhost; yields its address.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(5) + yield server.getsockname() + server.close() + + +@pytest.fixture +def mock_gai(listener: tuple[str, int]) -> Generator[Any]: + """Resolve every host to two copies of the listener's address.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)] * 2) as mock: + yield mock + + +def test_ensure_happy_eyeballs_patches_and_is_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shim replaces urllib3's create_connection exactly once.""" + import urllib3.util.connection + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + + ensure_happy_eyeballs() + patched = urllib3.util.connection.create_connection + assert patched is not stock + assert patched._esphome_patched + + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is patched + + +def test_connects_and_restores_socket_state( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The winning socket comes back blocking, with timeout and options set.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)], + ) + + try: + assert sock.getpeername() == listener + assert sock.gettimeout() == 5 + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0 + finally: + sock.close() + + +def test_single_address_connects( + create_connection: Any, listener: tuple[str, int] +) -> None: + """A host resolving to one address connects through the same path.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)]): + sock = create_connection(("example.com", listener[1]), timeout=5) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_falls_back_to_working_address( + create_connection: Any, listener: tuple[str, int], monkeypatch: pytest.MonkeyPatch +) -> None: + """An unreachable first address does not block the working one.""" + from esphome import happy_eyeballs + + # 192.0.2.1 (TEST-NET-1) blackholes or fails fast depending on the + # network; either way the second address must win well within the + # timeout instead of waiting out the first. A short stagger keeps the + # test's duration network independent. + monkeypatch.setattr(happy_eyeballs, "HAPPY_EYEBALLS_DELAY", 0.01) + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info(*listener)] + + with patch("socket.getaddrinfo", return_value=addr_infos): + sock = create_connection(("example.com", listener[1]), timeout=10) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_bracketed_ipv6_host_is_stripped( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """A bracketed IPv6 literal is unbracketed before resolution.""" + sock = create_connection(("[::1]", listener[1]), timeout=5) + + try: + assert mock_gai.call_args[0][0] == "::1" + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_source_address_is_bound( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The socket binds to the requested source address before connecting.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + source_address=("127.0.0.1", 0), + ) + + try: + assert sock.getsockname()[0] == "127.0.0.1" + finally: + sock.close() + + +def test_socket_factory_failure_closes_socket( + listener: tuple[str, int], mock_gai: Any +) -> None: + """A socket-option failure fails the connect instead of leaking sockets. + + Instrumented at ``_set_socket_options`` (which the factory calls with + the just-created socket) rather than by patching ``socket.socket``, + which is platform dependent: the event loop's internal socketpair use + differs between platforms. + """ + created: list[socket.socket] = [] + + def failing_set_options(sock: socket.socket, options: Any) -> None: + created.append(sock) + raise OSError("bad socket option") + + # Patch before building the closure; it binds _set_socket_options at + # creation time. + with patch("urllib3.util.connection._set_socket_options", new=failing_set_options): + create_connection = _make_create_connection() + with pytest.raises(OSError): + create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(999999, 999999, 1)], + ) + + assert created, "socket factory never ran" + assert all(sock.fileno() == -1 for sock in created), "socket leaked open" + + +def test_default_timeout_yields_blocking_socket( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """Without an explicit timeout the socket follows the global default.""" + sock = create_connection(("example.com", listener[1])) + + try: + assert sock.gettimeout() is socket.getdefaulttimeout() + finally: + sock.close() + + +def test_settimeout_failure_closes_socket( + create_connection: Any, mock_gai: Any +) -> None: + """A failure restoring socket state closes the winner instead of leaking.""" + bad_sock = Mock() + bad_sock.settimeout.side_effect = OSError("bad timeout") + + with ( + patch("esphome.async_thread.run_async", return_value=bad_sock), + pytest.raises(OSError, match="bad timeout"), + ): + create_connection(("example.com", 80), timeout=5) + + bad_sock.close.assert_called_once() + + +def test_connect_timeout_raises() -> None: + """A connect that never completes raises within the timeout.""" + + async def never(*args: Any, **kwargs: Any) -> None: + await asyncio.sleep(60) + + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info("192.0.2.2", 9)] + + # Patch before building the closure; it binds start_connection at + # creation time. + with patch("aiohappyeyeballs.start_connection", new=never): + create_connection = _make_create_connection() + with ( + patch("socket.getaddrinfo", return_value=addr_infos), + pytest.raises(TimeoutError), + ): + create_connection(("example.com", 80), timeout=0.1) + + +def test_invalid_host_raises_location_parse_error(create_connection: Any) -> None: + """Hostnames urllib3 would reject are still rejected.""" + from urllib3.exceptions import LocationParseError + + with pytest.raises(LocationParseError): + create_connection(("a" * 300, 80)) + + +def test_empty_getaddrinfo_raises_oserror(create_connection: Any) -> None: + """An empty resolution matches stock urllib3's OSError, not ValueError.""" + with ( + patch("socket.getaddrinfo", return_value=[]), + pytest.raises(OSError, match="empty"), + ): + create_connection(("example.com", 80), timeout=5) + + +def test_ensure_falls_back_to_stock_when_internals_move( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """If urllib3 private names disappear, downloads keep the stock connect + and the warning is latched to fire once, not per download.""" + import urllib3.util.connection + + from esphome import happy_eyeballs + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + factory = Mock(side_effect=ImportError("gone")) + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + monkeypatch.setattr(happy_eyeballs, "_make_create_connection", factory) + + ensure_happy_eyeballs() + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is stock + assert factory.call_count == 1 + assert caplog.text.count("Happy Eyeballs unavailable") == 1 + + +def test_ensure_survives_missing_urllib3( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """An unimportable urllib3 degrades with a warning instead of raising.""" + import sys + + with patch.dict(sys.modules, {"urllib3.util.connection": None}): + ensure_happy_eyeballs() + assert "Happy Eyeballs unavailable" in caplog.text + + +def test_requests_routes_through_shim(monkeypatch: pytest.MonkeyPatch) -> None: + """Patching urllib3's create_connection actually reroutes requests.""" + from http.server import BaseHTTPRequestHandler, HTTPServer + import threading + + import requests + import urllib3.util.connection + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + host, port = server.server_address + + calls: list[Any] = [] + shim = _make_create_connection() + + def counting(*args: Any, **kwargs: Any) -> Any: + calls.append(args) + return shim(*args, **kwargs) + + counting._esphome_patched = True + monkeypatch.setattr(urllib3.util.connection, "create_connection", counting) + + real_getaddrinfo = socket.getaddrinfo + + def fake_getaddrinfo(h: str, p: int, *args: Any, **kwargs: Any) -> Any: + if h == "shim-test.invalid": + return [_addr_info(host, port), _addr_info(host, port)] + return real_getaddrinfo(h, p, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + try: + with requests.Session() as session: + session.trust_env = False + resp = session.get(f"http://shim-test.invalid:{port}/", timeout=5) + assert resp.status_code == 200 + assert resp.content == b"ok" + assert calls, "requests did not go through the patched create_connection" + finally: + server.shutdown() + server.server_close() From b0a9bfd381262d292e5c4177813845391b93a4b2 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Mon, 10 Aug 2026 16:59:32 +0300 Subject: [PATCH 06/11] [ld6002b] Add area and zone configuration (5/5) (#17823) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ld6002b/__init__.py | 3 +- esphome/components/ld6002b/binary_sensor.py | 46 +- esphome/components/ld6002b/button/__init__.py | 40 +- esphome/components/ld6002b/const.py | 12 + esphome/components/ld6002b/ld6002b.cpp | 588 +++++++++++++++++- esphome/components/ld6002b/ld6002b.h | 176 +++++- esphome/components/ld6002b/number/__init__.py | 99 +++ esphome/components/ld6002b/select/__init__.py | 16 +- esphome/components/ld6002b/sensor.py | 90 ++- .../ld6002b/test_final_validate.py | 146 ++++- tests/components/ld6002b/common.yaml | 53 ++ 11 files changed, 1219 insertions(+), 50 deletions(-) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index af074fc7ea..99f2ead3bb 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.types import ConfigType from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE @@ -14,7 +15,7 @@ ld6002b_ns = cg.esphome_ns.namespace("ld6002b") LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice) -def _validate_wakeup_options(config): +def _validate_wakeup_options(config: ConfigType) -> ConfigType: """Reject wake options that would silently do nothing. Runs before the schema so the defaults for the keys below have not been diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 319ace6f5d..63f7b40c23 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -4,24 +4,35 @@ import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY from . import LD6002BComponent -from .const import CONF_LD6002B_ID, MAX_TARGETS +from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS DEPENDENCIES = ["ld6002b"] -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ), - } -).extend( - { - cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ) - for i in range(MAX_TARGETS) - } +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ), + } + ) + .extend( + { + cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(MAX_TARGETS) + } + ) + .extend( + { + cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(AREA_COUNT) + } + ) ) @@ -36,3 +47,8 @@ async def to_code(config): if target_config := config.get(f"target_{i + 1}"): sens = await binary_sensor.new_binary_sensor(target_config) cg.add(hub.set_target_presence_binary_sensor(i, sens)) + + for i in range(AREA_COUNT): + if area_config := config.get(f"detection_area_{i}"): + sens = await binary_sensor.new_binary_sensor(area_config) + cg.add(hub.set_area_presence_binary_sensor(i, sens)) diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index 0046131b62..c327c331c6 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -2,15 +2,21 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ( + CONF_AREA_ID, CONF_ID, CONF_WAKEUP_PIN, ENTITY_CATEGORY_CONFIG, ENTITY_CATEGORY_DIAGNOSTIC, ) import esphome.final_validate as fv +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( + CONF_APPLY_AREA, + CONF_AUTO_INTERFERENCE, + CONF_CLEAR_INTERFERENCE, + CONF_GET_AREAS, CONF_GET_DELAY, CONF_GET_INSTALLATION, CONF_GET_LOW_POWER_MODE, @@ -19,6 +25,7 @@ from ..const import ( CONF_GET_TRIGGER_SPEED, CONF_GET_Z_RANGE, CONF_LD6002B_ID, + CONF_RESET_DETECTION_AREA, CONF_RESET_UNATTENDED, CONF_WAKE, ) @@ -31,6 +38,21 @@ ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_APPLY_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_GET_AREAS): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), cv.Optional(CONF_GET_DELAY): button.button_schema( LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), @@ -62,10 +84,21 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config): +def final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] + if config.get(CONF_APPLY_AREA): + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance", + path=[CONF_APPLY_AREA], + ) + 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]) @@ -81,6 +114,11 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate BUTTON_MAP = { + CONF_APPLY_AREA: ButtonType.APPLY_AREA, + CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE, + CONF_GET_AREAS: ButtonType.GET_AREAS, + CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE, + CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA, CONF_GET_DELAY: ButtonType.GET_DELAY, CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index fac9f08015..b7c3f54a6f 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,6 +1,11 @@ +CONF_APPLY_AREA = "apply_area" +CONF_AREA_CONFIG = "area_config" +CONF_AUTO_INTERFERENCE = "auto_interference" CONF_AUTO_WAKE = "auto_wake" +CONF_CLEAR_INTERFERENCE = "clear_interference" CONF_CLUSTER_ID = "cluster_id" CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_AREAS = "get_areas" CONF_GET_DELAY = "get_delay" CONF_GET_INSTALLATION = "get_installation" CONF_GET_LOW_POWER_MODE = "get_low_power_mode" @@ -16,6 +21,7 @@ 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_DETECTION_AREA = "reset_detection_area" CONF_RESET_UNATTENDED = "reset_unattended" CONF_TARGET_DISPLAY = "target_display" CONF_TRIGGER_SPEED = "trigger_speed" @@ -26,4 +32,10 @@ CONF_Z = "z" CONF_Z_MAX = "z_max" CONF_Z_MIN = "z_min" +KEY_X_MIN = "x_min" +KEY_X_MAX = "x_max" +KEY_Y_MIN = "y_min" +KEY_Y_MAX = "y_max" + +AREA_COUNT = 4 MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 25b3da174c..ca6b9b9552 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -15,12 +15,16 @@ static constexpr uint32_t SETUP_DELAY_MS = 100; // Command/message types static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_AREA = 0x0202; 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_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; +static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; @@ -32,6 +36,10 @@ 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_AUTO_INTERFERENCE = 0x01; +static constexpr uint32_t CMD_GET_AREAS = 0x02; +static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03; +static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04; 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; @@ -55,13 +63,26 @@ 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 uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats +static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats +static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area + +static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display 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_AUTO_INTERFERENCE: + return "auto_interference"; + case CMD_GET_AREAS: + return "get_areas"; + case CMD_CLEAR_INTERFERENCE: + return "clear_interference"; + case CMD_RESET_DETECTION_AREA: + return "reset_detection_area"; case CMD_GET_DELAY: return "get_delay"; case CMD_POINT_CLOUD_ON: @@ -115,6 +136,8 @@ static const char *frame_type_name(uint16_t type) { switch (type) { case TYPE_CONTROL: return "control"; + case TYPE_SET_AREA: + return "set_area"; case TYPE_SET_HOLD_DELAY: return "set_hold_delay"; case TYPE_SET_Z_RANGE: @@ -125,6 +148,12 @@ static const char *frame_type_name(uint16_t type) { return "report_target"; case TYPE_REPORT_POINT_CLOUD: return "report_point_cloud"; + case TYPE_REPORT_AREA_PRESENCE: + return "report_area_presence"; + case TYPE_REPORT_INTERFERENCE_AREAS: + return "report_interference_areas"; + case TYPE_REPORT_DETECTION_AREAS: + return "report_detection_areas"; case TYPE_REPORT_DELAY: return "report_delay"; case TYPE_REPORT_SENSITIVITY: @@ -150,6 +179,8 @@ static const char *frame_type_name(uint16_t type) { static bool is_expected_control_report(uint32_t command, uint16_t type) { switch (command) { + case CMD_GET_AREAS: + return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS; case CMD_GET_DELAY: return type == TYPE_REPORT_DELAY; case CMD_GET_SENSITIVITY: @@ -200,6 +231,10 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { data[3] = (value >> 24) & 0xFF; } +void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) { + write_u32_le(data, static_cast(value)); +} + void LD6002BComponent::write_f32_le(uint8_t *data, float value) { uint32_t raw; std::memcpy(&raw, &value, sizeof(raw)); @@ -356,6 +391,37 @@ void LD6002BComponent::setup() { this->send_control_command_(CMD_GET_LOW_POWER); } + bool want_area_report = false; +#ifdef USE_SENSOR + for (const auto &area : this->interference_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + if (!want_area_report) { + for (const auto &area : this->detection_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + } +#endif +#ifdef USE_NUMBER + if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr || + this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr || + this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) { + want_area_report = true; + } +#endif + if (want_area_report) { + this->send_control_command_(CMD_GET_AREAS); + } + + this->init_area_id_pref_(); this->init_version_pref_(); #ifdef USE_TEXT_SENSOR @@ -374,7 +440,7 @@ void LD6002BComponent::dump_config() { this->auto_wake_ ? "true" : "false", static_cast(this->max_data_len_)); if (this->wakeup_pin_ != nullptr) { LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); - ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_); + ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_); } #ifdef USE_SENSOR LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); @@ -386,12 +452,31 @@ void LD6002BComponent::dump_config() { LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id); } + for (auto &area : this->interference_areas_) { + LOG_SENSOR(" ", "Interference Area X Min", area.x_min); + LOG_SENSOR(" ", "Interference Area X Max", area.x_max); + LOG_SENSOR(" ", "Interference Area Y Min", area.y_min); + LOG_SENSOR(" ", "Interference Area Y Max", area.y_max); + LOG_SENSOR(" ", "Interference Area Z Min", area.z_min); + LOG_SENSOR(" ", "Interference Area Z Max", area.z_max); + } + for (auto &area : this->detection_areas_) { + LOG_SENSOR(" ", "Detection Area X Min", area.x_min); + LOG_SENSOR(" ", "Detection Area X Max", area.x_max); + LOG_SENSOR(" ", "Detection Area Y Min", area.y_min); + LOG_SENSOR(" ", "Detection Area Y Max", area.y_max); + LOG_SENSOR(" ", "Detection Area Z Min", area.z_min); + LOG_SENSOR(" ", "Detection Area Z Max", area.z_max); + } #endif #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); for (uint8_t i = 0; i < MAX_TARGETS; i++) { LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]); + } #endif #ifdef USE_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); @@ -402,6 +487,12 @@ void LD6002BComponent::dump_config() { 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_); + LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_); + LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_); + LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_); + LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_); + LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_); + LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_); #endif #ifdef USE_SWITCH LOG_SWITCH(" ", "Low Power", this->low_power_switch_); @@ -412,6 +503,7 @@ void LD6002BComponent::dump_config() { LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); LOG_SELECT(" ", "Installation Mode", this->installation_select_); + LOG_SELECT(" ", "Area ID", this->area_id_select_); #endif } @@ -527,6 +619,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ } if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_); + const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_; // This settles one expected reply; the rest stay owed and become the debt for the next command. this->send_generation_++; this->stale_ack_type_ = type; @@ -536,6 +629,10 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ this->command_sent_ = false; this->last_send_ms_ = 0; this->process_command_queue_(); + if (refresh_areas) { + this->area_write_in_flight_ = false; + this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + } return; } @@ -557,6 +654,15 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ case TYPE_REPORT_POINT_CLOUD: this->handle_point_cloud_(data, len); break; + case TYPE_REPORT_AREA_PRESENCE: + this->handle_area_presence_(data, len); + break; + case TYPE_REPORT_INTERFERENCE_AREAS: + this->handle_area_report_(true, data, len); + break; + case TYPE_REPORT_DETECTION_AREAS: + this->handle_area_report_(false, data, len); + break; case TYPE_REPORT_DELAY: this->handle_delay_report_(data, len); break; @@ -654,8 +760,9 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) this->target_presence_any_ = (reported > 0); #ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; if (this->presence_binary_sensor_ != nullptr) { - this->presence_binary_sensor_->publish_state(this->target_presence_any_); + this->presence_binary_sensor_->publish_state(presence); } #endif this->update_work_mode_fallback_(); @@ -728,6 +835,84 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { #endif } +// 0x0A0A carries one uint32 per detection area -- the protocol names the four +// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The +// interference areas have no presence report: a target inside one is what they +// exist to suppress. +void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) { + const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN; + if (len < needed) + return; + + this->area_presence_any_ = false; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + bool present = state != 0; + this->area_presence_any_ = this->area_presence_any_ || present; +#ifdef USE_BINARY_SENSOR + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(present); + } +#endif + } + +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) { + uint16_t needed = AREA_COUNT * AREA_DATA_LEN; + if (len < needed) + return; + + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint16_t offset = i * AREA_DATA_LEN; + float x_min = read_f32_le(data + offset + 0); + float x_max = read_f32_le(data + offset + 4); + float y_min = read_f32_le(data + offset + 8); + float y_max = read_f32_le(data + offset + 12); + float z_min = read_f32_le(data + offset + 16); + float z_max = read_f32_le(data + offset + 20); + +#ifdef USE_SENSOR + AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i]; + if (area.x_min != nullptr) + area.x_min->publish_state(x_min); + if (area.x_max != nullptr) + area.x_max->publish_state(x_max); + if (area.y_min != nullptr) + area.y_min->publish_state(y_min); + if (area.y_max != nullptr) + area.y_max->publish_state(y_max); + if (area.z_min != nullptr) + area.z_min->publish_state(z_min); + if (area.z_max != nullptr) + area.z_max->publish_state(z_max); +#endif + + AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i]; + store.x_min = x_min; + store.x_max = x_max; + store.y_min = y_min; + store.y_max = y_max; + store.z_min = z_min; + store.z_max = z_max; + + uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + bool selected_interference = selected_id < AREA_COUNT; + uint8_t selected_index = selected_interference ? selected_id : static_cast(selected_id - AREA_COUNT); + if (selected_interference == interference && selected_index == i) { + this->update_area_numbers_(store); + } + } + this->try_apply_pending_area_(interference); +} + void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { if (len < 4) return; @@ -815,13 +1000,24 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1 void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { if (len < 1) return; -#ifdef USE_TEXT_SENSOR + // Zero is the unattended half of this transition. Read outside the text sensor's + // ifdef because the area sensors do not need one configured to have gone stale. const bool low_power = (data[0] == 0); +#ifdef USE_TEXT_SENSOR if (this->work_mode_text_sensor_ != nullptr) { this->work_mode_reported_ = true; this->publish_work_mode_(low_power); } #endif + // Protocol V1.2 section 2.1.17: this message is sent only on the transition + // between the unattended low-power mode and normal operation, so a zero is the + // module stating that nobody is in any area. Not while a target is still being + // tracked, though: the reset_unattended command is undocumented on whether it + // forces this report, and where two statements from the module disagree the live + // one wins. + if (low_power && !this->target_presence_any_) { + this->clear_area_presence_(); + } } void LD6002BComponent::update_work_mode_fallback_() { @@ -832,9 +1028,10 @@ void LD6002BComponent::update_work_mode_fallback_() { 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_; + // Target presence is only meaningful while the stream that maintains it runs. + // Area presence keeps its own report, so it still counts with the target stream + // off and low power alone decides only when neither half has anything to say. + const bool presence = (this->target_display_enabled_ && this->target_presence_any_) || this->area_presence_any_; this->publish_work_mode_(this->low_power_enabled_ && !presence); #endif } @@ -857,6 +1054,16 @@ void LD6002BComponent::publish_work_mode_(bool low_power) { void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { if (number == nullptr) return; + if (std::isnan(value)) { + // NAN is this component's "the module has not told us yet". Publishing it on an + // entity that has never had a state would report a nan where unknown is the + // truth; on one that already shows a value it is the only way to say that value + // no longer describes the selected area. + if (number->has_state()) { + number->publish_state(value); + } + 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 @@ -891,14 +1098,14 @@ void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) #endif } -void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { +bool 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); - return; + return false; } if (this->cmd_count_ >= CMD_QUEUE_SIZE) { ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); - return; + return false; } PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_]; @@ -911,6 +1118,7 @@ void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_ this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE; this->cmd_count_++; this->process_command_queue_(); + return true; } void LD6002BComponent::process_command_queue_() { @@ -945,6 +1153,18 @@ void LD6002BComponent::process_command_queue_() { } else { ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); } + if (this->active_command_.type == TYPE_SET_AREA) { + this->area_write_in_flight_ = false; + } + // The deferred apply is waiting on the report this command would have + // brought back, and nothing else re-arms it. Dropping it here is the + // difference between one apply lost to a timeout and one that rides in on + // an unrelated area report later, writing bounds the user has moved on from. + if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply"); + } // A reply may still be in flight for the attempt we just gave up on, so carry one over as // debt rather than clearing the ledger, or that late ACK would retire the successor. Only // one: reaching this point means nothing was answered at all, so the older attempts are @@ -1067,10 +1287,10 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t this->last_traffic_ms_ = now; } -void LD6002BComponent::send_control_command_(uint32_t command) { +bool LD6002BComponent::send_control_command_(uint32_t command) { uint8_t data[4]; write_u32_le(data, command); - this->queue_command_(TYPE_CONTROL, data, sizeof(data)); + return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); } void LD6002BComponent::send_z_range_() { @@ -1090,6 +1310,64 @@ void LD6002BComponent::send_z_range_() { this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); } +void LD6002BComponent::apply_area_config_() { + if (!this->area_id_set_) { + ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); + return; + } + if (this->area_id_ >= AREA_ID_COUNT) { + ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_); + return; + } + + const bool interference = this->area_id_ < AREA_COUNT; + const uint8_t index = interference ? this->area_id_ : static_cast(this->area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + if (!std::isnan(this->area_x_min_)) + desired.x_min = this->area_x_min_; + if (!std::isnan(this->area_x_max_)) + desired.x_max = this->area_x_max_; + if (!std::isnan(this->area_y_min_)) + desired.y_min = this->area_y_min_; + if (!std::isnan(this->area_y_max_)) + desired.y_max = this->area_y_max_; + if (!std::isnan(this->area_z_min_)) + desired.z_min = this->area_z_min_; + if (!std::isnan(this->area_z_max_)) + desired.z_max = this->area_z_max_; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Ask first: a read that never reached the queue would leave a deferral waiting + // on a report nobody requested, with the user's values already retired for it. + if (!this->send_control_command_(CMD_GET_AREAS)) { + ESP_LOGW(TAG, "Area read not queued; area config left unapplied"); + return; + } + this->deferred_apply_pending_ = true; + this->pending_area_id_ = this->area_id_; + // The ledger, not the mirror: the mirror also carries whatever the module last + // reported for the axes the user never touched, and staging those would hand them + // back later wearing the user's badge -- a module value the next report is then + // kept away from. Staging only what was actually typed is also what makes the + // replay's overlay right: the untouched axes come from the fresh report. An + // empty ledger is a meaning rather than a gap, then: an apply with nothing + // staged rewrites the area exactly as the report just described it, which is + // what a direct apply with nothing staged already does. + this->pending_area_updates_ = this->area_edits_; + // Staged above, so they are the deferred apply's values now rather than an + // unsent edit. Anything typed from here belongs to whatever the user does + // next, which may well be a different area. + this->area_edits_ = AreaConfig{}; + ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying"); + return; + } + // Only a write the module will actually see retires them. + if (this->queue_area_config_(this->area_id_, desired)) { + this->area_edits_ = AreaConfig{}; + } +} + 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 @@ -1124,6 +1402,30 @@ void LD6002BComponent::set_number_value(NumberType type, float value) { this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data)); break; } + case NumberType::AREA_X_MIN: + this->area_x_min_ = value; + this->area_edits_.x_min = value; + break; + case NumberType::AREA_X_MAX: + this->area_x_max_ = value; + this->area_edits_.x_max = value; + break; + case NumberType::AREA_Y_MIN: + this->area_y_min_ = value; + this->area_edits_.y_min = value; + break; + case NumberType::AREA_Y_MAX: + this->area_y_max_ = value; + this->area_edits_.y_max = value; + break; + case NumberType::AREA_Z_MIN: + this->area_z_min_ = value; + this->area_edits_.z_min = value; + break; + case NumberType::AREA_Z_MAX: + this->area_z_max_ = value; + this->area_edits_.z_max = value; + break; } } @@ -1154,9 +1456,179 @@ void LD6002BComponent::set_select_value(SelectType type, size_t index) { this->send_control_command_(CMD_INSTALL_SIDE); } break; + case SelectType::AREA_ID: + this->area_id_ = static_cast(index); + this->area_id_set_ = true; + this->update_area_numbers_for_id_(this->area_id_); + this->save_area_id_pref_(this->area_id_); + break; } } +void LD6002BComponent::update_area_numbers_(const AreaConfig &area) { + // A report refreshes every axis the user is not in the middle of changing. An + // unapplied edit is the one value here the module cannot know about, so taking + // the report over it would discard what the user typed with nothing to show for it. + const AreaConfig &edits = this->area_edits_; + if (std::isnan(edits.x_min)) + this->area_x_min_ = area.x_min; + if (std::isnan(edits.x_max)) + this->area_x_max_ = area.x_max; + if (std::isnan(edits.y_min)) + this->area_y_min_ = area.y_min; + if (std::isnan(edits.y_max)) + this->area_y_max_ = area.y_max; + if (std::isnan(edits.z_min)) + this->area_z_min_ = area.z_min; + if (std::isnan(edits.z_max)) + this->area_z_max_ = area.z_max; + this->publish_area_numbers_(); +} + +// The mirror, not the report: an axis a report was kept away from has to keep its +// displayed value too, or the entity and the value the next apply sends disagree. +void LD6002BComponent::publish_area_numbers_() { +#ifdef USE_NUMBER + this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_); + this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_); + this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_); + this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_); + this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_); + this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_); +#endif +} + +void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) { + if (area_id >= AREA_ID_COUNT) + return; + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + // The edits belonged to the area being navigated away from. + this->area_edits_ = AreaConfig{}; + this->update_area_numbers_(area); +} + +bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) { + // One frame carries all three pairs and cannot express a crossed one; the module + // would keep a box nothing can ever be inside. Both callers arrive with the six + // bounds resolved, so this is the last place that can say no -- and the return + // value is how saying no reaches the caller, which must not then retire the edits + // the user still has to fix. + if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) { + ESP_LOGW(TAG, "Area %u not written, min above max", area_id); + return false; + } + uint8_t data[AREA_CONFIG_LEN]; + write_int32_le(data, static_cast(area_id)); + write_f32_le(data + 4, desired.x_min); + write_f32_le(data + 8, desired.x_max); + write_f32_le(data + 12, desired.y_min); + write_f32_le(data + 16, desired.y_max); + write_f32_le(data + 20, desired.z_min); + write_f32_le(data + 24, desired.z_max); + + if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) { + // Nothing is on its way, so the cache must not claim these bounds, the ack + // refresh must not be armed for an ack that cannot come, and the values stay + // the user's unsent edit. + return false; + } + this->area_write_in_flight_ = true; + + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + store = desired; + // The six numbers show one area at a time, and a deferred apply can land here for + // an area the user has navigated away from. Same question handle_area_report_ + // asks before it touches them. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (area_id == selected_id) { + this->update_area_numbers_(store); + } + return true; +} + +void LD6002BComponent::try_apply_pending_area_(bool reported_interference) { + if (!this->deferred_apply_pending_) { + return; + } + if (this->pending_area_id_ >= AREA_ID_COUNT) { + this->deferred_apply_pending_ = false; + return; + } + const bool interference = this->pending_area_id_ < AREA_COUNT; + const uint8_t index = + interference ? this->pending_area_id_ : static_cast(this->pending_area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + + if (!std::isnan(this->pending_area_updates_.x_min)) + desired.x_min = this->pending_area_updates_.x_min; + if (!std::isnan(this->pending_area_updates_.x_max)) + desired.x_max = this->pending_area_updates_.x_max; + if (!std::isnan(this->pending_area_updates_.y_min)) + desired.y_min = this->pending_area_updates_.y_min; + if (!std::isnan(this->pending_area_updates_.y_max)) + desired.y_max = this->pending_area_updates_.y_max; + if (!std::isnan(this->pending_area_updates_.z_min)) + desired.z_min = this->pending_area_updates_.z_min; + if (!std::isnan(this->pending_area_updates_.z_max)) + desired.z_max = this->pending_area_updates_.z_max; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Only the report covering this area's half can still fill it in, and there is + // exactly one of those per read. Once it has landed with a bound still unknown, + // nothing further is coming and waiting means waiting forever. + if (reported_interference == interference) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete"); + } + return; + } + + const uint8_t area_id = this->pending_area_id_; + this->deferred_apply_pending_ = false; + if (!this->queue_area_config_(area_id, desired)) { + // Nothing was queued, so this is a drop like the other two: hand the staged + // values back rather than leaving them with no ledger to protect them. + this->restore_deferred_edits_(); + } +} + +void LD6002BComponent::init_area_id_pref_() { +#ifdef USE_SELECT + if (this->area_id_select_ == nullptr) { + return; + } + this->area_id_pref_ = this->area_id_select_->make_entity_preference(); + this->area_id_pref_initialized_ = true; + + uint8_t value = 0; + if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) { + // No stored selection. The numbers are about to display this area either way, + // so select it for real: a displayed area that apply_area then refuses to write + // is the one combination the user cannot make sense of. + value = AREA_ID_DEFAULT; + } + this->area_id_select_->publish_state(value); + this->area_id_ = value; + this->area_id_set_ = true; + this->update_area_numbers_for_id_(value); +#endif +} + +void LD6002BComponent::save_area_id_pref_(uint8_t value) { +#ifdef USE_SELECT + if (!this->area_id_pref_initialized_) { + return; + } + this->area_id_pref_.save(&value); +#endif +} + void LD6002BComponent::init_version_pref_() { #ifdef USE_TEXT_SENSOR if (this->ota_version_text_sensor_ == nullptr) { @@ -1211,6 +1683,74 @@ void LD6002BComponent::clear_target_slot_(uint8_t index) { } #endif +void LD6002BComponent::restore_deferred_edits_() { + // The staged values become an unsent edit again, but only for the user who is + // still looking at the area they were staged for; anyone else's ledger belongs to + // the area they are on now. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (this->pending_area_id_ != selected_id) { + return; + } + // Axis by axis rather than a whole-struct assignment: the user can have edited + // another bound while the deferral was in flight, and that edit is newer than + // anything the deferral staged. Assigning over the ledger would drop it back to + // NaN and let the next report take the value away. A live edit wins; only an axis + // with nothing in the ledger takes its staged value back. + // + // The mirror moves with the ledger, because on the report path handle_area_report_ + // ran update_area_numbers_ before the replay, with the ledger still empty -- so the + // mirror already holds the module's bounds and both the entities and the next apply + // would build on them. On the timeout path no report arrived, the mirror still + // holds the staged values, and this is an identity. + const AreaConfig &staged = this->pending_area_updates_; + if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) { + this->area_edits_.x_min = staged.x_min; + this->area_x_min_ = staged.x_min; + } + if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) { + this->area_edits_.x_max = staged.x_max; + this->area_x_max_ = staged.x_max; + } + if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) { + this->area_edits_.y_min = staged.y_min; + this->area_y_min_ = staged.y_min; + } + if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) { + this->area_edits_.y_max = staged.y_max; + this->area_y_max_ = staged.y_max; + } + if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) { + this->area_edits_.z_min = staged.z_min; + this->area_z_min_ = staged.z_min; + } + if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) { + this->area_edits_.z_max = staged.z_max; + this->area_z_max_ = staged.z_max; + } + this->publish_area_numbers_(); +} + +void LD6002BComponent::clear_area_presence_() { + if (!this->area_presence_any_) { + return; + } + // Nothing else corrects this: 0x0A0A carries no period the protocol states and no + // command stops it, so the module going unattended is the only moment the + // component can know a stored "occupied" has stopped being true. + this->area_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + for (uint8_t i = 0; i < AREA_COUNT; i++) { + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(false); + } + } + const bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#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 @@ -1242,8 +1782,9 @@ void LD6002BComponent::clear_target_state_() { if (this->target_presence_any_) { this->target_presence_any_ = false; #ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; if (this->presence_binary_sensor_ != nullptr) { - this->presence_binary_sensor_->publish_state(this->target_presence_any_); + this->presence_binary_sensor_->publish_state(presence); } #endif this->update_work_mode_fallback_(); @@ -1284,6 +1825,27 @@ void LD6002BComponent::set_switch_state(SwitchType type, bool state) { void LD6002BComponent::press_button(ButtonType type) { switch (type) { + case ButtonType::APPLY_AREA: + this->apply_area_config_(); + break; + case ButtonType::AUTO_INTERFERENCE: + this->send_control_command_(CMD_AUTO_INTERFERENCE); + // The module recomputes the interference areas without reporting them. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::GET_AREAS: + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::CLEAR_INTERFERENCE: + this->send_control_command_(CMD_CLEAR_INTERFERENCE); + // The module rewrites the areas but does not report them, so ask for the new geometry the + // way the apply_area ack path does; the queue keeps it behind the command above. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::RESET_DETECTION_AREA: + this->send_control_command_(CMD_RESET_DETECTION_AREA); + this->send_control_command_(CMD_GET_AREAS); + break; case ButtonType::GET_DELAY: this->send_control_command_(CMD_GET_DELAY); break; diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 141f4ff027..bea3804312 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -31,6 +31,10 @@ namespace esphome::ld6002b { static constexpr uint8_t MAX_TARGETS = 3; +static constexpr uint8_t AREA_COUNT = 4; +// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so +// this is the whole id space TYPE_SET_AREA accepts. +static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2; 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. @@ -41,12 +45,19 @@ enum class NumberType : uint8_t { Z_MIN, Z_MAX, LOW_POWER_SLEEP, + AREA_X_MIN, + AREA_X_MAX, + AREA_Y_MIN, + AREA_Y_MAX, + AREA_Z_MIN, + AREA_Z_MAX, }; enum class SelectType : uint8_t { SENSITIVITY, TRIGGER_SPEED, INSTALLATION_MODE, + AREA_ID, }; enum class SwitchType : uint8_t { @@ -56,6 +67,11 @@ enum class SwitchType : uint8_t { }; enum class ButtonType : uint8_t { + APPLY_AREA, + AUTO_INTERFERENCE, + GET_AREAS, + CLEAR_INTERFERENCE, + RESET_DETECTION_AREA, GET_DELAY, GET_SENSITIVITY, GET_TRIGGER_SPEED, @@ -76,8 +92,25 @@ struct TargetSensors { sensor::Sensor *cluster_id{nullptr}; }; +struct AreaSensors { + sensor::Sensor *x_min{nullptr}; + sensor::Sensor *x_max{nullptr}; + sensor::Sensor *y_min{nullptr}; + sensor::Sensor *y_max{nullptr}; + sensor::Sensor *z_min{nullptr}; + sensor::Sensor *z_max{nullptr}; +}; #endif +struct AreaConfig { + float x_min{NAN}; + float x_max{NAN}; + float y_min{NAN}; + float y_max{NAN}; + float z_min{NAN}; + float z_max{NAN}; +}; + struct VersionPref { char value[20]; }; @@ -122,6 +155,67 @@ class LD6002BComponent : public Component, public uart::UARTDevice { return; this->targets_[target].cluster_id = sensor; } + void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_min = sensor; + } + void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_max = sensor; + } + void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_min = sensor; + } + void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_max = sensor; + } + void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_min = sensor; + } + void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_max = sensor; + } + + void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_min = sensor; + } + void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_max = sensor; + } + void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_min = sensor; + } + void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_max = sensor; + } + void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_min = sensor; + } + void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_max = sensor; + } #endif #ifdef USE_BINARY_SENSOR @@ -131,6 +225,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice { return; this->target_presence_[target] = sensor; } + void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) { + if (area >= AREA_COUNT) + return; + this->area_presence_[area] = sensor; + } #endif #ifdef USE_TEXT_SENSOR @@ -143,12 +242,20 @@ class LD6002BComponent : public Component, public uart::UARTDevice { 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; } + + void set_area_x_min_number(number::Number *number) { this->area_x_min_number_ = number; } + void set_area_x_max_number(number::Number *number) { this->area_x_max_number_ = number; } + void set_area_y_min_number(number::Number *number) { this->area_y_min_number_ = number; } + void set_area_y_max_number(number::Number *number) { this->area_y_max_number_ = number; } + void set_area_z_min_number(number::Number *number) { this->area_z_min_number_ = number; } + void set_area_z_max_number(number::Number *number) { this->area_z_max_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; } + void set_area_id_select(select::Select *select) { this->area_id_select_ = select; } #endif #ifdef USE_SWITCH @@ -176,6 +283,8 @@ class LD6002BComponent : public Component, public uart::UARTDevice { 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_area_presence_(const uint8_t *data, uint16_t len); + void handle_area_report_(bool interference, 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); @@ -189,22 +298,35 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void publish_work_mode_(bool low_power); // Drops every target-derived reading and the slot table they are indexed by. void clear_target_state_(); + void clear_area_presence_(); + void restore_deferred_edits_(); + void publish_area_numbers_(); #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 update_area_numbers_(const AreaConfig &area); + void update_area_numbers_for_id_(uint8_t area_id); + bool queue_area_config_(uint8_t area_id, const AreaConfig &desired); + void try_apply_pending_area_(bool reported_interference); + void init_area_id_pref_(); + void save_area_id_pref_(uint8_t value); void init_version_pref_(); void save_version_pref_(const char *value); - void queue_command_(uint16_t type, const uint8_t *data, uint8_t len); + // Returns whether the command was queued: it is dropped, with a log line, when + // the payload is too long or the ring is full. + bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len); void process_command_queue_(); void send_command_(uint16_t type, const uint8_t *data, uint8_t len); 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); + // Returns whether the command reached the queue; see queue_command_. + bool send_control_command_(uint32_t command); void send_z_range_(); + void apply_area_config_(); void wake_(); static uint16_t read_u16_be(const uint8_t *data); @@ -212,16 +334,20 @@ class LD6002BComponent : public Component, public uart::UARTDevice { 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_int32_le(uint8_t *data, int32_t value); static void write_f32_le(uint8_t *data, float value); #ifdef USE_SENSOR std::array targets_{}; sensor::Sensor *target_count_sensor_{nullptr}; sensor::Sensor *point_count_sensor_{nullptr}; + std::array interference_areas_{}; + std::array detection_areas_{}; #endif #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; std::array target_presence_{}; + std::array area_presence_{}; #endif #ifdef USE_TEXT_SENSOR text_sensor::TextSensor *work_mode_text_sensor_{nullptr}; @@ -234,11 +360,21 @@ class LD6002BComponent : public Component, public uart::UARTDevice { number::Number *z_min_number_{nullptr}; number::Number *z_max_number_{nullptr}; number::Number *low_power_sleep_number_{nullptr}; + + number::Number *area_x_min_number_{nullptr}; + number::Number *area_x_max_number_{nullptr}; + number::Number *area_y_min_number_{nullptr}; + number::Number *area_y_max_number_{nullptr}; + number::Number *area_z_min_number_{nullptr}; + number::Number *area_z_max_number_{nullptr}; #endif #ifdef USE_SELECT select::Select *sensitivity_select_{nullptr}; select::Select *trigger_speed_select_{nullptr}; select::Select *installation_select_{nullptr}; + select::Select *area_id_select_{nullptr}; + ESPPreferenceObject area_id_pref_{}; + bool area_id_pref_initialized_{false}; #endif #ifdef USE_SWITCH switch_::Switch *low_power_switch_{nullptr}; @@ -264,9 +400,13 @@ class LD6002BComponent : public Component, public uart::UARTDevice { uint8_t *data_buf_{nullptr}; uint16_t next_frame_id_{0}; - // Sized for the boot burst: with every platform configured, setup() enqueues - // roughly ten GET/config commands back to back before the first ack lands. - static constexpr uint8_t CMD_QUEUE_SIZE = 16; + // Sized for the two bursts that reach it, both counted as what is still queued + // once the first command is dequeued: boot leaves 11 with every platform + // configured, and pressing all fourteen buttons before an ack lands leaves 15. + // Neither overflowed 16, but one free slot is not headroom, and overflowing is a + // dropped command with only a log line to show for it. Costs 256 bytes more per + // configured instance, and this component is MULTI_CONF. + static constexpr uint8_t CMD_QUEUE_SIZE = 24; static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; // A sleeping module consumes the first frame to wake and answers only the one after it. static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; @@ -276,6 +416,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // 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"; + // Named so a burst of writes collapses to one read once they settle, rather than + // one read per write. + static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; // 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; @@ -307,6 +450,24 @@ class LD6002BComponent : public Component, public uart::UARTDevice { float z_min_{NAN}; float z_max_{NAN}; + float area_x_min_{NAN}; + float area_x_max_{NAN}; + float area_y_min_{NAN}; + float area_y_max_{NAN}; + float area_z_min_{NAN}; + float area_z_max_{NAN}; + // What the user has typed and not yet applied; NaN per axis means "nothing of + // mine here, take the module's value". Same sentinel shape as + // pending_area_updates_. Exactly two things empty it: the area_id select moving + // to another area, and an apply that was accepted. A write the bounds guard + // refused leaves it alone, and a deferred apply that had to be dropped hands its + // staged values back here -- but only while the user is still on the area they + // were staged for. Either way the values stay the user's to fix. + AreaConfig area_edits_{}; + std::array interference_area_values_{}; + std::array detection_area_values_{}; + uint8_t area_id_{0xFF}; + bool area_id_set_{false}; // Which person owns each target_N slot, so a slot survives the module re-sorting its array. std::array slot_cluster_{}; @@ -318,9 +479,14 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // The report handlers read these and drop anything a stopped stream still emits. bool target_display_enabled_{false}; bool point_cloud_enabled_{false}; + bool area_presence_any_{false}; + bool area_write_in_flight_{false}; bool work_mode_reported_{false}; bool low_power_enabled_{false}; bool low_power_reported_{false}; + bool deferred_apply_pending_{false}; + uint8_t pending_area_id_{0xFF}; + AreaConfig pending_area_updates_{}; bool last_work_mode_valid_{false}; bool last_work_mode_low_power_{false}; diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 10e9e89dc8..7e0be66c64 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_DURATION, ENTITY_CATEGORY_CONFIG, @@ -9,14 +11,22 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_SECOND, ) +import esphome.final_validate as fv +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( + CONF_APPLY_AREA, + CONF_AREA_CONFIG, CONF_HOLD_DELAY, CONF_LD6002B_ID, CONF_LOW_POWER_SLEEP_TIME, CONF_Z_MAX, CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, ) DEPENDENCIES = ["ld6002b"] @@ -51,10 +61,83 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_DURATION, entity_category=ENTITY_CATEGORY_CONFIG, ), + cv.Optional(CONF_AREA_CONFIG): cv.Schema( + { + cv.Optional(KEY_X_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_X_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + 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, + ), + } + ), } ) +def final_validate(config: ConfigType) -> ConfigType: + if config.get(CONF_AREA_CONFIG) is None: + return config + + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + has_apply_area = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None + for entry in full_config.get(CONF_BUTTON, []) + ) + if not has_apply_area: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate + + async def to_code(config): hub = await cg.get_variable(config[CONF_LD6002B_ID]) @@ -80,3 +163,19 @@ async def to_code(config): ) await cg.register_parented(n, config[CONF_LD6002B_ID]) cg.add(getattr(hub, setter)(n)) + + if area_config := config.get(CONF_AREA_CONFIG): + for key, number_type, setter in ( + (KEY_X_MIN, NumberType.AREA_X_MIN, "set_area_x_min_number"), + (KEY_X_MAX, NumberType.AREA_X_MAX, "set_area_x_max_number"), + (KEY_Y_MIN, NumberType.AREA_Y_MIN, "set_area_y_min_number"), + (KEY_Y_MAX, NumberType.AREA_Y_MAX, "set_area_y_max_number"), + (CONF_Z_MIN, NumberType.AREA_Z_MIN, "set_area_z_min_number"), + (CONF_Z_MAX, NumberType.AREA_Z_MAX, "set_area_z_max_number"), + ): + if conf := area_config.get(key): + n = await number.new_number( + conf, number_type, min_value=-10, max_value=10, step=0.1 + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py index 3fcc117e2f..3da647ee2c 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select/__init__.py @@ -1,7 +1,7 @@ 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 esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG from .. import LD6002BComponent, ld6002b_ns from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED @@ -11,6 +11,16 @@ DEPENDENCIES = ["ld6002b"] LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select) SelectType = ld6002b_ns.enum("SelectType", is_class=True) +AREA_ID_OPTIONS = [ + "interference_area_0", + "interference_area_1", + "interference_area_2", + "interference_area_3", + "detection_area_0", + "detection_area_1", + "detection_area_2", + "detection_area_3", +] CONFIG_SCHEMA = cv.Schema( { @@ -24,6 +34,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG ), + cv.Optional(CONF_AREA_ID): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), } ) @@ -47,6 +60,7 @@ SELECT_MAP = ( "set_installation_select", ["top", "side"], ), + (CONF_AREA_ID, SelectType.AREA_ID, "set_area_id_select", AREA_ID_OPTIONS), ) diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index ff88d343b9..3aedaf9fdd 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -12,11 +12,18 @@ from esphome.const import ( from . import LD6002BComponent from .const import ( + AREA_COUNT, CONF_CLUSTER_ID, CONF_DOPPLER_INDEX, CONF_LD6002B_ID, CONF_POINT_COUNT, CONF_Z, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, MAX_TARGETS, ) @@ -68,20 +75,79 @@ TARGET_SCHEMA = cv.Schema( } ) - -CONFIG_SCHEMA = cv.Schema( +AREA_SCHEMA = cv.Schema( { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( - accuracy_decimals=0, + cv.Optional(KEY_X_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( - accuracy_decimals=0, + cv.Optional(KEY_X_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), } -).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) +) + +# (config key, C++ setter axis) for the six bounds every area sensor block carries. +_AREA_AXES = ( + (KEY_X_MIN, "x_min"), + (KEY_X_MAX, "x_max"), + (KEY_Y_MIN, "y_min"), + (KEY_Y_MAX, "y_max"), + (CONF_Z_MIN, "z_min"), + (CONF_Z_MAX, "z_max"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_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)}) + .extend( + {cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) + .extend( + {cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) +) async def to_code(config): @@ -112,3 +178,11 @@ async def to_code(config): if cluster_id_config := target_config.get(CONF_CLUSTER_ID): sens = await sensor.new_sensor(cluster_id_config) cg.add(hub.set_target_cluster_id_sensor(i, sens)) + + for kind in ("interference", "detection"): + for i in range(AREA_COUNT): + if area_config := config.get(f"{kind}_area_{i}"): + for key, axis in _AREA_AXES: + if axis_config := area_config.get(key): + sens = await sensor.new_sensor(axis_config) + cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens)) diff --git a/tests/component_tests/ld6002b/test_final_validate.py b/tests/component_tests/ld6002b/test_final_validate.py index 49fa35eb13..0bb091533b 100644 --- a/tests/component_tests/ld6002b/test_final_validate.py +++ b/tests/component_tests/ld6002b/test_final_validate.py @@ -1,30 +1,62 @@ -"""Tests for the wake button's wakeup_pin requirement in ld6002b.""" +"""Tests for the ld6002b validators that reach across platforms. + +wake needs a pin on its own hub, apply_area needs a select on its own hub, and +area_config needs both a button and a select on its own hub. Every one of them +is a same-instance check, which is the half that breaks quietly. +""" from __future__ import annotations import pytest -from esphome.components.ld6002b.button import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.components.ld6002b.button import ( + CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA, +) +from esphome.components.ld6002b.const import CONF_AREA_CONFIG, CONF_Z_MIN +from esphome.components.ld6002b.number import ( + CONFIG_SCHEMA as NUMBER_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as NUMBER_FINAL_VALIDATE_SCHEMA, +) from esphome.config import Config import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WAKEUP_PIN, PlatformFramework +from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, + CONF_ID, + CONF_WAKEUP_PIN, + PlatformFramework, +) from esphome.core import ID from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable HUB_ID = "ld6002b_hub" +OTHER_HUB_ID = "ld6002b_other" -def _full_config(hub: ConfigType) -> Config: +def _full_config( + hub: ConfigType, + *, + selects: list[ConfigType] | None = None, + buttons: list[ConfigType] | None = None, +) -> Config: """A full config carrying one ld6002b hub, as the ID pass leaves it. final_validate resolves the hub through get_path_for_id, so the declaring path has to be registered the way validate_config registers it: the path of the id value itself, whose parent is the hub's own config. + + The platform lists are what the cross-platform validators scan, so a test can + say which of them exist and which hub each one names. """ full = Config() full["ld6002b"] = [hub] full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID])) + if selects is not None: + full["select"] = selects + if buttons is not None: + full[CONF_BUTTON] = buttons return full @@ -44,10 +76,33 @@ def _buttons(**buttons: str) -> ConfigType: return config +def _select(*, hub_id: str = HUB_ID) -> ConfigType: + """A select platform config naming area_id on the given hub.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_ID: {"name": "Area ID"}, + } + + +def _area_numbers(*, hub_id: str = HUB_ID) -> ConfigType: + """A number platform config carrying one area_config bound.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_CONFIG: {CONF_Z_MIN: {"name": "Area Z Min"}}, + } + + def _validated(config: ConfigType) -> ConfigType: """Run the button schema, then the final validation the hub is checked in.""" - config = CONFIG_SCHEMA(config) - FINAL_VALIDATE_SCHEMA(config) + config = BUTTON_CONFIG_SCHEMA(config) + BUTTON_FINAL_VALIDATE_SCHEMA(config) + return config + + +def _validated_numbers(config: ConfigType) -> ConfigType: + """The same two passes for the number platform.""" + config = NUMBER_CONFIG_SCHEMA(config) + NUMBER_FINAL_VALIDATE_SCHEMA(config) return config @@ -80,3 +135,82 @@ def test_other_buttons_do_not_need_the_pin( ) _validated(_buttons(get_delay="Get Delay")) + + +def test_apply_area_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """apply_area sends the staged bounds to whichever area the select names.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_apply_area_select_on_another_hub_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A select exists, but on a second ld6002b -- which cannot serve this one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), selects=[_select(hub_id=OTHER_HUB_ID)] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_area_config_without_apply_area_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The six numbers only stage a write; apply_area is what sends it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config(_hub(wakeup_pin=False), selects=[_select()]), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires button\.apply_area for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) + + +def test_area_config_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The validator's other half: the staged bounds also need an area to land in.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), buttons=[_buttons(apply_area="Apply Area")] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires select\.area_id for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index e31af49aec..ee881bd787 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -42,6 +42,32 @@ sensor: name: Target-3 Dop cluster_id: name: Target-3 Cluster + interference_area_0: + x_min: + name: Interference-0 X Min + x_max: + name: Interference-0 X Max + y_min: + name: Interference-0 Y Min + y_max: + name: Interference-0 Y Max + z_min: + name: Interference-0 Z Min + z_max: + name: Interference-0 Z Max + detection_area_0: + x_min: + name: Detection-0 X Min + x_max: + name: Detection-0 X Max + y_min: + name: Detection-0 Y Min + y_max: + name: Detection-0 Y Max + z_min: + name: Detection-0 Z Min + z_max: + name: Detection-0 Z Max binary_sensor: - platform: ld6002b @@ -50,6 +76,8 @@ binary_sensor: name: Presence target_1: name: Target-1 Presence + detection_area_0: + name: Detection Area-0 Presence text_sensor: - platform: ld6002b @@ -70,6 +98,19 @@ number: name: Z Max low_power_sleep_time: name: Low Power Sleep + area_config: + x_min: + name: Area X Min + x_max: + name: Area X Max + y_min: + name: Area Y Min + y_max: + name: Area Y Max + z_min: + name: Area Z Min + z_max: + name: Area Z Max select: - platform: ld6002b @@ -80,6 +121,8 @@ select: name: Trigger Speed installation_mode: name: Installation + area_id: + name: Area ID switch: - platform: ld6002b @@ -94,6 +137,16 @@ switch: button: - platform: ld6002b ld6002b_id: ld6002b_radar + apply_area: + name: Apply Area + auto_interference: + name: Auto Interference + get_areas: + name: Get Areas + clear_interference: + name: Clear Interference + reset_detection_area: + name: Reset Detection get_delay: name: Get Delay get_sensitivity: From 3656375516e08022f19e1e7b2467274da113819a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 10 Aug 2026 10:00:29 -0400 Subject: [PATCH 07/11] [sendspin] Bump sendspin-cpp to v0.7.1 (#18232) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e20925f323..d0c2112ba9 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b4b20cf221..9448b93cc9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.0 + version: 0.7.1 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 02fa18b74fb0a319f3858ed10dd145e270c02c25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 09:44:01 -0500 Subject: [PATCH 08/11] [core] Skip colorama init for terminal and dashboard runs (#18224) --- esphome/__main__.py | 20 +- esphome/log.py | 23 +- tests/unit_tests/conftest.py | 14 + .../fixtures/log/setup_log_probe.py | 21 ++ tests/unit_tests/test_lazy_imports.py | 24 +- tests/unit_tests/test_log.py | 267 +++++++++++++++++- 6 files changed, 347 insertions(+), 22 deletions(-) create mode 100644 tests/unit_tests/fixtures/log/setup_log_probe.py diff --git a/esphome/__main__.py b/esphome/__main__.py index cb45dd7c5f..cc1e12cb3a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1941,7 +1941,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_name = args.name for c in new_name: if c not in ALLOWED_NAME_CHARS: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{c}' is an invalid character for names. Valid characters are: " @@ -1954,7 +1954,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: yaml = yaml_util.load_yaml(CORE.config_path) if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: - print( + safe_print( color( AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." ) @@ -2001,7 +2001,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) > 1 ): - print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")) + safe_print( + color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename") + ) return 1 new_raw = re.sub( @@ -2019,7 +2021,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` # would otherwise just re-flash the same hostname). if new_name == old_name: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2029,7 +2031,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path: Path = CORE.config_dir / (new_name + ".yaml") if new_path.resolve() == CORE.config_path.resolve(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2037,7 +2039,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) return 1 if new_path.exists(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"Cannot rename: {new_path} already exists. " @@ -2045,7 +2047,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) ) return 1 - print( + safe_print( f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" ) print() @@ -2054,7 +2056,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: - print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) + safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() return 1 @@ -2080,7 +2082,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: if CORE.config_path != new_path: CORE.config_path.unlink() - print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) + safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) print() return 0 diff --git a/esphome/log.py b/esphome/log.py index b120c930d0..1f208bb909 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -1,5 +1,7 @@ from enum import Enum import logging +import sys +from typing import TextIO from esphome.core import CORE @@ -72,13 +74,30 @@ class ESPHomeLogFormatter(logging.Formatter): return message +def _is_tty(stream: TextIO | None) -> bool: + # A stream can be missing, closed, or not a real file object; colorama + # tolerates all three, so treat them like a redirect and let its own + # handling apply. + if stream is None or getattr(stream, "closed", True): + return False + return hasattr(stream, "isatty") and stream.isatty() + + def setup_log( log_level: int = logging.INFO, include_timestamp: bool = False, ) -> None: - import colorama + # colorama translates ANSI escapes for old Windows consoles and strips + # them from redirected output. POSIX terminals render ANSI natively, and + # dashboard runs escape their color codes before printing, so both would + # use colorama as a plain passthrough; skip the import there (it pulls + # in ctypes, ~3ms on every CLI invocation). + if sys.platform == "win32" or not ( + CORE.dashboard or (_is_tty(sys.stdout) and _is_tty(sys.stderr)) + ): + import colorama - colorama.init() + colorama.init() # Setup logging - will map log level from string to constant logging.basicConfig(level=log_level) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 13450b10f0..9de8f715ef 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -10,6 +10,7 @@ not be part of a unit test suite. """ from collections.abc import Generator +import os from pathlib import Path import sys from unittest.mock import Mock, patch @@ -40,6 +41,19 @@ def fixture_path() -> Path: return here / "fixtures" +@pytest.fixture +def probe_env() -> dict[str, str]: + """Environment for running fixture probe scripts as subprocesses. + + Running a script file drops the cwd from sys.path, so prepend the + repo root for the child. + """ + python_path = str(package_root) + if ambient := os.environ.get("PYTHONPATH"): + python_path = os.pathsep.join((python_path, ambient)) + return os.environ | {"PYTHONPATH": python_path} + + @pytest.fixture def setup_core(tmp_path: Path) -> Path: """Set up CORE with test paths.""" diff --git a/tests/unit_tests/fixtures/log/setup_log_probe.py b/tests/unit_tests/fixtures/log/setup_log_probe.py new file mode 100644 index 0000000000..b9e2e02a8c --- /dev/null +++ b/tests/unit_tests/fixtures/log/setup_log_probe.py @@ -0,0 +1,21 @@ +"""Report whether setup_log() pulled in colorama, then print a colored line. + +Executed as a subprocess by test_log.py because module imports are +process-global: the parent prints ``colorama_loaded=True/False`` plus an +ANSI colored line so the caller can observe whether the codes survive to +the stream. Pass ``--dashboard`` to simulate a dashboard-spawned run. +""" + +import sys + +from esphome.core import CORE +from esphome.log import setup_log + +if "--dashboard" in sys.argv: + CORE.dashboard = True + +setup_log() + +print(f"colorama_loaded={'colorama' in sys.modules}") +print("\033[31mred\033[0m end") +sys.stdout.flush() diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 2e09c4a945..b6878c33a2 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -15,7 +15,6 @@ test pins down *which* heavy modules must stay out entirely. from __future__ import annotations import importlib.util -import os from pathlib import Path import subprocess import sys @@ -120,18 +119,17 @@ def test_watched_heavy_modules_exist() -> None: def _leaked_from_fixture( - fixture_path: Path, script_name: str, extra: tuple[str, ...] = () + fixture_path: Path, + env: dict[str, str], + script_name: str, + extra: tuple[str, ...] = (), ) -> str: """Run a fixture script with the watched modules on argv. - Running a script file drops the cwd from sys.path, so prepend the - repo root for the child; a non-zero exit surfaces the child's stderr. + ``env`` comes from the ``probe_env`` fixture so the child can import + the repo checkout; a non-zero exit surfaces the child's stderr. """ script = fixture_path / "lazy_imports" / script_name - python_path = str(Path(__file__).parents[2]) - if ambient := os.environ.get("PYTHONPATH"): - python_path = os.pathsep.join((python_path, ambient)) - env = os.environ | {"PYTHONPATH": python_path} result = subprocess.run( [sys.executable, str(script), *FAST_PATH_HEAVY_MODULES, *extra], capture_output=True, @@ -145,12 +143,13 @@ def _leaked_from_fixture( def test_storage_json_fast_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """``apply_to_core`` runs on the upload/logs fast path for every platform; parsing the stored framework version must not drag in the validation stack or the esp32 component package. """ - leaked = _leaked_from_fixture(fixture_path, "storage_json_fast_path.py") + leaked = _leaked_from_fixture(fixture_path, probe_env, "storage_json_fast_path.py") assert not leaked, ( f"storage_json.apply_to_core pulls in heavy modules: {leaked}. " "The upload/logs fast path skips validation; importing the " @@ -160,12 +159,15 @@ def test_storage_json_fast_path_does_not_import_heavy_modules( def test_esptool_upload_fast_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """The esptool serial upload reads the esp32 variant from CORE.data; resolving it must not drag in the esp32 component package or the validation stack. """ - leaked = _leaked_from_fixture(fixture_path, "esptool_upload_fast_path.py") + leaked = _leaked_from_fixture( + fixture_path, probe_env, "esptool_upload_fast_path.py" + ) assert not leaked, ( f"upload_using_esptool pulls in heavy modules: {leaked}. " "The upload fast path skips validation; importing the validation " @@ -266,6 +268,7 @@ def test_yaml_util_does_not_import_heavy_modules() -> None: def test_upload_command_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """The single-config dispatch path checks the bundle suffix on every run; reading it from esphome.const must not drag in esphome.bundle @@ -273,6 +276,7 @@ def test_upload_command_path_does_not_import_heavy_modules( """ leaked = _leaked_from_fixture( fixture_path, + probe_env, "upload_command_fast_path.py", extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, ) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 02798f1029..194b38209b 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,6 +1,44 @@ +from collections.abc import Generator +import errno +import io +import logging +import os +from pathlib import Path +import select +import subprocess +import sys +import time + import pytest -from esphome.log import AnsiFore, AnsiStyle, color +from esphome.core import CORE +from esphome.log import AnsiFore, AnsiStyle, color, setup_log + + +class _FakeTty(io.StringIO): + def isatty(self) -> bool: + return True + + +@pytest.fixture +def restore_logging_state() -> Generator[None, None, None]: + """Undo the global logging changes setup_log() makes.""" + root = logging.getLogger() + handlers = root.handlers[:] + formatters = [handler.formatter for handler in handlers] + level = root.level + urllib3_level = logging.getLogger("urllib3").level + yield + root.handlers[:] = handlers + for handler, formatter in zip(handlers, formatters, strict=True): + handler.setFormatter(formatter) + root.setLevel(level) + logging.getLogger("urllib3").setLevel(urllib3_level) + + +def _probe_command(fixture_path: Path, *args: str) -> list[str]: + """Build the command line for the setup_log probe fixture script.""" + return [sys.executable, str(fixture_path / "log" / "setup_log_probe.py"), *args] def test_color_keep_returns_unchanged_message() -> None: @@ -78,3 +116,230 @@ def test_ansi_fore_keep_is_enum_member() -> None: assert bool(AnsiFore.KEEP) is True # But the value itself is still an empty string assert AnsiFore.KEEP.value == "" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_output_strips_ansi( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A redirected run must keep colorama so ANSI codes are stripped.""" + result = subprocess.run( + _probe_command(fixture_path), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=True" in result.stdout + assert "red end" in result.stdout + assert "\033" not in result.stdout + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """Dashboard runs escape their color codes, so colorama must not load.""" + result = subprocess.run( + _probe_command(fixture_path, "--dashboard"), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=False" in result.stdout + # Codes pass through untouched for the dashboard to handle. + assert "\033[31mred\033[0m end" in result.stdout + + +def _run_probe_on_pty( + fixture_path: Path, probe_env: dict[str, str], *, stderr_to_pty: bool +) -> str: + """Run the probe with stdout on a pty and return the decoded pty output. + + With ``stderr_to_pty=False`` stderr goes to a pipe instead, giving the + mixed tty/redirect stream combination while keeping any traceback + available for the exit assertion. + """ + # Unix-only; a module-level import would break test collection on + # Windows, where all the callers are skipped anyway. + import pty + + controller, follower = pty.openpty() + proc = None + output = b"" + deadline = time.monotonic() + 60 + try: + try: + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + finally: + os.close(follower) + while True: + timeout = deadline - time.monotonic() + if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: + pytest.fail(f"pty probe produced no EOF in time; got {output!r}") + try: + chunk = os.read(controller, 1024) + except OSError as err: + # macOS raises EIO once the child closes its end of the pty; + # anything else is a real failure, not end-of-stream. + if err.errno != errno.EIO: + raise + break + if not chunk: + break + output += chunk + stderr_text = "" + if proc.stderr is not None: + stderr_text = proc.stderr.read().decode(errors="replace") + proc.stderr.close() + assert proc.wait(60) == 0, stderr_text + finally: + os.close(controller) + if proc is not None and proc.poll() is None: + proc.kill() + proc.wait() + return output.decode() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_tty_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A terminal run must skip colorama and keep ANSI codes intact.""" + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=True) + assert "colorama_loaded=False" in text + assert "\033[31mred\033[0m end" in text + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_mixed_streams_init_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A tty stdout with a redirected stderr must still initialize colorama. + + The guard requires both streams to be a tty; collapsing it to a + single-stream check would stop stripping ANSI from a redirected + stderr while stdout is a terminal. + """ + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=False) + assert "colorama_loaded=True" in text + # stdout is a tty, so colorama leaves its codes alone. + assert "\033[31mred\033[0m end" in text + + +@pytest.fixture +def colorama_probe( + monkeypatch: pytest.MonkeyPatch, restore_logging_state: None +) -> Generator[None, None, None]: + """Shared preamble for the in-process guard-branch tests. + + Clears colorama from sys.modules so the assertions prove what + setup_log() itself did, and snapshots CORE.verbose/quiet, which is + not a no-op: CORE.reset() does not restore them, so without the + snapshot setup_log()'s log-level side effects would leak into later + tests. + """ + monkeypatch.delitem(sys.modules, "colorama", raising=False) + monkeypatch.setattr(CORE, "verbose", CORE.verbose) + monkeypatch.setattr(CORE, "quiet", CORE.quiet) + yield + # init() rebinds sys.stdout/stderr; restore them before monkeypatch + # puts the originals back. + if (colorama := sys.modules.get("colorama")) is not None: + colorama.deinit() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The dashboard side of the guard must not import colorama.""" + monkeypatch.setattr(CORE, "dashboard", True) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_tty_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The tty side of the guard must not import colorama.""" + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_branch_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """Redirected streams must keep importing and initializing colorama.""" + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + setup_log() + assert "colorama" in sys.modules + + +@pytest.mark.parametrize("broken", ["missing", "closed"]) +def test_setup_log_broken_streams_import_colorama( + broken: str, monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """A missing or closed stream counts as a redirect and must not crash. + + colorama tolerates both, so setup_log() has to reach its init rather + than raise inside the tty probe. + """ + if broken == "missing": + stream = None + else: + stream = io.StringIO() + stream.close() + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + setup_log() + assert "colorama" in sys.modules + + +def test_setup_log_win32_always_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The Windows clause must init colorama even when both streams are ttys. + + Old Windows consoles need colorama to translate ANSI escapes, so the + platform check has to win over the tty check. colorama itself keys + off os.name, so on a POSIX host its init/deinit pair is a + passthrough. + """ + monkeypatch.setattr(sys, "platform", "win32") + # Both streams are ttys: without the platform clause this combination + # would skip colorama. + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" in sys.modules From d9567b2974f2ab6ff149589bae5289cf3ae376c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:05:37 -0500 Subject: [PATCH 09/11] Normalize marker-wrapped callable keys in the schema dump (#18218) --- script/build_language_schema.py | 20 +++++++++- tests/script/test_build_language_schema.py | 43 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index f6dcf00851..2b64cb0256 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1134,13 +1134,29 @@ def convert_keys(converted, schema, path): else: converted["key"] = "String" key_string_match = re.search( - r"", str(k), re.IGNORECASE + r"", str(k), re.IGNORECASE ) if key_string_match: converted["key_type"] = key_string_match.group(1) else: converted["key_type"] = str(k) + # A marker-wrapped callable key (e.g. script.execute's + # ``cv.Optional(validate_parameter_name)``) is a wildcard matcher; + # ``str(marker)`` is the function repr, whose heap address would + # churn the dump every build. Normalize like the bare-callable + # branch above: record the validator name in ``key_type`` and file + # the config var under ``string``. + key_name = str(k) + if isinstance(k, vol.Marker) and callable(k.schema): + key_string_match = re.search( + r"", key_name, re.IGNORECASE + ) + result["key_type"] = ( + key_string_match.group(1) if key_string_match else key_name + ) + key_name = "string" + # ``cv.OnlyWith`` / ``cv.OnlyWithout`` expose ``default`` as # a property that returns ``vol.UNDEFINED`` when the gating # component isn't loaded — and at schema-generation time @@ -1220,7 +1236,7 @@ def convert_keys(converted, schema, path): for base_k, base_v in get_overridden_config(k, converted).items(): if base_k in result and base_v == result[base_k]: result.pop(base_k) - converted["schema"][S_CONFIG_VARS][str(k)] = result + converted["schema"][S_CONFIG_VARS][key_name] = result if "key" in converted and converted["key"] == "String": config_vars = converted["schema"]["config_vars"] assert len(config_vars) == 1 diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8bbaa2773a..f3d4bbcba6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -3,11 +3,13 @@ from __future__ import annotations import ast +from collections.abc import Callable import importlib.util import json from pathlib import Path import subprocess import sys +from typing import Any import pytest @@ -205,6 +207,47 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: assert "sensitive_source" not in entry +def _wildcard_validator(value: Any) -> Any: + return value + + +def test_convert_keys_marker_wrapped_callable_key_normalizes() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional(_wildcard_validator): cv.string}, "/root") + + config_vars = converted["schema"]["config_vars"] + assert set(config_vars) == {"string"} + assert config_vars["string"]["key"] == "Optional" + assert config_vars["string"]["key_type"] == "_wildcard_validator" + + +def test_convert_keys_marker_wrapped_callable_beside_fixed_keys() -> None: + converted: dict = {} + _bls.convert_keys( + converted, + {cv.Required("id"): cv.string, cv.Optional(_wildcard_validator): cv.string}, + "/root", + ) + + assert set(converted["schema"]["config_vars"]) == {"id", "string"} + + +def test_convert_keys_bare_callable_dotted_qualname() -> None: + def make_validator() -> Callable[[Any], Any]: + def validator(value: Any) -> Any: + return value + + return validator + + converted: dict = {} + _bls.convert_keys(converted, {make_validator(): cv.string}, "/root") + + assert converted["key"] == "String" + assert converted["key_type"].endswith("make_validator..validator") + assert "at 0x" not in converted["key_type"] + assert set(converted["schema"]["config_vars"]) == {"string"} + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # From c8c929d48792ec013935265edb61357f27f91a15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:06:06 -0500 Subject: [PATCH 10/11] [ble_device_base] Merge adv and scan response before delivery on rp2 (#18217) --- .../ble_device_base/scan_response_merger.cpp | 150 ++++++++++++++ .../ble_device_base/scan_response_merger.h | 152 +++++++++++++++ .../components/ln882h_ble_tracker/__init__.py | 3 + .../ln882h_ble_tracker/ln882h_ble_tracker.cpp | 162 ++-------------- .../ln882h_ble_tracker/ln882h_ble_tracker.h | 65 +------ .../components/rp2_ble_tracker/__init__.py | 3 + .../rp2_ble_tracker/rp2_ble_tracker.cpp | 62 +++--- .../rp2_ble_tracker/rp2_ble_tracker.h | 35 ++-- esphome/core/defines.h | 2 + tests/components/ble_device_base/__init__.py | 4 + .../test_scan_response_merger.cpp | 183 ++++++++++++++++++ 11 files changed, 572 insertions(+), 249 deletions(-) create mode 100644 esphome/components/ble_device_base/scan_response_merger.cpp create mode 100644 esphome/components/ble_device_base/scan_response_merger.h create mode 100644 tests/components/ble_device_base/test_scan_response_merger.cpp diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp new file mode 100644 index 0000000000..15445cee02 --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -0,0 +1,150 @@ +#include "scan_response_merger.h" + +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include + +namespace esphome::ble_device_base { + +void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, bool raw_only) { + if (this->dispatcher_ == nullptr) + return; + this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only, + *this->scan_continuous_ ? nullptr : this->log_tag_); +} + +void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, uint32_t now) { + // One pass: find a same-device entry (deliver + reuse) while remembering the + // first free slot as the fallback. + PendingAdv *slot = nullptr; + PendingAdv *free_slot = nullptr; + for (auto &p : this->pending_adv_) { + if (!p.used) { + if (free_slot == nullptr) + free_slot = &p; + continue; + } + if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + // Same device advertised again before its scan response arrived — deliver + // the previous advertisement (its scan response is not coming) and reuse + // the slot, so no frame is ever lost. + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + slot = &p; + break; + } + } + if (slot == nullptr) + slot = free_slot; + if (slot == nullptr) { + // Table full — degrade gracefully: deliver the advertisement unmerged. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false); + return; + } + slot->used = true; + this->pending_count_++; + memcpy(slot->mac, mac, 6); + slot->addr_type = addr_type; + slot->rssi = rssi; + slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); + memcpy(slot->data, data, slot->data_len); + slot->stored_ms = now; +} + +void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len) { + // Fast-out on the empty table (sweep/flush use the same guard); this is the + // hottest caller. + if (this->pending_count_ != 0) { + for (auto &p : this->pending_adv_) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + // Append in place: the slot is released on delivery, so its 62-byte + // buffer (legacy adv + scan response) holds the merged frame directly. + const uint8_t room = sizeof(p.data) - p.data_len; + const uint8_t add = (data_len <= room) ? data_len : room; + memcpy(p.data + p.data_len, data, add); + p.used = false; + this->pending_count_--; + // The advertisement's RSSI, not the scan response's (header contract). + this->deliver_(mac, p.rssi, addr_type, p.data, p.data_len + add, /*raw_only=*/false); + return; + } + } + } + // Unmatched scan-response: goes out on the raw callback only (HA merges per + // address); local listeners/triggers receive each advertisement exactly once + // via the merged/plain path above. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true); +} + +void ScanResponseMerger::sweep(uint32_t now) { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void ScanResponseMerger::flush() { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag) { + // Raw callback (the raw-advertisement path). Both full advertisements and + // unmatched scan responses (raw_only) are forwarded. + if (this->raw_callback_.is_set()) { + const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac), + .data = data, + .data_len = data_len, + .rssi = rssi, + .addr_type = addr_type}; + this->raw_callback_.invoke(adv); + } + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Scan-response-only frames are never parsed for local sensors/triggers. + if (raw_only) + return; + ESPBTDevice device; + device.from_scan_result(mac, rssi, addr_type, data, data_len); + // The listener list holds sensors AND the tracker's automation triggers + // (the triggers are listeners, exactly like esp32_ble_tracker), so one + // loop feeds both and ORs into `found`. + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) { + found = true; + } + } + if (!found && log_unclaimed_tag != nullptr) + this->discovered_log_.log_device(log_unclaimed_tag, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +void AdvDispatcher::on_scan_end() { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif +} + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h new file mode 100644 index 0000000000..9415664fcf --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -0,0 +1,152 @@ +// Shared support for trackers whose controller delivers advertisement and +// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates +// both into one result before ESPHome sees it): +// +// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is +// held briefly, its scan response is appended on arrival and the pair is +// delivered as ONE merged frame. Merged delivery is what the receiving side +// is built around: Home Assistant keeps the latest raw frame per device and +// skips re-parsing when it is unchanged — split delivery alternates two raw +// frames per device and defeats both. +// +// AdvDispatcher — the delivery half every such tracker repeats: raw +// callback, listener parsing, discovered-device log. Trackers delegate +// their BLEHub register_listener / set_raw_advertisement_callback here. +// +// The merger delivers straight into the tracker's AdvDispatcher — bind() wires +// the pair once in setup(). Single-task use only (every tracker calls this on +// the ESPHome main task). The clock is caller-provided: pass the same clock to +// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(), +// never mixed). + +#pragma once + +#include "esphome/core/defines.h" + +// Emitted (cg.add_define) by each tracker that adopts the merger, so builds +// whose tracker merges in-stack (esp32) never compile this code. +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include "ble_device.h" +#include "ble_hub.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ble_device_base { + +/// The delivery half of a split-report tracker, shared so the dispatch +/// contract (raw-callback ordering, raw_only gate, discovered-log policy) +/// lives in one place. Owns the members every tracker otherwise duplicates; +/// the tracker's BLEHub methods delegate here. +class AdvDispatcher { + public: + void register_listener(ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; } + /// Dispatch one (possibly merged) advertisement: the raw callback, and — + /// unless raw_only — parsing for listeners/triggers. raw_only marks + /// unmatched scan-response frames: forwarded on the raw callback only, never + /// parsed for local sensors/triggers (Home Assistant merges per address). + /// log_unclaimed_tag: when non-null, a device no listener claimed is logged + /// under this tag (esp32_ble_tracker parity: pass the tracker TAG on + /// one-shot scans, nullptr on continuous scans, which would spam). + void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag); + /// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup. + void on_scan_end(); + + protected: + RawAdvertisementCallback raw_callback_{}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; + // Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only + // writer so a no-listener build does not carry an unused vector. + DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +class ScanResponseMerger { + public: + /// Wire the merger's output; call once in the tracker's setup(). Every + /// delivered frame goes to dispatcher->dispatch(); scan_continuous is read + /// at each delivery (runtime continuous flips are honored) to decide the + /// unclaimed-device log tag, so both pointers must outlive the merger — + /// tracker members always do. + void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) { + this->dispatcher_ = dispatcher; + this->scan_continuous_ = scan_continuous; + this->log_tag_ = log_tag; + } + /// Hold a scannable advertisement, waiting for its scan response. The + /// tracker calls this only when it wants the merge (scannable advertisement + /// while an active scan runs) and delivers everything else directly. A + /// same-device re-advertisement delivers the held frame (its scan response + /// is not coming) and reuses the slot; a full table degrades gracefully to + /// unmerged delivery. + void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + uint32_t now); + /// A scan response arrived: append it to the held advertisement from the + /// same device and deliver the pair as one frame. The merged frame reports + /// the ADVERTISEMENT's RSSI — every unmerged path reports the + /// advertisement's measurement, so a device's RSSI must not jump between two + /// measurements depending on merge timing. Unmatched responses are delivered + /// raw_only. + void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len); + /// Timeout flush (call from loop() with the stash_adv() clock): deliver + /// held advertisements whose scan response never arrived (device didn't + /// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS. + void sweep(uint32_t now); + /// Deliver every held advertisement now (scan period/scan is ending, before + /// on_scan_end fires): unmerged delivery, same as the timeout path. + void flush(); + /// Lets loop() skip the cross-TU sweep() call in the common case (empty: + /// passive scan, or every pair already matched). + bool empty() const { return this->pending_count_ == 0; } + + private: + /// All delivery funnels through here: an unbound merger (bind() not called) + /// drops the frame instead of jumping through a null pointer, mirroring the + /// guard-before-invoke convention of the ble_hub.h callback slots. + void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only); + + // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum + // as ESP-IDF delivers on ESP32. + struct PendingAdv { + bool used{false}; + uint8_t mac[6]; + uint8_t addr_type; + int8_t rssi; + uint8_t data_len; // <= sizeof(data) + uint8_t data[62]; + uint32_t stored_ms; + }; + // Sized for the unanswered case: a pair that IS answered normally matches + // within one report-queue drain, so a slot is held for the full timeout only + // by scannable devices that never reply. 8 concurrent such advertisers + // before the merge degrades (frames still delivered, just unmerged) at + // ~80 B each. + static constexpr size_t MAX_PENDING_ADV = 8; + // On air a scan response follows its advertisement by T_IFS (150 µs) — the + // timeout only covers HOST-side report queuing under WiFi/BLE coexistence, + // measured on-device (ln882h) at up to ~136 ms. 300 ms = >2x that margin, + // while staying below any device's re-advertising period. + static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; + AdvDispatcher *dispatcher_{nullptr}; + const bool *scan_continuous_{nullptr}; // read at delivery; see bind() + const char *log_tag_{nullptr}; + // pending_count_ mirrors the number of set `used` flags; both are updated + // together on every transition. + PendingAdv pending_adv_[MAX_PENDING_ADV]; + uint8_t pending_count_{0}; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 45f1b95164..8443799144 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -129,6 +129,9 @@ 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") + # Compiles the shared adv + scan-response merge (the LN controller + # delivers the pair as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index cddcd6c17d..11ea46525c 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -3,7 +3,6 @@ #include "ln882h_ble_tracker.h" #include -#include #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -22,6 +21,9 @@ void LN882HBLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // rw task and delivers here on the main task. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); // scan_running_ check: an on_boot start_scan action (priority 600) runs // before this setup() (200) and enable_loop() is a no-op pre-setup — parking // the loop here would strand that already-running scan. @@ -72,19 +74,11 @@ void LN882HBLETracker::loop() { this->start_scan_(); } } - // Flush pending scannable advertisements whose scan response never arrived - // (device didn't answer / frame lost) — delivered unmerged after the timeout. - // Main-task only, like every consumer of pending_adv_. + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. Main-task only, like every merger call. const uint32_t now = millis(); - if (this->pending_count_ != 0) { - for (auto &p : this->pending_adv_) { - if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { - p.used = false; - this->pending_count_--; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - } - } - } + if (!this->merger_.empty()) + this->merger_.sweep(now); if (this->scan_continuous_) { if (!this->scan_running_) { @@ -145,129 +139,25 @@ void LN882HBLETracker::dump_config() { } // --------------------------------------------------------------------------- -// Adv/scan-response demux with Bluedroid-style merge: the LN controller -// delivers the pair as separate reports; a scannable advertisement is held -// until its scan response arrives and delivered as one merged frame. +// Adv/scan-response demux into the shared merger (ble_device_base): the LN +// controller delivers the pair as separate reports; a scannable advertisement +// is held until its scan response arrives and delivered as one merged frame. // --------------------------------------------------------------------------- void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) { if (report.is_scan_response) { - this->deliver_scan_rsp_(report); + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); return; } // Stash only while the scan runs: after a one-shot stop the loop is - // disabled and nothing would sweep the table, so a late report would + // disabled and nothing would sweep the merger, so a late report would // surface minutes later as a fresh advertisement. if (this->scan_running_ && this->scan_active_ && report.scannable) { - this->stash_adv_(report); + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis()); return; } - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); -} - -// Hold a scannable advertisement, waiting (≤ PENDING_ADV_TIMEOUT_MS) for its -// scan response. -void LN882HBLETracker::stash_adv_(const ln882h_ble::BLEScanReport &report) { - // One pass: find a same-device entry (deliver + reuse) while remembering the - // first free slot as the fallback. - PendingAdv *slot = nullptr; - PendingAdv *free_slot = nullptr; - for (auto &p : this->pending_adv_) { - if (!p.used) { - if (free_slot == nullptr) - free_slot = &p; - continue; - } - if (p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { - // Same device advertised again before its scan response arrived — deliver - // the previous advertisement (its scan response is not coming) and reuse - // the slot, so no frame is ever lost. - p.used = false; - this->pending_count_--; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - slot = &p; - break; - } - } - if (slot == nullptr) - slot = free_slot; - if (slot == nullptr) { - // Table full — degrade gracefully: deliver the advertisement unmerged. - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); - return; - } - slot->used = true; - this->pending_count_++; - memcpy(slot->mac, report.mac, 6); - slot->addr_type = report.addr_type; - slot->rssi = report.rssi; - slot->data_len = (report.data_len <= sizeof(slot->data)) ? report.data_len : sizeof(slot->data); - memcpy(slot->data, report.data, slot->data_len); - slot->stored_ms = millis(); -} - -// Scan response arrived: merge it with the pending advertisement from the same -// device into ONE frame (ESP-IDF/Bluedroid semantics). -void LN882HBLETracker::deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report) { - // Fast-out on the empty table (loop()/flush use the same guard); this is - // the hottest caller. - if (this->pending_count_ != 0) { - for (auto &p : this->pending_adv_) { - if (p.used && p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { - // Append in place: the slot is released on delivery, so its 62-byte - // buffer (legacy adv + scan response) holds the merged frame directly. - const uint8_t room = sizeof(p.data) - p.data_len; - const uint8_t add = (report.data_len <= room) ? report.data_len : room; - memcpy(p.data + p.data_len, report.data, add); - p.used = false; - this->pending_count_--; - // The advertisement's RSSI, not the scan response's: every unmerged path - // reports the advertisement's measurement, so a device's RSSI must not - // jump between two measurements depending on merge timing. - this->process_adv_(report.mac, p.rssi, report.addr_type, p.data, p.data_len + add, /*raw_only=*/false); - return; - } - } - } - // Unmatched scan-response: goes out on the raw callback only (HA merges per - // address); local listeners/triggers receive each advertisement exactly once - // via the merged/plain path above. - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/true); -} - -void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint8_t data_len, bool raw_only) { - // 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}; - this->raw_advertisement_callback_.invoke(adv); - } - -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Scan-response-only frames are never parsed for local sensors/triggers. - if (raw_only) - return; - ble_device_base::ESPBTDevice device; - device.from_scan_result(mac, rssi, addr_type, data, data_len); - // The listener list holds sensors AND this tracker's automation triggers - // (the triggers are listeners, exactly like esp32_ble_tracker), so one - // loop feeds both and ORs into `found`. - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) { - found = true; - } - } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } // --------------------------------------------------------------------------- @@ -356,29 +246,11 @@ void LN882HBLETracker::stop_scan_() { // Close a scan period: deliver held advertisements whose scan response never // came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock. void LN882HBLETracker::end_scan_period_(uint32_t now) { - this->flush_pending_adv_(); -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + this->merger_.flush(); + this->dispatcher_.on_scan_end(); this->scan_period_start_ = now; } -// Deliver every held advertisement now (scan period/scan is ending): unmerged -// delivery, same as the timeout path in loop(). Main-task only. -void LN882HBLETracker::flush_pending_adv_() { - if (this->pending_count_ == 0) - return; - for (auto &p : this->pending_adv_) { - if (p.used) { - p.used = false; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - } - } - this->pending_count_ = 0; -} - } // namespace esphome::ln882h_ble_tracker #endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 9c0e0b2f1a..2d88b938dd 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -9,6 +9,7 @@ #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/components/ln882h_ble/ln882h_ble.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -75,12 +76,10 @@ class LN882HBLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { // The LN882H controller supports active scanning; adv + scan response arrive @@ -108,27 +107,11 @@ class LN882HBLETracker : public Component, void on_scan_report(const ln882h_ble::BLEScanReport &report) override; protected: - // Bluedroid-style adv + scan-response merging (ESP-IDF concatenates both into - // one result before ESPHome sees it; the LN controller reports them separately): - // a scannable advertisement is held here briefly, its scan response is appended - // on arrival and the pair is delivered as ONE merged frame. Held entries whose - // scan response never arrives are flushed by loop() after PENDING_ADV_TIMEOUT_MS. - // All of this runs on the main task (the controller queue already crossed tasks), - // so no locking is involved. - void stash_adv_(const ln882h_ble::BLEScanReport &report); - void deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report); - // Dispatch one (possibly merged) advertisement: the raw - // callback, and — unless raw_only — parsing for listeners/triggers. raw_only - // marks unmatched scan-response frames: forwarded on the raw callback only, - // never to local sensors/triggers (HA merges per address). - void process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, - bool raw_only); void start_scan_(); void stop_scan_(); // Close a scan period: flush held advertisements (unmerged) BEFORE // on_scan_end fires, then re-anchor the period clock to `now`. void end_scan_period_(uint32_t now); - void flush_pending_adv_(); bool scan_running_{false}; bool scan_active_{false}; @@ -147,45 +130,13 @@ class LN882HBLETracker : public Component, #endif uint32_t scan_start_time_{0}; - // Pending scannable advertisements awaiting their scan response (active scan). - // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum as - // ESP-IDF delivers on ESP32. Main-task only. - struct PendingAdv { - bool used{false}; - uint8_t mac[6]; - uint8_t addr_type; - int8_t rssi; - uint8_t data_len; // <= sizeof(data) - uint8_t data[62]; - uint32_t stored_ms; - }; - // Sized for the unanswered case: a pair that IS answered normally matches - // within one queue drain, so a slot is held for the full timeout only by - // scannable devices that never reply. 8 concurrent such advertisers before - // the merge degrades (frames still delivered, just unmerged) at ~80 B each. - static constexpr size_t MAX_PENDING_ADV = 8; - // On air a scan response follows its advertisement by T_IFS (150 µs) — the - // timeout only covers HOST-side report queuing in rw_task under WiFi/BLE - // coexistence, measured on-device at up to ~136 ms. 300 ms = >2x that margin, - // while staying below any device's re-advertising period. - static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; - PendingAdv pending_adv_[MAX_PENDING_ADV]; - // Occupied pending_adv_ slots — lets loop()'s timeout sweep skip the table - // in the common case (empty: passive scan, or every pair already matched). - uint8_t pending_count_{0}; + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks); the merger is clocked by millis() throughout this tracker. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() - - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif }; } // namespace esphome::ln882h_ble_tracker diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 15c1229a85..99262babce 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -57,6 +57,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_RP2_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (BTstack delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index c2bb93a32e..2a87d617f8 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -24,6 +24,9 @@ void RP2BLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // BTstack packet handler (IRQ) and delivers here on the main loop. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); #ifdef USE_OTA_STATE_LISTENER // Pause scanning while an OTA update is in flight — the BLE scan competes with // the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker. @@ -64,6 +67,10 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin void RP2BLETracker::loop() { const uint32_t now = App.get_loop_component_start_time(); + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); if (this->scan_running_ && !this->parent_->is_active()) { // The controller was disabled underneath us (e.g. a lambda calling // rp2040_ble's disable()); the scan died with the stack. Reconcile so the @@ -119,30 +126,32 @@ void RP2BLETracker::dump_config() { YESNO(this->scan_continuous_)); } -void RP2BLETracker::on_scan_report(const rp2040_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), - .data = report.data, - .data_len = report.data_len, - .rssi = report.rssi, - .addr_type = report.addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } +// GAP advertising event types as BTstack reports them (Core spec advertising +// report event types; the tracker deliberately does not include BTstack +// headers). ADV_IND and ADV_SCAN_IND are the scannable types. +static constexpr uint8_t ADV_EVENT_TYPE_ADV_IND = 0; +static constexpr uint8_t ADV_EVENT_TYPE_ADV_SCAN_IND = 2; +static constexpr uint8_t ADV_EVENT_TYPE_SCAN_RSP = 4; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - ble_device_base::ESPBTDevice device; - device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) - found = true; +// Demux advertisements vs scan responses into the shared merger: BTstack +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { + if (report.adv_event_type == ADV_EVENT_TYPE_SCAN_RSP) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && + (report.adv_event_type == ADV_EVENT_TYPE_ADV_IND || report.adv_event_type == ADV_EVENT_TYPE_ADV_SCAN_IND)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } void RP2BLETracker::start_scan() { @@ -229,11 +238,10 @@ void RP2BLETracker::stop_scan_() { } void RP2BLETracker::fire_scan_end_() { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); } } // namespace esphome::rp2_ble_tracker diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 054f6a65d2..02bd7dc145 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -4,6 +4,7 @@ #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/components/rp2040_ble/rp2040_ble.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -51,25 +52,22 @@ class RP2BLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { - // BTstack delivers scan responses as separate advertisement reports rather - // than merging them into the advertisement — consumers relying on - // scan-response fields (device names) get them only where the receiver - // merges per address (Home Assistant does). GATT is available when the - // BTstack connection backend is compiled in (bluetooth_proxy active). + // BTstack delivers scan responses as separate advertisement reports; this + // tracker merges the pair before delivery (shared ScanResponseMerger, + // Bluedroid semantics). GATT is available when the BTstack connection + // backend is compiled in (bluetooth_proxy active). #ifdef USE_BLE_GATT_CLIENT constexpr bool has_gatt = true; #else constexpr bool has_gatt = false; #endif - return {.active_scan = true, .merges_scan_response = false, .gatt = has_gatt, .scan_mode_switch = true}; + return {.active_scan = true, .merges_scan_response = true, .gatt = has_gatt, .scan_mode_switch = true}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. @@ -104,16 +102,13 @@ class RP2BLETracker : public Component, bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure #endif - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main loop. Merger clock: stash_adv() reads the + // PARENT's cached loop time (on_scan_report runs inside rp2040_ble's queue + // drain), sweep() this component's — same App.loop() pass, so the delta + // stays non-negative and the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; }; } // namespace esphome::rp2_ble_tracker diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ad24d27369..7be217383e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -469,6 +469,7 @@ #define USE_RP2_BLE_TRACKER #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_BLE_GATT_CLIENT #define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define USE_RP2040_VARIANT_RP2040 @@ -500,6 +501,7 @@ #define USE_BK72XX_BLE_TRACKER #endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/tests/components/ble_device_base/__init__.py b/tests/components/ble_device_base/__init__.py index 1b041df8df..4dbd0becd8 100644 --- a/tests/components/ble_device_base/__init__.py +++ b/tests/components/ble_device_base/__init__.py @@ -6,7 +6,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # resolve_irk() is compiled only when a sensor configures irk: # (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has # no sensors, so emit the define here to put the real IRK path under test. + # Likewise the scan-response merger (emitted by the split-report trackers) + # and the listener vector it dispatches into (codegen-sized by consumers). async def to_code_testing(config): cg.add_define("USE_BLE_DEVICE_IRK") + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4) manifest.to_code = to_code_testing diff --git a/tests/components/ble_device_base/test_scan_response_merger.cpp b/tests/components/ble_device_base/test_scan_response_merger.cpp new file mode 100644 index 0000000000..013c7bf8f9 --- /dev/null +++ b/tests/components/ble_device_base/test_scan_response_merger.cpp @@ -0,0 +1,183 @@ +// The host test build gets this from the manifest override; clang-tidy does not. +#ifndef USE_BLE_SCAN_RESPONSE_MERGER +#define USE_BLE_SCAN_RESPONSE_MERGER +#endif + +#include + +#include +#include +#include + +#include "esphome/components/ble_device_base/scan_response_merger.h" + +namespace esphome::ble_device_base::testing { +namespace { + +// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot +// bookkeeping, the same-device reuse path, the table-full fallback, the +// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate. +// Delivery is observed through a real AdvDispatcher: the raw callback sees +// every frame (including raw_only), a listener only the parsed ones. + +struct DeliveredFrame { + uint64_t address; + std::vector data; + int8_t rssi; +}; + +struct RawCapture { + std::vector frames; + + static void trampoline(void *self, const RawAdvertisement &adv) { + auto *capture = static_cast(self); + capture->frames.push_back({adv.address, std::vector(adv.data, adv.data + adv.data_len), adv.rssi}); + } +}; + +class CountingListener : public ESPBTDeviceListener { + public: + bool parse_device(const ESPBTDevice &device) override { + this->parsed++; + return true; // claimed: keeps the discovered log quiet + } + int parsed{0}; +}; + +class ScanResponseMergerTest : public ::testing::Test { + protected: + void SetUp() override { + this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline}); + this->dispatcher_.register_listener(&this->listener_); + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test"); + } + + void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) { + std::vector data(data_len, fill); + this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now); + } + + void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) { + std::vector data(data_len, fill); + this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len); + } + + ScanResponseMerger merger_; + AdvDispatcher dispatcher_; + RawCapture raw_; + CountingListener listener_; + bool scan_continuous_{true}; +}; + +constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; +constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16}; + +TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) { + this->stash_(MAC_A, -40, 20, 0xAA); + EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered + + this->scan_rsp_(MAC_A, -70, 10, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 1u); + const auto &frame = this->raw_.frames[0]; + ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame + EXPECT_EQ(frame.data[0], 0xAA); + EXPECT_EQ(frame.data[19], 0xAA); + EXPECT_EQ(frame.data[20], 0xBB); + // The advertisement's RSSI, never the scan response's. + EXPECT_EQ(frame.rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) { + this->stash_(MAC_A, -40, 20, 0xAA); + this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 20u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot + + this->scan_rsp_(MAC_A, -70, 5, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot + EXPECT_EQ(this->raw_.frames[1].rssi, -45); +} + +TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) { + uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00}; + for (uint8_t i = 0; i < 8; i++) { + mac[5] = i; + this->stash_(mac, -50, 10, i); + } + EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held + + mac[5] = 8; + this->stash_(mac, -50, 10, 8); // 9th device: no slot left + ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + + this->merger_.flush(); // the 8 held frames are all still intact + EXPECT_EQ(this->raw_.frames.size(), 9u); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) { + this->stash_(MAC_A, -40, 31, 0xAA); + this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 62u); + EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB); + EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB); +} + +TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) { + this->scan_rsp_(MAC_B, -60, 12, 0xDD); + ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path + EXPECT_EQ(this->raw_.frames[0].rssi, -60); + EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners +} + +TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) { + std::vector adv(20, 0xAA); + this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0); + std::vector rsp(10, 0xBB); + this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size()); + // Same MAC, different addr_type: no merge — the response goes out raw_only. + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + EXPECT_EQ(this->listener_.parsed, 0); + EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held +} + +TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout + EXPECT_TRUE(this->raw_.frames.empty()); + this->merger_.sweep(1301); + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000); + this->merger_.flush(); + EXPECT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->listener_.parsed, 2); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) { + ScanResponseMerger unbound; + std::vector data(20, 0xAA); + unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0); + unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size()); + unbound.sweep(1000); + unbound.flush(); // no null jump anywhere + EXPECT_TRUE(unbound.empty()); +} + +} // namespace +} // namespace esphome::ble_device_base::testing From f3d1fc0d643ceaba252e8a0ecaeecf127a0c1bcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:47:56 -0500 Subject: [PATCH 11/11] [bluetooth_proxy] Migrate esp32 onto the neutral GATT backend (#18198) --- .../components/ble_device_base/__init__.py | 5 +- .../ble_device_base/ble_client_state.h | 10 + .../ble_device_base/ble_gatt_client.h | 76 +- .../bluetooth_connection/__init__.py | 180 +++- .../bluetooth_connection.cpp | 27 + .../bluetooth_connection.h | 13 +- .../bluetooth_connection_bluedroid.cpp | 772 ++++++++++++++++++ .../bluetooth_connection_bluedroid.h | 142 ++++ .../bluetooth_connection_esp32.cpp | 484 ----------- .../bluetooth_connection_esp32.h | 76 -- .../bluetooth_connection_gatt_backend.h | 13 +- .../bluetooth_connection_hub.cpp | 93 +-- .../bluetooth_connection_hub.h | 131 +-- .../bluetooth_connection_rp2.cpp | 45 +- .../bluetooth_connection_rp2.h | 15 +- .../components/bluetooth_proxy/__init__.py | 99 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 140 ++-- .../bluetooth_proxy/bluetooth_proxy.h | 11 +- esphome/config_helpers.py | 15 +- esphome/core/defines.h | 2 + .../ble_device_base/test_slot_counter.py | 2 + .../test_outer_schema_mirror.py | 11 +- .../bluetooth_proxy/test_platform_gates.py | 58 +- .../test_gatt_client_contract.cpp | 45 +- .../test-passive.esp32-c6-idf.yaml | 12 + tests/unit_tests/test_config_helpers.py | 17 +- 26 files changed, 1518 insertions(+), 976 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h delete mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp delete mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_esp32.h create mode 100644 tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index fa66448867..ae03003713 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -163,8 +163,9 @@ _request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE) def request_gatt_client() -> None: """Compile in the neutral GATT client contract (ble_gatt_client.h) and - claim one connection slot. Called by bluetooth_proxy once per connection - it instantiates on a hub platform.""" + claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT; + distinct from the proxy's validated connection budget). Called by + bluetooth_connection.new_gatt_backend() once per backend instance.""" cg.add_define("USE_BLE_GATT_CLIENT") _request_gatt_connection_slot() diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h index b0c91397fc..92754b70b4 100644 --- a/esphome/components/ble_device_base/ble_client_state.h +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -17,6 +17,16 @@ namespace esphome::ble_device_base { /// client backend. static constexpr int GATT_ERR_NOT_CONNECTED = -1; static constexpr int GATT_ERR_NO_MEMORY = -2; +/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency, +/// e.g. a service table failing its own bounds checks. +static constexpr int GATT_ERR_UNLIKELY = 0x0E; + +/// 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; + +/// ATT MTU before negotiation completes (Bluetooth spec default). +static constexpr uint16_t DEFAULT_ATT_MTU = 23; // Preferred connection parameters shared by every platform's GATT client so // the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 74548f578f..b95fb6878a 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -5,10 +5,11 @@ // Exactly one GATT backend exists per build, so BLEGattConnection is a // compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract // interface. -// The hub BluetoothConnection wrapper drives it and receives completions -// through its event-sink methods, which the backend calls directly. All sink -// calls are delivered on the ESPHome main loop; borrowed data pointers are -// valid only for the duration of the call. +// A consumer - the hub wrapper streaming the raw database, or a direct +// consumer owning a dedicated backend and resolving handles by UUID - +// drives it and receives completions through the GattClientListener +// interface. All listener calls 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 @@ -78,27 +79,55 @@ struct GattServiceTable { uint16_t descriptor_count{0}; }; +/// The event surface a backend delivers completions through - the one place +/// with genuine runtime polymorphism (several consumer types, one non-virtual +/// backend). Methods default to no-ops; consumers override what they consume. +/// No destructor: components are never destroyed. +/// on_connection_state carries the negotiated MTU and an HCI status/reason. +/// Codegen wires the listener before setup(), so backends skip null checks. +class GattClientListener { + 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) {} +}; + // The BLEGattConnection op surface, asserted where the alias binds // (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives -// through the sink) or a synchronous error (busy, not connected, stack +// 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. +// - gatt_disconnect: also cancels a connect in progress (named to coexist +// with a platform stack's own void disconnect() on one backend class). +// Nonzero means nothing to tear down and no completion will follow; an +// accepted teardown (0) always reaches a terminal on_connection_state. +// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not +// started closing - the in-flight connect resumes and completes normally. +// False once the teardown owns the link (or nothing was scheduled). // - 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). -// - completions: connect and disconnect land in on_connection_state, +// 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 gatt_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 -concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { - conn.set_listener(sink); +// on_notify_state, characteristic writes (with and without response) and +// descriptor writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) { + conn.set_listener(listener); { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; - { conn.disconnect() } -> std::same_as; + { conn.gatt_disconnect() } -> std::same_as; + { conn.cancel_gatt_disconnect() } -> std::same_as; { conn.discover_services() } -> std::same_as; { conn.read_characteristic(uint16_t{}) } -> std::same_as; { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; @@ -109,22 +138,9 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t * { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; { conn.get_service_table() } -> std::same_as; { conn.release_services() } -> std::same_as; -}; - -// The event sink the backend calls directly (the hub BluetoothConnection -// wrapper), asserted where the wrapper is defined: on_connection_state -// carries the negotiated MTU and an HCI status/disconnect reason. The -// requirements check call validity, not exact parameter types; keep sink -// parameters at the documented widths (uint16_t handles and lengths). -template -concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { - { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; - { sink.on_service_discovery_done(int{}) } -> std::same_as; - { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; - { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; - { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; - { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; - { sink.on_pairing_result(int{}) } -> std::same_as; + // Connection-type hint for backends that tune parameters by it; others + // carry an inline no-op. + { conn.set_connection_type(ConnectionType{}) } -> std::same_as; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 1dc1969a6a..8c218c0954 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,23 +1,34 @@ -"""Per-platform GATT connection backends the Bluetooth proxy drives. +"""Per-platform GATT connection backends and the helpers to embed one. -Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no -user-facing configuration; the proxy's codegen declares and registers the -connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the +Bluetooth proxy's codegen declares and registers the backend instances +through gatt_client_schema()/hub_connection_schema() + new_gatt_backend(). """ -import functools +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import esphome.codegen as cg -from esphome.config_helpers import filter_source_files_from_platform -from esphome.const import PLATFORM_RP2, PlatformFramework +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, +) +import esphome.config_validation as cv +from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework from esphome.core import CORE +from esphome.types import ConfigType def AUTO_LOAD() -> list[str]: - """The esp32 connection header includes esp32_ble_client, so the closure - must be self-satisfying; no target platform (tooling) gets the union.""" - if CORE.is_esp32 or CORE.target_platform is None: - return ["ble_device_base", "esp32_ble_client"] + """ble_device_base plus the platform BLE stack the build's backend + registers with (the Bluedroid header includes the tracker's), so + consumers need not know. The platform-less arm serves manifest tooling.""" + 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"] @@ -29,39 +40,134 @@ bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") # 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. +# Slot limits for the hub platforms running the connection-capable proxy; +# the backend registry itself is _PLATFORM_BACKENDS below. HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} -# The hub-platform wrapper and the rp2 BTstack backend codegen classes. +# 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" -@functools.cache -def esp32_connection_class() -> cg.MockObjClass: - """Lazy: importing esp32_ble_client registers esp32-only automations as - an import side effect, which must not leak into other platforms.""" - from esphome.components import esp32_ble_client +def _esp32_schema_fragment() -> cv.Schema: + from esphome.components import esp32_ble_tracker - return bluetooth_connection_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase + 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)} ) -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "bluetooth_connection_esp32.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, - }, - "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, - } -) +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 registers + # as a raw client (it is the tracker's ESPBTClient). + await esp32_ble_tracker.register_raw_client(backend, 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), +} + + +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, plus the component keys (setup_priority and + friends now apply to the backend, the slot's real Component). Same + platform rules as gatt_client_schema().""" + return ( + gatt_client_schema(platform) + .extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +async def new_gatt_backend(config: ConfigType) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema() and register + it with its platform stack. The connection slot is claimed at validation + (the proxy's slot validators), not here. + """ + from esphome.components import ble_device_base + + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(config[CONF_BACKEND_ID]) + # The backend is the slot's real Component: component keys from the + # connection entry (setup_priority, ...) apply to it. Consumers whose own + # schema carries keys that register_component would misapply to the + # backend (e.g. a polling interval) must not put them in this config. + await cg.register_component(backend, config) + await _backend_entry().register(backend, config) + return backend + + +# Named so tests can pin the hub entry against bluetooth_proxy's platform +# list (this module cannot import bluetooth_proxy to derive it). +SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { + "bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]), + # 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}, +} + +FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 57833edbd2..94bb119c84 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -1,5 +1,10 @@ #include "bluetooth_connection.h" +#ifdef USE_ESP32 +#include +#include +#endif + #ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" @@ -40,3 +45,25 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size } // namespace esphome::bluetooth_connection #endif // BLUETOOTH_CONNECTION_HAS_GATT + +#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 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 2125d5b34f..5052e7eca1 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,10 +16,15 @@ #include #endif -// A GATT connection backend exists in this build: esp32 (Bluedroid) or a hub -// platform with the neutral GATT client compiled in. Single-sourced here so -// the proxy and this component cannot drift. -#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT) +// The connection-aware API request handlers are compiled: a GATT backend is +// wired by codegen (one slot per connection). This is the single spelling of +// that predicate - the hub wrapper and the API request handlers gate on it. +// The wrapper serves the proxy's API surface, so it compiles only when a +// backend AND the proxy are present; advertisement-only and backend-only +// builds get the clean-error handlers instead. Address-scoped maintenance +// (unpair, cache clear) still works there through the per-platform free +// functions below. +#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) #define BLUETOOTH_CONNECTION_HAS_GATT #endif diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp new file mode 100644 index 0000000000..f24d261c57 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -0,0 +1,772 @@ +#include "bluetooth_connection_bluedroid.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +// The in-place streamer serves the proxy's service-discovery API; backend-only +// builds compile without the proxy headers or the streamer. +#ifdef USE_BLUETOOTH_PROXY +#include "bluetooth_connection.h" +#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 + +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; + +// ---- tracker surface ---- + +void BluedroidGattClient::connect() { this->tracker_connect_(); } +void BluedroidGattClient::disconnect() { this->gatt_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: no CLOSE_EVT will come. Settle a live link so the consumer + // frees its slot, then re-register the app on the next enable. + auto down_st = this->state(); + if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + this->set_state(ClientState::INIT); + return; + } + auto st = this->state(); + if (st == ClientState::INIT) { + // Parity with BLEClientBase: a failed registration marks the slot + // failed and idles it without retry. + auto ret = esp_ble_gattc_app_register(this->app_id); + if (ret) { + ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); + this->mark_failed(); + } + // Do not wait for REG_EVT; a dropped event must not wedge the slot. + this->set_idle_(); + } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { + // The one teardown safety net: a lost CLOSE_EVT, or a scheduled + // teardown whose OPEN_EVT never arrives. + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_); + // Release before idling: a lost completion must not leak the cache. + this->release_services(); + this->set_idle_(); // also clears want_disconnect_ + this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); + } + } else { + // The loop stays on while a link exists (stack-down watch, pre-started + // search flush); it settles only back at IDLE. + this->deliver_pending_search_(); + if (this->state() == ClientState::IDLE) { + this->disable_loop(); + } + } +} + +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) { + // Only from idle: clobbering DISCONNECTING would open a new link the + // stale CLOSE_EVT then tears 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_); + // Per-attempt latches; the search machine is reset by set_idle_(), the + // one door back to IDLE. + this->services_released_ = false; + this->seen_mtu_ = false; + this->mtu_failed_ = 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. + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, + FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT)); + } else { + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + 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(this->remote_addr_type_), true); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + // CONNECT_EVT never fired; nothing to close. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ret); + } +} + +int BluedroidGattClient::gatt_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_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->want_disconnect_ = true; + // Arm the safety window: a lost OPEN_EVT must not leak the teardown. + this->disconnecting_started_ = millis(); + this->enable_loop(); + 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) { + // Terminal state now rather than leaning on the scheduled-teardown timer. + ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_); + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + 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_(); +} + +bool BluedroidGattClient::cancel_gatt_disconnect() { + // Only a scheduled teardown (want_disconnect_ latched while the open is + // still in flight) is cancellable; once closing started the terminal + // report settles the race. + if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) { + return false; + } + this->want_disconnect_ = false; + return true; +} + +int BluedroidGattClient::discover_services() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + switch (this->search_state_) { + case SearchState::PRESTARTED: + // The pending SEARCH_CMPL reports once it lands. + this->search_state_ = SearchState::CLAIMED; + return 0; + case SearchState::PRESTART_DONE: + // Already landed: the flush after the connected report delivers + // (loop() covers a claim made outside that event drain). + this->search_state_ = SearchState::REPORT_PENDING; + this->enable_loop(); + return 0; + case SearchState::CLAIMED: + case SearchState::REPORT_PENDING: + return 0; // One completion is already owed to this claimant. + case SearchState::NONE: + break; + } + int err = this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr)); + if (err == 0) { + this->search_state_ = SearchState::CLAIMED; + } + return err; +} + +int BluedroidGattClient::read_characteristic(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + 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) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // 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(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) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + 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) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + 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(data), + ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // 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() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + 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; + // Always set: terminates any in-flight stream on every cache config. + this->services_released_ = true; +#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // A failed clean leaves a stale database the next connection could serve + // as authoritative. A disabled stack invalidates its own cache; skip the + // meaningless call instead of warning on every OTA/ble.disable teardown. + if (esp32_ble::global_ble->is_active()) { + this->check_and_log_error_("esp_ble_gattc_cache_clean", esp_ble_gattc_cache_clean(this->remote_bda_)); + } +#endif +} + +// ---- 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; + this->search_state_ = SearchState::NONE; + this->search_status_ = 0; +} + +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(); +} + +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 ---- + +int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) { + // Step down from the fast discovery params. + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + if (status != ESP_GATT_OK) { + // A failed discovery reads as a clean zero from the count calls below; + // honoring the event status stops it becoming an authoritative empty + // list. + return status; + } + 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. + auto count_status = primary_status != ESP_GATT_OK ? primary_status : secondary_status; + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", count_status); + return count_status; + } + this->service_total_ = primary + secondary; + return 0; +} + +// Reports a completed search once claimed; delivery consumes the state so +// a re-discovery issues a real search. +void BluedroidGattClient::deliver_pending_search_() { + if (this->search_state_ != SearchState::REPORT_PENDING) + return; + this->search_state_ = SearchState::NONE; + this->listener_->on_service_discovery_done(this->search_status_); +} + +#ifdef USE_BLUETOOTH_PROXY +// The wrapper's compile-time streamer detection must keep finding this +// method; a signature drift would silently fall back to the table streamer, +// which proxy builds compile without a materializer. +static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); + +void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { + if (this->services_released_) { + // Released under the stream: park without services-done so a partial + // list is never cached as authoritative (the client retries after its + // GetServices timeout). + ESP_LOGW(TAG, "[%d] [%s] Services released mid-stream, parking", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + return; + } + if (conn.send_service_ >= this->service_total_) { + conn.send_service_ = DONE_SENDING_SERVICES; + conn.proxy_->send_gatt_services_done(conn.address_); + this->release_services(); + 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; + esp_gatt_status_t svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, + &svc_count, conn.send_service_); + if (svc_status != 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.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND); + return; + } + uint16_t total_char_count = 0; + auto 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_gattc_warning_("esp_ble_gattc_get_attr_count", char_count_status); + conn.abort_service_stream(char_count_status); + 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_OK || cc == 0) { + // An early terminator contradicts the count from the same cache; + // never stream a silently truncated list. + this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status); + conn.abort_service_stream(char_status != ESP_GATT_OK ? char_status : ESP_GATT_NOT_FOUND); + return; + } + 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.abort_service_stream(desc_count_status); + 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_OK || dc == 0) { + this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status); + conn.abort_service_stream(desc_status != ESP_GATT_OK ? desc_status : ESP_GATT_NOT_FOUND); + return; + } + 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 // USE_BLUETOOTH_PROXY + +// ---- events ---- + +void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { + auto st = this->state(); + if (st == ClientState::IDLE) { + // Late OPEN_EVT after the slot went IDLE (open-error race, or the + // teardown net gave up): close a won link, never resurrect the slot. + ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d)", this->connection_index_, param->open.status); + if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) { + // A failed close here leaks a live link nothing tracks; make it heard. + this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id)); + } + 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->listener_->on_connection_state(false, 0, param->open.status); + return; + } + if (this->disconnect_pending()) { + // Open resolved with a teardown scheduled: close now (conn_id_ stays 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 with the default MTU. The + // cached path never waits for (or reports) the exchange - seen_mtu_ + // suppresses the CFG_MTU report, matching the previous esp32 behavior. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + } else { + // Discovery-bound connection: start the search now so it overlaps the + // MTU exchange. On a refusal fall back to the serialized path - the + // consumer's own discover_services() call retries the real search. + if (this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, param->open.conn_id, nullptr)) == 0) { + this->search_state_ = SearchState::PRESTARTED; + } + if (this->mtu_failed_ && !this->seen_mtu_) { + // Refused MTU request: report with the default so the consumer + // proceeds. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + this->deliver_pending_search_(); + } + } +} + +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::gattc_event_handler(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->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); + // No CFG_MTU_EVT will follow; OPEN_EVT reports with the default. + this->mtu_failed_ = true; + } + 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); + } + if (!this->seen_mtu_ && !this->disconnect_pending() && this->state() != ClientState::DISCONNECTING) { + // Teardown owns the link: suppress the connected report here like + // OPEN_EVT and SEARCH_CMPL do; the terminal report settles it. + this->seen_mtu_ = true; + // The connected report waited for the MTU; forwarded, not stored. + this->listener_->on_connection_state( + true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0); + // The consumer requests discovery from inside that report; when the + // pre-started search already finished, complete it in the same drain. + this->deliver_pending_search_(); + } + 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->listener_->on_connection_state(false, 0, 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_); + if (this->state() == ClientState::DISCONNECTING) { + // Teardown owns the link; the result is never delivered, skip the + // work. + break; + } + this->search_status_ = this->handle_search_cmpl_(static_cast(param->search_cmpl.status)); + this->search_state_ = + this->search_state_ == SearchState::CLAIMED ? SearchState::REPORT_PENDING : SearchState::PRESTART_DONE; + this->set_state(ClientState::ESTABLISHED); + this->deliver_pending_search_(); + 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::gap_event_handler(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 refused response means no AUTH_CMPL, so answer the + // pairing request with the failure. + int sec_err = this->check_and_log_error_("esp_ble_gap_security_rsp", + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true)); + if (sec_err != 0) { + this->listener_->on_pairing_result(sec_err); + } + 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 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h new file mode 100644 index 0000000000..19b89ea5cd --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -0,0 +1,142 @@ +// 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; the real esp_ble_gattc_open happens in +// the tracker-invoked connect() override. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +#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 +#include + +namespace esphome::bluetooth_connection { + +#ifdef USE_BLUETOOTH_PROXY +class BluetoothConnection; +#endif + +// One class carries both halves: the tracker's ESPBTClient surface (its +// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the +// virtual connect()/disconnect()) and the neutral contract ops. The +// contract's teardown op is named gatt_disconnect() because the tracker's +// void disconnect() cannot overload with an int-returning twin. +class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { + public: + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; + + // Lifecycle of one connection attempt's service search. + enum class SearchState : uint8_t { + NONE, // no search this attempt + PRESTARTED, // issued at OPEN_EVT, no claimant yet + PRESTART_DONE, // completed with search_status_ latched, no claimant yet + CLAIMED, // in flight with a claimant (pre-started or direct) + REPORT_PENDING // completed and claimed: deliver on the next flush + }; + + 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 ---- + 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; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int gatt_disconnect(); + bool cancel_gatt_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); + // Contract stub: the proxy streams in place; the on-demand materializer + // for direct consumers lands with #18205. NOTE: a direct consumer reaching + // this stub gets an empty table indistinguishable from a service-less + // peer - do not ship one against this backend before the materializer. + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services(); + +#ifdef USE_BLUETOOTH_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: + bool check_addr_(const esp_bd_addr_t &addr) const; + void tracker_connect_(); + void handle_open_evt_(esp_ble_gattc_cb_param_t *param); + void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param); + int handle_search_cmpl_(esp_gatt_status_t status); + void deliver_pending_search_(); + void unconditional_disconnect_(); + void set_idle_(); + void set_disconnecting_(); + 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); + + // Group 1: pointers / composed objects + ble_device_base::GattClientListener *listener_{nullptr}; + // Group 2: 4-byte types + uint32_t disconnecting_started_{0}; + + // Group 3: arrays + esp_bd_addr_t remote_bda_{}; + + // Group 4: 2-byte types + uint16_t conn_id_{UNSET_CONN_ID}; + uint16_t service_total_{0}; + + // Group 5: 1-byte types + esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes + // 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_{0}; + // Terminates an in-flight stream (never send a partial list as authoritative) + // and marks a cleaned cache unsafe to walk (Bluedroid asserts). + bool services_released_ : 1 {false}; + // The connected report waits for the MTU exchange; OPEN_EVT alone would + // hand HA the default 23. + bool seen_mtu_ : 1 {false}; + // The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead. + bool mtu_failed_ : 1 {false}; + // Search issued at OPEN_EVT overlaps the MTU exchange; discover_services() + // completes from it. Reset by set_idle_(). + static_assert(static_cast(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow"); + SearchState search_state_ : 4 {SearchState::NONE}; + // esp_gatt_status_t of the completed search, held until claimed. + uint8_t search_status_{0}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp deleted file mode 100644 index f5c59ca43a..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ /dev/null @@ -1,484 +0,0 @@ -#include "bluetooth_connection_esp32.h" - -#include "esphome/components/api/api_pb2.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP32 - -#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" - -namespace esphome::bluetooth_connection { - -namespace espbt = esphome::esp32_ble_tracker; - -using ble_device_base::ESPBTUUID; - -static const char *const TAG = "bluetooth_connection"; - -conn_err_t unpair_device(uint64_t address) { - esp_bd_addr_t bd_addr; - ble_device_base::uint64_to_mac_msb_first(address, bd_addr); - return esp_ble_remove_bond_device(bd_addr); -} - -conn_err_t clear_gatt_cache(uint64_t address) { - esp_bd_addr_t bd_addr; - ble_device_base::uint64_to_mac_msb_first(address, bd_addr); - return esp_ble_gattc_cache_clean(bd_addr); -} - -void BluetoothConnection::dump_config() { - ESP_LOGCONFIG(TAG, "BLE Connection:"); - BLEClientBase::dump_config(); -} - -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); - // 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) { this->proxy_->reset_connection_slot_(this, reason); } - -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->proxy_->client_supports_efficient_uuids(); - - // Prepare response - api::BluetoothGATTGetServicesResponse resp; - resp.address = this->address_; - - // Dynamic batching based on actual size - // Keep running total of actual message size - size_t current_size = resp.calculate_size(); - int16_t batch_start = this->send_service_; - - 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, ESPBTUUID::from_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, ESPBTUUID::from_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, ESPBTUUID::from_uuid(desc_result.uuid), - use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; - } - } - } // end if (total_char_count > 0) - - 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. - 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; - } -} - -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 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 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(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 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 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(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 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); -} - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h deleted file mode 100644 index fb60d93e9c..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ /dev/null @@ -1,76 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/components/esp32_ble_client/ble_client_base.h" - -#include "bluetooth_connection.h" - -namespace esphome::bluetooth_proxy { -class BluetoothProxy; -} // namespace esphome::bluetooth_proxy - -namespace esphome::bluetooth_connection { - -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; - // The proxy's connections never consume parsed ESPBTDevice objects. - bool wants_parsed_advertisements() override { return false; } - - 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"); - } - - bool has_gatt_services() const { return this->service_count_ != 0; } - - /// Start connecting: record the API address type and hand the client to the - /// tracker's promote loop (it pauses the scan and opens the connection). - void initiate_connection(uint8_t address_type) { - this->set_remote_addr_type(static_cast(address_type)); - this->set_state(esp32_ble_tracker::ClientState::DISCOVERED); - } - - void set_address(uint64_t address) override; - - protected: - friend class bluetooth_proxy::BluetoothProxy; - - void on_disconnect_complete(esp_err_t reason) override; - - void send_service_for_discovery_(); - void reset_connection_(esp_err_t reason); - 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) - bluetooth_proxy::BluetoothProxy *proxy_; - - // Group 2: 2-byte types - int16_t send_service_{INIT_SENDING_SERVICES}; // see bluetooth_connection.h cursor states - - // Group 3: 1-byte types - bool seen_mtu_or_services_{false}; - // 1 byte used, 1 byte padding -}; - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h index d8792b88c1..3c982d81ae 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -15,19 +15,21 @@ #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 BluetoothConnection; - class StubGattBackend { public: - void set_listener(BluetoothConnection *listener) {} + 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 gatt_disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + bool cancel_gatt_disconnect() { return false; } 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) { @@ -43,6 +45,7 @@ class StubGattBackend { 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() {} }; @@ -55,7 +58,7 @@ class StubGattBackend { namespace esphome::ble_device_base { using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); #undef ESPHOME_BLE_GATT_CONNECTION_TYPE diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index ec03f18e1d..b913bb9a55 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,7 @@ -// Hub-platform connection wrapper (USE_RP2 hub builds today). +// The proxy's per-slot connection wrapper, shared by every platform. #include "bluetooth_connection_hub.h" -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +#ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -26,11 +26,11 @@ void BluetoothConnection::set_address(uint64_t address) { format_mac_addr_upper(mac, this->address_str_); } -void BluetoothConnection::start_connect_() { - // No connect timeout here (esp32 parity): the client's own timeout or - // the api-gone sweep drives disconnect(). +void BluetoothConnection::initiate_connection(uint8_t address_type) { + // 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_); + int err = this->backend_->connect(this->address_, address_type); if (err != 0) { ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); this->reset_connection_(err); @@ -38,40 +38,21 @@ void BluetoothConnection::start_connect_() { } void BluetoothConnection::disconnect() { - // Idempotent like the esp32 class: 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). + // Idempotent: the proxy's teardown loop calls this every 100 ms while the + // API subscriber is gone, and a repeat call reaching the backend would + // re-arm its teardown timer so the safety timeout never fires. 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_); + int err = this->backend_->gatt_disconnect(); + if (err != 0) { + // Nonzero means nothing to tear down (both backends): free the slot. + // Accepted teardowns always reach a terminal report. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle, err=%d", this->connection_index_, this->address_str_, err); 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 mirroring the esp32 base class: if the backend's disconnect - // completion is lost, force the slot free instead of leaking it. - static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; - if (this->state_ == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > 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) { @@ -96,32 +77,24 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } -// ---- backend event sink ---- +// ---- 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 ignored: nonzero just means the backend was already idle, and + // re-arming a freed slot could clobber a new reservation. + this->backend_->gatt_disconnect(); 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) { + int err = this->backend_->gatt_disconnect(); + if (err != 0) { // 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; } @@ -130,7 +103,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { // The API client has the services cached; never discover them. No // discovery phase needs the fast interval, so settle straight into the - // shared steady-state parameters (same lifecycle place as esp32). + // 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, @@ -145,14 +121,13 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int return; } // V3_WITHOUT_CACHE: discover services first — the connected response is - // sent when discovery completes, mirroring the esp32 flow (MTU + services - // before the response). + // sent when discovery completes (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->latch_pending_error_(err); this->disconnect(); } return; @@ -171,7 +146,7 @@ void BluetoothConnection::on_service_discovery_done(int error) { 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->latch_pending_error_(error); this->disconnect(); return; } @@ -334,9 +309,9 @@ void BluetoothConnection::send_service_for_discovery_() { } // 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. + // sending services-done (a resubscribing client gets silence and its 30 s + // timeout, never an authoritative partial list) and free the table; the + // api-gone sweep tears the connection down anyway. 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_, @@ -380,8 +355,7 @@ void BluetoothConnection::send_service_for_discovery_() { 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(); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); return; } if (char_count > 0) { @@ -397,8 +371,7 @@ void BluetoothConnection::send_service_for_discovery_() { 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(); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); return; } if (desc_count == 0) { @@ -433,4 +406,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index e79ee9e7a8..82d9ae7db4 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -1,17 +1,17 @@ -// Hub-platform BluetoothConnection: drives the build's GATT backend (the +// BluetoothConnection: drives the build's GATT backend (the // ble_device_base::BLEGattConnection alias) and translates its events into -// the same API messages the esp32 class emits. -// Presents the identical method surface, so the proxy's GATT dispatch -// compiles against either class unchanged. +// 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 "esphome/core/defines.h" - -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) - #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_HAS_GATT + #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" #include "esphome/core/helpers.h" @@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; -class BluetoothConnection final { +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) { @@ -33,7 +33,7 @@ class BluetoothConnection final { backend->set_listener(this); } - // ---- proxy dispatch surface (mirrors the esp32 class) ---- + // ---- 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); @@ -41,21 +41,31 @@ class BluetoothConnection final { 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_(); + /// Streamer abort: latch the GATT cause, park the cursor, tear down. + void abort_service_stream(conn_err_t err) { + this->latch_pending_error_(err); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); } + + /// Start connecting with the API address type (BLE_ADDR_TYPE_* code + /// space). Failures report through the same reset path a failed open + /// takes. + void initiate_connection(uint8_t address_type); void disconnect(); + /// A connect request racing a scheduled teardown: true when the backend + /// had not started closing - the in-flight open resumes and reports + /// connected. False once the teardown owns the link. + bool cancel_teardown() { + if (this->state_ == ClientState::DISCONNECTING && this->backend_->cancel_gatt_disconnect()) { + this->state_ = ClientState::CONNECTING; + return true; + } + return false; + } bool is_paired() const { return this->paired_; } void set_unpaired() { this->paired_ = false; } conn_err_t pair() { return this->backend_->pair(); } - // A backend disconnect() is a single call that also cancels an in-progress - // connect; there is no deferred-disconnect state to track. - bool disconnect_pending() const { return false; } - void cancel_pending_disconnect() {} void set_address(uint64_t address); uint64_t get_address() const { return this->address_; } @@ -65,39 +75,58 @@ class BluetoothConnection final { 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; } + 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 (esp32 parity — a repeat GetServices is silently ignored there, - // never answered with an authoritative empty database). + // 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 — hub connections have no - /// Component loop of their own (the esp32 class streams from its own - /// loop() and has the same 10 s safety net in its base class). + /// Stream any pending service-discovery batch (proxy loop; the backend + /// owns the disconnect safety timer). void process_pending_services() { if (this->send_service_ >= 0) { - this->send_service_for_discovery_(); + this->stream_pending_(this->backend_); } - this->check_disconnect_timeout_(); } - // ---- backend event sink (called directly by the backend, main loop) ---- - void on_connection_state(bool connected, uint16_t mtu, int error); - void on_service_discovery_done(int error); - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error); - void on_write_result(uint16_t handle, int error); - void on_notify_state(uint16_t handle, bool enabled, int error); - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len); - void on_pairing_result(int status); + // ---- 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_(); + /// First cause wins: a later, less specific error must not overwrite it. + void latch_pending_error_(conn_err_t err) { + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + // 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 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); @@ -109,28 +138,26 @@ class BluetoothConnection final { // Group 2: 2-byte types int16_t send_service_{INIT_SENDING_SERVICES}; - uint16_t mtu_{23}; + uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; // 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}; + // Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48. + static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); + static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), + "connection_type_ bitfield too narrow"); + ClientState state_ : 3 {ClientState::IDLE}; + bool paired_ : 1 {false}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + uint8_t connection_index_ : 4 {0}; + bool services_discovered_ : 1 {false}; }; -static_assert(ble_device_base::GattClientEventSinkContract, - "The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)"); - } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc730659f5..dc77d448a5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,6 +1,5 @@ #include "bluetooth_connection_rp2.h" -#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -26,7 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // and keeps the scan inhibited, so the engine cancels after 20 s. The // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; -static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; // Can-send windows normally open within a connection interval (tens of ms). static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; @@ -384,7 +382,7 @@ void RP2GattClient::loop() { RP2GattNotifyEvent *notify; while ((notify = this->notify_queue_.pop()) != nullptr) { - if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { + if (this->notify_subscribed_(notify->handle)) { this->listener_->on_notify_data(notify->handle, notify->data, notify->len); } this->notify_pool_.release(notify); @@ -395,7 +393,7 @@ void RP2GattClient::loop() { // Control events must not be lost; the connection state is no longer // trustworthy — recover with a forced teardown. ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); - this->disconnect(); + this->gatt_disconnect(); } uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); if (notify_dropped > 0) { @@ -426,11 +424,11 @@ void RP2GattClient::loop() { // reclaims state if the disconnection event is lost. Dropping engine // state without gap_disconnect would leak the live link and the // single GATT slot for the rest of the boot. - this->disconnect(); + this->gatt_disconnect(); } } } else if (this->state_ == EngineState::DISCONNECTING) { - if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } @@ -448,9 +446,7 @@ void RP2GattClient::loop() { } if (timed_out) { ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); - } + this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); } } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && this->event_queue_.empty() && this->notify_queue_.empty())) { @@ -474,9 +470,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(true, this->mtu_, 0); - } + this->listener_->on_connection_state(true, this->mtu_, 0); } break; case RP2GattEvent::QUERY_COMPLETE: @@ -486,9 +480,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->finish_write_no_rsp_(event.status); break; case RP2GattEvent::PAIRING_RESULT: - if (this->listener_ != nullptr) { - this->listener_->on_pairing_result(event.status); - } + this->listener_->on_pairing_result(event.status); break; } } @@ -514,9 +506,7 @@ void RP2GattClient::finish_write_no_rsp_(uint8_t status) { return; } this->op_type_ = OpType::NONE; - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, status); - } + this->listener_->on_write_result(this->op_handle_, status); } void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { @@ -576,9 +566,7 @@ void RP2GattClient::fail_connection_(uint8_t reason) { this->cleanup_link_state_(); this->release_scan_inhibit_(); this->state_ = EngineState::IDLE; - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(false, 0, reason); - } + this->listener_->on_connection_state(false, 0, reason); } void RP2GattClient::cleanup_link_state_() { @@ -621,9 +609,6 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { OpType op = this->op_type_; this->op_type_ = OpType::NONE; - if (this->listener_ == nullptr) { - return; - } switch (op) { case OpType::READ_CHAR: case OpType::READ_DESC: @@ -796,9 +781,7 @@ void RP2GattClient::finish_discovery_(int error) { if (error != 0) { this->release_services(); } - if (this->listener_ != nullptr) { - this->listener_->on_service_discovery_done(error); - } + this->listener_->on_service_discovery_done(error); } ble_device_base::GattServiceTable RP2GattClient::get_service_table() { @@ -873,7 +856,7 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { return 0; } -int RP2GattClient::disconnect() { +int RP2GattClient::gatt_disconnect() { switch (this->state_) { case EngineState::IDLE: return GATT_ERR_NOT_CONNECTED; @@ -990,7 +973,7 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui return 0; } } - if (status == 0 && this->listener_ != nullptr) { + if (status == 0) { this->listener_->on_write_result(handle, 0); } return status; @@ -1092,9 +1075,7 @@ int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { } } } - if (this->listener_ != nullptr) { - this->listener_->on_notify_state(handle, enable, 0); - } + this->listener_->on_notify_state(handle, enable, 0); return 0; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index d5bf76e6ee..df43ebd66d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -26,8 +26,6 @@ namespace esphome::bluetooth_connection { -class BluetoothConnection; - // 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 @@ -80,11 +78,14 @@ class RP2GattClient final : public Component, public Parentedlistener_ = listener; } + 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 gatt_disconnect(); + // Teardown starts inside gatt_disconnect() on this backend; nothing is + // ever scheduled, so there is nothing to cancel. + bool cancel_gatt_disconnect() { return false; } int discover_services(); int read_characteristic(uint16_t handle); int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); @@ -94,6 +95,8 @@ class RP2GattClient final : public Component, public Parented event_queue_; esphome::EventPool event_pool_; @@ -174,7 +177,7 @@ class RP2GattClient final : public Component, public Parented list[str]: @@ -27,7 +33,7 @@ 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_client", "esp32_ble_tracker"] + return ["bluetooth_connection", "esp32_ble_tracker"] if CORE.target_platform in _HUB_PLATFORMS: return ["ble_device_base", "bluetooth_connection"] # No target platform, or one this component does not support: tooling @@ -36,7 +42,6 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: return [ "ble_device_base", "bluetooth_connection", - "esp32_ble_client", "esp32_ble_tracker", ] @@ -47,8 +52,9 @@ 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. +# Coupled to bluetooth_connection: platforms here are also listed in its +# _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and FILTER_SOURCE_FILES +# hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] @@ -59,7 +65,6 @@ _LOGGER = logging.getLogger(__name__) CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" -CONF_BACKEND_ID = "backend_id" DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") @@ -86,12 +91,7 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - BluetoothConnection = bluetooth_connection.esp32_connection_class() - CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(BluetoothConnection), - } - ).extend(cv.COMPONENT_SCHEMA) + CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32) def validate_connections(config): if CONF_CONNECTIONS in config: @@ -154,16 +154,7 @@ 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.""" - from esphome.components import rp2040_ble - - connection_schema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( - bluetooth_connection.RP2GattClient - ), - } - ) + 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 @@ -182,11 +173,6 @@ def _rp2_config_schema() -> cv.All: cv.Schema( { **_COMMON_SCHEMA_KEYS, - # The GATT backend drives the controller directly (connect, GATT - # ops), not through the tracker hub. - cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( - rp2040_ble.RP2040BLE - ), cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, @@ -212,25 +198,25 @@ def _rp2_config_schema() -> cv.All: return cv.All(schema, populate_connections) -async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble - - # One wrapper + backend pair per slot (the esp32 arm's pattern). - for connection_conf in config[CONF_CONNECTIONS]: - ble_device_base.request_gatt_client() - backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) - await cg.register_component(backend, connection_conf) - await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) +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().""" + connections = config.get(CONF_CONNECTIONS, []) + # The api component sizes BluetoothConnectionsFreeResponse.allocated with + # this define whenever a proxy is present (zero on advertisement-only + # hubs); sized here so it can never diverge from the loop below. + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + for connection_conf in connections: + backend = await bluetooth_connection.new_gatt_backend(connection_conf) connection = cg.new_Pvariable(connection_conf[CONF_ID]) cg.add(connection.set_backend(backend)) cg.add(var.register_connection(connection)) -# Per-platform schema builders and connection codegen; every key of -# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by -# tests/component_tests/bluetooth_proxy/). +# 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} -_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code} # Keys every platform arm declares identically; each arm spreads this dict so @@ -381,15 +367,7 @@ async def _to_code_esp32(config: ConfigType) -> None: # registration into the proxy; the other hubs are polled instead. cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") - # Define max connections for protobuf fixed array - connection_count = len(config.get(CONF_CONNECTIONS, [])) - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) - - 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) + await _connections_to_code(var, config) if config.get(CONF_CACHE_SERVICES): add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) @@ -403,16 +381,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None: hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) 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) - if not slots: - return - - await _GATT_HUB_TO_CODE[CORE.target_platform](var, config) + await _connections_to_code(var, config) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 88d8cc1885..0a16567549 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -132,13 +132,6 @@ void BluetoothProxy::log_advertisement_flush_() { } void BluetoothProxy::dump_config() { -#ifdef USE_ESP32 - ESP_LOGCONFIG(TAG, - "Bluetooth Proxy:\n" - " Active: %s\n" - " Connections: %d", - YESNO(this->active_), this->connection_count_); -#else // 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. @@ -162,32 +155,8 @@ void BluetoothProxy::dump_config() { " Adapter MAC: %s", scan_mode, mac_out); #endif -#endif } -#ifdef USE_ESP32 - -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; - } - 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(); - } - } -} - -#endif // USE_ESP32 - #ifdef BLUETOOTH_CONNECTION_HAS_GATT // maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. @@ -200,11 +169,8 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c ESP_LOGE(TAG, "Connection registry full, dropping registration"); return; } -#ifndef USE_ESP32 - // esp32 assigns connection_index_ in BLEClientBase::setup(); the hub - // class has no Component lifecycle, so the index is assigned here. + // The hub wrapper has no Component lifecycle, so the index is assigned here. connection->connection_index_ = this->connection_count_; -#endif this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; #endif @@ -274,16 +240,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == 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()); + } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { + ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", + connection->get_connection_index(), connection->address_str()); return; } else if (connection->state() != ClientState::INIT) { + // Covers CONNECTING too: a repeat request during a connect attempt is + // ignored the same way. this->log_connection_request_ignored_(connection, connection->state()); return; } @@ -315,7 +278,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { - // Both connection classes expose the same pairing surface; success is + // 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) { @@ -486,11 +449,33 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #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 + void BluetoothProxy::loop() { #ifdef BLUETOOTH_CONNECTION_HAS_GATT - // Stream pending service-discovery batches every iteration (esp32 parity: - // its connections stream from their own per-iteration Component loop). - // send_service_for_discovery_() handles a vanished API connection itself. + // 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(); } @@ -502,10 +487,19 @@ void BluetoothProxy::loop() { return; this->last_advertisement_flush_time_ = now; + if (this->connections_free_pending_ && this->api_connection_ != nullptr) { + // Resend a dropped slot-state update, paced by the 100 ms gate so the + // retry does not hammer the congestion it exists to survive; the + // advertisement-only arm answers DISCONNECT requests with this message + // too, so the drain compiles on every proxy build. + this->connections_free_pending_ = false; + this->send_connections_free(this->api_connection_); + } + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // The API subscriber is gone: tear down any connections it left behind - // (disconnect() on an already-disconnecting backend is a no-op). + // (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) { @@ -550,12 +544,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: - this->send_device_unpairing(msg.address, false, GATT_NOT_CONNECTED); + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { + // Address-scoped maintenance needs no connection slot: real on esp32 + // (Bluedroid bond table), the stub elsewhere keeps the old error reply. + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: - this->send_device_clear_cache(msg.address, false, GATT_NOT_CONNECTED); + } + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; + } } } @@ -595,29 +595,6 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT -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 - void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { // A previous subscriber still holds the slot. This is almost always a stale @@ -631,6 +608,8 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), this->api_connection_->get_peername_to(old_peername)); } + // A stale retry latch belongs to the previous subscriber's session. + this->connections_free_pending_ = false; this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). @@ -646,6 +625,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; + this->connections_free_pending_ = false; } void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { @@ -656,6 +636,8 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui call.connected = connected; call.mtu = mtu; call.error = error; + // Fire and forget: a drop is covered by the client's own timeouts and the + // retried connections-free state. this->api_connection_->send_message(call); } void BluetoothProxy::send_connections_free() { @@ -665,7 +647,13 @@ void BluetoothProxy::send_connections_free() { } void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { - api_connection->send_message(this->connections_free_response_); + // Latch only for the current subscriber: loop() resends to api_connection_. + if (!api_connection->send_message(this->connections_free_response_) && api_connection == this->api_connection_) { + // V like the api layer's own buffer-full log: a D would ride the same + // full connection. + ESP_LOGV(TAG, "Connections-free update deferred, TCP buffer full"); + this->connections_free_pending_ = true; + } } void BluetoothProxy::send_gatt_services_done(uint64_t address) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d7150617d3..26f99fcca2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -5,8 +5,6 @@ #ifdef USE_BLUETOOTH_PROXY #include -#include -#include #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" @@ -17,11 +15,7 @@ #include "esphome/components/ble_device_base/ble_hub_impl.h" -#ifdef USE_ESP32 -#include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" -#elif defined(USE_BLE_GATT_CLIENT) #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" -#endif namespace esphome::bluetooth_proxy { @@ -29,7 +23,6 @@ namespace esphome::bluetooth_proxy { // re-exported here so the proxy code reads unqualified. using bluetooth_connection::CONN_OK; using bluetooth_connection::conn_err_t; -using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::INIT_SENDING_SERVICES; @@ -261,6 +254,10 @@ class BluetoothProxy final : public Component { // Group 4: 1-byte types grouped together bool active_; + // A dropped send (full TCP buffer) would leave the API client with a stale + // slot state forever; the cached response is current by construction, so + // retrying it from loop() is an idempotent resync. + bool connections_free_pending_{false}; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML #ifndef USE_BLE_SCANNER_STATE_CALLBACK diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c0a3b99968..c82c2b3dbe 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Collection from esphome.const import ( CONF_LEVEL, @@ -98,6 +98,19 @@ def merge_config(old, new): return new +def frameworks_for_platforms(platforms: Collection[str]) -> set[PlatformFramework]: + """All PlatformFramework members whose platform is in `platforms`. + + For FILTER_SOURCE_FILES maps that must stay in sync with a platform + registry: deriving the framework set here means a platform added to the + registry cannot validate and then fail at link on a filtered-out file. + """ + known = {pf.value[0].value for pf in PlatformFramework} + if unknown := set(platforms) - known: + raise ValueError(f"unknown platform(s): {sorted(unknown)}") + return {pf for pf in PlatformFramework if pf.value[0].value in platforms} + + def filter_source_files_from_platform( files_map: dict[str, set[PlatformFramework]], ) -> Callable[[], list[str]]: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7be217383e..bfb019d7ae 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -306,6 +306,8 @@ #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define USE_ESP32_BLE_TRACKER +#define USE_BLE_GATT_CLIENT +#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index e784c9871e..86ee53fe8a 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -109,6 +109,8 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only( generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" + # One neutral GATT backend slot per connection (the hub-model flip). + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" def test_counts_reset_between_compiles( diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index 32e5daf4bb..765b2e48d4 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -15,6 +15,13 @@ import voluptuous as vol from esphome import config_validation as cv from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema + +def _esp32_schema_keys() -> dict[str, object]: + # The builder names its platform explicitly, so no CORE state is needed + # (this also mirrors how the language-schema dumper calls it). + return _keys(_schema_of(_esp32_config_schema())) + + # esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA # plumbing (derived, so a future core key does not fail this component's test), # generated IDs (not user-walkable options), and connections (must validate @@ -38,7 +45,7 @@ def _keys(schema: vol.Schema) -> dict[str, object]: def test_outer_scalar_keys_exist_in_esp32_schema() -> None: outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() missing = set(outer) - set(esp32) assert not missing, ( f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema " @@ -51,7 +58,7 @@ def test_esp32_scalars_all_walkable() -> None: """Every non-generated esp32 scalar option must appear in the outer schema (connections is deliberately excluded — it must validate exactly once).""" outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() scalar = { name for name, key in esp32.items() diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 55e6fe2ca7..a47dfd53fa 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -9,11 +9,13 @@ import pytest from esphome import config_validation as cv from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy +from esphome.config_helpers import frameworks_for_platforms from esphome.const import ( CONF_ACTIVE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, PlatformFramework, @@ -177,28 +179,49 @@ def test_rp2_rejects_esp32_only_keys_by_name( bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) +def test_hub_source_filter_covers_every_hub_platform() -> None: + # bluetooth_connection cannot import this module to derive the hub.cpp + # framework set, so pin it here: a platform admitted to the proxy but + # missing from the filter would validate, then fail at link. + expected = frameworks_for_platforms( + [*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32] + ) + hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[ + "bluetooth_connection_hub.cpp" + ] + assert expected == hub_frameworks + + def test_bluetooth_connection_auto_load_covers_its_includes() -> None: - # The esp32 connection header includes esp32_ble_client; the auto load - # must satisfy that closure itself (regression: it once relied on the - # consumer's auto loads). + # The backend registers with its platform BLE stack (and the Bluedroid + # header includes the tracker's), so that closure lives here and + # consumers stay platform-blind; the platform-less arm is the union for + # manifest-resolving tooling. _set_platform("esp32") - assert "esp32_ble_client" in bluetooth_connection.AUTO_LOAD() + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"] _set_platform("rp2") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"] + _set_platform("ln882x") assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] - # No target platform (tooling resolving the manifest): the union, so - # dependency closures stay complete for build_codeowners and friends. _set_platform(None) - assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] + assert bluetooth_connection.AUTO_LOAD() == [ + "ble_device_base", + "esp32_ble_tracker", + "rp2040_ble", + ] def test_every_registered_hub_platform_has_a_schema_arm() -> None: - # A platform added to HUB_MAX_CONNECTIONS without a schema builder, - # codegen arm, or _HUB_PLATFORMS entry would only fail when a config for - # it is validated (or not even then); pin all three couplings here. + # A platform added to HUB_MAX_CONNECTIONS without a schema builder or + # _HUB_PLATFORMS entry would only fail when a config for it is validated + # (or not even then); pin both couplings here. Connection codegen is + # shared (bluetooth_connection.new_gatt_backend), so it needs no arm. registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) - assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE) assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # Hub platforms must also be in the backend registry the shared codegen + # helpers dispatch on. + assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS) # The outer walkable schema's bound must stay the loosest platform cap. assert ( max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) @@ -220,9 +243,14 @@ def test_defines_h_mirrors_the_rp2_slot_cap() -> None: assert int(match.group(1)) == cap, ( f"defines.h rp2 arm carries {match.group(1)}, expected {cap}" ) - # The static-analysis client count scales with the same cap. - match = re.search(r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", defines) - assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from defines.h" + # The static-analysis client count scales with the same cap. Scoped to + # the USE_RP2 block: the esp32 arm carries its own count. + rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines) + assert rp2_block is not None, "no USE_RP2 platform block in defines.h" + match = re.search( + r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1) + ) + assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block" assert int(match.group(1)) == cap, ( - f"ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" + f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" ) diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index 25b6cbf002..89eb56642e 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -2,7 +2,7 @@ // configured; this TU pins it on the host so the header cannot rot unseen. // The contract is a concept (BLEGattConnection is a per-platform alias), so // the minimal backend here proves the concept stays satisfiable and routes -// events through the duck-typed sink the way a real backend does. +// events through the GattClientListener interface the way a real backend does. #define USE_BLE_GATT_CLIENT #include "esphome/components/ble_device_base/ble_gatt_client.h" @@ -11,37 +11,37 @@ namespace esphome::ble_device_base::testing { -struct RecordingSink { - void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } - void on_service_discovery_done(int error) { this->discovery_error_ = error; } - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} - void on_write_result(uint16_t handle, int error) {} - void on_notify_state(uint16_t handle, bool enabled, int error) {} - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} - void on_pairing_result(int status) {} +// Overrides only what it records; the interface's defaults cover the rest. +class RecordingListener : public GattClientListener { + public: + void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } + void on_service_discovery_done(int error) override { this->discovery_error_ = error; } + void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; } + bool connected_{false}; int discovery_error_{0}; + uint16_t write_handle_{0}; }; -static_assert(GattClientEventSinkContract, "the recording sink must cover the full event-sink surface"); - class MinimalConnection { public: - void set_listener(RecordingSink *listener) { this->listener_ = listener; } + void set_listener(GattClientListener *listener) { this->listener_ = listener; } int connect(uint64_t address, uint8_t addr_type) { - if (this->listener_ != nullptr) - this->listener_->on_connection_state(true, 517, 0); + this->listener_->on_connection_state(true, 517, 0); return 0; } - int disconnect() { return 0; } + bool cancel_gatt_disconnect() { return false; } + int gatt_disconnect() { return 0; } int discover_services() { - if (this->listener_ != nullptr) - this->listener_->on_service_discovery_done(0); + this->listener_->on_service_discovery_done(0); return 0; } int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + this->listener_->on_write_result(handle, 0); + return 0; + } int read_descriptor(uint16_t handle) { return 0; } int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } int notify_characteristic(uint16_t handle, bool enable) { return 0; } @@ -51,23 +51,26 @@ class MinimalConnection { } GattServiceTable get_service_table() { return {}; } void release_services() {} + void set_connection_type(ConnectionType ct) {} protected: - RecordingSink *listener_{nullptr}; + GattClientListener *listener_{nullptr}; }; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "a minimal backend must satisfy the contract the alias asserts"); TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; - RecordingSink listener; + RecordingListener listener; connection.set_listener(&listener); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_TRUE(listener.connected_); EXPECT_EQ(connection.discover_services(), 0); EXPECT_EQ(listener.discovery_error_, 0); EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); + EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0); + EXPECT_EQ(listener.write_handle_, 7); // A default table is empty and safe to walk. GattServiceTable table = connection.get_service_table(); diff --git a/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml new file mode 100644 index 0000000000..b3445f16c8 --- /dev/null +++ b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml @@ -0,0 +1,12 @@ +# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is +# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the +# address-scoped maintenance path that a connections build never exercises. +# Under batch grouping the active default build is what runs; the standalone +# compile of this fixture is what exercises the passive gating. +packages: + common: !include common.yaml + +esp32_ble_tracker: + +bluetooth_proxy: + active: false diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 1c850e3759..88913c0f23 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -3,7 +3,13 @@ from collections.abc import Callable from unittest.mock import patch -from esphome.config_helpers import filter_source_files_from_platform, get_logger_level +import pytest + +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, + get_logger_level, +) from esphome.const import ( CONF_LEVEL, CONF_LOGGER, @@ -133,3 +139,12 @@ def test_get_logger_level() -> None: mock_config = {CONF_LOGGER: {}} with patch("esphome.config_helpers.CORE.config", mock_config): assert get_logger_level() == "DEBUG" + + +def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None: + assert frameworks_for_platforms(["esp32"]) == { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + } + with pytest.raises(ValueError, match="unknown platform"): + frameworks_for_platforms(["esp32", "not_a_platform"])