Merge pull request #21097 from esphome/bump-2026.10.0b5

2026.10.0b5
This commit is contained in:
Jesse Hills
2026-10-19 14:03:02 +13:00
committed by GitHub
68 changed files with 887 additions and 165 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.10.0b4
PROJECT_NUMBER = 2026.10.0b5
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0
RUN \
platformio settings set enable_telemetry No \
+8 -2
View File
@@ -403,6 +403,9 @@ message WizardCapabilities {
message SendspinCapabilities {
// Bitmask of the Sendspin features this device supports
uint32 feature_flags = 1;
// The Sendspin client_id: base64url of the client's static public key, 43 characters.
// Empty until the Sendspin client has started once since boot.
string client_id = 2 [(max_data_length) = 43];
}
message DeviceCapabilitiesResponse {
@@ -3043,12 +3046,15 @@ enum SerialProxyIdentityFlag {
// IDs below are empty
}
// The descriptor fields of a USB device as seen by the host stack
// The descriptor fields of a USB device as seen by the host stack, and the port's index
// among those a vendor driver exposes on the interface
message UsbDeviceDescriptor {
uint32 vendor_id = 1;
uint32 product_id = 2;
uint32 bcd_device = 3;
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to
uint32 interface_number = 4; // bInterfaceNumber the port is bound through
uint32 interface_class = 5; // bInterfaceClass of that interface
uint32 port_number = 6;
}
// The identity of the device behind a port. Identifies one physical device among others of
@@ -2128,6 +2128,7 @@ bool APIConnection::send_device_capabilities_response_() {
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id();
#endif
return this->send_message(resp);
}
@@ -152,10 +152,7 @@ void OutgoingConnectionManager::loop(APIServer *server) {
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
if (!network::is_connected()) {
// Flips within seconds of boot; recheck fast so a deep sleep wake
// window is not spent waiting
this->schedule_wait_(now, NETWORK_RETRY_MS);
return;
return; // still WAITING with the wait elapsed, so the next loop rechecks
}
struct sockaddr_storage addr;
socklen_t addr_len = this->target_sockaddr_(&addr);
@@ -197,7 +194,6 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
}
this->state_ = DialState::DIAL_STATE_CONNECTING;
this->state_ts_ = now;
this->last_poll_ = now;
}
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
@@ -206,10 +202,6 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
this->schedule_retry_(now);
return;
}
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
return;
}
this->last_poll_ = now;
int err = 0;
switch (socket::poll_connect(*this->dial_socket_, err)) {
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
@@ -59,8 +59,6 @@ class OutgoingConnectionManager {
static constexpr uint32_t BACKOFF_MIN_MS = 5000;
static constexpr uint32_t BACKOFF_MAX_MS = 300000;
static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000;
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
static constexpr uint32_t NETWORK_RETRY_MS = 500;
static constexpr uint32_t PRECONDITION_RETRY_MS = 5000;
// A deep sleep wake window is too short to spend on the delay, so those
// builds dial out as soon as the target is gone
@@ -104,7 +102,6 @@ class OutgoingConnectionManager {
uint32_t backoff_{BACKOFF_MIN_MS};
uint32_t wait_{BOOT_WAIT_MS};
uint32_t state_ts_{0};
uint32_t last_poll_{0};
// Byte-aligned types last
#ifndef API_OUTGOING_CONNECTION_HOST
+6
View File
@@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) {
uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id);
return pos;
}
uint32_t SendspinCapabilities::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, msg.feature_flags);
size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0;
return size;
}
#endif
@@ -4175,6 +4177,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number);
return pos;
}
uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
@@ -4184,6 +4188,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.product_id);
size += ProtoSize::calc_uint32(1, msg.bcd_device);
size += ProtoSize::calc_uint32(1, msg.interface_number);
size += ProtoSize::calc_uint32(1, msg.interface_class);
size += ProtoSize::calc_uint32(1, msg.port_number);
return size;
}
uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
+3
View File
@@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage {
class SendspinCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
StringRef client_id{nullptr, 0}; // null until set, encode only
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -4083,6 +4084,8 @@ class UsbDeviceDescriptor final : public ProtoMessage {
uint32_t product_id{0};
uint32_t bcd_device{0};
uint32_t interface_number{0};
uint32_t interface_class{0};
uint32_t port_number{0};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
+3
View File
@@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("client_id"), this->client_id);
return out.c_str();
}
#endif
@@ -2942,6 +2943,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class);
dump_field(out, ESPHOME_PSTR("port_number"), this->port_number);
return out.c_str();
}
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
+2 -2
View File
@@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data)
void BL0906::loop() {
while (this->available())
this->flush();
this->read();
if (this->current_stage_ == STAGE_IDLE) {
// Woken up between cycles to drain the action queue. Go back to sleep.
@@ -92,7 +92,7 @@ void BL0906::advance_stage_() {
void BL0906::setup() {
while (this->available())
this->flush();
this->read();
this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE));
// Calibration (1: register address; 2: value before calibration; 3: value after calibration)
this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1
@@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() {
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}};
esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;
@@ -34,6 +34,15 @@ void DebugComponent::dump_config() {
this->free_heap_ = get_free_heap_();
ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_);
uint32_t uptime = static_cast<uint32_t>(millis_64() / 1000);
uint32_t days = uptime / 86400;
uptime -= days * 86400;
uint32_t hours = uptime / 3600;
uptime -= hours * 3600;
uint32_t minutes = uptime / 60;
uptime -= minutes * 60;
ESP_LOGD(TAG, "Uptime: %" PRIu32 "d %02" PRIu32 "h %02" PRIu32 "m %02" PRIu32 "s", days, hours, minutes, uptime);
pos = get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>(device_info_buffer), pos);
#ifdef USE_TEXT_SENSOR
+1
View File
@@ -208,6 +208,7 @@ def _final_validate(config) -> None:
else:
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
elif CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
+5 -4
View File
@@ -1099,7 +1099,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
# See: https://github.com/pioarduino/esp-idf/releases
ARDUINO_IDF_VERSION_LOOKUP = {
cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1),
cv.Version(3, 3, 12): cv.Version(5, 5, 5),
cv.Version(3, 3, 12): cv.Version(5, 5, 6),
cv.Version(3, 3, 11): cv.Version(5, 5, 5),
cv.Version(3, 3, 10): cv.Version(5, 5, 5),
cv.Version(3, 3, 9): cv.Version(5, 5, 4),
@@ -1123,9 +1123,9 @@ ARDUINO_IDF_VERSION_LOOKUP = {
# The default/recommended esp-idf framework version
# - https://github.com/espressif/esp-idf/releases
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(5, 5, 5),
"latest": cv.Version(5, 5, 5),
"dev": cv.Version(5, 5, 5),
"recommended": cv.Version(5, 5, 6),
"latest": cv.Version(5, 5, 6),
"dev": cv.Version(5, 5, 6),
}
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
@@ -1135,6 +1135,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(5, 5, 6): cv.Version(55, 3, 312),
cv.Version(5, 5, 5): cv.Version(55, 3, 312),
cv.Version(5, 5, 4): cv.Version(55, 3, 39),
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
@@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) {
esp_err_t BLEClientBase::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}};
esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;
+52 -1
View File
@@ -20,6 +20,10 @@
#ifdef USE_DASHBOARD_IMPORT
#include "esphome/components/dashboard_import/dashboard_import.h"
#endif
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
#include "esphome/components/ethernet/ethernet_component.h"
#include "esphome/components/wifi/wifi_component.h"
#endif
namespace esphome::mdns {
@@ -39,6 +43,45 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp");
// Wrap build-time defines into flash storage
MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION);
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi");
MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet");
// Same order as the default route in NetworkComponent::loop()
const char *MDNSComponent::connected_network_() const {
const bool ethernet = ethernet::global_eth_component->is_connected();
const bool wifi = wifi::global_wifi_component->is_connected();
#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI
if (wifi)
return NETWORK_WIFI;
#endif
if (ethernet)
return NETWORK_ETHERNET;
if (wifi)
return NETWORK_WIFI;
return nullptr;
}
void MDNSComponent::update_network_txt_() {
if (this->network_txt_ == nullptr)
return;
// With no interface connected, keep the last value instead of flapping to wifi
const char *network = this->connected_network_();
if (network == nullptr || network == this->network_txt_)
return;
if (!this->publish_network_txt_(network)) {
// The _esphomelib service is missing, so a retry would fail too
this->network_txt_ = nullptr;
return;
}
ESP_LOGD(TAG, "Network TXT: %s", network);
this->network_txt_ = network;
#ifdef USE_MDNS_STORE_SERVICES
this->network_record_->value = MDNS_STR(network);
#endif
}
#endif
void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) {
#ifdef USE_MDNS_STORE_SERVICES
auto &services = this->services_;
@@ -160,7 +203,15 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
txt_records.push_back({MDNS_STR(TXT_BOARD), MDNS_STR(VALUE_BOARD)});
#if defined(USE_WIFI)
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
const char *network = this->connected_network_();
this->network_txt_ = network != nullptr ? network : NETWORK_WIFI;
txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)});
#ifdef USE_MDNS_STORE_SERVICES
// services is services_ here, which never reallocates
this->network_record_ = &txt_records.back();
#endif
#elif defined(USE_WIFI)
MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi");
txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)});
#elif defined(USE_ETHERNET)
+22
View File
@@ -32,6 +32,12 @@
#define USE_MDNS_DEVICE_INFO_TXT
#endif
// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface.
// OpenThread hands its services to the SRP client once, so it keeps the fixed value.
#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD)
#define USE_MDNS_DYNAMIC_NETWORK_TXT
#endif
namespace esphome::mdns {
// Helper struct that identifies strings that may be stored in flash storage (similar to LogString)
@@ -150,6 +156,22 @@ class MDNSComponent final : public Component
void setup_buffers_and_register_(PlatformRegisterFn platform_register);
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000;
/// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr.
const char *connected_network_() const;
/// Check the connected interface and publish a changed "network" TXT value.
void update_network_txt_();
/// Platform-specific: replace the "network" TXT value of the registered _esphomelib service.
bool publish_network_txt_(const char *network);
/// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish.
const char *network_txt_{nullptr};
#ifdef USE_MDNS_STORE_SERVICES
/// The "network" record in services_, kept in step with network_txt_.
MDNSTXTRecord *network_record_{nullptr};
#endif
#endif
#ifdef USE_MDNS_DYNAMIC_TXT
/// Storage for runtime-generated TXT values from user lambdas
/// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations.
+18 -1
View File
@@ -89,7 +89,24 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr
}
#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD
void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); }
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
bool MDNSComponent::publish_network_txt_(const char *network) {
// ESP-IDF copies the value and announces the changed record
esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err));
return false;
}
return true;
}
#endif
void MDNSComponent::setup() {
this->setup_buffers_and_register_(register_esp32);
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); });
#endif
}
void MDNSComponent::on_shutdown() {
#ifndef USE_OPENTHREAD
+19
View File
@@ -46,7 +46,26 @@ void MDNSComponent::start_polling_window_() {
}
#endif
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
ESPHOME_LOG_TAG(TAG, "mdns");
bool MDNSComponent::publish_network_txt_(const char *network) {
// LEAmDNS replaces the value of an existing key; announce() sends the change
if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) {
ESP_LOGW(TAG, "Failed to update network TXT");
return false;
}
MDNS.announce();
this->start_polling_window_();
return true;
}
#endif
void MDNSComponent::setup() {
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
// No-op until the first IP-up event registers the services
this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); });
#endif
// arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses
// on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our
// own IP state listener. Both WiFi and Ethernet have the same listener signature —
@@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip):
def transforms(self) -> set[str]:
"""
Return the set of transformations supported by this driver chip.
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY.
A model with an empty init sequence has no command interface at all, so it
cannot apply any transform in hardware.
"""
if self.initsequence is not None and len(self.initsequence) == 0:
return set()
return super().transforms - {CONF_SWAP_XY}
+1 -57
View File
@@ -16,6 +16,7 @@
#include "lwip/dns.h"
#include "lwip/err.h"
#include "mqtt_component.h"
#include "mqtt_topic_match.h"
#ifdef USE_API
#include "esphome/components/api/api_server.h"
@@ -581,63 +582,6 @@ void MQTTClientComponent::disable() {
this->on_shutdown();
}
/** Check if the message topic matches the given subscription topic
*
* INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings.
*
* @param message The message topic that was received from the MQTT server. Note: this must not contain
* wildcard characters as mandated by the MQTT spec.
* @param subscription The subscription topic we are matching against.
* @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$".
* @param past_separator Are we past the first '/' topic separator.
* @return true if the subscription topic matches the message topic, false otherwise.
*/
static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) {
// Reached end of both strings at the same time, this means we have a successful match
if (*message == '\0' && *subscription == '\0')
return true;
// Either the message or the subscribe are at the end. This means they don't match.
if (*message == '\0' || *subscription == '\0')
return false;
bool do_wildcards = is_normal || past_separator;
if (*subscription == '+' && do_wildcards) {
// single level wildcard
// consume + from subscription
subscription++;
// consume everything from message until '/' found or end of string
while (*message != '\0' && *message != '/') {
message++;
}
// after this, both pointers will point to a '/' or to the end of the string
return topic_match(message, subscription, is_normal, true);
}
if (*subscription == '#' && do_wildcards) {
// multilevel wildcard - MQTT mandates that this must be at end of subscribe topic
return true;
}
// this handles '/' and normal characters at the same time.
if (*message != *subscription)
return false;
past_separator = past_separator || *subscription == '/';
// consume characters
subscription++;
message++;
return topic_match(message, subscription, is_normal, past_separator);
}
static bool topic_match(const char *message, const char *subscription) {
return topic_match(message, subscription, *message != '\0' && *message != '$', false);
}
void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) {
#ifdef USE_ESP8266
// IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266.
@@ -0,0 +1,28 @@
#pragma once
namespace esphome::mqtt {
/// True if the message topic matches the subscription, with + and # wildcards.
/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics.
inline bool topic_match(const char *message, const char *subscription) {
// First-level wildcards do not match topics that begin with '$'
bool wildcards = *message != '$';
while (*message != '\0' && *subscription != '\0') {
if (wildcards && *subscription == '#')
return true;
if (wildcards && *subscription == '+') {
subscription++;
while (*message != '\0' && *message != '/')
message++;
continue;
}
if (*message != *subscription)
return false;
wildcards |= *subscription == '/';
message++;
subscription++;
}
return *message == *subscription; // a match only if both ended
}
} // namespace esphome::mqtt
@@ -37,6 +37,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
.reserved = {},
};
int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER);
@@ -243,6 +243,14 @@ std::optional<std::string> SendspinHub::get_pairing_token() const {
return this->client_->pairing_token();
}
// THREAD CONTEXT: Main loop
StringRef SendspinHub::get_client_id() const {
if (this->client_ == nullptr) {
return StringRef();
}
return StringRef(this->client_->client_id());
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
void SendspinHub::set_unpaired_access_enabled(bool enabled) {
this->unpaired_access_ = enabled;
@@ -198,6 +198,12 @@ class SendspinHub final : public Component,
/// started once; it stays available after the client stops. Main loop only.
std::optional<std::string> get_pairing_token() const;
/// @brief Returns the Sendspin client_id: base64url of the client's static public key.
///
/// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into
/// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only.
StringRef get_client_id() const;
/// @brief Sets the device information reported to the server in the `client/hello` message.
///
/// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the
@@ -412,6 +412,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api:
msg.usb.product_id = scratch.product_id;
msg.usb.bcd_device = scratch.bcd_device;
msg.usb.interface_number = this->usb_channel_->get_interface_number();
msg.usb.interface_class = this->usb_channel_->get_interface_class();
msg.usb.port_number = this->usb_channel_->get_port_number();
msg.manufacturer = StringRef(scratch.manufacturer);
msg.product = StringRef(scratch.product);
msg.serial_number = StringRef(scratch.serial_number);
+5 -25
View File
@@ -1,8 +1,7 @@
import esphome.codegen as cg
from esphome.components import binary_sensor, sensor, socket, uart
from esphome.components import socket, uart
from esphome.components.const import (
CONF_ALLOWED_IPS,
CONF_CONNECTED,
CONF_DATA_BITS,
CONF_HOST,
CONF_PARITY,
@@ -11,22 +10,16 @@ from esphome.components.const import (
CONF_STOP_BITS,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_BAUD_RATE,
CONF_ID,
CONF_PORT,
DEVICE_CLASS_CONNECTIVITY,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_TOTAL_INCREASING,
)
from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT
from esphome.types import ConfigType
CODEOWNERS = ["@Bascht74"]
DEPENDENCIES = ["network"]
AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"]
AUTO_LOAD = ["uart", "socket"]
DOMAIN = "tcp_uart"
MULTI_CONF = True
CONF_DISCONNECTS = "disconnects"
CONF_TCP_UART_ID = "tcp_uart_id"
tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart")
TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component)
@@ -45,15 +38,6 @@ BASE_SCHEMA = cv.Schema(
cv.Optional(
CONF_RECONNECT_INTERVAL, default="5s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_TOTAL_INCREASING,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
).extend(cv.COMPONENT_SCHEMA)
@@ -99,7 +83,3 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_parity(config[CONF_PARITY]))
if (host := config.get(CONF_HOST)) is not None:
cg.add(var.set_host(host))
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, var.set_connected_sensor)
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor)
@@ -0,0 +1,25 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
from esphome.components.const import CONF_CONNECTED
import esphome.config_validation as cv
from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from . import CONF_TCP_UART_ID, TcpUart
DEPENDENCIES = ["tcp_uart"]
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart),
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_TCP_UART_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor)
+31
View File
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_TOTAL_INCREASING,
)
from esphome.types import ConfigType
from . import CONF_TCP_UART_ID, TcpUart
DEPENDENCIES = ["tcp_uart"]
CONF_DISCONNECTS = "disconnects"
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart),
cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_TOTAL_INCREASING,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_TCP_UART_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor)
+6
View File
@@ -17,9 +17,11 @@ void TcpUart::setup() {
#ifdef USE_SOCKET_TCP_LISTENER
this->listener_.begin(TAG);
#endif
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr) {
this->connected_sensor_->publish_state(false);
}
#endif
#ifdef USE_SENSOR
if (this->disconnects_sensor_ != nullptr) {
this->disconnects_sensor_->publish_state(0);
@@ -38,7 +40,9 @@ void TcpUart::dump_config() {
#ifdef USE_SOCKET_TCP_LISTENER
this->listener_.dump_config();
#endif
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
#endif
#ifdef USE_SENSOR
LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_);
#endif
@@ -58,9 +62,11 @@ void TcpUart::sync_link_() {
// Unread bytes of the last session stay readable while down, never into the next one.
this->rx_start_ = this->rx_end_ = 0;
}
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr) {
this->connected_sensor_->publish_state(up);
}
#endif
#ifdef USE_SENSOR
// Only edges get here, so down is the falling edge.
if (!up && this->disconnects_sensor_ != nullptr) {
+7
View File
@@ -1,6 +1,9 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
@@ -23,7 +26,9 @@ class TcpUart : public uart::UARTComponent, public Component {
void set_host(const char *host) { this->link_.set_host(host); }
void set_port(uint16_t port) { this->link_.set_port(port); }
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
#ifdef USE_BINARY_SENSOR
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
#endif
#ifdef USE_SENSOR
void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; }
#endif
@@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component {
#ifdef USE_SOCKET_TCP_LISTENER
socket::TcpListener listener_;
#endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *connected_sensor_{nullptr};
#endif
#ifdef USE_SENSOR
sensor::Sensor *disconnects_sensor_{nullptr};
uint32_t disconnects_{0};
+3 -20
View File
@@ -1,8 +1,7 @@
import esphome.codegen as cg
from esphome.components import binary_sensor, sensor, socket, uart
from esphome.components import socket, uart
from esphome.components.const import (
CONF_ALLOWED_IPS,
CONF_CONNECTED,
CONF_HOST,
CONF_RECONNECT_INTERVAL,
CONF_ROLE,
@@ -14,9 +13,6 @@ from esphome.const import (
CONF_ID,
CONF_PORT,
CONF_UART_ID,
DEVICE_CLASS_CONNECTIVITY,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_TOTAL_INCREASING,
)
from esphome.core import CORE
import esphome.final_validate as fv
@@ -25,10 +21,10 @@ from esphome.types import ConfigType
CODEOWNERS = ["@Bascht74"]
DOMAIN = "uart_tcp"
DEPENDENCIES = ["network", "uart"]
AUTO_LOAD = ["binary_sensor", "sensor", "socket"]
AUTO_LOAD = ["socket"]
MULTI_CONF = True
CONF_DISCONNECTS = "disconnects"
CONF_UART_TCP_ID = "uart_tcp_id"
uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp")
UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice)
@@ -42,15 +38,6 @@ BASE_SCHEMA = cv.Schema(
cv.Optional(
CONF_RECONNECT_INTERVAL, default="5s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_TOTAL_INCREASING,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
).extend(cv.COMPONENT_SCHEMA)
@@ -155,7 +142,3 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
if (host := config.get(CONF_HOST)) is not None:
cg.add(var.set_host(host))
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, var.set_connected_sensor)
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor)
@@ -0,0 +1,25 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
from esphome.components.const import CONF_CONNECTED
import esphome.config_validation as cv
from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from . import CONF_UART_TCP_ID, UartTcp
DEPENDENCIES = ["uart_tcp"]
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp),
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_UART_TCP_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor)
+31
View File
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_TOTAL_INCREASING,
)
from esphome.types import ConfigType
from . import CONF_UART_TCP_ID, UartTcp
DEPENDENCIES = ["uart_tcp"]
CONF_DISCONNECTS = "disconnects"
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp),
cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_TOTAL_INCREASING,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_UART_TCP_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor)
+6
View File
@@ -19,9 +19,11 @@ void UartTcp::setup() {
#ifdef USE_SOCKET_TCP_LISTENER
this->listener_.begin(TAG);
#endif
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr) {
this->connected_sensor_->publish_state(false);
}
#endif
#ifdef USE_SENSOR
if (this->disconnects_sensor_ != nullptr) {
this->disconnects_sensor_->publish_state(0);
@@ -40,7 +42,9 @@ void UartTcp::dump_config() {
#ifdef USE_SOCKET_TCP_LISTENER
this->listener_.dump_config();
#endif
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
#endif
#ifdef USE_SENSOR
LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_);
#endif
@@ -60,9 +64,11 @@ void UartTcp::sync_link_() {
// The driver kept whatever arrived while the link was down.
this->discard_uart_();
}
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr) {
this->connected_sensor_->publish_state(up);
}
#endif
#ifdef USE_SENSOR
// Only edges get here, so down is the falling edge.
if (!up && this->disconnects_sensor_ != nullptr) {
+7
View File
@@ -1,6 +1,9 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
@@ -22,7 +25,9 @@ class UartTcp final : public Component, public uart::UARTDevice {
void set_host(const char *host) { this->link_.set_host(host); }
void set_port(uint16_t port) { this->link_.set_port(port); }
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
#ifdef USE_BINARY_SENSOR
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
#endif
#ifdef USE_SENSOR
void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; }
#endif
@@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice {
#ifdef USE_SOCKET_TCP_LISTENER
socket::TcpListener listener_;
#endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *connected_sensor_{nullptr};
#endif
// Loop start time of the last socket-to-UART write; sizes the next paced write.
uint32_t last_write_ms_{0};
#ifdef USE_SENSOR
-8
View File
@@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo
}
}
std::vector<CdcEps> USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) {
auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl);
// ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare
for (auto &cdc_dev : result) {
cdc_dev.interrupt_interface_number = 0xFF;
}
return result;
}
} // namespace esphome::usb_uart
#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 ||
+5 -5
View File
@@ -88,11 +88,11 @@ std::vector<CdcEps> USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev
ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed");
continue;
}
if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) {
cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}});
} else {
cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}});
}
CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN
? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}
: CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber};
eps.bulk_interface_class = data_desc->bInterfaceClass;
cdc_devs.push_back(eps);
}
return cdc_devs;
}
+1
View File
@@ -217,6 +217,7 @@ static optional<CdcEps> get_uart(const usb_config_desc_t *config_desc, uint8_t i
}
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
return eps;
}
+4 -1
View File
@@ -189,7 +189,10 @@ std::vector<CdcEps> USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev
}
if (in_ep && out_ep) {
cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber});
CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber};
eps.bulk_interface_class = intf->bInterfaceClass;
eps.interrupt_interface_class = intf->bInterfaceClass;
cdc_devs.push_back(eps);
break; // PL2303 is single-port
}
}
+4 -2
View File
@@ -41,14 +41,17 @@ static optional<CdcEps> get_cdc(const usb_config_desc_t *config_desc, uint8_t in
if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) {
eps.notify_ep = ep;
eps.interrupt_interface_number = intf_desc->bInterfaceNumber;
eps.interrupt_interface_class = intf_desc->bInterfaceClass;
} else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN &&
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
eps.in_ep = ep;
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
} else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) &&
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
eps.out_ep = ep;
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
} else {
ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes);
continue;
@@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() {
if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
if (!this->claim_comm_interface_) {
ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)",
channel->cdc_dev_.interrupt_interface_number);
ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number);
} else {
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
channel->cdc_dev_.interrupt_interface_number, 0);
+19 -2
View File
@@ -39,6 +39,9 @@ struct CdcEps {
// must remain valid even when the interface itself is not claimed.
uint8_t interrupt_interface_number{0xFF};
bool interrupt_interface_claimed{false};
// bInterfaceClass of each interface above, which decides how a host binds the port
uint8_t bulk_interface_class{0};
uint8_t interrupt_interface_class{0};
};
enum CH34xChipType : uint8_t {
@@ -186,6 +189,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
: this->cdc_dev_.bulk_interface_number;
}
/// bInterfaceClass of the interface `get_interface_number()` names
uint8_t get_interface_class() const {
return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class
: this->cdc_dev_.bulk_interface_class;
}
/// Index of this port among the ports on its interface. Every driver here exposes one
/// port per interface; a multiplexed driver would index its channels.
uint8_t get_port_number() const { return 0; }
protected:
// Not directly instantiable; construct a concrete channel type instead.
USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {}
@@ -317,13 +330,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm {
};
class USBUartTypeCH34X : public USBUartTypeCdcAcm {
public:
USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {}
USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {
// ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The
// comm interface is left in place unclaimed: on the CDC-style chips it is still the
// interface a host binds, and so the one the port is identified by.
this->claim_comm_interface_ = false;
}
void dump_config() override;
protected:
bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override;
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
private:
CH34xChipType chiptype_{CHIP_UNKNOWN};
@@ -955,10 +955,39 @@ void AsyncEventSourceResponse::process_close_() {
}
}
void AsyncEventSourceResponse::refresh_lru_() {
// httpd only advances a session's LRU counter when a request arrives, and an event stream receives one
// request ever, so the purge that frees a socket for a new client would evict the live streams first.
// The HTTPD task owns the session table, so the refresh runs there.
// A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh.
if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0)
return;
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS ||
this->lru_work_queued_.load(std::memory_order_acquire)) {
return;
}
this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval
this->lru_work_queued_.store(true, std::memory_order_release);
if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) {
this->lru_work_queued_.store(false, std::memory_order_release);
}
}
void AsyncEventSourceResponse::refresh_lru_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
const int fd = response->fd_.load();
if (response->owns_session_(fd)) {
httpd_sess_update_lru_counter(response->hd_, fd);
}
// Release self only after the HTTPD-task callback has finished every access.
response->lru_work_queued_.store(false, std::memory_order_release);
}
void AsyncEventSourceResponse::close_session_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
const int fd = response->fd_.load();
if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) {
if (response->owns_session_(fd)) {
// The HTTPD task remains the session owner. Shutting the socket down makes
// its next select/recv path delete the session and invoke destroy().
shutdown(fd, SHUT_RDWR);
@@ -972,7 +1001,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) {
// httpd frees a session before closing its socket, so the fd may already be a new client's.
// Treated as would-block; the stall timer ends a session that never becomes ours again.
const int fd = this->fd_.load();
if (httpd_sess_get_ctx(this->hd_, fd) != this) {
if (!this->owns_session_(fd)) {
return 0;
}
struct msghdr msg {};
@@ -1081,6 +1110,7 @@ void AsyncEventSourceResponse::loop() {
this->process_close_();
return;
}
this->refresh_lru_();
drain_tail_();
process_deferred_queue_();
if (this->close_requested_)
@@ -329,10 +329,15 @@ class AsyncEventSourceResponse {
};
void process_close_();
static void close_session_work(void *arg);
void refresh_lru_();
static void refresh_lru_work(void *arg);
// httpd frees a session before closing its socket, so the fd may already belong to a new client
bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; }
// Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object.
// Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object.
bool safe_to_delete_() const {
return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire);
return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) &&
!this->lru_work_queued_.load(std::memory_order_acquire);
}
static void destroy(void *p);
@@ -347,11 +352,13 @@ class AsyncEventSourceResponse {
RAMUniquePtr<uint8_t[]> tail_;
uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress.
uint32_t next_close_attempt_ms_{0};
uint32_t last_lru_refresh_ms_{0};
uint16_t tail_cap_{0};
uint16_t tail_len_{0}; // Zero means nothing pending
uint16_t tail_sent_{0};
// Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done.
std::atomic<bool> close_work_queued_{false};
std::atomic<bool> lru_work_queued_{false};
// Main-loop only; the HTTPD task never reads or writes these flags.
bool close_requested_{false};
bool close_retry_warning_logged_{false};
@@ -373,6 +380,7 @@ class AsyncEventSourceResponse {
static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000;
static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250;
static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000;
static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000;
};
using AsyncEventSourceClient = AsyncEventSourceResponse;
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.10.0b4"
__version__ = "2026.10.0b5"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+2 -2
View File
@@ -147,7 +147,7 @@ extends = common:arduino
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
platform_packages =
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz
framework = arduino, espidf ; Arduino as an ESP-IDF component
lib_deps =
@@ -183,7 +183,7 @@ extra_scripts =
extends = common:idf
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
platform_packages =
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz
framework = espidf
lib_deps =
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.4.0
click==8.5.0
aioesphomeapi==46.10.0
aioesphomeapi==46.12.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
@@ -0,0 +1,22 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: ssd1677
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO23
cs_pin: GPIO5
update_interval: never
dimensions:
width: 200
height: 200
@@ -580,3 +580,15 @@ def test_model_with_no_default_init_sequence_generates(
# init_sequence_length, ...); a length of 0 confirms the empty init
# sequence array was generated instead of raising during code generation.
assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp)
def test_test_card_overrides_update_interval(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""With nothing else to draw, update_interval is not left at never for the test card."""
main_cpp = generate_main(component_config_path("test_card_update_interval.yaml"))
match = re.search(r"epaper_display->set_update_interval\((\d+)\);", main_cpp)
assert match is not None
assert int(match.group(1)) != 4294967295
@@ -93,12 +93,12 @@ def test_transform_matches_model_support(
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
# ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays
# The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays
# never support axis swapping).
model = MODELS["ESP32-8048S070"]
model = MODELS["ST7701S"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}})
# An unsupported axis may be explicitly disabled (a harmless no-op)...
@@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x(
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}})
def test_pure_rgb_model_has_no_hardware_transform(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A model with no init sequence has no command interface, so no transform
can be applied in hardware and every rotation must be done in software.
Previously such models claimed mirror_x/mirror_y support, so a rotation of
180 was handed to the (nonexistent) hardware and the display was not rotated.
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
model = MODELS["ESP32-8048S070"]
assert model.initsequence == ()
assert model.transforms == set()
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
for rotation in (90, 180, 270):
config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation})
assert not model.rotation_as_transform(config)
metadata = get_display_metadata(config["id"])
assert metadata.rotation == rotation
assert not metadata.has_hardware_rotation
with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"):
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}})
def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -0,0 +1,9 @@
# wifi and ethernet together: the _esphomelib "network" TXT value follows
# the connected interface, so the update path is compiled.
wifi:
ssid: MySSID
password: password1
api:
mdns:
@@ -0,0 +1,17 @@
packages:
mdns: !include common-dual-interface.yaml
ethernet:
type: W5500
clk_pin: GPIO19
mosi_pin: GPIO21
miso_pin: GPIO23
cs_pin: GPIO18
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 10Mhz
network:
priority:
- ethernet
- wifi
@@ -0,0 +1,17 @@
packages:
mdns: !include common-dual-interface.yaml
ethernet:
type: W5500
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled
network:
priority:
- wifi
- ethernet
+103
View File
@@ -0,0 +1,103 @@
#include <gtest/gtest.h>
#include <random>
#include <string>
#include "esphome/components/mqtt/mqtt_topic_match.h"
namespace esphome::mqtt::testing {
// The previous recursive implementation, kept to check the loop matches it
static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) {
if (*message == '\0' && *subscription == '\0')
return true;
if (*message == '\0' || *subscription == '\0')
return false;
bool do_wildcards = is_normal || past_separator;
if (*subscription == '+' && do_wildcards) {
subscription++;
while (*message != '\0' && *message != '/')
message++;
return reference_match(message, subscription, is_normal, true);
}
if (*subscription == '#' && do_wildcards)
return true;
if (*message != *subscription)
return false;
past_separator = past_separator || *subscription == '/';
return reference_match(message + 1, subscription + 1, is_normal, past_separator);
}
static bool reference_match(const char *message, const char *subscription) {
return reference_match(message, subscription, *message != '\0' && *message != '$', false);
}
TEST(MQTTTopicMatch, Exact) {
EXPECT_TRUE(topic_match("a/b/c", "a/b/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/b"));
EXPECT_FALSE(topic_match("a/b", "a/b/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/b/d"));
}
TEST(MQTTTopicMatch, SingleLevelWildcard) {
EXPECT_TRUE(topic_match("a/b/c", "a/+/c"));
EXPECT_TRUE(topic_match("a/b/c", "+/+/+"));
EXPECT_TRUE(topic_match("a//c", "a/+/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/+"));
EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c"));
}
TEST(MQTTTopicMatch, MultiLevelWildcard) {
EXPECT_TRUE(topic_match("a/b/c", "a/#"));
EXPECT_TRUE(topic_match("a/b/c", "#"));
EXPECT_TRUE(topic_match("a/b/c", "a/+/#"));
EXPECT_FALSE(topic_match("b/c", "a/#"));
}
TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) {
EXPECT_FALSE(topic_match("$SYS/broker", "#"));
EXPECT_FALSE(topic_match("$SYS/broker", "+/broker"));
EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#"));
EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+"));
}
TEST(MQTTTopicMatch, EdgeCases) {
EXPECT_TRUE(topic_match("", ""));
EXPECT_FALSE(topic_match("a", ""));
// Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level
EXPECT_FALSE(topic_match("a/b", "a/b/#"));
EXPECT_FALSE(topic_match("a/", "a/+"));
EXPECT_TRUE(topic_match("a/$x", "a/+"));
}
// Recursion overflowed the ESP32 loop stack at about 250 characters
TEST(MQTTTopicMatch, LongTopic) {
std::string topic(4096, 'x');
topic[100] = '/';
EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str()));
std::string other = topic;
other.back() = 'y';
EXPECT_FALSE(topic_match(topic.c_str(), other.c_str()));
}
TEST(MQTTTopicMatch, MatchesPreviousImplementation) {
// Small alphabet so wildcards, separators and '$' meet often
static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'};
static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'};
std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
auto random_string = [&rng](const char *chars, size_t count) {
std::string s(rng() % 8, ' ');
for (char &c : s)
c = chars[rng() % count];
return s;
};
for (int i = 0; i < 20000; i++) {
std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS));
std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS));
ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()),
reference_match(message.c_str(), subscription.c_str()))
<< "message='" << message << "' subscription='" << subscription << "'";
}
}
} // namespace esphome::mqtt::testing
+16
View File
@@ -0,0 +1,16 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The harness gives a multi-instance component an empty list and never calls its to_code,
# so take a single empty config instead and emit what the proxy messages and handlers
# need. No proxy is configured, so the count is 0, which keeps the capability messages as
# the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only.
manifest.multi_conf = False
async def to_code_testing(config):
cg.add_define("USE_SERIAL_PROXY")
cg.add_define("SERIAL_PROXY_COUNT", 0)
manifest.to_code = to_code_testing
@@ -0,0 +1,51 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/proto.h"
namespace esphome::api::testing {
// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check.
static std::vector<uint8_t> encode(const UsbDeviceDescriptor &msg) {
const uint32_t size = msg.calculate_size();
APIBuffer buf;
EXPECT_TRUE(buf.resize(size == 0 ? 1 : size));
uint8_t *pos = buf.data();
#ifdef ESPHOME_DEBUG_API
uint8_t *proto_debug_end_ = buf.data() + buf.size();
#endif
uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG);
EXPECT_EQ(static_cast<uint32_t>(end - buf.data()), size);
return {buf.data(), end};
}
TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) {
UsbDeviceDescriptor msg;
msg.vendor_id = 0x10C4;
msg.product_id = 0xEA60;
msg.bcd_device = 0x0100;
msg.interface_number = 1;
msg.interface_class = 0xFF;
msg.port_number = 2;
const std::vector<uint8_t> expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80,
0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02};
EXPECT_EQ(encode(msg), expected);
}
TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) {
// The new fields are additive with zero defaults, so an old client sees the message it always did
UsbDeviceDescriptor msg;
msg.vendor_id = 0x303A;
msg.product_id = 0x1001;
msg.interface_number = 1;
const std::vector<uint8_t> expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01};
EXPECT_EQ(encode(msg), expected);
}
} // namespace esphome::api::testing
+3 -2
View File
@@ -1,3 +1,4 @@
# The disconnects counter is compiled only with USE_SENSOR; declaring the
# sensor domain makes the C++ unit test build define it.
# The connection entities are compiled only with USE_BINARY_SENSOR and
# USE_SENSOR; declaring the domains makes the C++ unit test build define them.
binary_sensor:
sensor:
+8
View File
@@ -10,8 +10,16 @@ tcp_uart:
allowed_ips:
- 192.0.2.20
- 192.0.2.0/24
binary_sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_server
connected:
name: TCP UART Server Connected
sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_server
disconnects:
name: TCP UART Server Disconnects
@@ -7,7 +7,15 @@ tcp_uart:
host: 192.0.2.10
port: 502
reconnect_interval: 10s
binary_sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_1
connected:
name: TCP UART Connected
sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_1
disconnects:
name: TCP UART Disconnects
+3 -2
View File
@@ -1,3 +1,4 @@
# The disconnects counter is compiled only with USE_SENSOR; declaring the
# sensor domain makes the C++ unit test build define it.
# The connection entities are compiled only with USE_BINARY_SENSOR and
# USE_SENSOR; declaring the domains makes the C++ unit test build define them.
binary_sensor:
sensor:
+8
View File
@@ -10,7 +10,15 @@ uart_tcp:
allowed_ips:
- 192.168.1.10
- 192.168.1.0/24
binary_sensor:
- platform: uart_tcp
uart_tcp_id: bridge
connected:
name: UART TCP Connected
sensor:
- platform: uart_tcp
uart_tcp_id: bridge
disconnects:
name: UART TCP Disconnects
@@ -12,7 +12,15 @@ uart_tcp:
host: 192.0.2.20
port: 502
reconnect_interval: 10s
binary_sensor:
- platform: uart_tcp
uart_tcp_id: bridge
connected:
name: UART TCP Connected
sensor:
- platform: uart_tcp
uart_tcp_id: bridge
disconnects:
name: UART TCP Disconnects
+8
View File
@@ -8,7 +8,15 @@ uart_tcp:
uart_id: uart_bus
host: 127.0.0.1
port: 44502
binary_sensor:
- platform: uart_tcp
uart_tcp_id: bridge
connected:
name: UART TCP Connected
sensor:
- platform: uart_tcp
uart_tcp_id: bridge
disconnects:
name: UART TCP Disconnects
@@ -20,6 +20,10 @@ tcp_uart:
host: 127.0.0.1
port: 18125
reconnect_interval: 3s
binary_sensor:
- platform: tcp_uart
tcp_uart_id: tcp_uart_1
connected:
name: Bridge Connected
@@ -0,0 +1,64 @@
esphome:
name: uart-mock-bl0906-stray-rx
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy DEPENDENCIES = ["uart"]
uart:
baud_rate: 9600
port: /dev/null
# Answers register reads; the first temperature answer has one stray trailing byte
uart_mock:
id: mock_uart
baud_rate: 19200
on_tx:
- then:
- lambda: |-
// Command and register address arrive as separate one-byte writes
static bool read_pending = false;
static bool stray_sent = false;
if (data.size() != 1)
return;
if (!read_pending) {
read_pending = data[0] == 0x35;
return;
}
read_pending = false;
const uint8_t address = data[0];
uint32_t raw;
switch (address) {
case 0x5E: // temperature: (300 - 64) * 12.5 / 59 - 40 = 10 C
raw = 300;
break;
case 0x16: // voltage
raw = 0x2A1B3C;
break;
default:
return;
}
const uint8_t l = raw & 0xFF, m = (raw >> 8) & 0xFF, h = (raw >> 16) & 0xFF;
std::vector<uint8_t> reply{l, m, h, static_cast<uint8_t>((address + l + m + h) ^ 0xFF)};
if (address == 0x5E && !stray_sent) {
stray_sent = true;
reply.push_back(0xAA);
}
id(mock_uart).inject_to_rx_buffer(reply);
sensor:
- platform: bl0906
uart_id: mock_uart
update_interval: 1s
temperature:
name: Temperature
voltage:
name: Voltage
@@ -24,11 +24,15 @@ uart_tcp:
port: 18126
allowed_ips:
- 127.0.0.1
connected:
name: Bridge Connected
- id: denied_bridge
uart_id: denied_bus
role: server
port: 18127
allowed_ips:
- 192.0.2.1
binary_sensor:
- platform: uart_tcp
uart_tcp_id: bridge
connected:
name: Bridge Connected
@@ -0,0 +1,57 @@
"""A BL0906 that leaves a stray byte in the receive buffer keeps reading."""
from __future__ import annotations
import asyncio
from pathlib import Path
from aioesphomeapi import EntityState, SensorInfo
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_uart_mock_bl0906_stray_rx(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A stray received byte does not stop the read cycle."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
lines: list[str] = []
states: dict[int, EntityState] = {}
async with (
run_compiled(yaml_config, line_callback=lines.append),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
temperature = require_entity(entities, "temperature", SensorInfo)
voltage = require_entity(entities, "voltage", SensorInfo)
client.subscribe_states(lambda state: states.__setitem__(state.key, state))
def state_of(entity: SensorInfo) -> float | None:
state = states.get(entity.key)
return None if state is None or state.missing_state else state.state
try:
async with asyncio.timeout(10.0):
while state_of(voltage) is None:
await asyncio.sleep(0.05)
except TimeoutError:
pytest.fail(f"No voltage reading; log: {lines[-20:]}")
assert any("RX inject 5 bytes" in x for x in lines), "stray byte not sent"
assert state_of(temperature) == pytest.approx(10.0, abs=0.01)
uref = 1.097 * 100000 / (13162 * 100 * 1000)
assert state_of(voltage) == pytest.approx(0x2A1B3C * uref, rel=1e-5)
assert not any("Junk on wire" in x for x in lines)
@@ -71,7 +71,7 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = {
"sendspin": 6,
},
"WizardCapabilities": {"configured": 1},
"SendspinCapabilities": {"feature_flags": 1},
"SendspinCapabilities": {"feature_flags": 1, "client_id": 2},
"BluetoothProxyCapabilities": {
"feature_flags": 1,
"mac_address": 2,