Merge remote-tracking branch 'upstream/dev' into noise-session-resume

This commit is contained in:
J. Nick Koston
2026-08-24 17:37:39 -05:00
44 changed files with 1994 additions and 377 deletions
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
category: "/language:${{matrix.language}}"
+28 -1
View File
@@ -232,6 +232,7 @@ enum SerialProxyPortType {
message SerialProxyInfo {
string name = 1; // Human-readable port name
SerialProxyPortType port_type = 2; // Port type (RS232, RS485)
uint32 configured_line_states = 3; // Bitmask of SerialProxyLineStateFlags this instance can drive
}
// DeviceInfoResponse max_data_length values:
@@ -2640,6 +2641,22 @@ message ZWaveProxyRequest {
bytes data = 2;
}
enum ZWaveProxyStatus {
ZWAVE_PROXY_STATUS_OK = 0; // Request completed successfully
ZWAVE_PROXY_STATUS_IN_USE = 1; // Denied: another client is already subscribed
ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2; // Request type not supported
}
// Acknowledges a ZWaveProxyRequest (subscribe/unsubscribe). Sent since API 1.16.
message ZWaveProxyRequestResponse {
option (id) = 151;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_ZWAVE_PROXY";
ZWaveProxyRequestType type = 1; // Which request type this responds to
ZWaveProxyStatus status = 2; // Result status
}
// ==================== INFRARED ====================
// Note: Feature and capability flag enums are defined in
// esphome/components/infrared/infrared.h
@@ -2783,12 +2800,18 @@ message SerialProxyGetModemPinsResponse {
uint32 instance = 1; // Instance index (0-based)
uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags
SerialProxyStatus status = 3; // INVALID_ARGUMENT if the instance index is out of range (since API 1.16)
}
enum SerialProxyRequestType {
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; // Subscribe to receive data from this serial proxy instance
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent)
// Values below are only valid in SerialProxyRequestResponse.type, identifying which
// operation is being acknowledged. Sending them in SerialProxyRequest.type is an
// error the device answers with INVALID_ARGUMENT.
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
}
enum SerialProxyStatus {
@@ -2797,6 +2820,8 @@ enum SerialProxyStatus {
SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error
SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance
SERIAL_PROXY_STATUS_PORT_IN_USE = 5; // Denied: another client holds the port
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
}
// Generic request message for simple serial proxy operations
@@ -2809,7 +2834,9 @@ message SerialProxyRequest {
SerialProxyRequestType type = 2; // Request type
}
// Response to a SerialProxyRequest (e.g. flush completion or failure)
// Acknowledges a serial proxy operation; the type field identifies which
// operation is being acknowledged. Flush has been acknowledged since the
// message was introduced; all other acknowledgements are sent since API 1.16.
message SerialProxyRequestResponse {
option (id) = 147;
option (source) = SOURCE_SERVER;
+76 -33
View File
@@ -1381,7 +1381,12 @@ void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
}
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
ZWaveProxyRequestResponse resp{};
resp.type = msg.type;
resp.status = zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Z-Wave proxy response");
}
}
#endif
@@ -1550,15 +1555,50 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
#endif
#ifdef USE_SERIAL_PROXY
static enums::SerialProxyStatus serial_proxy_result_to_status(serial_proxy::SerialProxyResult result) {
switch (result) {
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_OK:
return enums::SERIAL_PROXY_STATUS_OK;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS:
return enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE:
return enums::SERIAL_PROXY_STATUS_PORT_IN_USE;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT:
return enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT:
return enums::SERIAL_PROXY_STATUS_TIMEOUT;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED:
return enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ERROR:
return enums::SERIAL_PROXY_STATUS_ERROR;
}
return enums::SERIAL_PROXY_STATUS_ERROR; // Unreachable; all enum values handled above
}
static void send_serial_proxy_ack(APIConnection *conn, uint32_t instance, enums::SerialProxyRequestType type,
enums::SerialProxyStatus status) {
SerialProxyRequestResponse resp{};
resp.instance = instance;
resp.type = type;
resp.status = status;
if (!conn->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
}
void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance,
static_cast<uint32_t>(proxies.size()));
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
msg.stop_bits, msg.data_size);
serial_proxy::SerialProxyResult result = proxies[msg.instance]->configure(
this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits, msg.data_size);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
serial_proxy_result_to_status(result));
}
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
@@ -1574,20 +1614,30 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
proxies[msg.instance]->set_modem_pins(this, msg.line_states);
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_modem_pins(this, msg.line_states);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
serial_proxy_result_to_status(result));
}
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
SerialProxyGetModemPinsResponse resp{};
resp.instance = msg.instance;
resp.line_states = proxies[msg.instance]->get_modem_pins();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
// Pre-1.16 clients do not read the status field and would take this error
// for a successful "both pins deasserted" answer; let them time out as before
if (!this->client_supports_api_version(1, 16)) {
return;
}
resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
} else {
resp.line_states = proxies[msg.instance]->get_modem_pins();
}
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
@@ -1597,40 +1647,31 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
send_serial_proxy_ack(this, msg.instance, msg.type, enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
auto *proxy = proxies[msg.instance];
enums::SerialProxyStatus status;
switch (msg.type) {
case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
proxies[msg.instance]->serial_proxy_request(this, msg.type);
status = serial_proxy_result_to_status(proxy->serial_proxy_request(this, msg.type));
break;
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: {
SerialProxyRequestResponse resp{};
resp.instance = msg.instance;
resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH;
switch (proxies[msg.instance]->flush_port()) {
case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
resp.status = enums::SERIAL_PROXY_STATUS_OK;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
break;
}
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
status = serial_proxy_result_to_status(proxy->flush_port(this));
break;
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
// Response-only discriminators; never valid in a request
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
break;
}
default:
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
break;
}
send_serial_proxy_ack(this, msg.instance, msg.type, status);
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
@@ -1781,7 +1822,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 15;
resp.api_version_minor = 16;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1915,6 +1956,7 @@ bool APIConnection::send_device_info_response_() {
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
info.configured_line_states = proxy->get_configured_modem_pins();
}
#endif
#ifdef USE_API_NOISE
@@ -1975,6 +2017,7 @@ bool APIConnection::send_device_capabilities_response_() {
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
info.configured_line_states = proxy->get_configured_modem_pins();
}
#endif
return this->send_message(resp);
+16
View File
@@ -102,12 +102,14 @@ uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PAR
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->port_type));
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states);
return pos;
}
uint32_t SerialProxyInfo::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->name.size());
size += this->port_type ? 2 : 0;
size += ProtoSize::calc_uint32(1, this->configured_line_states);
return size;
}
#endif
@@ -3952,6 +3954,18 @@ uint32_t ZWaveProxyRequest::calculate_size() const {
size += ProtoSize::calc_length(1, this->data_len);
return size;
}
uint8_t *ZWaveProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->type));
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->status));
return pos;
}
uint32_t ZWaveProxyRequestResponse::calculate_size() const {
uint32_t size = 0;
size += this->type ? 2 : 0;
size += this->status ? 2 : 0;
return size;
}
#endif
#ifdef USE_INFRARED
uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -4194,12 +4208,14 @@ uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast<uint32_t>(this->status));
return pos;
}
uint32_t SerialProxyGetModemPinsResponse::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->instance);
size += ProtoSize::calc_uint32(1, this->line_states);
size += this->status ? 2 : 0;
return size;
}
bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+29 -1
View File
@@ -334,6 +334,11 @@ enum ZWaveProxyRequestType : uint32_t {
ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1,
ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2,
};
enum ZWaveProxyStatus : uint32_t {
ZWAVE_PROXY_STATUS_OK = 0,
ZWAVE_PROXY_STATUS_IN_USE = 1,
ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2,
};
#endif
#ifdef USE_SERIAL_PROXY
enum SerialProxyParity : uint32_t {
@@ -345,6 +350,8 @@ enum SerialProxyRequestType : uint32_t {
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0,
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1,
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2,
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3,
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4,
};
enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_OK = 0,
@@ -352,6 +359,8 @@ enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_ERROR = 2,
SERIAL_PROXY_STATUS_TIMEOUT = 3,
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4,
SERIAL_PROXY_STATUS_PORT_IN_USE = 5,
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6,
};
#endif
@@ -523,6 +532,7 @@ class SerialProxyInfo final : public ProtoMessage {
public:
StringRef name{};
enums::SerialProxyPortType port_type{};
uint32_t configured_line_states{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
@@ -3151,6 +3161,23 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage {
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class ZWaveProxyRequestResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 151;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("z_wave_proxy_request_response"); }
#endif
enums::ZWaveProxyRequestType type{};
enums::ZWaveProxyStatus status{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_INFRARED
class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage {
@@ -3335,12 +3362,13 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage {
class SerialProxyGetModemPinsResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 143;
static constexpr uint8_t ESTIMATED_SIZE = 8;
static constexpr uint8_t ESTIMATED_SIZE = 10;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_response"); }
#endif
uint32_t instance{0};
uint32_t line_states{0};
enums::SerialProxyStatus status{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
+28
View File
@@ -816,6 +816,18 @@ template<> const char *proto_enum_to_string<enums::ZWaveProxyRequestType>(enums:
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::ZWaveProxyStatus>(enums::ZWaveProxyStatus value) {
switch (value) {
case enums::ZWAVE_PROXY_STATUS_OK:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_OK");
case enums::ZWAVE_PROXY_STATUS_IN_USE:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_IN_USE");
case enums::ZWAVE_PROXY_STATUS_NOT_SUPPORTED:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_NOT_SUPPORTED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
#ifdef USE_SERIAL_PROXY
template<> const char *proto_enum_to_string<enums::SerialProxyParity>(enums::SerialProxyParity value) {
@@ -838,6 +850,10 @@ template<> const char *proto_enum_to_string<enums::SerialProxyRequestType>(enums
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE");
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_FLUSH");
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -854,6 +870,10 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_TIMEOUT");
case enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_NOT_SUPPORTED");
case enums::SERIAL_PROXY_STATUS_PORT_IN_USE:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_PORT_IN_USE");
case enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_INVALID_ARGUMENT");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -914,6 +934,7 @@ const char *SerialProxyInfo::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyInfo"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("port_type"), static_cast<enums::SerialProxyPortType>(this->port_type));
dump_field(out, ESPHOME_PSTR("configured_line_states"), this->configured_line_states);
return out.c_str();
}
#endif
@@ -2648,6 +2669,12 @@ const char *ZWaveProxyRequest::dump_to(DumpBuffer &out) const {
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
const char *ZWaveProxyRequestResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyRequestResponse"));
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ZWaveProxyRequestType>(this->type));
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::ZWaveProxyStatus>(this->status));
return out.c_str();
}
#endif
#ifdef USE_INFRARED
const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
@@ -2757,6 +2784,7 @@ const char *SerialProxyGetModemPinsResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetModemPinsResponse"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("line_states"), this->line_states);
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SerialProxyStatus>(this->status));
return out.c_str();
}
const char *SerialProxyRequest::dump_to(DumpBuffer &out) const {
@@ -55,8 +55,11 @@ int HOT IRAM_ATTR GPIOOneWireBus::reset_int() {
delayMicroseconds(1);
}
// delay J
delayMicroseconds(start + 480 - micros());
// delay J: finish the 480us slot, but never spin if it already elapsed
// (unsigned wrap here would busy-wait for minutes with interrupts off)
uint32_t elapsed = micros() - start;
if (elapsed < 480)
delayMicroseconds(480 - elapsed);
this->pin_.digital_write(true);
this->pin_.pin_mode(gpio::FLAG_OUTPUT);
return r ? 1 : 0;
@@ -14,6 +14,7 @@ void KeyCollector::loop() {
}
void KeyCollector::dump_config() {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
ESP_LOGCONFIG(TAG, "Key Collector:");
if (this->min_length_ > 0)
ESP_LOGCONFIG(TAG, " min length: %d", this->min_length_);
@@ -35,6 +36,7 @@ void KeyCollector::dump_config() {
ESP_LOGCONFIG(TAG, " allowed keys '%s'", this->allowed_keys_.c_str());
if (this->timeout_ > 0)
ESP_LOGCONFIG(TAG, " entry timeout: %0.1f", this->timeout_ / 1000.0);
#endif
}
void KeyCollector::add_provider(key_provider::KeyProvider *provider) {
+1 -1
View File
@@ -345,7 +345,7 @@ int MipiRgb::get_height() {
}
}
static const char *get_pin_name(GPIOPin *pin, std::span<char, GPIO_SUMMARY_MAX_LEN> buffer) {
[[maybe_unused]] static const char *get_pin_name(GPIOPin *pin, std::span<char, GPIO_SUMMARY_MAX_LEN> buffer) {
if (pin == nullptr)
return "None";
pin->dump_summary(buffer.data(), buffer.size());
@@ -3,6 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstdio>
static const char *const TAG = "online_image";
static const char *const CONTENT_TYPE_HEADER_NAME = "content-type";
@@ -62,30 +63,11 @@ void OnlineImage::update() {
headers.push_back({IF_MODIFIED_SINCE_HEADER_NAME, this->last_modified_});
}
// Add Accept header based on image format
const char *accept_mime_type;
runtime_image::ImageFormat format = this->get_format();
switch (format) {
#ifdef USE_RUNTIME_IMAGE_BMP
case runtime_image::BMP:
accept_mime_type = "image/bmp,*/*;q=0.8";
break;
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
case runtime_image::JPEG:
accept_mime_type = "image/jpeg,*/*;q=0.8";
break;
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
case runtime_image::PNG:
accept_mime_type = "image/png,*/*;q=0.8";
break;
#endif
default:
accept_mime_type = "image/*,*/*;q=0.8";
break;
}
headers.push_back({"Accept", accept_mime_type});
// Accept: "<mime>,*/*;q=0.8"; 32 covers the longest MIME type plus the suffix
char accept_header[32];
snprintf(accept_header, sizeof(accept_header), "%s,*/*;q=0.8", runtime_image::get_mime_type_for_format(format));
headers.push_back({"Accept", accept_header});
// User headers last so they can override any of the above
for (auto &header : this->request_headers_) {
@@ -122,32 +104,18 @@ void OnlineImage::update() {
if (format == runtime_image::AUTO) {
// Try to auto-detect format from Content-Type header
auto content_type_header = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME);
const char *content_type = content_type_header.c_str();
ESP_LOGV(TAG, "Content-Type: %s", content_type);
// Includes aliases seen from real servers (older IIS, CDNs, S3)
if (str_contains_ignore_case(content_type, "image/bmp") ||
str_contains_ignore_case(content_type, "image/x-ms-bmp") ||
str_contains_ignore_case(content_type, "image/x-bmp")) {
format = runtime_image::BMP;
} else if (str_contains_ignore_case(content_type, "image/jpeg") ||
str_contains_ignore_case(content_type, "image/jpg")) {
format = runtime_image::JPEG;
} else if (str_contains_ignore_case(content_type, "image/png") ||
str_contains_ignore_case(content_type, "image/x-png")) {
format = runtime_image::PNG;
} else if (str_contains_ignore_case(content_type, "image/")) {
ESP_LOGW(TAG, "Unsupported image type: '%s'", content_type);
this->end_connection_();
this->download_error_callback_.call();
return;
auto content_type = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME);
ESP_LOGV(TAG, "Content-Type: %s", content_type.c_str());
auto mime_format = esphome::runtime_image::get_format_for_mime_type(content_type.c_str());
if (mime_format.has_value()) {
format = *mime_format;
} else {
// TODO: implement auto-detection in runtime_image by sniffing the first few bytes of the image data
if (content_type_header.empty()) {
ESP_LOGW(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly");
if (content_type.empty()) {
ESP_LOGE(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly");
} else if (str_contains_ignore_case(content_type.c_str(), "image/")) {
ESP_LOGE(TAG, "Image format '%s' not supported.", content_type.c_str());
} else {
ESP_LOGE(TAG, "Could not determine image format from Content-Type: '%s'. Set `format:` explicitly",
content_type);
ESP_LOGE(TAG, "Server did not return an image (Content-Type: '%s')", content_type.c_str());
}
this->end_connection_();
this->download_error_callback_.call();
@@ -0,0 +1,44 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "image_format.h"
namespace esphome::runtime_image {
struct MimeLookup {
const char *mime_type;
ImageFormat format;
};
// The first entry per format is its canonical MIME type; the rest are aliases
// seen from real servers (older IIS, CDNs, S3)
static constexpr MimeLookup MIME_LOOKUP_TABLE[] = {
#ifdef USE_RUNTIME_IMAGE_BMP
{"image/bmp", ImageFormat::BMP}, {"image/x-ms-bmp", ImageFormat::BMP}, {"image/x-bmp", ImageFormat::BMP},
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
{"image/jpeg", ImageFormat::JPEG}, {"image/jpg", ImageFormat::JPEG},
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
{"image/png", ImageFormat::PNG}, {"image/x-png", ImageFormat::PNG},
#endif
};
const char *get_mime_type_for_format(ImageFormat format) {
for (const auto &entry : MIME_LOOKUP_TABLE) {
if (entry.format == format) {
return entry.mime_type;
}
}
return "image/*"; // AUTO or compiled-out format
}
std::optional<ImageFormat> get_format_for_mime_type(const char *mime_type) {
for (const auto &entry : MIME_LOOKUP_TABLE) {
if (str_contains_ignore_case(mime_type, entry.mime_type)) {
return entry.format;
}
}
return std::nullopt;
}
} // namespace esphome::runtime_image
@@ -1,5 +1,7 @@
#pragma once
#include <optional>
namespace esphome::runtime_image {
/**
@@ -17,4 +19,9 @@ enum ImageFormat {
BMP,
};
/// Canonical MIME type for a format; "image/*" for AUTO/unknown
const char *get_mime_type_for_format(ImageFormat format);
/// Case-insensitive substring match of known media types; nullopt if none found
std::optional<ImageFormat> get_format_for_mime_type(const char *mime_type);
} // namespace esphome::runtime_image
@@ -1,7 +1,6 @@
#include "runtime_image.h"
#include "image_decoder.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
@@ -89,12 +89,12 @@ void SerialProxy::dump_config() {
this->dtr_pin_ != nullptr ? "configured" : "not configured");
}
void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size) {
SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control,
uint8_t parity, uint8_t stop_bits, uint8_t data_size) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
ESP_LOGD(TAG,
@@ -105,25 +105,29 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat
auto *uart_comp = this->parent_;
if (uart_comp == nullptr) {
ESP_LOGE(TAG, "UART component not available");
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR;
}
// Validate all parameters before applying any (values come from a remote client)
if (baudrate == 0) {
ESP_LOGW(TAG, "Invalid baud rate: 0");
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (stop_bits < 1 || stop_bits > 2) {
ESP_LOGW(TAG, "Invalid stop bits: %u (must be 1 or 2)", stop_bits);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (data_size < 5 || data_size > 8) {
ESP_LOGW(TAG, "Invalid data bits: %u (must be 5-8)", data_size);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (parity > 2) {
ESP_LOGW(TAG, "Invalid parity: %u (must be 0-2)", parity);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (flow_control) {
ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported");
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
// Apply validated parameters
@@ -143,10 +147,7 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat
#if defined(USE_ESP8266) || defined(USE_ESP32)
uart_comp->load_settings(true);
#endif
if (flow_control) {
ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported");
}
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {
@@ -163,13 +164,20 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui
this->write_array(data, len);
}
void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
// Asserting a pin that is not configured must fail so the client learns the signal never
// reached the wire; deasserting an absent pin is harmless and stays allowed. Clients can
// avoid this by masking against SerialProxyInfo.configured_line_states.
if ((line_states & ~this->get_configured_modem_pins()) != 0) {
ESP_LOGW(TAG, "Requested modem pin not configured on serial proxy [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
const bool rts = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_RTS) != 0;
const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0;
ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr));
@@ -182,6 +190,7 @@ void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t li
this->dtr_state_ = dtr;
this->dtr_pin_->digital_write(dtr);
}
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
uint32_t SerialProxy::get_modem_pins() const {
@@ -189,9 +198,26 @@ uint32_t SerialProxy::get_modem_pins() const {
(this->dtr_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
}
uart::UARTFlushResult SerialProxy::flush_port() {
SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
#ifdef USE_API
// Flushing stalls the port, so it gets the same ownership check as writes
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
ESP_LOGV(TAG, "Flushing serial proxy [%" PRIu32 "]", this->instance_index_);
return this->flush();
switch (this->flush()) {
case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
return SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS;
case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
return SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT;
case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR;
}
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR; // Unreachable; all enum values handled above
}
#ifdef USE_API
@@ -200,12 +226,13 @@ bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) con
this->api_connection_->is_connection_setup();
}
void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) {
SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection,
api::enums::SerialProxyRequestType type) {
switch (type) {
case api::enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
if (this->api_connection_ == api_connection) {
ESP_LOGV(TAG, "API connection is already subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
if (this->api_connection_ != nullptr) {
// A living subscriber keeps exclusive access. Its connection may be dead without
@@ -213,26 +240,27 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::
// in that case let the new client take over instead of locking it out.
if (this->api_connection_->is_connection_setup()) {
ESP_LOGE(TAG, "Only one API subscription is allowed at a time");
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
}
this->api_connection_ = api_connection;
this->enable_loop();
ESP_LOGV(TAG, "API connection subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
case api::enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
// Unsubscribe is idempotent: not being subscribed is not an error
if (this->api_connection_ != api_connection) {
ESP_LOGV(TAG, "API connection is not subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
this->api_connection_ = nullptr;
this->disable_loop();
ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_);
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
default:
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(type));
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
}
#endif
+23 -5
View File
@@ -38,6 +38,17 @@ enum SerialProxyLineStateFlag : uint32_t {
SERIAL_PROXY_LINE_STATE_FLAG_DTR = 1 << 1, ///< DTR (Data Terminal Ready)
};
/// Result of a client-initiated operation; mapped to api::enums::SerialProxyStatus by the API layer
enum class SerialProxyResult : uint8_t {
SERIAL_PROXY_RESULT_OK, ///< Operation completed or request accepted
SERIAL_PROXY_RESULT_ASSUMED_SUCCESS, ///< Platform cannot confirm TX drain; success assumed
SERIAL_PROXY_RESULT_PORT_IN_USE, ///< Denied: another live client holds the port
SERIAL_PROXY_RESULT_INVALID_ARGUMENT, ///< A parameter value is out of range
SERIAL_PROXY_RESULT_ERROR, ///< Driver or hardware error
SERIAL_PROXY_RESULT_TIMEOUT, ///< Timed out before TX completed
SERIAL_PROXY_RESULT_NOT_SUPPORTED, ///< Requested feature is not available on this instance
};
/// Maximum bytes to read from UART in a single loop iteration
inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256;
@@ -73,14 +84,14 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// @param parity Parity setting (0=none, 1=even, 2=odd)
/// @param stop_bits Number of stop bits (1 or 2)
/// @param data_size Number of data bits (5-8)
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size);
SerialProxyResult configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size);
/// Get the currently subscribed API connection (nullptr if none)
api::APIConnection *get_api_connection() { return this->api_connection_; }
/// Handle a subscribe/unsubscribe request from an API client
void serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type);
SerialProxyResult serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type);
/// Write data received from an API client to the serial device
/// @param api_connection The API connection sending the data
@@ -89,13 +100,20 @@ class SerialProxy final : public uart::UARTDevice, public Component {
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len);
/// Set modem pin states from a bitmask of SerialProxyLineStateFlag values
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states);
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states);
/// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values
uint32_t get_modem_pins() const;
/// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values
uint32_t get_configured_modem_pins() const {
return (this->rts_pin_ != nullptr ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) |
(this->dtr_pin_ != nullptr ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
}
/// Flush the serial port (block until all TX data is sent)
uart::UARTFlushResult flush_port();
/// @param api_connection The API connection requesting the flush
SerialProxyResult flush_port(api::APIConnection *api_connection);
/// Set the RTS GPIO pin (from YAML configuration)
void set_rts_pin(GPIOPin *pin) { this->rts_pin_ = pin; }
+3
View File
@@ -9,6 +9,7 @@ SCALE = "scale"
CONF_ATTRIBUTE_ID = "attribute_id"
KEY_ZIGBEE_EP = "zigbee_ep"
KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num"
KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl"
DEVICE_ID = {
"RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"),
@@ -18,11 +19,13 @@ DEVICE_ID = {
cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e")
CLUSTER_ID = {
"BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC,
"TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME,
"BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT,
"ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT,
}
CLUSTER_ROLE = {
"SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"),
"CLIENT": cg.RawExpression("EZB_ZCL_CLUSTER_CLIENT"),
}
attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e")
ATTR_TYPE = {
+38 -10
View File
@@ -1,13 +1,15 @@
import esphome.codegen as cg
from esphome.components import time as time_
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL
from esphome.core import CORE
from esphome.types import ConfigType
from .. import consume_endpoint
from ..const import zigbee_ns
from ..const_esp32 import ROLE
from ..const_zephyr import CONF_ZIGBEE_ID
from ..zigbee_ep_esp32 import add_clusters_to_first_ep, get_first_ep_num
from ..zigbee_zephyr import (
ZigbeeClusterDesc,
ZigbeeComponent,
@@ -22,26 +24,52 @@ DEPENDENCIES = ["zigbee"]
ZigbeeTime = zigbee_ns.class_("ZigbeeTime", time_.RealTimeClock)
def _validate_zigbee_time(config: ConfigType) -> ConfigType:
if CORE.is_nrf52:
return consume_endpoint(config)
if CORE.is_esp32:
cl = [
{
CONF_ID: "TIME",
ROLE: "CLIENT",
},
{
CONF_ID: "TIME",
ROLE: "SERVER",
},
]
add_clusters_to_first_ep(cl)
return config
CONFIG_SCHEMA = cv.All(
time_.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(ZigbeeTime),
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(
ZigbeeComponent
),
cv.GenerateID(CONF_ZIGBEE_ID): cv.use_id(ZigbeeComponent),
cv.SplitDefault(
CONF_UPDATE_INTERVAL,
nrf52="1s",
esp32="15min",
): cv.update_interval, # override default from TIME_SCHEMA. Remove once nrf52 implementation is aligned.
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.polling_component_schema("1s")),
consume_endpoint,
).extend(cv.COMPONENT_SCHEMA),
_validate_zigbee_time,
)
async def to_code(config: ConfigType) -> None:
CORE.add_job(_add_time, config)
if CORE.using_zephyr:
CORE.add_job(_add_time_zephyr, config)
if CORE.is_esp32:
zb = await cg.get_variable(config[CONF_ZIGBEE_ID])
var = cg.new_Pvariable(config[CONF_ID], zb, get_first_ep_num())
await cg.register_component(var, config)
await time_.register_time(var, config)
async def _add_time(config: ConfigType) -> None:
async def _add_time_zephyr(config: ConfigType) -> None:
slot_index = get_slot_index()
# Create unique names for this sensor's variables based on slot index
@@ -0,0 +1,118 @@
#include "zigbee_time_esp32.h"
#if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME)
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::zigbee {
static const char *const TAG = "zigbee.time";
// This time standard is the number of
// seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time).
constexpr time_t EPOCH_2000 = 946684800;
static ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void ZigbeeTime::setup() {
global_time = this;
if (this->parent_->is_started()) {
this->register_zb_time_();
} else {
this->parent_->add_on_start_callback([this]() { this->register_zb_time_(); });
}
}
void ZigbeeTime::register_zb_time_() {
ezb_zcl_time_interface_t time_interface = {
.get_utc_time = esphome::zigbee::ZigbeeTime::get_utc_time,
.set_utc_time = esphome::zigbee::ZigbeeTime::set_utc_time,
};
ezb_err_t ret;
if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
this->set_timeout("zb_time_register", 100, [this]() { this->register_zb_time_(); });
return;
}
ret = ezb_zcl_time_server_interface_register(this->endpoint_, time_interface);
esp_zigbee_lock_release();
if (ret != EZB_ERR_NONE) {
ESP_LOGW(TAG, "Setup failed: %d", ret);
this->mark_failed();
return;
}
this->registered_ = true;
this->parent_->add_on_join_callback([this](bool x) { this->update(); });
if (this->parent_->is_joined()) {
this->update();
}
}
void ZigbeeTime::status_cb(ezb_err_t status) {
if (status == EZB_ERR_NONE) {
ESP_LOGV(TAG, "Time synchronization successful");
} else if (status == EZB_ERR_TIMEOUT) {
ESP_LOGW(TAG, "Time synchronization timed out");
} else {
ESP_LOGW(TAG, "Time synchronization failed with error: %d", status);
}
}
void ZigbeeTime::update() {
if (this->parent_->is_joined() && this->registered_) {
if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
ESP_LOGV(TAG, "Updating time sync from Zigbee network...");
ezb_zcl_time_server_synchronize_time(this->endpoint_, 10, esphome::zigbee::ZigbeeTime::status_cb,
EZB_ZCL_TIME_SERVER_RANK_MASTER);
esp_zigbee_lock_release();
this->retry_count_ = 0;
} else {
if (this->retry_count_ == 0) {
ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time, will retry maximum 3 times");
}
if (this->retry_count_ < 3) {
this->set_timeout("zb_time_sync", 100, [this]() { this->update(); });
this->retry_count_++;
} else {
ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time");
this->retry_count_ = 0;
}
}
} else {
ESP_LOGD(TAG, "Not connected to Zigbee network, cannot synchronize time");
}
}
uint32_t ZigbeeTime::get_utc_time() {
const time_t now = global_time->timestamp_now();
if (now < EPOCH_2000) {
return 0xFFFFFFFF; // ZCL invalid UTCTime
}
return (uint32_t) (now - EPOCH_2000);
}
void ZigbeeTime::set_utc_time(uint32_t utc) {
// prevent overflow
if (utc <= (std::numeric_limits<uint32_t>::max() - EPOCH_2000)) {
global_time->set_epoch_time(utc + EPOCH_2000);
}
}
void ZigbeeTime::set_epoch_time(uint32_t utc) {
// called from zigbee task, defer to main loop
this->defer([this, utc]() {
ESP_LOGV(TAG, "Setting device time to UTC: %u", static_cast<unsigned>(utc));
this->synchronize_epoch_(utc);
});
App.wake_loop_threadsafe();
}
void ZigbeeTime::dump_config() {
ESP_LOGCONFIG(TAG,
"Zigbee Time\n"
" Endpoint: %u",
this->endpoint_);
RealTimeClock::dump_config();
}
} // namespace esphome::zigbee
#endif
@@ -0,0 +1,34 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME)
#include "esphome/core/component.h"
#include "esphome/components/time/real_time_clock.h"
#include "../zigbee_esp32.h"
namespace esphome::zigbee {
class ZigbeeComponent;
class ZigbeeTime final : public time::RealTimeClock {
public:
ZigbeeTime(ZigbeeComponent *parent, uint8_t ep) : parent_(parent), endpoint_(ep) {}
void setup() override;
void update() override;
void dump_config() override;
void set_epoch_time(uint32_t utc);
protected:
void register_zb_time_();
static void set_utc_time(uint32_t utc);
static uint32_t get_utc_time();
static void status_cb(ezb_err_t status);
ZigbeeComponent *parent_;
uint8_t endpoint_;
uint8_t retry_count_{0};
bool registered_{false};
};
} // namespace esphome::zigbee
#endif
+46 -7
View File
@@ -18,6 +18,7 @@ from .const_esp32 import (
DEVICE_TYPE,
KEY_ZIGBEE_EP,
KEY_ZIGBEE_EP_NO_NUM,
KEY_ZIGBEE_FIRST_EP_CL,
ROLE,
)
@@ -95,11 +96,11 @@ def _get_next_ep_num(eps: list[int]) -> int:
def _compare_clusters(
existing_ep: dict[str, Any],
ep: dict[str, Any],
existing_cl_list: list[dict[str, Any]],
cl_list: list[dict[str, Any]],
) -> tuple[str | int, str] | None:
existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]]
for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]:
existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_cl_list]
for cl in [(cl[CONF_ID], cl[ROLE]) for cl in cl_list]:
if cl in existing_clusters:
return cl
return None
@@ -110,7 +111,7 @@ def _merge_endpoints(
ep: dict[str, Any],
use_type: bool | None,
) -> bool:
if _compare_clusters(existing_ep, ep):
if _compare_clusters(existing_ep.get(CONF_CLUSTERS, []), ep.get(CONF_CLUSTERS, [])):
return False
if (
ep.get(DEVICE_TYPE)
@@ -200,6 +201,17 @@ def create_ep(router: bool) -> None:
# clear list so that it is not processed again
del zb_data[KEY_ZIGBEE_EP_NO_NUM]
# Add clusters to first ep
cl_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_FIRST_EP_CL, [])
if cl_list:
first_ep = ep_dict[get_first_ep_num()]
first_ep.setdefault(CONF_CLUSTERS, [])
if cl := _compare_clusters(first_ep[CONF_CLUSTERS], cl_list):
raise cv.Invalid(
f"Endpoint {get_first_ep_num()} has more than one cluster with cluster id {cl[0]} and role {cl[1]}."
)
first_ep[CONF_CLUSTERS] += cl_list
del zb_data[KEY_ZIGBEE_FIRST_EP_CL]
# Add default device type to endpoints that have none
for ep in ep_dict.values():
@@ -207,6 +219,15 @@ def create_ep(router: bool) -> None:
ep[DEVICE_TYPE] = "CUSTOM_ATTR"
def get_first_ep_num() -> int | None:
"""Return the number of the first endpoint."""
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {})
if ep_dict:
return min(ep_dict.keys())
return None
def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None:
"""Add a Zigbee endpoint configuration to CORE.data.
@@ -230,8 +251,8 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non
# check if the existing endpoint has same clusters
existing_ep = ep_dict[ep_num]
if cl := _compare_clusters(
existing_ep,
ep,
existing_ep.get(CONF_CLUSTERS, []),
ep.get(CONF_CLUSTERS, []),
):
raise cv.Invalid(
f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}."
@@ -245,3 +266,21 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non
if use_type or ep.get(DEVICE_TYPE):
ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type}
ep_dict[ep_num] = ep
def add_clusters_to_first_ep(cl: list[dict[str, Any]]) -> None:
"""Add a list of Zigbee clusters to CORE.data.
Args:
cl: list of cluster dictonaries.
"""
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
cl_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_FIRST_EP_CL, [])
if cluster := _compare_clusters(
cl_list,
cl,
):
raise cv.Invalid(
f"Only one cluster with cluster id {cluster[0]} and role {cluster[1]} can be added to first endpoint."
)
cl_list += cl
@@ -30,6 +30,8 @@ ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_i
return ezb_zcl_basic_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_IDENTIFY:
return ezb_zcl_identify_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_TIME:
return ezb_zcl_time_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT:
return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_BINARY_INPUT:
@@ -49,6 +51,8 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_
return ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_IDENTIFY:
return ezb_zcl_identify_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_TIME:
return ezb_zcl_time_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT:
return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_BINARY_INPUT:
@@ -194,12 +194,13 @@ void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) {
}
}
void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) {
api::enums::ZWaveProxyStatus ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection,
api::enums::ZWaveProxyRequestType type) {
switch (type) {
case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE:
if (this->api_connection_ == api_connection) {
ESP_LOGV(TAG, "API connection is already subscribed");
return;
return api::enums::ZWAVE_PROXY_STATUS_OK;
}
if (this->api_connection_ != nullptr) {
// A living subscriber keeps exclusive access. Its connection may be dead without
@@ -207,25 +208,26 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en
// in that case let the new client take over instead of locking it out.
if (this->api_connection_->is_connection_setup()) {
ESP_LOGE(TAG, "Only one API subscription is allowed at a time");
return;
return api::enums::ZWAVE_PROXY_STATUS_IN_USE;
}
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
}
this->api_connection_ = api_connection;
ESP_LOGV(TAG, "API connection is now subscribed");
break;
return api::enums::ZWAVE_PROXY_STATUS_OK;
case api::enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
// Unsubscribe is idempotent: not being subscribed is not an error
if (this->api_connection_ != api_connection) {
ESP_LOGV(TAG, "API connection is not subscribed");
return;
return api::enums::ZWAVE_PROXY_STATUS_OK;
}
this->api_connection_ = nullptr;
break;
return api::enums::ZWAVE_PROXY_STATUS_OK;
default:
ESP_LOGW(TAG, "Unknown request type: %" PRIu32, static_cast<uint32_t>(type));
break;
return api::enums::ZWAVE_PROXY_STATUS_NOT_SUPPORTED;
}
}
+2 -1
View File
@@ -60,7 +60,8 @@ class ZWaveProxy final : public uart::UARTDevice, public Component {
bool can_proceed() override;
void api_connection_authenticated(api::APIConnection *conn);
void zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type);
api::enums::ZWaveProxyStatus zwave_proxy_request(api::APIConnection *api_connection,
api::enums::ZWaveProxyRequestType type);
api::APIConnection *get_api_connection() { return this->api_connection_; }
uint32_t get_feature_flags() const { return ZWaveProxyFeature::FEATURE_ZWAVE_PROXY_ENABLED; }
+76 -24
View File
@@ -2,6 +2,7 @@
from collections.abc import Callable
from ctypes.util import find_library
from functools import partial
import json
import logging
import os
@@ -20,9 +21,11 @@ from esphome.framework_helpers import (
create_venv,
download_from_mirrors,
download_with_resume,
failure_reason,
get_python_env_executable_path,
get_system_python_path,
rmdir,
run_batch_downloads,
run_command,
run_command_ok,
str_to_lst_of_str,
@@ -690,6 +693,18 @@ def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None:
)
def _download_tool(
dist_path: Path, entry: dict, tracker: Callable[[int], None]
) -> None:
download_with_resume(
entry["url"],
dist_path / entry["dest"],
sha256=entry["sha256"],
size=entry["size"],
progress=tracker,
)
def _prefetch_idf_tool_archives(
framework_path: Path,
targets_str: str,
@@ -702,10 +717,10 @@ def _prefetch_idf_tool_archives(
which makes large archives effectively impossible to fetch on unstable
connections (#17703). This asks the framework's idf_tools (via
``get_tool_downloads.py``) which archives the coming install needs, then
downloads each into ``<IDF_TOOLS_PATH>/dist`` with
``download_with_resume``. The installer then finds the verified archives
already in place ("file ... is already downloaded") and never touches the
network.
downloads them into ``<IDF_TOOLS_PATH>/dist`` with
``download_with_resume``, a few at a time under one combined progress
bar. The installer then finds the verified archives already in place
("file ... is already downloaded") and never touches the network.
Strictly best-effort: any failure here just logs and returns, leaving
``idf_tools.py install`` to download whatever is missing exactly as
@@ -727,30 +742,67 @@ def _prefetch_idf_tool_archives(
)
return
dist_path = get_idf_tools_path() / "dist"
entries = [
entry
for entry in json.loads(stdout)
if not (dist_path / entry["dest"]).is_file()
]
for index, entry in enumerate(entries, start=1):
_LOGGER.info(
"Downloading %s (%d/%d) ...", entry["name"], index, len(entries)
)
try:
download_with_resume(
entry["url"],
dist_path / entry["dest"],
sha256=entry["sha256"],
size=entry["size"],
entries = []
seen_dests: set[str] = set()
for entry in json.loads(stdout):
if (dist_path / entry["dest"]).is_file():
continue
# Never download unverified: an entry without sha256/size is
# left to the installer, which fails loudly on a bad archive.
# Checked before the dedupe so it cannot shadow a verifiable
# duplicate of the same dest.
if not (entry.get("sha256") and entry.get("size")):
_LOGGER.warning(
"Tool %s has no sha256/size in the download list; "
"leaving it to the installer",
entry["name"],
)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Keep prefetching the remaining archives; the installer
# will retry this one itself (without resume).
_LOGGER.warning("Could not prefetch %s: %s", entry["name"], e)
continue
if entry["dest"] in seen_dests:
# Two workers on one .part file would interleave
# seek/truncate writes; mirror the library prefetch's dedupe
continue
seen_dests.add(entry["dest"])
entries.append(entry)
if not entries:
return
_LOGGER.info(
"Downloading %d ESP-IDF tool archive(s): %s",
len(entries),
", ".join(entry["name"] for entry in entries),
)
# No sequential fallback here: skipping the prefetch would lose the
# resume workaround for #17703, and every entry has a size (above).
# A failed archive is retried by the installer itself (without
# resume); keep prefetching the rest.
failures = run_batch_downloads(
"Downloading ESP-IDF tools",
[
(
entry["name"],
entry["size"],
partial(_download_tool, dist_path, entry),
)
for entry in entries
],
)
for name, e in failures:
# failure_reason: a message-less exception must not log blank
_LOGGER.warning("Could not prefetch %s: %s", name, failure_reason(e))
_LOGGER.debug("Prefetch failure detail", exc_info=e)
if len(failures) == len(entries):
# A systematic fault, not one flaky mirror: the resume
# workaround (#17703) is off for this whole install
_LOGGER.error(
"Every ESP-IDF tool prefetch failed; the installer will "
"download without resume"
)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# The installer downloads anything missing itself; never let the
# prefetch become a new way for the install to fail.
_LOGGER.warning("ESP-IDF tool prefetch failed: %s", e)
_LOGGER.warning("ESP-IDF tool prefetch failed: %s", failure_reason(e))
_LOGGER.debug("Prefetch failure detail", exc_info=True)
def _check_esphome_idf_framework_install(
+17 -21
View File
@@ -398,27 +398,23 @@ def run_compile(config, verbose: bool) -> int:
return rc
_LOGGER.info("Regenerating CMakeLists.txt with discovered components...")
write_project(minimal=False)
# Restamp the reference file has_outdated_files() compares against.
# A reconfigure that only changes properties or plain variables
# (sdkconfig options, the exclusion set) does not rewrite
# CMakeCache.txt, so without this the watched inputs stay newer
# forever and every subsequent build repeats the discovery pass.
# Done after the full write so an interrupt cannot leave a minimal
# CMakeLists behind that is already marked fresh.
cmakecache = CORE.relative_build_path("build/CMakeCache.txt")
if cmakecache.is_file():
os.utime(cmakecache)
if CORE.testing_mode:
# Reconfigure again so cmake is up to date with the full
# component list before the build's idf.py invocation runs --
# idf.py build would otherwise re-run cmake and regenerate
# memory.ld, wiping the DRAM/IRAM patches applied below.
# Outside testing mode ninja's own configure-time dep on
# CMakeLists.txt handles the re-run as part of the build step.
rc = run_reconfigure()
if rc != 0:
_LOGGER.error("Reconfigure with discovered components failed")
return rc
# Explicit reconfigure: ninja only re-runs cmake when CMakeLists.txt
# is strictly newer than build.ninja, which fails on coarse-mtime
# filesystems (#18682). Also keeps idf.py from regenerating memory.ld
# in testing mode.
rc = run_reconfigure()
if rc != 0:
_LOGGER.error("Reconfigure with discovered components failed")
return rc
# cmake does not rewrite CMakeCache.txt when only properties change,
# so restamp it or every build repeats discovery. Only after success,
# or a failed reconfigure would be marked fresh. build.ninja is
# restamped too so the cache is not newer and ninja does not
# re-run cmake.
for name in ("build/CMakeCache.txt", "build/build.ninja"):
path = CORE.relative_build_path(name)
if path.is_file():
os.utime(path)
# In testing mode, generate the linker script first, patch DRAM/IRAM sizes,
# then build. memory.ld is regenerated by ninja during the build phase,
+218 -19
View File
@@ -1,7 +1,8 @@
"""Generic toolchain installation helpers shared across framework implementations."""
from collections.abc import Iterable
from contextlib import ExitStack
from collections.abc import Callable, Iterable, Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack, contextmanager, suppress
import hashlib
import io
import json
@@ -10,6 +11,7 @@ import os
from pathlib import Path
import subprocess
import sys
import threading
import time
from typing import IO, TYPE_CHECKING
@@ -24,6 +26,7 @@ PathType = str | os.PathLike
_LOGGER = logging.getLogger(__name__)
# Attempts per mirror URL before falling through to the next mirror; only
# mid-stream drops retry (resuming when the server gave a validator),
# connect errors move on to the next mirror immediately.
@@ -699,7 +702,11 @@ def _response_validator(resp: "requests.Response") -> str | None:
def _stream_response_to_file(
resp: "requests.Response", f: IO[bytes], offset: int, size: int | None = None
resp: "requests.Response",
f: IO[bytes],
offset: int,
size: int | None = None,
progress: Callable[[int], None] | None = None,
) -> None:
"""Stream an open ``_open_ranged`` response body into ``f`` at ``offset``.
@@ -707,21 +714,187 @@ def _stream_response_to_file(
(effective offset 0) discards the stale bytes. ``offset`` also seeds the
progress bar so a resumed download shows overall progress. ``size`` is
the known full file size; when None it is derived from the response's
content-length, and without either there is no progress bar.
content-length, and without either there is no bar. With ``progress``
set no bar is drawn here; the callback gets the absolute byte count.
"""
f.seek(offset)
f.truncate(offset)
total_size = size or offset + _content_length(resp)
downloaded = offset
progress = ProgressBar("Downloading") if total_size > 0 else None
own_bar: ProgressBar | None = None
if progress is None:
own_bar = ProgressBar("Downloading") if total_size > 0 else None
progress = (
(lambda done: own_bar.update(done / total_size))
if own_bar
else (lambda _: None)
)
progress(downloaded)
for chunk in resp.iter_content(chunk_size=256 * 1024):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if progress is not None:
progress.update(downloaded / total_size)
if progress is not None:
progress.update(1)
progress(downloaded)
if own_bar is not None:
own_bar.update(1)
# Concurrent downloads per batch; enough to hide latency without
# hammering the host or the mirrors.
BATCH_DOWNLOAD_WORKERS = 4
def run_batch_downloads(
header: str,
jobs: list[tuple[str, int, Callable[[Callable[[int], None]], None]]],
max_workers: int = BATCH_DOWNLOAD_WORKERS,
) -> list[tuple[str, BaseException]]:
"""Run ``(name, size, fetch)`` download jobs concurrently under one bar.
Each ``fetch(tracker)`` reports absolute byte counts; the bar total is
the sum of the sizes. Failures are returned after the bar is done so
warnings never land on its row. Ctrl-C drops queued jobs and aborts
in-flight ones at their next progress tick or backoff boundary (a
parked socket read defers that by its timeout, and an in-progress
archive extraction runs to completion); resumable destinations
(``download_with_resume``) keep their fetched ``.part`` bytes.
``jobs`` must be non-empty.
"""
progress = _BatchDownloadProgress(header, sum(size for _, size, _ in jobs))
cancelled = threading.Event()
def _run(
name: str, fetch: Callable[[Callable[[int], None]], None]
) -> tuple[str, BaseException] | None:
tracker = progress.tracker()
def checked(done: int) -> None:
if cancelled.is_set():
raise _BatchDownloadCancelled
tracker(done)
try:
fetch(checked)
except (_BatchDownloadCancelled, Exception) as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# The cancelled arm exists for the tracker rollback below; the
# batch re-raises the interrupt, so the list is never returned
# after Ctrl-C. A bar-frame write failure must not displace the
# download error.
with suppress(Exception):
tracker(0)
failure = (name, err)
else:
failure = None
return failure
ex = ThreadPoolExecutor(max_workers=max_workers)
try:
with progress.logging_guard():
futures = [ex.submit(_run, name, fetch) for name, _, fetch in jobs]
return [failure for f in futures if (failure := f.result()) is not None]
except BaseException:
# Without this the non-daemon workers download to completion before
# the interpreter can exit, making Ctrl-C ineffective for minutes
cancelled.set()
raise
finally:
ex.shutdown(wait=True, cancel_futures=True)
progress.done()
class _BatchDownloadCancelled(BaseException):
"""Raised inside a download job to abandon it after Ctrl-C.
BaseException, like KeyboardInterrupt: a broad ``except Exception`` in
the download layers must not convert an abort into a retry.
"""
class _BatchDownloadProgress:
"""One bar across several concurrent downloads, summing tracker bytes.
The lock also serialises stderr writes so workers never interleave
frames; a ``total`` of 0 draws nothing. Call ``done()`` at the end so a
bar short of 100% still ends its line.
"""
def __init__(self, header: str, total: int) -> None:
self._bar = ProgressBar(header) if total > 0 else None
self._total = total
self._sum = 0
self._lock = threading.Lock()
def tracker(self) -> Callable[[int], None]:
if self._bar is None:
return lambda _: None
last = 0
def update(done: int) -> None:
nonlocal last
with self._lock:
self._sum += done - last
last = done
# A bar-write failure (broken stderr pipe) must not surface
# as a download failure and cost the .part file
with suppress(Exception):
self._bar.update(min(self._sum / self._total, 1))
return update
def done(self) -> None:
if self._bar is not None:
self._bar.done()
@contextmanager
def logging_guard(self) -> Iterator[None]:
r"""End a partial bar row before any log record while active.
Worker warnings (mirror retries) share stderr with the bar's \r
frames; without this the record lands mid-row and the next frame
overwrites it. A handler-level filter runs just before emit, so
only a tiny window remains for a concurrent frame.
"""
the_bar = self._bar
if the_bar is None:
yield
return
lock = self._lock
class _EndRow(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
# Handler.handle() runs filters outside handleError's try; a
# stderr write failure must not escape through the log call
with lock, suppress(Exception):
the_bar.interrupt()
return True
end_row = _EndRow()
handlers = logging.getLogger().handlers
for handler in handlers:
handler.addFilter(end_row)
try:
yield
finally:
for handler in handlers:
handler.removeFilter(end_row)
def _part_path(dest: Path) -> Path:
"""The in-progress sidecar ``download_with_resume`` streams into."""
return dest.with_name(dest.name + ".part")
def _cancellable_sleep(
delay: float, progress: Callable[[int], None] | None, done: int
) -> None:
"""Backoff sleep that still observes a batch cancellation tick."""
if progress is None:
time.sleep(delay)
return
end = time.monotonic() + delay
while (remaining := end - time.monotonic()) > 0:
progress(done) # raises when the batch was cancelled
time.sleep(min(0.5, remaining))
def download_with_resume(
@@ -734,6 +907,7 @@ def download_with_resume(
attempts: int = 5,
timeout: int = 30,
retry_connect_errors: bool = True,
progress: Callable[[int], None] | None = None,
) -> None:
"""Download ``url`` to ``dest``, resuming partial downloads.
@@ -756,6 +930,9 @@ def download_with_resume(
of consuming attempts for callers with their own fallback, like
``download_from_mirrors``.
``progress`` replaces the built-in bar: it receives the absolute bytes of
``dest`` obtained so far (see ``_BatchDownloadProgress``).
Raises EsphomeError when all attempts are exhausted.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only needed
@@ -767,7 +944,7 @@ def download_with_resume(
ensure_happy_eyeballs()
dest = Path(dest)
part = dest.with_name(dest.name + ".part")
part = _part_path(dest)
meta = part.with_name(part.name + ".meta")
dest.parent.mkdir(parents=True, exist_ok=True)
last_error: Exception | None = None
@@ -779,6 +956,8 @@ def download_with_resume(
if dest.is_file() and (sha256 is not None or size is not None):
try:
_verify_file(dest, sha256, size)
if progress is not None:
progress(size if size is not None else dest.stat().st_size)
return
except EsphomeError:
dest.unlink()
@@ -824,7 +1003,7 @@ def download_with_resume(
# Recorded so a later run can prove an If-Range
# resume of this part file safe.
_write_download_meta(meta, url, validator, expected_total)
_stream_response_to_file(resp, f, offset, size)
_stream_response_to_file(resp, f, offset, size, progress)
# else: a previous run already wrote every byte (or more) but
# was killed before the rename below. Skip the network entirely
# — a Range request past EOF would draw HTTP 416 — and let
@@ -833,6 +1012,10 @@ def download_with_resume(
expected_size = size if size is not None else expected_total
_verify_file(part, sha256, expected_size or None)
if progress is not None:
# Also credits a part file an earlier run completed without
# streaming anything this time.
progress(expected_size or part.stat().st_size)
if not expected_size and sha256 is None:
# No sha, no size, and the server sent no usable
# content-length: nothing can prove the download complete
@@ -880,11 +1063,11 @@ def download_with_resume(
raise EsphomeError(
f"Failed to download {url} after {attempts} attempts: "
f"{_failure_reason(last_error)}"
f"{failure_reason(last_error)}"
) from last_error
def _failure_reason(e: Exception) -> str:
def failure_reason(e: BaseException) -> str:
"""Format a download exception for the aggregated error message.
``requests`` appends " for url: <url>" to HTTP errors; the URL is already
@@ -900,7 +1083,7 @@ def _spent_attempts_error(e: Exception, attempts: int) -> Exception:
the sweep classifies it as permanent."""
from esphome.core import EsphomeError
err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}")
err = EsphomeError(f"failed after {attempts} attempts: {failure_reason(e)}")
err.__cause__ = e
return err
@@ -911,6 +1094,7 @@ def _try_mirrors_once(
f: IO[bytes] | None,
timeout: int,
failures: list[tuple[str, Exception]],
progress: Callable[[int], None] | None = None,
) -> str | None:
"""Single pass over the resolved mirror ``urls``, one try per URL.
@@ -939,6 +1123,7 @@ def _try_mirrors_once(
# next mirror immediately; only mid-stream drops
# retry-with-resume on the same URL.
retry_connect_errors=False,
progress=progress,
)
return url
except (requests.RequestException, OSError, EsphomeError) as e:
@@ -980,7 +1165,7 @@ def _try_mirrors_once(
if offset == 0:
validator = _response_validator(resp)
expected_total = _content_length(resp)
_stream_response_to_file(resp, f, offset)
_stream_response_to_file(resp, f, offset, progress=progress)
if expected_total and f.tell() != expected_total:
raise EsphomeError(
@@ -1029,6 +1214,7 @@ def download_from_mirrors(
substitutions: dict[str, str],
target: io.RawIOBase | IO[bytes] | PathType,
timeout: int = 30,
progress: Callable[[int], None] | None = None,
) -> str:
"""
Download file from multiple mirrors with substitution support.
@@ -1038,6 +1224,8 @@ def download_from_mirrors(
substitutions: Dictionary of substitutions to apply to URLs
target: Target file path or file-like object
timeout: Download timeout in seconds
progress: Passed through to the download (see ``download_with_resume``);
replaces the built-in per-file bar
Returns:
The source URL.
@@ -1102,7 +1290,9 @@ def download_from_mirrors(
for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1):
sweep_failures: list[tuple[str, Exception]] = []
if (
url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures)
url := _try_mirrors_once(
urls, path_target, f, timeout, sweep_failures, progress
)
) is not None:
return url
failures.extend(sweep_failures)
@@ -1119,12 +1309,21 @@ def download_from_mirrors(
_LOGGER.warning(
"Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)",
transient[0],
_failure_reason(transient[1]),
failure_reason(transient[1]),
delay,
sweep + 1,
_MIRROR_SWEEP_ATTEMPTS,
)
time.sleep(delay)
# Tick with the bytes already on disk so a combined bar holds
# steady during the backoff instead of rewinding to zero
done = 0
if progress is not None:
if f is not None:
done = f.tell()
else:
part = _part_path(path_target)
done = part.stat().st_size if part.is_file() else 0
_cancellable_sleep(delay, progress, done)
# 4. Report every attempted URL if all mirrors failed. failures spans
# all sweeps (deduplicated by URL and reason), so neither an early
@@ -1133,7 +1332,7 @@ def download_from_mirrors(
seen: set[tuple[str, str]] = set()
attempts = ""
for url, e in failures:
reason = _failure_reason(e)
reason = failure_reason(e)
if (url, reason) not in seen:
seen.add((url, reason))
attempts += f"\n {url}\n {reason}"
+12 -1
View File
@@ -738,11 +738,22 @@ class ProgressBar:
sys.stderr.flush()
def done(self) -> None:
if not self.enabled:
# No frame drawn, or the 100% frame already ended its own line
if not self.enabled or self.last_progress is None or self.last_progress == 100:
return
sys.stderr.write("\n")
sys.stderr.flush()
def interrupt(self) -> None:
"""End a mid-row frame so the next write starts on its own row.
The next ``update()`` redraws the bar; a finished bar stays done.
"""
if self.last_progress == 100:
return
self.done()
self.last_progress = None
def docs_url(path: str) -> str:
"""Return the URL to the documentation for a given path."""
+261 -132
View File
@@ -15,6 +15,7 @@ regardless of which toolchain consumes the result.
from collections import deque
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from functools import partial
import glob
import hashlib
import itertools
@@ -30,7 +31,13 @@ from urllib.request import url2pathname
from esphome import git
from esphome.core import CORE, EsphomeError, Library
from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir
from esphome.framework_helpers import (
archive_extract_all,
download_from_mirrors,
failure_reason,
rmdir,
run_batch_downloads,
)
_LOGGER = logging.getLogger(__name__)
@@ -70,6 +77,10 @@ SRC_FILE_EXTENSIONS = list(SOURCE_KIND_FOR_SUFFIX)
DOMAIN = "pio_components"
# Marks a cache dir whose archive finished extracting; a missing marker
# means a torn extraction that must be redone
_EXTRACTED_MARKER = ".esphome_extracted"
ESPHOME_DATA_KEY = "ESPHOME"
ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
# Captured extra-script LINKFLAGS; kept apart from build.flags so they reach
@@ -93,12 +104,13 @@ class Source:
class URLSource(Source):
def __init__(self, url: str):
def __init__(self, url: str, size: int | None = None):
self.url = url
# Archive size as reported by the registry, when known; sizes the
# combined prefetch bar without any extra network probe
self.size = size
def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = ""
) -> Path:
def _cache_dir(self, dir_suffix: str, salt: str, namespace: str) -> Path:
# Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so
# the build files each backend writes into the library dir can't collide.
base_dir = Path(CORE.data_dir) / DOMAIN
@@ -108,22 +120,40 @@ class URLSource(Source):
h.update(self.url.encode())
if salt:
h.update(salt.encode())
path = base_dir / h.hexdigest()[:8] / dir_suffix
return base_dir / h.hexdigest()[:8] / dir_suffix
def is_cached(self, dir_suffix: str, salt: str = "", namespace: str = "") -> bool:
"""Whether a completed extraction already exists for this source."""
return (
self._cache_dir(dir_suffix, salt, namespace) / _EXTRACTED_MARKER
).is_file()
def download(
self,
dir_suffix: str,
force: bool = False,
salt: str = "",
namespace: str = "",
progress: Callable[[int], None] | None = None,
) -> Path:
path = self._cache_dir(dir_suffix, salt, namespace)
# Marker file written last to signal a complete extraction. Using a
# marker (instead of just `path.is_dir()`) means an interrupted
# extraction is correctly detected and re-run on the next invocation,
# and lets us extract directly into ``path`` — avoiding a
# post-extraction rename that races with antivirus on Windows.
extracted_marker = path / ".esphome_extracted"
extracted_marker = path / _EXTRACTED_MARKER
if not extracted_marker.is_file() or force:
rmdir(path, msg=f"Clean up library directory {path}")
# Download in temporary file
with tempfile.NamedTemporaryFile() as tmp:
_LOGGER.info("Downloading %s ...", self.url)
if progress is None:
# A batch caller draws one combined bar and logs the list
_LOGGER.info("Downloading %s ...", self.url)
_LOGGER.debug("Location: %s", path)
download_from_mirrors([self.url], {}, tmp.file)
download_from_mirrors([self.url], {}, tmp.file, progress=progress)
_LOGGER.debug("Extracting archive to %s ...", path)
archive_extract_all(tmp.file, path)
@@ -415,6 +445,27 @@ def split_list_by_condition(
return matched, non_matched
def _valid_manifest_shape(data: Any) -> bool:
"""Whether the manifest has the dict shapes every backend dereferences.
A bare json.load imposes no shape; validating once here means a
malformed third-party manifest fails by library name instead of a raw
TypeError/AttributeError in a backend.
"""
if not isinstance(data, dict):
return False
build = data.get("build", {})
esphome_data = data.get(ESPHOME_DATA_KEY, {})
return (
isinstance(build, dict)
and isinstance(esphome_data, dict)
and isinstance(esphome_data.get(ESPHOME_DATA_LINK_FLAGS_KEY, []), list)
and isinstance(build.get("srcDir", ""), str)
and isinstance(build.get("includeDir", ""), str)
and isinstance(build.get("srcFilter", ""), (str, list))
)
def check_library_data(data: dict, platform: str | None, framework: str):
"""
Check whether a library manifest is compatible with the target toolchain.
@@ -537,9 +588,10 @@ def _make_registry_client() -> Any:
def _resolve_registry_version(
owner: str | None, pkgname: str, requirements: set[str]
) -> tuple[str, str, str, str]:
) -> tuple[str, str, str, str, int | None]:
"""Resolve a registry package to the single highest version satisfying ALL
the given requirements; return ``(owner, name, version, download_url)``.
the given requirements; return ``(owner, name, version, download_url,
size)`` (``size`` is None when the registry omits it).
Intersecting every requirement (rather than resolving each consumer in
isolation) makes the result independent of processing order and guarantees
@@ -569,7 +621,7 @@ def _resolve_registry_version(
pkgfile = registry.pick_compatible_pkg_file(best["files"])
if not pkgfile:
raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}")
return owner, name, best["name"], pkgfile["download_url"]
return owner, name, best["name"], pkgfile["download_url"], pkgfile.get("size")
def split_flag_entry(entry: Any, owner: str) -> list[str]:
@@ -859,6 +911,85 @@ def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool:
)
def _fetch_source(
component: ConvertedLibrary,
salt: str,
namespace: str,
tracker: Callable[[int], None],
) -> None:
# Straight to URLSource: only it takes progress, and mutating the
# shared component from a worker is the authoritative loop's job
component.source.download(
component.get_sanitized_name(), salt=salt, namespace=namespace, progress=tracker
)
def _prefetch_wave(
wave: list[tuple[str, ConvertedLibrary]], salt: str, namespace: str
) -> None:
"""Best-effort parallel download of a wave's registry archives.
The walk's own ``download()`` stays authoritative; duplicate URLs
prefetch once so two threads never share a cache directory. Archives
whose size the registry did not report are left to the sequential
loop, whose per-file bars don't interleave. A node a sibling in the
same wave supersedes has its archive fetched in vain (knowing better
would need the manifests being downloaded).
"""
try:
components: list[ConvertedLibrary] = []
seen: set[str] = set()
for _key, component in wave:
source = component.source
if not isinstance(source, URLSource) or not source.size:
continue
if source.url in seen:
continue
seen.add(source.url)
try:
cached = source.is_cached(
component.get_sanitized_name(), salt=salt, namespace=namespace
)
except OSError as err:
# Best-effort, but visibly: a systematic probe failure makes
# every warm build re-download every archive
_LOGGER.warning("Cache probe for %s failed: %s", component.name, err)
cached = False
if cached:
# A warm build must stay silent
continue
components.append(component)
if not components:
return
# Single-item waves (a dependency chain discovers one archive per
# wave) go through the same runner: one download method, one bar
_LOGGER.info(
"Downloading %d library archive(s): %s",
len(components),
", ".join(c.name for c in components),
)
failures = run_batch_downloads(
"Downloading libraries",
[
(c.name, c.source.size, partial(_fetch_source, c, salt, namespace))
for c in components
],
)
for name, err in failures:
# The sequential call below retries and raises the real error
_LOGGER.warning(
"Prefetch of %s failed (retrying sequentially): %s",
name,
failure_reason(err),
)
_LOGGER.debug("Prefetch failure detail", exc_info=err)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Same policy as the ESP-IDF twin: the prefetch must never become a
# new way for the build to fail
_LOGGER.warning("Library prefetch failed: %s", failure_reason(err))
_LOGGER.debug("Prefetch failure detail", exc_info=True)
def convert_libraries(
libraries: list[Library], backend: LibraryBackend
) -> list[ConvertedLibrary]:
@@ -955,136 +1086,134 @@ def convert_libraries(
top_level_keys = set(top_level)
worklist = deque(dict.fromkeys(top_level))
while worklist:
key = worklist.popleft()
node = nodes[key]
# Drain the frontier sequentially (spec resolution mutates shared
# state), then prefetch the wave in parallel
wave: list[tuple[str, ConvertedLibrary]] = []
while worklist:
key = worklist.popleft()
node = nodes[key]
# Re-resolve only when the requirement set grew; requirements
# only ever grow, so the fixpoint converges and cycles terminate
requirements = frozenset(node.requirements)
if resolved_requirements.get(key) == requirements:
continue
resolved_requirements[key] = requirements
# Re-resolve only when the requirement set grew; requirements
# only ever grow, so the fixpoint converges and cycles terminate
requirements = frozenset(node.requirements)
if resolved_requirements.get(key) == requirements:
continue
resolved_requirements[key] = requirements
if node.is_git:
component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref))
elif node.is_local:
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
else:
owner, name, version, url = _resolve_registry_version(
node.owner, node.pkgname, node.requirements
)
component = ConvertedLibrary(
_owner_pkgname_to_name(owner, name), version, URLSource(url)
)
component.download(salt=salt, namespace=backend.cache_key)
if node.is_git:
component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref))
elif node.is_local:
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
else:
owner, name, version, url, size = _resolve_registry_version(
node.owner, node.pkgname, node.requirements
)
component = ConvertedLibrary(
_owner_pkgname_to_name(owner, name), version, URLSource(url, size)
)
wave.append((key, component))
_prefetch_wave(wave, salt, backend.cache_key)
for key, component in wave:
node = nodes[key]
if frozenset(node.requirements) != resolved_requirements[key]:
# Requirements grew mid-wave: skip parsing a manifest the
# next wave will re-resolve and replace
worklist.append(key)
continue
component.download(salt=salt, namespace=backend.cache_key)
source_dir = component.source_dir
library_json_path = source_dir / "library.json"
library_properties_path = source_dir / "library.properties"
has_json = library_json_path.is_file()
has_properties = library_properties_path.is_file()
if not has_json and not has_properties and not node.is_local:
# An interrupted clone/extraction self-heals with one forced
# re-download; a local source has nothing to re-download
_LOGGER.warning(
"Library %s at %s is missing library.json and library.properties; "
"re-downloading",
key,
source_dir,
)
component.download(force=True, salt=salt, namespace=backend.cache_key)
source_dir = component.source_dir
library_json_path = source_dir / "library.json"
library_properties_path = source_dir / "library.properties"
has_json = library_json_path.is_file()
has_properties = library_properties_path.is_file()
if has_json:
component.data = parse_library_json(library_json_path)
elif has_properties:
component.data = parse_library_properties(library_properties_path)
else:
# Local sources are user input (EsphomeError); a registry/git
# miss means a corrupt cache (RuntimeError)
error_cls = EsphomeError if node.is_local else RuntimeError
raise error_cls(
f"Invalid PIO library {key}: missing library.json and "
f"library.properties in {source_dir}"
)
# A bare json.load imposes no shape; every backend dereferences
# these fields, so validate once here and name the library
malformed = not isinstance(component.data, dict)
if not malformed:
build = component.data.get("build", {})
esphome_data = component.data.get(ESPHOME_DATA_KEY, {})
malformed = (
not isinstance(build, dict)
or not isinstance(esphome_data, dict)
or not isinstance(
esphome_data.get(ESPHOME_DATA_LINK_FLAGS_KEY, []), list
if not has_json and not has_properties and not node.is_local:
# An interrupted clone/extraction self-heals with one forced
# re-download; a local source has nothing to re-download
_LOGGER.warning(
"Library %s at %s is missing library.json and library.properties; "
"re-downloading",
key,
source_dir,
)
or not isinstance(build.get("srcDir", ""), str)
or not isinstance(build.get("includeDir", ""), str)
or not isinstance(build.get("srcFilter", ""), (str, list))
)
if malformed:
# Fail fast only for a library the user asked for; a defect in
# an unrequested corner of the graph must not block the build
if key in top_level_keys:
raise EsphomeError(f"Library {key} has a malformed manifest")
_LOGGER.warning("Skipping dependency %s: malformed manifest", key)
continue
warn_properties_depends(component.name, component.data)
try:
check_library_data(component.data, backend.platform, backend.framework)
except InvalidLibrary as e:
# An explicitly requested library fails fast; the routine
# cross-platform skip stays at debug, other causes warn
if key in top_level_keys:
reason = (
f"is not compatible with {backend.framework}"
if isinstance(e, IncompatiblePlatform)
else "has a malformed manifest"
)
raise RuntimeError(f"Requested library {key} {reason}: {e}") from e
if isinstance(e, IncompatiblePlatform):
_LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e))
component.download(force=True, salt=salt, namespace=backend.cache_key)
has_json = library_json_path.is_file()
has_properties = library_properties_path.is_file()
if has_json:
component.data = parse_library_json(library_json_path)
elif has_properties:
component.data = parse_library_properties(library_properties_path)
else:
_LOGGER.warning("Skipping dependency %s: %s", key, str(e))
continue
components[key] = component
# Requirements changed (we got past the short-circuit above), so
# (re)walk this component's dependencies.
node.edges = set()
for dependency in normalize_dependencies(
component.data.get("dependencies"), component.name
):
if "version" not in dependency:
# Cannot resolve from the registry; common for bundled
# names (Wire, SPI) -- unactionable noise above debug
_LOGGER.debug(
"Skip version-less dependency %r of %s",
dependency.get("name"),
component.name,
# Local sources are user input (EsphomeError); a registry/git
# miss means a corrupt cache (RuntimeError)
error_cls = EsphomeError if node.is_local else RuntimeError
raise error_cls(
f"Invalid PIO library {key}: missing library.json and "
f"library.properties in {source_dir}"
)
if not _valid_manifest_shape(component.data):
# Fail fast only for a library the user asked for; a defect
# in an unrequested corner of the graph must not block the
# build
if key in top_level_keys:
raise EsphomeError(f"Library {key} has a malformed manifest")
_LOGGER.warning("Skipping dependency %s: malformed manifest", key)
continue
if not dependency_is_usable(
dependency, backend.platform, backend.framework, component.name
warn_properties_depends(component.name, component.data)
try:
check_library_data(component.data, backend.platform, backend.framework)
except InvalidLibrary as e:
# An explicitly requested library fails fast; the routine
# cross-platform skip stays at debug, other causes warn
if key in top_level_keys:
reason = (
f"is not compatible with {backend.framework}"
if isinstance(e, IncompatiblePlatform)
else "has a malformed manifest"
)
raise RuntimeError(f"Requested library {key} {reason}: {e}") from e
if isinstance(e, IncompatiblePlatform):
_LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e))
else:
_LOGGER.warning("Skipping dependency %s: %s", key, str(e))
continue
components[key] = component
# Requirements changed (we got past the short-circuit above), so
# (re)walk this component's dependencies.
node.edges = set()
for dependency in normalize_dependencies(
component.data.get("dependencies"), component.name
):
continue
dep_name = _owner_pkgname_to_name(
dependency.get("owner"), dependency.get("name")
)
if is_lib_ignored(dep_name, lib_ignore):
_LOGGER.debug("Skip ignored dependency %s", dep_name)
continue
# The version field may actually be a URL (git/archive dependency).
dep_version = dependency["version"]
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)
if "version" not in dependency:
# Cannot resolve from the registry; common for bundled
# names (Wire, SPI) -- unactionable noise above debug
_LOGGER.debug(
"Skip version-less dependency %r of %s",
dependency.get("name"),
component.name,
)
continue
if not dependency_is_usable(
dependency, backend.platform, backend.framework, component.name
):
continue
dep_name = _owner_pkgname_to_name(
dependency.get("owner"), dependency.get("name")
)
if is_lib_ignored(dep_name, lib_ignore):
_LOGGER.debug("Skip ignored dependency %s", dep_name)
continue
# The version field may actually be a URL (git/archive dependency).
dep_version = dependency["version"]
dep_url = _url_or_none(dep_version)
if dep_url is not None:
dep_version = None
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)
# A git or local source wins over the same component requested from the
# registry. That's intentional, but warn so the dropped registry spec isn't
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==46.0.0
aioesphomeapi==46.2.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.150.0
puremagic==2.2.0
+1 -1
View File
@@ -1,6 +1,6 @@
pylint==4.0.7
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.16.3 # also change in .pre-commit-config.yaml when updating
ruff==0.16.4 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
prek==0.4.14 # also change in .github/workflows/ci.yml when updating
@@ -13,17 +13,18 @@ namespace api {
class APIConnection;
} // namespace api
namespace uart {
enum class UARTFlushResult : uint8_t {
UART_FLUSH_RESULT_SUCCESS,
UART_FLUSH_RESULT_ASSUMED_SUCCESS,
UART_FLUSH_RESULT_TIMEOUT,
UART_FLUSH_RESULT_FAILED,
};
} // namespace uart
namespace serial_proxy {
enum class SerialProxyResult : uint8_t {
SERIAL_PROXY_RESULT_OK,
SERIAL_PROXY_RESULT_ASSUMED_SUCCESS,
SERIAL_PROXY_RESULT_PORT_IN_USE,
SERIAL_PROXY_RESULT_INVALID_ARGUMENT,
SERIAL_PROXY_RESULT_ERROR,
SERIAL_PROXY_RESULT_TIMEOUT,
SERIAL_PROXY_RESULT_NOT_SUPPORTED,
};
class SerialProxy {
public:
void set_instance_index(uint32_t index) { this->instance_index_ = index; }
@@ -31,13 +32,20 @@ class SerialProxy {
const char *get_name() const { return ""; }
api::enums::SerialProxyPortType get_port_type() const { return {}; }
api::APIConnection *get_api_connection() { return nullptr; }
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint32_t stop_bits, uint32_t data_size) {}
SerialProxyResult serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
SerialProxyResult configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {}
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
uint32_t get_modem_pins() const { return 0; }
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
uint32_t get_configured_modem_pins() const { return 0; }
SerialProxyResult flush_port(api::APIConnection *api_connection) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; }
protected:
uint32_t instance_index_{0};
@@ -15,7 +15,9 @@ namespace zwave_proxy {
class ZWaveProxy {
public:
api::APIConnection *get_api_connection() { return nullptr; }
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
api::enums::ZWaveProxyStatus zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {
return api::enums::ZWAVE_PROXY_STATUS_OK;
}
void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {}
void api_connection_authenticated(api::APIConnection *conn) {}
uint32_t get_feature_flags() const { return 0; }
@@ -0,0 +1,61 @@
#include <gtest/gtest.h>
#include <optional>
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::testing {
TEST(RuntimeImageMime, FormatForKnownMimeTypes) {
EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
#ifdef USE_RUNTIME_IMAGE_JPEG
EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG);
EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG);
#endif // USE_RUNTIME_IMAGE_JPEG
}
TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) {
EXPECT_EQ(get_format_for_mime_type("Image/PNG"), PNG);
EXPECT_EQ(get_format_for_mime_type("IMAGE/BMP"), BMP);
}
TEST(RuntimeImageMime, FormatMatchesContentTypeWithParameters) {
// Content-Type headers may carry parameters after the media type
EXPECT_EQ(get_format_for_mime_type("image/png; charset=binary"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/bmp;name=\"a.bmp\""), BMP);
}
TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) {
EXPECT_EQ(get_format_for_mime_type("text/html"), std::nullopt);
EXPECT_EQ(get_format_for_mime_type("application/octet-stream"), std::nullopt);
EXPECT_EQ(get_format_for_mime_type("image/*"), std::nullopt);
EXPECT_EQ(get_format_for_mime_type(""), std::nullopt);
EXPECT_EQ(get_format_for_mime_type(nullptr), std::nullopt);
}
TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) {
EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp");
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
#ifdef USE_RUNTIME_IMAGE_JPEG
EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg");
#endif // USE_RUNTIME_IMAGE_JPEG
// AUTO has no single MIME type and falls back to the wildcard
EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*");
// Every decodable format must resolve back to itself through its MIME type
for (ImageFormat format : {
BMP,
PNG,
#ifdef USE_RUNTIME_IMAGE_JPEG
JPEG,
#endif // USE_RUNTIME_IMAGE_JPEG
}) {
EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format;
}
}
} // namespace esphome::runtime_image::testing
+3
View File
@@ -41,3 +41,6 @@ number:
min_value: 2
max_value: 100
step: 1
time:
- platform: zigbee
@@ -10,6 +10,3 @@ zigbee:
on_start:
then:
- logger.log: "Started zigbee stack"
time:
- platform: zigbee
@@ -5,3 +5,6 @@ zigbee:
on_join:
then:
- logger.log: "Joined network"
time:
- platform: zigbee
@@ -263,6 +263,17 @@ def test_device_capabilities_response_has_id_150() -> None:
)
def test_z_wave_proxy_request_response_has_id_151() -> None:
body = _extract_proto_message(PROTO_TEXT, "ZWaveProxyRequestResponse")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "ZWaveProxyRequestResponse is missing `option (id)`"
assert int(match.group(1)) == 151, (
f"ZWaveProxyRequestResponse has id {match.group(1)}, expected 151. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None:
"""The six superseded fields must not carry `[deprecated = true]` in
api.proto, or the generator drops them and old clients stop receiving
+12 -4
View File
@@ -660,7 +660,7 @@ def _patch_registry(monkeypatch, versions):
def test_resolve_registry_version_intersects_constraints(monkeypatch):
_patch_registry(monkeypatch, ["1.10018.1", "1.10021.0", "1.10021.1"])
owner, name, version, url = _resolve_registry_version(
owner, name, version, url, _size = _resolve_registry_version(
"esphome", "libsodium", {"==1.10021.0", "^1.10018.1"}
)
assert (owner, name, version) == ("esphome", "libsodium", "1.10021.0")
@@ -669,7 +669,9 @@ def test_resolve_registry_version_intersects_constraints(monkeypatch):
def test_resolve_registry_version_picks_highest_satisfying(monkeypatch):
_patch_registry(monkeypatch, ["1.0.0", "1.5.0", "2.0.0"])
_owner, _name, version, _url = _resolve_registry_version("o", "p", {"^1.0.0"})
_owner, _name, version, _url, _size = _resolve_registry_version(
"o", "p", {"^1.0.0"}
)
assert version == "1.5.0"
@@ -719,7 +721,7 @@ def test_generate_idf_components_dedupes_shared_dependency(
resolve_calls.append(pkgname)
captured[f"{owner}/{pkgname}"] = set(requirements)
version = "1.10021.0" if pkgname == "C" else "1.0.0"
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz"
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz", None
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
@@ -778,7 +780,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
def fake_resolve(owner, pkgname, requirements):
resolve_calls.append(pkgname)
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz"
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
@@ -834,6 +836,7 @@ def test_generate_idf_components_handles_dependency_cycle(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -891,6 +894,7 @@ def test_generate_idf_components_git_overrides_registry_warns(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -927,6 +931,7 @@ def test_generate_idf_components_missing_manifest_raises(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -971,6 +976,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -1004,6 +1010,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -1040,6 +1047,7 @@ def test_generate_idf_components_incompatible_dependency_skipped(
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
+175 -11
View File
@@ -2,6 +2,7 @@
# pylint: disable=protected-access
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
import importlib.util
import io
@@ -14,7 +15,7 @@ import subprocess
import sys
import tarfile
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@@ -887,6 +888,78 @@ _PREFETCH_JSON = json.dumps(
)
def test_prefetch_leaves_unverifiable_entries_to_the_installer(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An entry missing sha256 or size must not download unverified; the
installer handles it and fails loudly on a bad archive."""
entries = json.loads(_PREFETCH_JSON)
del entries[0]["sha256"]
del entries[1]["size"]
entries.append(
{
"name": "gcc@14.2.0",
"url": "https://example.com/gcc.tar.gz",
"size": 67,
"sha256": "ef" * 32,
"dest": "gcc.tar.gz",
}
)
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert [call[0][0] for call in download.call_args_list] == [
"https://example.com/gcc.tar.gz"
]
assert download.call_args[1]["sha256"] == "ef" * 32
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 67)
assert "cmake@3.30.2 has no sha256/size" in caplog.text
assert "ninja@1.12.1 has no sha256/size" in caplog.text
def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
entries = json.loads(_PREFETCH_JSON)
for entry in entries:
del entry["sha256"]
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
download.assert_not_called()
def test_prefetch_dedupes_entries_by_dest(tmp_path: Path) -> None:
"""Two entries resolving to one dest would interleave writes into the
same .part file; only the first downloads."""
entries = json.loads(_PREFETCH_JSON)
dup = dict(entries[0]) | {"name": "cmake-alias@3.30.2"}
entries.append(dup)
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dests = [call[0][1].name for call in download.call_args_list]
assert dests.count("cmake-3.30.2.tar.gz") == 1
def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
with (
patch(
@@ -895,16 +968,58 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
):
# Materialize the lazy mock before threads race its first creation
tracker = progress_cls.return_value.tracker.return_value
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dist = get_idf_tools_path() / "dist"
assert download.call_count == 2
assert download.call_args_list[0][0] == (
"https://example.com/cmake.tar.gz",
dist / "cmake-3.30.2.tar.gz",
)
assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123}
# Archives download concurrently, so the call order is not fixed.
calls = {call[0]: call[1] for call in download.call_args_list}
assert set(calls) == {
("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz"),
("https://example.com/ninja.zip", dist / "ninja.zip"),
}
kwargs = calls[("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz")]
assert kwargs["sha256"] == "ab" * 32
assert kwargs["size"] == 123
# every archive reports into the one combined progress bar via the
# cancellation-checked wrapper; verify it delegates to the tracker
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 123 + 45)
before = tracker.call_count
for kw in calls.values():
kw["progress"](7)
assert tracker.call_count == before + len(calls)
def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
"""More than one archive fans out over a bounded thread pool."""
entries = [
{
"name": f"tool{i}@1",
"url": f"https://example.com/tool{i}.tar.gz",
"size": 10,
"sha256": "ab" * 32,
"dest": f"tool{i}.tar.gz",
}
for i in range(6)
]
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, json.dumps(entries), ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch(
"esphome.framework_helpers.ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as pool,
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.assert_called_once_with(max_workers=4)
assert download.call_count == 6
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
@@ -959,11 +1074,12 @@ def test_prefetch_failures_never_raise(
assert expected_log in caplog.text
def test_prefetch_one_failed_archive_does_not_stop_the_rest(
def test_prefetch_total_failure_logs_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A single archive failing its download must not abort the prefetch of
the remaining archives."""
"""Every archive failing is a systematic fault (proxy, bad kwarg), not
a flaky mirror; it must be distinguishable at ERROR because the resume
workaround is off for the whole install."""
with (
patch(
"esphome.espidf.framework.run_command",
@@ -971,7 +1087,32 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
),
patch(
"esphome.espidf.framework.download_with_resume",
side_effect=[OSError("network down"), None],
side_effect=OSError("proxy refuses everything"),
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert "Every ESP-IDF tool prefetch failed" in caplog.text
def test_prefetch_one_failed_archive_does_not_stop_the_rest(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A single archive failing its download must not abort the prefetch of
the remaining archives."""
def _fail_cmake_download(url: str, *args, **kwargs) -> None:
if "cmake" in url:
raise OSError("network down")
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.espidf.framework.download_with_resume",
side_effect=_fail_cmake_download,
) as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
@@ -979,6 +1120,29 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
assert download.call_count == 2
assert "Could not prefetch cmake@3.30.2" in caplog.text
# One flaky archive is routine, never the systematic-fault ERROR
assert "Every ESP-IDF tool prefetch failed" not in caplog.text
def test_prefetch_finishes_progress_bar_and_cancels_queue(tmp_path: Path) -> None:
"""The batch bar is closed out after the pool, and the pool is shut down
with cancel_futures so Ctrl-C does not drain every queued archive."""
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
patch("esphome.framework_helpers.ThreadPoolExecutor") as pool_cls,
):
pool = MagicMock(wraps=ThreadPoolExecutor(max_workers=2))
pool_cls.return_value = pool
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
pool.shutdown.assert_called_once_with(wait=True, cancel_futures=True)
progress_cls.return_value.done.assert_called_once_with()
def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
+49
View File
@@ -299,10 +299,13 @@ def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> No
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
cmakecache = CORE.relative_build_path("build/CMakeCache.txt")
build_ninja = CORE.relative_build_path("build/build.ninja")
cmakecache.parent.mkdir(parents=True, exist_ok=True)
cmakecache.write_text("")
build_ninja.write_text("")
old = cmakecache.stat().st_mtime - 100
os.utime(cmakecache, (old, old))
os.utime(build_ninja, (old, old))
with (
patch.object(toolchain, "need_reconfigure", return_value=True),
@@ -314,6 +317,8 @@ def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> No
assert toolchain.run_compile(config, verbose=False) == 0
assert cmakecache.stat().st_mtime > old
# build.ninja must not be older than the cache or ninja re-runs cmake
assert build_ninja.stat().st_mtime >= cmakecache.stat().st_mtime
def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None:
@@ -334,6 +339,50 @@ def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None:
assert not CORE.relative_build_path("build/CMakeCache.txt").exists()
def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
setup_core: Path,
) -> None:
"""The full CMakeLists write is followed by a reconfigure (#18682); a
failure there stops the build and leaves the cache unstamped."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
cmakecache = CORE.relative_build_path("build/CMakeCache.txt")
cmakecache.parent.mkdir(parents=True, exist_ok=True)
cmakecache.write_text("")
old = cmakecache.stat().st_mtime - 100
os.utime(cmakecache, (old, old))
calls: list[tuple] = []
reconfigures = 0
def record_write(minimal: bool = False) -> None:
calls.append(("write_project", minimal))
def record_reconfigure() -> int:
nonlocal reconfigures
reconfigures += 1
calls.append(("run_reconfigure",))
return 1 if reconfigures == 2 else 0
with (
patch.object(toolchain, "need_reconfigure", return_value=True),
patch("esphome.build_gen.espidf.write_project", side_effect=record_write),
patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure),
patch.object(toolchain, "run_idf_py", return_value=0) as mock_build,
patch.object(toolchain, "print_summary"),
):
assert not CORE.testing_mode
assert toolchain.run_compile(config, verbose=False) == 1
assert calls == [
("write_project", True),
("run_reconfigure",),
("write_project", False),
("run_reconfigure",),
]
mock_build.assert_not_called()
assert cmakecache.stat().st_mtime == old
def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
"""compile_process_limit is forwarded to run_idf_py as the job limit."""
_setup_build(setup_core)
+6 -2
View File
@@ -998,10 +998,10 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
assert "100%" in captured.err
assert "Done" in captured.err
# Test done method
# done() after the 100% frame adds nothing; that frame ended its line
progress.done()
captured = capsys.readouterr()
assert captured.err == "\n"
assert captured.err == ""
# Test same progress doesn't update
progress.update(0.5)
@@ -1010,6 +1010,10 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
# Should only see one update (second call shouldn't write)
assert captured.err.count("50%") == 1
# done() after a mid-way frame ends the line
progress.done()
assert capsys.readouterr().err == "\n"
# Tests for SHA256 authentication
@pytest.mark.usefixtures("mock_time")
+275
View File
@@ -12,6 +12,8 @@ from pathlib import Path
import subprocess
import sys
import tarfile
import threading
import time
from unittest.mock import MagicMock, Mock, call, patch
import zipfile
@@ -22,6 +24,7 @@ from esphome import framework_helpers
from esphome.core import EsphomeError
from esphome.framework_helpers import (
_7z_extract_all,
_BatchDownloadProgress,
_detect_archive_root,
_rename_with_retry,
_tar_extract_all,
@@ -36,6 +39,7 @@ from esphome.framework_helpers import (
get_python_env_executable_path,
get_system_python_path,
rmdir,
run_batch_downloads,
run_command,
run_command_ok,
str_to_lst_of_str,
@@ -1111,6 +1115,218 @@ class TestDownloadWithResume:
assert mock_get.call_args[1]["headers"] == {}
assert dest.read_bytes() == b"data"
def test_progress_callback_reports_absolute_bytes(self, tmp_path: Path) -> None:
"""With a callback no bar is drawn; the callback sees the running
byte count of this file, then its final verified size."""
dest = tmp_path / "tool.tar.gz"
resp = _mock_response(b"")
resp.headers = {"content-length": "7"}
resp.iter_content.return_value = [b"1234", b"567"]
seen: list[int] = []
with (
patch("requests.get", return_value=resp),
patch("esphome.framework_helpers.ProgressBar") as bar_cls,
):
download_with_resume(
"https://example.com/t", dest, size=7, progress=seen.append
)
assert seen == [0, 4, 7, 7]
bar_cls.assert_not_called()
def test_progress_callback_seeds_with_resume_offset(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
good = hashlib.sha256(b"12345678").hexdigest()
seen: list[int] = []
with patch("requests.get", return_value=_resumed_response(b"678")):
download_with_resume(
"https://example.com/t", dest, sha256=good, size=8, progress=seen.append
)
assert seen[0] == 5
assert seen[-1] == 8
def test_progress_callback_credits_already_complete_download(
self, tmp_path: Path
) -> None:
"""A verified dest from an earlier run still counts toward the batch."""
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"12345678")
seen: list[int] = []
with patch("requests.get") as mock_get:
download_with_resume(
"https://example.com/t", dest, size=8, progress=seen.append
)
mock_get.assert_not_called()
assert seen == [8]
def test_run_batch_downloads_ctrl_c_aborts_in_flight_jobs() -> None:
"""Ctrl-C cancels in-flight downloads at their next tick instead of
letting non-daemon workers download to completion."""
started = threading.Event()
ticks: list[int] = []
def interrupter(tracker) -> None:
started.wait(5)
raise KeyboardInterrupt
def slow_download(tracker) -> None:
started.set()
for i in range(500):
tracker(i)
ticks.append(i)
time.sleep(0.01)
t0 = time.monotonic()
with pytest.raises(KeyboardInterrupt):
run_batch_downloads(
"Downloading",
[("boom", 0, interrupter), ("slow", 0, slow_download)],
max_workers=2,
)
# Uncancelled, slow_download alone takes ~5s
assert time.monotonic() - t0 < 3
assert len(ticks) < 500
def test_cancellation_escapes_broad_except_in_fetch() -> None:
"""A fetch that wraps its work in except Exception cannot swallow the
Ctrl-C sentinel (it is a BaseException)."""
from esphome.framework_helpers import _BatchDownloadCancelled
started = threading.Event()
swallowed = []
def interrupter(tracker) -> None:
started.wait(5)
raise KeyboardInterrupt
def greedy_fetch(tracker) -> None:
started.set()
try:
for i in range(500):
tracker(i)
time.sleep(0.01)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
swallowed.append(err)
t0 = time.monotonic()
with pytest.raises(KeyboardInterrupt):
run_batch_downloads(
"Downloading",
[("boom", 0, interrupter), ("greedy", 0, greedy_fetch)],
max_workers=2,
)
assert time.monotonic() - t0 < 3
assert not swallowed
assert issubclass(_BatchDownloadCancelled, BaseException)
assert not issubclass(_BatchDownloadCancelled, Exception)
def test_logging_guard_ends_the_bar_row_before_a_record() -> None:
r"""A worker warning gets its own line instead of the bar's \r row."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(5)
with progress.logging_guard():
logging.getLogger("esphome.test").warning("mirror retry")
# The partial 50% frame ended its line before the record was emitted
assert stream.getvalue().endswith("50% \n")
# And the next tick redraws the frame on a fresh row
progress.tracker()(2)
assert stream.getvalue().endswith("70% ")
def test_logging_guard_without_a_bar_is_a_no_op() -> None:
"""An unknown total draws no bar; the guard passes records through."""
progress = _BatchDownloadProgress("Downloading", 0)
with progress.logging_guard():
logging.getLogger("esphome.test").warning("plain record")
def test_cancellable_sleep_sleeps_between_ticks() -> None:
"""An uncancelled backoff actually waits out its delay in slices."""
from esphome.framework_helpers import _cancellable_sleep
ticks: list[int] = []
t0 = time.monotonic()
_cancellable_sleep(0.05, ticks.append, 3)
assert time.monotonic() - t0 >= 0.05
assert ticks and all(t == 3 for t in ticks)
def test_cancellable_sleep_aborts_at_the_tick() -> None:
"""A backoff sleep observes the cancellation raise promptly."""
from esphome.framework_helpers import _BatchDownloadCancelled, _cancellable_sleep
def cancelled_tick(done: int) -> None:
raise _BatchDownloadCancelled
t0 = time.monotonic()
with pytest.raises(_BatchDownloadCancelled):
_cancellable_sleep(30, cancelled_tick, 0)
assert time.monotonic() - t0 < 1
class Test_BatchDownloadProgress:
def test_sums_trackers_into_one_bar(self) -> None:
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 100)
a = progress.tracker()
b = progress.tracker()
a(10)
b(20)
a(30)
a(0) # a restart from zero takes that file's bytes back out
bar_cls.assert_called_once_with("Downloading")
updates = [c[0][0] for c in bar_cls.return_value.update.call_args_list]
assert updates == [0.1, 0.3, 0.5, 0.2]
def test_clamps_at_one(self) -> None:
"""Sizes are advisory; an over-delivering server never pushes past 100%."""
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(25)
assert bar_cls.return_value.update.call_args[0][0] == 1
def test_unknown_total_draws_nothing(self) -> None:
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
progress = _BatchDownloadProgress("Downloading", 0)
progress.tracker()(5)
progress.done()
bar_cls.assert_not_called()
def test_done_ends_an_unfinished_bar(self) -> None:
"""A batch that stops short of 100% (a failed archive) still ends its
line so the next log message starts on a fresh row."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(5)
progress.done()
assert stream.getvalue().endswith("50% \n")
def test_done_before_any_frame_writes_nothing(self) -> None:
"""A batch aborted before any tracker fired must not emit a stray
newline for a bar that was never drawn."""
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
_BatchDownloadProgress("Downloading", 10).done()
assert stream.getvalue() == ""
def test_done_after_full_bar_adds_nothing(self) -> None:
stream = io.StringIO()
stream.isatty = lambda: True # type: ignore[method-assign]
with patch("esphome.helpers.sys.stderr", stream):
progress = _BatchDownloadProgress("Downloading", 10)
progress.tracker()(10)
progress.done()
assert stream.getvalue().endswith("100% Done...\r\n")
class TestDownloadFromMirrors:
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
@@ -1123,6 +1339,22 @@ class TestDownloadFromMirrors:
assert url == "https://example.com/f"
assert target.read_bytes() == b"filedata"
def test_file_object_target_reports_progress(self) -> None:
"""The library prefetch's production path: a file-object target
streams through the mirror fallback and ticks the tracker."""
buf = io.BytesIO()
ticks: list[int] = []
with patch(
"requests.get",
return_value=_mock_response(b"filedata"),
):
url = download_from_mirrors(
["https://example.com/f"], {}, buf, progress=ticks.append
)
assert url == "https://example.com/f"
assert buf.getvalue() == b"filedata"
assert ticks and ticks[-1] == len(b"filedata")
def test_substitutions_applied_to_url(self, tmp_path: Path) -> None:
with patch(
"requests.get",
@@ -1468,6 +1700,49 @@ class TestDownloadFromMirrors:
assert mock_get.call_count == 2
mock_sleep.assert_called_once_with(2)
def test_backoff_tick_reports_filelike_bytes(self) -> None:
"""For a file-like target the backoff tick carries f.tell(), so the
combined bar holds steady through the sweep retry."""
target = io.BytesIO()
ticks: list[int] = []
with (
patch(
"requests.get",
side_effect=[
req.ConnectionError("down"),
_mock_response(b"data"),
],
),
patch("esphome.framework_helpers._cancellable_sleep") as mock_sleep,
):
download_from_mirrors(
["https://mirror1.com/f"], {}, target, progress=ticks.append
)
# No bytes had streamed at backoff time, so the tick carries 0
assert mock_sleep.call_args == call(2, ticks.append, 0)
assert target.getvalue() == b"data"
def test_backoff_tick_reports_partial_bytes(self, tmp_path: Path) -> None:
"""The backoff tick carries the bytes already in the part file, so a
combined bar holds steady instead of rewinding to zero."""
dest = tmp_path / "out.bin"
(tmp_path / "out.bin.part").write_bytes(b"12345")
ticks: list[int] = []
with (
patch(
"requests.get",
side_effect=[
req.ConnectionError("down"),
_mock_response(b"data"),
],
),
patch("esphome.framework_helpers._cancellable_sleep") as mock_sleep,
):
download_from_mirrors(
["https://mirror1.com/f"], {}, dest, progress=ticks.append
)
assert mock_sleep.call_args == call(2, ticks.append, 5)
def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None:
"""An HTTP 404 will not heal on its own; fail after a single pass."""
with (
+14
View File
@@ -1124,6 +1124,20 @@ def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None:
assert bar.enabled is True
def test_progressbar_interrupt_keeps_finished_bar_done(monkeypatch) -> None:
"""interrupt() on a bar whose 100% frame already ended its own line
must not reset it, or the next tick would redraw a second Done row."""
stream = MagicMock(spec=io.TextIOWrapper)
stream.isatty.return_value = True
monkeypatch.setattr(CORE, "dashboard", False)
bar = ProgressBar("Uploading", stream=stream)
bar.update(1)
assert bar.last_progress == 100
bar.interrupt()
assert bar.last_progress == 100
@pytest.mark.parametrize(
("seconds", "expected"),
[
+193 -2
View File
@@ -153,13 +153,15 @@ def test_localsource_download_returns_empty_build_dir(setup_core: Path) -> None:
assert plain != out
def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch):
def test_urlsource_download_extracts_then_reuses_marker(
setup_core, monkeypatch, caplog
):
monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None)
dl_calls: list[list[str]] = []
monkeypatch.setattr(
lib,
"download_from_mirrors",
lambda urls, headers, f: dl_calls.append(urls),
lambda urls, headers, f, progress=None: dl_calls.append(urls),
)
def fake_extract(fileobj, path):
@@ -178,6 +180,12 @@ def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch)
assert out2 == out
assert len(dl_calls) == 1
# A batch caller passes a tracker and owns the messaging; no per-file INFO
caplog.set_level("INFO")
src.download("mylib-batch", progress=lambda done: None)
assert len(dl_calls) == 2
assert "Downloading" not in caplog.text
def test_resolve_registry_version_raises_without_pkg_file(monkeypatch):
registry = lib._make_registry_client()
@@ -211,6 +219,7 @@ def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None:
pkgname,
"1.0.0",
f"http://x/{pkgname}.tar.gz",
None,
),
)
@@ -230,6 +239,38 @@ def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properti
_patch_registry_resolve(monkeypatch)
def test_wave_requirement_growth_defers_the_superseded_download(tmp_path, monkeypatch):
"""A's manifest constrains B while B sits in the same wave: B's
drain-time resolution is superseded, so its download defers to the
next wave instead of fetching a version that is immediately replaced."""
download_names: list[str] = []
manifests = {
"esphome/A": {
"name": "A",
"build": {},
"dependencies": {"esphome/B": ">=1.0"},
},
"esphome/B": {"name": "B", "build": {}},
}
def fake_download(self, force=False, salt="", namespace="", progress=None):
download_names.append(self.name)
self.path = tmp_path / self.get_require_name()
self.path.mkdir(parents=True, exist_ok=True)
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
# Hermetic: the stubbed registry reports no size, so no batch prefetch
_patch_registry_resolve(monkeypatch)
top = convert_libraries(
[Library("esphome/A", "1.0.0", None), Library("esphome/B", None, None)],
_backend(),
)
assert sorted(c.name for c in top) == ["esphome/A", "esphome/B"]
# B downloads exactly once, after its requirement set stabilized
assert download_names.count("esphome/B") == 1
def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch):
# A manifest provided as library.properties (Arduino style) instead of
# library.json must still be parsed and converted.
@@ -574,6 +615,65 @@ def test_lex_build_flags_dangling_flag_does_not_cross_entries(
assert "Ignoring trailing '-I'" in caplog.text
def test_prefetch_wave_downloads_registry_archives_in_parallel(
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Registry archives in one wave download concurrently, deduped by URL;
git/local sources and failures are left to the sequential call."""
calls: list[str] = []
def fake_download(
self, dir_suffix, force=False, salt="", namespace="", progress=None
):
calls.append(self.url)
if progress is not None:
progress(0)
if "boom" in self.url:
raise RuntimeError("boom")
monkeypatch.setattr(URLSource, "download", fake_download)
wave = [
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz", 1))),
# Duplicate URL must prefetch once (two threads must never extract
# into the same cache directory)
("b2", ConvertedLibrary("b2", "1.0", URLSource("https://x/b.tar.gz", 1))),
("c", ConvertedLibrary("c", "1.0", URLSource("https://x/boom.tar.gz", 1))),
("g", ConvertedLibrary("g", "*", lib.GitSource("https://x/g.git", None))),
]
lib._prefetch_wave(wave, "", "idf")
assert sorted(calls) == [
"https://x/a.tar.gz",
"https://x/b.tar.gz",
"https://x/boom.tar.gz",
]
# The failure surfaces at default verbosity, after the bar
assert "Prefetch of c failed (retrying sequentially)" in caplog.text
def test_prefetch_wave_unknown_size_left_to_sequential(
setup_core, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Archives without a registry-reported size skip the batch (their
sequential per-file bars don't interleave); the known subset still
prefetches."""
calls: list[str] = []
monkeypatch.setattr(
URLSource,
"download",
lambda self, dir_suffix, force=False, salt="", namespace="", progress=None: (
calls.append(self.url)
),
)
wave = [
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz", 1))),
("u", ConvertedLibrary("u", "1.0", URLSource("https://x/u.tar.gz"))),
]
lib._prefetch_wave(wave, "", "idf")
assert sorted(calls) == ["https://x/a.tar.gz", "https://x/b.tar.gz"]
def test_join_flag_args_empty_argument_warns_and_drops(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -582,6 +682,97 @@ def test_join_flag_args_empty_argument_warns_and_drops(
assert "Ignoring '-D' with empty argument in build_flags" in caplog.text
def test_prefetch_wave_cache_probe_failure_still_prefetches(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A filesystem probe failure warns (a systematic one re-downloads
everything) but still prefetches; a programming error is NOT swallowed
here, it reaches the outer blanket guard."""
calls: list[str] = []
monkeypatch.setattr(
URLSource,
"download",
lambda self, dir_suffix, **kw: calls.append(self.url),
)
monkeypatch.setattr(
URLSource,
"is_cached",
lambda self, *a, **kw: (_ for _ in ()).throw(OSError("cache root denied")),
)
wave = [
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz", 1))),
]
lib._prefetch_wave(wave, "", "idf")
assert sorted(calls) == ["https://x/a.tar.gz", "https://x/b.tar.gz"]
assert "Cache probe for a failed: cache root denied" in caplog.text
def test_prefetch_wave_internal_error_never_fails_the_build(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""The blanket guard keeps a prefetch bug from failing the walk."""
monkeypatch.setattr(URLSource, "is_cached", lambda self, *a, **kw: False)
monkeypatch.setattr(
lib,
"run_batch_downloads",
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("bug")),
)
wave = [
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz", 1))),
]
lib._prefetch_wave(wave, "", "idf")
assert "Library prefetch failed: bug" in caplog.text
def test_prefetch_wave_warm_cache_is_silent(
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Already-extracted archives download nothing; a warm build must not
print a Downloading line or draw a bar."""
monkeypatch.setattr(
URLSource,
"download",
lambda self, dir_suffix, **kw: (_ for _ in ()).throw(
AssertionError("downloaded")
),
)
wave = []
for name in ("a", "b", "c"):
comp = ConvertedLibrary(name, "1.0", URLSource(f"https://x/{name}.tar.gz", 1))
marker_dir = comp.source._cache_dir(comp.get_sanitized_name(), "", "idf")
marker_dir.mkdir(parents=True)
(marker_dir / ".esphome_extracted").touch()
wave.append((name, comp))
lib._prefetch_wave(wave, "", "idf")
assert "Downloading" not in caplog.text
def test_prefetch_wave_single_archive_uses_the_batch(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A dependency chain discovers one archive per wave; it downloads
through the same runner so there is one download method and one bar."""
caplog.set_level("INFO")
calls: list[str] = []
monkeypatch.setattr(URLSource, "is_cached", lambda self, *a, **kw: False)
monkeypatch.setattr(
URLSource,
"download",
lambda self, dir_suffix, force=False, salt="", namespace="", progress=None: (
calls.append(self.url)
),
)
lib._prefetch_wave(
[("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1)))],
"",
"idf",
)
assert calls == ["https://x/a.tar.gz"]
assert "Downloading 1 library archive(s): a" in caplog.text
def test_normalize_dependencies_forms(caplog) -> None:
"""Every PIO-legal spelling normalizes; unrecognizable entries warn."""
from esphome.platformio.library import normalize_dependencies