Compare commits

..
Author SHA1 Message Date
Jos van Eijndhoven 66bf06b5d3 [hdmi_cec] CEC message decoder from the original branch
The decoder sources as they were before they were taken out of #9189 to keep the review small
(commit 11ded9dfbf on the author's branch). They turn a frame into a one-line text with the opcode
name and its operands, for the log. They are not wired into the merged bus core yet: the original
called the decoder from Frame::to_text() behind HDMI_CEC_USE_DECODER, with a decode_messages
option in the configuration.
2026-10-14 19:12:46 -10:00
41e82b0295 [hdmi_cec] Add HDMI-CEC bus component (part 1: bus core) (#9189)
Co-authored-by: Stéphane Lepin <stephane.lepin@gmail.com>
Co-authored-by: Stéphane Lepin <Palakis@users.noreply.github.com>
Co-authored-by: Niko <nikokirste@hotmail.de>
Co-authored-by: Minims <github@minims.fr>
Co-authored-by: Nicholas Pace <segfly@users.noreply.github.com>
Co-authored-by: Nicholas Pace <3300476+segfly@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-15 04:16:09 +00:00
J. Nick Koston 39705a65e3 [bmi270] Keep the range name and scale tables in flash (#20715) 2026-10-14 17:44:46 -10:00
J. Nick Koston a522553e82 [lsm6ds] Keep the range and chip names in flash (#20718) 2026-10-14 17:43:54 -10:00
J. Nick Koston a07a1ab728 [qmi8658] Keep the range name tables in flash (#20714) 2026-10-14 17:43:34 -10:00
J. Nick Koston 9a5d2d9753 [api] Encode action strings straight from flash (#20425) 2026-10-14 17:38:39 -10:00
J. Nick Koston 3188e0d36f [ota] Fall back to a UDP data phase when the link is lossy (#20498) 2026-10-15 16:30:56 +13:00
Jonathan Swoboda f3eaf698d6 [mipi_rgb] Rotate pure RGB panels in software (#20757) 2026-10-14 22:05:01 -04:00
J. Nick Koston f7057e1270 [tests] Reset saved preferences before every C++ unit test (#20771) 2026-10-14 16:01:45 -10:00
J. Nick Koston 6edfbb594d [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) 2026-10-14 22:00:07 -04:00
J. Nick Koston 22defcd704 [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) 2026-10-14 21:59:56 -04:00
Jonathan Swoboda 0f408234d0 [core] Add ksz8851snl to the static analysis manifest for IDF 6 (#20788) 2026-10-14 21:53:17 -04:00
Jonathan Swoboda 92e0e6db6b [wifi] Call esp_supp_dpp_init without a callback on ESP-IDF 6 (#20789) 2026-10-15 01:40:07 +00:00
64 changed files with 3682 additions and 604 deletions

No files matched your search

+1
View File
@@ -257,6 +257,7 @@ esphome/components/hc8/* @omartijn
esphome/components/hdc2010/* @optimusprimespace @ssieb
esphome/components/hdc2080/* @G-Pereira @jesserockz
esphome/components/hdc302x/* @joshuasing
esphome/components/hdmi_cec/* @JosVanEijndhoven
esphome/components/he60r/* @clydebarrow
esphome/components/heatpumpir/* @rob-deutsch
esphome/components/hitachi_ac424/* @sourabhjaiswal
-33
View File
@@ -251,9 +251,6 @@ def _validate_supports_response(value: Any) -> str:
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small
ESP8266_ACTION_STRINGS_MAX_TOTAL = 384
VARIABLE_SCHEMA = cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
@@ -484,29 +481,6 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
return strings
def _action_strings_size(strings: list[str | None]) -> int:
"""Bytes needed to copy every string out of flash, each with its terminator."""
return sum(
len(string.encode("utf-8")) + 1 for string in strings if string is not None
)
def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
if not CORE.is_esp8266:
return config
actions = config.get(CONF_ACTIONS, [])
has_metadata = _has_action_metadata(actions)
for conf in actions:
size = _action_strings_size(_action_strings(conf, has_metadata))
if size > ESP8266_ACTION_STRINGS_MAX_TOTAL:
raise cv.Invalid(
f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, "
f"description and example text; ESP8266 allows at most "
f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action"
)
return config
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
if (
@@ -531,7 +505,6 @@ def _validate_wizard(config: ConfigType) -> ConfigType:
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_esp8266_action_strings,
_validate_outgoing_host_ipv6,
_validate_wizard,
)
@@ -587,7 +560,6 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_HOMEASSISTANT_STATES]:
cg.add_define("USE_API_HOMEASSISTANT_STATES")
scratch_size = 0
if actions:
# Metadata is compiled in for every action once any action declares it, because the
# string table layout is fixed by the define rather than per action
@@ -639,8 +611,6 @@ async def to_code(config: ConfigType) -> None:
func_args.append((native, name))
strings = _action_strings(conf, has_metadata)
table = _add_action_strings(index, strings)
if CORE.is_esp8266:
scratch_size = max(scratch_size, _action_strings_size(strings))
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
# Key is hashed here because the name is not readable at runtime on ESP8266
@@ -667,9 +637,6 @@ async def to_code(config: ConfigType) -> None:
cg.add(auto.add_action(unregister_action))
# Register all services at once - single allocation, no reallocations
cg.add(var.initialize_user_services(triggers))
if CORE.is_esp8266 and has_user_actions:
# Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action
cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1))
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
await wizard.to_code(wizard_config)
+8 -8
View File
@@ -1029,8 +1029,8 @@ message SubscribeHomeassistantServicesRequest {
}
message HomeassistantServiceMap {
string key = 1;
string value = 2;
string key = 1 [(progmem) = true];
string value = 2 [(progmem) = true];
}
message HomeassistantActionRequest {
@@ -1039,7 +1039,7 @@ message HomeassistantActionRequest {
option (no_delay) = true;
option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES";
string service = 1;
string service = 1 [(progmem) = true];
repeated HomeassistantServiceMap data = 2 [(fixed_vector) = true];
repeated HomeassistantServiceMap data_template = 3 [(fixed_vector) = true];
repeated HomeassistantServiceMap variables = 4 [(fixed_vector) = true];
@@ -1160,21 +1160,21 @@ enum SupportsResponseType {
}
message ListEntitiesServicesArgument {
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
string name = 1;
string name = 1 [(progmem) = true];
ServiceArgType type = 2;
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
}
message ListEntitiesServicesResponse {
option (id) = 41;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
string name = 1;
string name = 1 [(progmem) = true];
fixed32 key = 2 [(force) = true];
repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true];
SupportsResponseType supports_response = 4;
string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
}
message ExecuteServiceArgument {
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
+1 -1
View File
@@ -123,7 +123,7 @@ extend google.protobuf.FieldOptions {
// from an absent one (e.g. a UTC ParsedTimezone, which is all zeros).
optional bool track_presence = 50020 [default=false];
// progmem: The pointer_to_buffer bytes field points into flash (PROGMEM).
// progmem: The string field, or pointer_to_buffer bytes field, points into flash (PROGMEM).
// The generated encode and dump copy it with progmem_memcpy, as ESP8266
// cannot read flash with ordinary loads.
optional bool progmem = 50021 [default=false];
+8 -8
View File
@@ -1142,8 +1142,8 @@ uint32_t NoiseEncryptionSetKeyResponse::calc_size_msg(const void *self) {
#ifdef USE_API_HOMEASSISTANT_SERVICES
uint8_t *HomeassistantServiceMap::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const HomeassistantServiceMap *>(self);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value);
return pos;
}
uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) {
@@ -1155,7 +1155,7 @@ uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) {
}
uint8_t *HomeassistantActionRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const HomeassistantActionRequest *>(self);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service);
for (auto &it : msg.data) {
pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, it);
}
@@ -1326,13 +1326,13 @@ void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data
uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self,
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const ListEntitiesServicesArgument *>(self);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(msg.type));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example);
#endif
return pos;
}
@@ -1352,14 +1352,14 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) {
uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self,
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const ListEntitiesServicesResponse *>(self);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name);
pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key);
for (auto &it : msg.args) {
pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it);
}
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast<uint32_t>(msg.supports_response));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description);
#endif
return pos;
}
+8 -8
View File
@@ -1389,8 +1389,8 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage {
#ifdef USE_API_HOMEASSISTANT_SERVICES
class HomeassistantServiceMap final : public ProtoMessage {
public:
StringRef key{nullptr, 0}; // null until set, encode only
StringRef value{nullptr, 0}; // null until set, encode only
ProgmemStringRef key{};
ProgmemStringRef value{};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -1410,7 +1410,7 @@ class HomeassistantActionRequest final : public ProtoMessage {
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); }
#endif
StringRef service{nullptr, 0}; // null until set, encode only
ProgmemStringRef service{};
FixedVector<HomeassistantServiceMap> data{};
FixedVector<HomeassistantServiceMap> data_template{};
FixedVector<HomeassistantServiceMap> variables{};
@@ -1577,13 +1577,13 @@ class GetTimeResponse final : public ProtoDecodableMessage {
#ifdef USE_API_USER_DEFINED_ACTIONS
class ListEntitiesServicesArgument final : public ProtoMessage {
public:
StringRef name{nullptr, 0}; // null until set, encode only
ProgmemStringRef name{};
enums::ServiceArgType type{};
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef description{nullptr, 0}; // null until set, encode only
ProgmemStringRef description{};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef example{nullptr, 0}; // null until set, encode only
ProgmemStringRef example{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -1604,12 +1604,12 @@ class ListEntitiesServicesResponse final : public ProtoMessage {
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); }
#endif
StringRef name{nullptr, 0}; // null until set, encode only
ProgmemStringRef name{};
uint32_t key{0};
FixedVector<ListEntitiesServicesArgument> args{};
enums::SupportsResponseType supports_response{};
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef description{nullptr, 0}; // null until set, encode only
ProgmemStringRef description{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+18 -10
View File
@@ -12,8 +12,9 @@ namespace esphome::api {
#ifdef USE_ESP8266
// Out-of-line to avoid inlining strlen_P/memcpy_P at every call site
void DumpBuffer::append_p_esp8266(const char *str) {
size_t len = strlen_P(str);
void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); }
void DumpBuffer::append_p_esp8266(const char *str, size_t len) {
size_t space = CAPACITY - 1 - pos_;
if (len > space)
len = space;
@@ -134,6 +135,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co
progmem_memcpy(data_buf, data, len);
dump_bytes_field(out, field_name, data_buf, len, indent);
}
// Helper for string fields in flash
static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value,
int indent = 2) {
append_field_prefix(out, field_name, indent);
out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\n");
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
@@ -1512,13 +1520,13 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const {
#ifdef USE_API_HOMEASSISTANT_SERVICES
const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantServiceMap"));
dump_field(out, ESPHOME_PSTR("key"), this->key);
dump_field(out, ESPHOME_PSTR("value"), this->value);
dump_progmem_string_field(out, ESPHOME_PSTR("key"), this->key);
dump_progmem_string_field(out, ESPHOME_PSTR("value"), this->value);
return out.c_str();
}
const char *HomeassistantActionRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantActionRequest"));
dump_field(out, ESPHOME_PSTR("service"), this->service);
dump_progmem_string_field(out, ESPHOME_PSTR("service"), this->service);
for (const auto &it : this->data) {
out.append(4, ' ').append_p(ESPHOME_PSTR("data")).append(": ");
it.dump_to(out);
@@ -1613,19 +1621,19 @@ const char *GetTimeResponse::dump_to(DumpBuffer &out) const {
#ifdef USE_API_USER_DEFINED_ACTIONS
const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesArgument"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ServiceArgType>(this->type));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("description"), this->description);
dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("example"), this->example);
dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example);
#endif
return out.c_str();
}
const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesResponse"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("key"), this->key);
for (const auto &it : this->args) {
out.append(4, ' ').append_p(ESPHOME_PSTR("args")).append(": ");
@@ -1634,7 +1642,7 @@ const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const {
}
dump_field(out, ESPHOME_PSTR("supports_response"), static_cast<enums::SupportsResponseType>(this->supports_response));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("description"), this->description);
dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description);
#endif
return out.c_str();
}
+1 -1
View File
@@ -547,7 +547,7 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call
// arrive before the subscription and are lost - warn instead of failing silently.
ESP_LOGW(TAG, "Home Assistant %s '%.*s' dropped; %s",
call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"),
static_cast<int>(call.service.size()), call.service.empty() ? "" : call.service.c_str(),
static_cast<int>(call.service.size()), call.service.empty() ? "" : call.service.progmem_ptr(),
this->is_connected() ? LOG_STR_LITERAL("client has not subscribed to actions (yet)")
: LOG_STR_LITERAL("no client connected"));
}
+8 -8
View File
@@ -247,7 +247,7 @@ class CustomAPIDevice {
*/
void call_homeassistant_service(const std::string &service_name) {
HomeassistantActionRequest resp;
resp.service = StringRef(service_name);
resp.service = ProgmemStringRef(StringRef(service_name));
global_api_server->send_homeassistant_action(resp);
}
@@ -267,12 +267,12 @@ class CustomAPIDevice {
*/
void call_homeassistant_service(const std::string &service_name, const std::map<std::string, std::string> &data) {
HomeassistantActionRequest resp;
resp.service = StringRef(service_name);
resp.service = ProgmemStringRef(StringRef(service_name));
resp.data.init(data.size());
for (auto &it : data) {
auto &kv = resp.data.emplace_back();
kv.key = StringRef(it.first);
kv.value = StringRef(it.second); // data map lives until send completes
kv.key = ProgmemStringRef(StringRef(it.first));
kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes
}
global_api_server->send_homeassistant_action(resp);
}
@@ -289,7 +289,7 @@ class CustomAPIDevice {
*/
void fire_homeassistant_event(const std::string &event_name) {
HomeassistantActionRequest resp;
resp.service = StringRef(event_name);
resp.service = ProgmemStringRef(StringRef(event_name));
resp.is_event = true;
global_api_server->send_homeassistant_action(resp);
}
@@ -309,13 +309,13 @@ class CustomAPIDevice {
*/
void fire_homeassistant_event(const std::string &service_name, const std::map<std::string, std::string> &data) {
HomeassistantActionRequest resp;
resp.service = StringRef(service_name);
resp.service = ProgmemStringRef(StringRef(service_name));
resp.is_event = true;
resp.data.init(data.size());
for (auto &it : data) {
auto &kv = resp.data.emplace_back();
kv.key = StringRef(it.first);
kv.value = StringRef(it.second); // data map lives until send completes
kv.key = ProgmemStringRef(StringRef(it.first));
kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes
}
global_api_server->send_homeassistant_action(resp);
}
+4 -28
View File
@@ -116,42 +116,18 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
const Field *fields = this->fields_;
const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_;
// Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send
// Lambda results must live until the send; the constant strings are encoded from flash where they are
size_t lambda_count = 0;
#ifdef USE_ESP8266
size_t flash_len = 0;
#endif
for (size_t i = 0; i < total; i++) {
for (size_t i = 0; i < total; i++)
lambda_count += fields[i].fn != nullptr;
#ifdef USE_ESP8266
if (fields[i].fn == nullptr)
flash_len += ESPHOME_strlen_P(fields[i].value);
if (fields[i].key != nullptr)
flash_len += ESPHOME_strlen_P(fields[i].key);
#endif
}
FixedVector<std::string> results;
results.init(lambda_count);
#ifdef USE_ESP8266
SmallBufferWithHeapFallback<128, char> flash_copy(flash_len);
char *cursor = flash_copy.get();
#endif
auto string_ref = [&](const char *str) {
#ifdef USE_ESP8266
size_t len = ESPHOME_strlen_P(str);
memcpy_P(cursor, str, len);
StringRef ref(cursor, len);
cursor += len;
return ref;
#else
return StringRef(str);
#endif
};
auto string_ref = [](const char *str) { return ProgmemStringRef(str, ESPHOME_strlen_P(str)); };
auto value_ref = [&](const Field &field) {
if (field.fn == nullptr)
return string_ref(field.value);
results.push_back(field.fn(x...));
return StringRef(results.back());
return ProgmemStringRef(StringRef(results.back()));
};
auto fill = [&](FixedVector<HomeassistantServiceMap> &dest, uint8_t count) {
dest.init(count);
+1 -2
View File
@@ -99,8 +99,7 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie
static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3;
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
UserActionScratch scratch;
auto resp = service->encode_list_service_response(scratch);
auto resp = service->encode_list_service_response();
if (!this->client_->send_message(resp))
return false;
// at_ is this service's index
+34
View File
@@ -418,6 +418,18 @@ class ProtoEncode {
std::memcpy(pos + 2, ref.c_str(), ref.size());
return pos + 2 + ref.size();
}
/// encode_short_string_force for a string that may be in flash (PROGMEM).
[[nodiscard]] static inline uint8_t *encode_short_progmem_string_force(
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) {
#ifdef ESPHOME_DEBUG_API
assert(ref.size() < 128 && "encode_short_progmem_string_force: string exceeds max_data_length < 128");
#endif
PROTO_ENCODE_CHECK_BOUNDS(pos, 2 + ref.size());
pos[0] = tag;
pos[1] = static_cast<uint8_t>(ref.size());
progmem_memcpy(pos + 2, ref.progmem_ptr(), ref.size());
return pos + 2 + ref.size();
}
/// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field.
[[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32(
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) {
@@ -481,6 +493,17 @@ class ProtoEncode {
progmem_memcpy(pos, data, len);
return pos + len;
}
/// encode_progmem_bytes for a string field that may be in flash.
[[nodiscard]] static inline uint8_t *encode_progmem_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const ProgmemStringRef &ref) {
return encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, field_id,
reinterpret_cast<const uint8_t *>(ref.progmem_ptr()), ref.size());
}
[[nodiscard]] static inline uint8_t *encode_progmem_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const ProgmemStringRef &ref) {
return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id,
reinterpret_cast<const uint8_t *>(ref.progmem_ptr()), ref.size());
}
[[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, uint32_t value) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
@@ -680,9 +703,20 @@ class DumpBuffer {
return *this;
}
/// Append len bytes of a PROGMEM string
DumpBuffer &append_p(const char *str, size_t len) {
#ifdef USE_ESP8266
append_p_esp8266(str, len);
#else
append_impl_(str, len);
#endif
return *this;
}
#ifdef USE_ESP8266
/// Out-of-line ESP8266 PROGMEM append to avoid inlining strlen_P/memcpy_P at every call site
void append_p_esp8266(const char *str);
void append_p_esp8266(const char *str, size_t len);
#endif
const char *c_str() const { return buf_; }
+9 -20
View File
@@ -1,47 +1,36 @@
#include "user_services.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
namespace esphome::api {
StringRef UserServiceStatic::str_(size_t idx, std::span<char> &scratch) const {
ProgmemStringRef UserServiceStatic::str_(size_t idx) const {
const char *s = progmem_read_ptr(&this->strings_[idx]);
if (s == nullptr)
return {};
#ifdef USE_ESP8266
// Codegen sizes the scratch buffer for the largest service; the bound only guards other callers
if (scratch.empty())
return {};
size_t len = strnlen_P(s, scratch.size() - 1);
progmem_memcpy(scratch.data(), s, len);
scratch[len] = '\0';
StringRef ref(scratch.data(), len);
scratch = scratch.subspan(len + 1);
return ref;
#else
return StringRef(s);
#endif
return ProgmemStringRef(s, ESPHOME_strlen_P(s));
}
ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_(
std::span<const enums::ServiceArgType> arg_types, std::span<char> scratch) const {
std::span<const enums::ServiceArgType> arg_types) const {
ListEntitiesServicesResponse msg;
msg.name = this->str_(0, scratch);
msg.name = this->str_(0);
msg.key = this->key_;
msg.supports_response = this->supports_response_;
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
msg.description = this->str_(1, scratch);
msg.description = this->str_(1);
#endif
msg.args.init(arg_types.size());
for (size_t i = 0; i < arg_types.size(); i++) {
size_t base = USER_ACTION_HEADER_STRINGS + i * USER_ACTION_ARG_STRINGS;
auto &arg = msg.args.emplace_back();
arg.type = arg_types[i];
arg.name = this->str_(base, scratch);
arg.name = this->str_(base);
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
arg.description = this->str_(base + 1, scratch);
arg.example = this->str_(base + 2, scratch);
arg.description = this->str_(base + 1);
arg.example = this->str_(base + 2);
#endif
}
return msg;
+10 -21
View File
@@ -20,9 +20,8 @@ class APIServer;
class UserServiceDescriptor {
public:
/// Build the list-entities message. On ESP8266 the strings live in PROGMEM and are copied into
/// `scratch`, so the returned message is only valid while `scratch` is; other platforms ignore it.
virtual ListEntitiesServicesResponse encode_list_service_response(std::span<char> scratch) = 0;
/// Build the list-entities message; its strings may point into flash (PROGMEM).
virtual ListEntitiesServicesResponse encode_list_service_response() = 0;
virtual bool execute_service(const ExecuteServiceRequest &req) = 0;
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
@@ -37,13 +36,6 @@ template<typename T> T get_execute_arg_value(const ExecuteServiceArgument &arg);
template<typename T> enums::ServiceArgType to_service_arg_type();
// Scratch buffer list-entities hands to encode_list_service_response(); only ESP8266 copies into it
#ifdef USE_ESP8266
using UserActionScratch = std::array<char, API_USER_ACTION_STRINGS_SCRATCH_SIZE>;
#else
using UserActionScratch = std::array<char, 0>;
#endif
// Non-template base for YAML-defined services so the list-entities encoder is compiled once.
// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in
// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then
@@ -63,12 +55,9 @@ class UserServiceStatic : public UserServiceDescriptor {
: strings_(strings), key_(key), supports_response_(supports_response) {}
protected:
ListEntitiesServicesResponse encode_list_service_response_(std::span<const enums::ServiceArgType> arg_types,
std::span<char> scratch) const;
/// Reference table entry `idx`; nullptr gives an empty StringRef.
/// On ESP8266 the bytes are copied out of PROGMEM into `scratch` with a terminator, and the span
/// is advanced past the copy.
StringRef str_(size_t idx, std::span<char> &scratch) const;
ListEntitiesServicesResponse encode_list_service_response_(std::span<const enums::ServiceArgType> arg_types) const;
/// Table entry `idx` as it sits in flash; nullptr gives an empty string.
ProgmemStringRef str_(size_t idx) const;
const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr()
uint32_t key_;
@@ -79,9 +68,9 @@ template<typename... Ts> class UserServiceBase : public UserServiceStatic {
public:
using UserServiceStatic::UserServiceStatic;
ListEntitiesServicesResponse encode_list_service_response(std::span<char> scratch) override {
ListEntitiesServicesResponse encode_list_service_response() override {
std::array<enums::ServiceArgType, sizeof...(Ts)> arg_types = {to_service_arg_type<Ts>()...};
return this->encode_list_service_response_(arg_types, scratch);
return this->encode_list_service_response_(arg_types);
}
bool execute_service(const ExecuteServiceRequest &req) override {
@@ -125,9 +114,9 @@ template<typename... Ts> class UserServiceDynamic : public UserServiceDescriptor
this->key_ = fnv1_hash(this->name_.c_str());
}
ListEntitiesServicesResponse encode_list_service_response(std::span<char> /*scratch*/) override {
ListEntitiesServicesResponse encode_list_service_response() override {
ListEntitiesServicesResponse msg;
msg.name = StringRef(this->name_);
msg.name = ProgmemStringRef(StringRef(this->name_));
msg.key = this->key_;
msg.supports_response = enums::SUPPORTS_RESPONSE_NONE; // Dynamic services don't support responses yet
std::array<enums::ServiceArgType, sizeof...(Ts)> arg_types = {to_service_arg_type<Ts>()...};
@@ -135,7 +124,7 @@ template<typename... Ts> class UserServiceDynamic : public UserServiceDescriptor
for (size_t i = 0; i < sizeof...(Ts); i++) {
auto &arg = msg.args.emplace_back();
arg.type = arg_types[i];
arg.name = StringRef(this->arg_names_[i]);
arg.name = ProgmemStringRef(StringRef(this->arg_names_[i]));
}
return msg;
}
@@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() {
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}};
esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;
+11 -7
View File
@@ -2,6 +2,7 @@
#include "bmi270_config.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "esphome/core/progmem.h"
namespace esphome::bmi270 {
@@ -11,6 +12,9 @@ using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device defi
ESPHOME_LOG_TAG(TAG, "bmi270");
PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±4g", "±8g", "±16g");
PROGMEM_STRING_TABLE(GyroRangeStrings, "±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s");
#if defined(USE_ARDUINO) && !defined(USE_ESP32)
static constexpr size_t MAX_I2C_BUFFER_SIZE = 32;
#else
@@ -273,11 +277,10 @@ void BMI270Component::dump_config() {
return;
}
static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"};
static constexpr const char *const GYRO_RANGE_STRS[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"};
ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]);
ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]);
ESP_LOGCONFIG(TAG, " Accel range : %s",
LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX)));
ESP_LOGCONFIG(TAG, " Gyro range : %s",
LOG_STR_ARG(GyroRangeStrings::get_log_str(this->gyro_range_, GyroRangeStrings::LAST_INDEX)));
#ifdef USE_BMI270_BMM150
if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) {
ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)",
@@ -315,7 +318,8 @@ bool BMI270Component::update_data(motion::MotionData &data) {
}
// Scale factor: LSB/g depends on range
// raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb
static constexpr float ACCEL_SCALE[] = {
// In PROGMEM; each read is a 32-bit load, which is safe from flash on ESP8266
alignas(4) static constexpr float ACCEL_SCALE[] PROGMEM = {
2.0f / 32768.0f,
4.0f / 32768.0f,
8.0f / 32768.0f,
@@ -329,7 +333,7 @@ bool BMI270Component::update_data(motion::MotionData &data) {
// Gyroscope: registers 0x12–0x17 (6 bytes)
// Scale: full-scale range / 2^15
static constexpr float GYRO_SCALE[] = {
alignas(4) static constexpr float GYRO_SCALE[] PROGMEM = {
2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f,
};
static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8;
+1 -1
View File
@@ -243,7 +243,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back
"esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back
"esp_driver_dac", # DAC driver - only needed by esp32_dac component
"esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs
"esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, hdmi_cec, opentherm, Arduino BLE libs
"esp_driver_i2c", # I2C driver - re-included by i2c; esp32-camera pulls it back itself
"esp_driver_i2s", # I2S driver - only needed by i2s_audio component
"esp_driver_ledc", # LEDC PWM driver - re-included by ledc; esp32-camera pulls it back itself
@@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) {
esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}};
esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;
+101 -10
View File
@@ -44,11 +44,14 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
}
#endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 60000; // milliseconds for initial handshake
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
// A handshake this old gives way to a waiting connection: a client retrying over a lossy link otherwise queues
// behind its own stalled attempt until both time out
static constexpr uint32_t OTA_HANDSHAKE_PREEMPT = 10000;
static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000;
static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first
@@ -199,6 +202,9 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10;
#ifdef ESPHOME_OTA_UDP
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_UDP = 0x20;
#endif
// Noise needs the extended protocol: the prologue binds the 2-byte feature ack
static constexpr uint8_t CLIENT_NOISE_FEATURES =
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
@@ -208,6 +214,10 @@ static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
// Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the
// client must then send the image size frame and a deflate stream.
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08;
#ifdef ESPHOME_OTA_UDP
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_UDP = 0x10;
static constexpr uint8_t UDP_REQUEST_V1 = 0x01; // espota2.UDP_REQUEST
#endif
#ifdef USE_OTA_ENCRYPTION
inline bool ESPHomeOTAComponent::noise_offered_() const {
@@ -266,6 +276,12 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->cleanup_connection_();
return;
}
// Only unauthenticated handshakes reach here; the data phase blocks inside handle_data_
if (now - this->client_connect_time_ > OTA_HANDSHAKE_PREEMPT && this->server_->ready()) {
ESP_LOGD(TAG, "Newer connection waiting");
this->cleanup_connection_();
return;
}
switch (this->ota_state_) {
case OTAState::MAGIC_READ: {
@@ -329,6 +345,9 @@ void ESPHomeOTAComponent::handle_handshake_() {
static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack");
this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS;
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
#ifdef ESPHOME_OTA_UDP
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_UDP;
#endif
#ifdef USE_OTA_PARTITIONS
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
#endif
@@ -483,6 +502,10 @@ void ESPHomeOTAComponent::handle_data_() {
char *sbuf = reinterpret_cast<char *>(buf);
size_t image_size;
ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP;
#ifdef ESPHOME_OTA_UDP
UdpLink udp_link;
this->udp_ = nullptr; // client_connect_time_ is done with
#endif
// Set socket timeouts and blocking mode (see strategy table above)
struct timeval tv;
@@ -498,6 +521,38 @@ void ESPHomeOTAComponent::handle_data_() {
// Acknowledge auth OK - 1 byte
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
#ifdef ESPHOME_OTA_UDP
// The server always offers UDP on the extended protocol
if (this->extended_proto_() && (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_UDP) != 0) {
// Whether the client wants UDP (0: its link looks good), answered with our token
if (!this->data_readall_(buf, 1)) {
if (this->client_left_before_start_())
return;
this->log_read_error_(LOG_STR("UDP request"));
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
// 1 is this UDP format; other values are free for later ones, which we decline (a zero reply keeps TCP)
if (buf[0] == UDP_REQUEST_V1) {
this->udp_open_(udp_link);
if (udp_link.reply[0] == 0) {
ESP_LOGW(TAG, "UDP unavailable, using TCP");
}
}
if (!this->data_write_(udp_link.reply, sizeof(udp_link.reply)))
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
if (udp_link.reply[0] != 0) {
this->udp_ = &udp_link;
if (!this->udp_start_()) {
// Nothing started yet: just drop the session, which the client retries as a network failure
this->log_read_error_(LOG_STR("UDP start"));
this->cleanup_connection_();
return;
}
}
}
#endif
if (this->extended_proto_()) {
// Read ota type, 1 byte
if (!this->data_readall_(buf, 1)) {
@@ -598,11 +653,17 @@ void ESPHomeOTAComponent::handle_data_() {
// Acknowledge Update end OK - 1 byte
this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK);
// Read ACK
if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
this->log_read_error_(LOG_STR("ack"));
// do not go to error, this is not fatal
}
#ifdef ESPHOME_OTA_UDP
// UDP never reports a close: wait only until the client has our responses, not for its final ack
if (this->udp_ != nullptr) {
this->udp_linger_();
} else
#endif
// Read ACK
if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
this->log_read_error_(LOG_STR("ack"));
// do not go to error, this is not fatal
}
this->cleanup_connection_();
delay(10);
@@ -623,6 +684,9 @@ void ESPHomeOTAComponent::handle_data_() {
error:
this->data_write_byte_(static_cast<uint8_t>(error_code));
#ifdef ESPHOME_OTA_UDP
this->udp_linger_();
#endif
// Abort backend before cleanup - cleanup_connection_() destroys the backend.
// Always call abort() unconditionally: backends register external partitions before
@@ -651,7 +715,7 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) {
return false;
}
ssize_t read = this->client_->read(buf + at, len - at);
ssize_t read = this->transport_read_(buf + at, len - at);
if (read == -1) {
const int err = errno;
if (!this->would_block_(err)) {
@@ -676,6 +740,10 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) {
return true;
}
bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) {
#ifdef ESPHOME_OTA_UDP
if (this->udp_ != nullptr)
return this->udp_send_(buf, len);
#endif
uint32_t start = millis();
uint32_t at = 0;
while (len - at > 0) {
@@ -728,7 +796,20 @@ void ESPHomeOTAComponent::log_read_error_(const LogString *what) {
void ESPHomeOTAComponent::log_start_(const LogString *phase) {
char peername[socket::SOCKADDR_STR_LEN];
this->client_->getpeername_to(peername);
#ifdef ESPHOME_OTA_UDP
if (this->client_ == nullptr) {
// A committed UDP session has already dropped TCP
#ifdef ESPHOME_OTA_UDP_LWIP
ipaddr_ntoa_r(&this->udp_->peer_ip, peername, sizeof(peername));
#else
socket::format_sockaddr_to(reinterpret_cast<const struct sockaddr *>(&this->udp_->peer), this->udp_->peer_len,
peername);
#endif
} else
#endif
{
this->client_->getpeername_to(peername);
}
ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), peername);
}
@@ -840,7 +921,7 @@ ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) {
break;
}
#endif
read = this->client_->read(buf, requested);
read = this->transport_read_(buf, requested);
if (read > 0)
break;
if (read == 0) {
@@ -874,6 +955,11 @@ ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) {
void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) {
#if USE_OTA_VERSION == 2
#ifdef ESPHOME_OTA_UDP
// UDP acks every message itself
if (this->udp_ != nullptr)
return;
#endif
while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total ||
(xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) {
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
@@ -883,7 +969,12 @@ void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) {
}
void ESPHomeOTAComponent::cleanup_connection_() {
this->client_->close();
#ifdef ESPHOME_OTA_UDP
this->udp_ = nullptr; // the link itself lives on handle_data_'s stack
#endif
// Already gone once a UDP session committed
if (this->client_ != nullptr)
this->client_->close();
this->client_ = nullptr;
this->client_connect_time_ = 0;
this->handshake_buf_pos_ = 0;
+103 -5
View File
@@ -15,6 +15,25 @@
#include "esphome/core/preferences.h"
#include "esphome/core/hash_base.h"
// UDP data phase: raw lwIP needs a main loop lock (or a single thread); the host polls a socket instead
#ifdef USE_ESP32
#include <sdkconfig.h>
#endif
#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || \
(defined(USE_ESP32) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING))
#define ESPHOME_OTA_UDP
#define ESPHOME_OTA_UDP_LWIP
#include "lwip/ip_addr.h"
#include "lwip/pbuf.h"
#include "lwip/udp.h"
#if defined(USE_LIBRETINY) && !LWIP_TCPIP_CORE_LOCKING
#undef ESPHOME_OTA_UDP
#undef ESPHOME_OTA_UDP_LWIP
#endif
#elif defined(USE_HOST)
#define ESPHOME_OTA_UDP
#endif
namespace esphome {
/// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA.
@@ -65,6 +84,69 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
protected:
void handle_handshake_();
void handle_data_();
#ifdef ESPHOME_OTA_UDP
// UDP transport for the data phase; wire format in ota_esphome_udp.cpp
static constexpr size_t UDP_OUT_SIZE = 64; // our unacked responses: at most three encrypted ones
// Messages the device keeps unread: like TCP's 4 segment window, so no more memory than TCP holds
static constexpr uint8_t UDP_WINDOW = 4;
#ifdef ESPHOME_OTA_UDP_LWIP
using UdpSlot = struct pbuf *;
#else
using UdpSlot = struct OtaUdpDatagram *;
#endif
// Lives on handle_data_'s stack; the receive callback shares it under the lwIP lock
struct UdpLink {
~UdpLink();
void release(); // closes the socket and frees what is queued
#ifdef ESPHOME_OTA_UDP_LWIP
struct udp_pcb *pcb{nullptr};
ip_addr_t peer_ip;
#else
std::unique_ptr<socket::Socket> sock;
struct sockaddr_storage peer; // the TCP peer; its port is replaced by peer_port
socklen_t peer_len{0};
#endif
uint16_t peer_port{0}; // learned from the client's datagrams
// The session token, our answer to the client: top bit clear, so never an error code; all zero declines
uint8_t reply[4]{};
UdpSlot queue[UDP_WINDOW]{}; // slot seq % UDP_WINDOW for seq in [consumed, consumed + UDP_WINDOW)
uint16_t received{0}; // every seq before it is here
uint16_t consumed{0}; // the message being read
uint16_t read_offset{0}; // payload bytes of it already read
uint16_t out_base{0}; // stream offset of out[0]
uint8_t out_len{0};
bool committed{false}; // the client heard us over UDP
uint8_t out[UDP_OUT_SIZE];
};
// Fills link.reply on success; leaves it all zero (declined) otherwise
void udp_open_(UdpLink &link);
// Waits for the client to commit to UDP or fall back to TCP; false on failure
bool udp_start_();
// Like a blocking socket read with SO_RCVTIMEO: up to len bytes, or -1 with EWOULDBLOCK
ssize_t udp_read_(uint8_t *buf, size_t len);
bool udp_send_(const uint8_t *data, size_t len);
// In a UDP session, keeps answering until the client has our responses (an error code), briefly
void udp_linger_();
// A datagram from the client's IP; caller holds the lwIP lock (or runs in the receive callback)
static void udp_on_datagram(UdpLink &link, UdpSlot p, uint16_t port);
// Caller holds the lwIP lock (or runs in the receive callback)
static void udp_send_ack(UdpLink &link, uint16_t prompted_by);
#ifdef ESPHOME_OTA_UDP_LWIP
static void udp_recv_cb(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, uint16_t port);
void udp_poll_() {}
#else
void udp_poll_(); // hands what the socket received to udp_on_datagram
#endif
#endif
// Data phase only (udp_ shares storage with the handshake's connect time): TCP or, once committed, the UDP stream
inline ssize_t transport_read_(uint8_t *buf, size_t len) {
#ifdef ESPHOME_OTA_UDP
if (this->udp_ != nullptr)
return this->udp_read_(buf, len);
#endif
return this->client_->read(buf, len);
}
#ifdef USE_OTA_PASSWORD
static constexpr size_t SHA256_HEX_SIZE = 64; // SHA256 hash as hex string (32 bytes * 2)
bool handle_auth_send_();
@@ -75,7 +157,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
#endif // USE_OTA_PASSWORD
bool readall_(uint8_t *buf, size_t len);
bool writeall_(const uint8_t *buf, size_t len);
inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); }
#ifdef USE_OTA_ENCRYPTION
// Heap-allocated only while an encrypted OTA session is active.
@@ -105,18 +186,21 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
ssize_t noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext);
bool noise_readall_(uint8_t *buf, size_t len);
ssize_t noise_read_data_(uint8_t *buf, size_t capacity);
bool noise_write_byte_(uint8_t byte);
static constexpr size_t NOISE_MAX_RESPONSE = 4; // the UDP token
bool noise_write_(const uint8_t *data, size_t len);
#endif // USE_OTA_ENCRYPTION
// Data-phase I/O dispatch: through the noise transport when a session is
// active, straight to the socket otherwise.
inline bool data_write_byte_(uint8_t byte) {
// len is at most NOISE_MAX_RESPONSE
inline bool data_write_(const uint8_t *data, size_t len) {
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr)
return this->noise_write_byte_(byte);
return this->noise_write_(data, len);
#endif
return this->write_byte_(byte);
return this->writeall_(data, len);
}
inline bool data_write_byte_(uint8_t byte) { return this->data_write_(&byte, 1); }
// When encrypted, buf must have room for len + noise::MAC_SIZE bytes.
inline bool data_readall_(uint8_t *buf, size_t len) {
#ifdef USE_OTA_ENCRYPTION
@@ -200,7 +284,21 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
std::unique_ptr<socket::Socket> client_;
ota::OTABackendPtr backend_;
#ifdef ESPHOME_OTA_UDP_LWIP
union {
uint32_t client_connect_time_{0}; // handshake states
UdpLink *udp_; // data phase (never both), points into handle_data_'s stack
};
static_assert(sizeof(UdpLink *) == sizeof(uint32_t), "clearing client_connect_time_ must clear udp_");
#else
uint32_t client_connect_time_{0};
#ifdef ESPHOME_OTA_UDP
UdpLink *udp_{nullptr}; // data phase, points into handle_data_'s stack
#endif
#endif
#if defined(ESPHOME_OTA_UDP) && defined(USE_OTA_ENCRYPTION)
static_assert(sizeof(UdpLink::reply) <= NOISE_MAX_RESPONSE, "the token goes out through noise_write_");
#endif
static constexpr size_t HANDSHAKE_BUF_SIZE = 5;
// Buffer size for OTA data transfer. The upload client derives its maximum
// encrypted frame plaintext from this (espota2.NOISE_MAX_PLAINTEXT is this
@@ -266,13 +266,15 @@ ssize_t ESPHomeOTAComponent::noise_read_data_(uint8_t *buf, size_t capacity) {
return this->noise_read_frame_blocking_(buf, noise::MAC_SIZE + 1, max_ciphertext);
}
/// Blocking write of one response byte as an encrypted frame.
bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) {
uint8_t frame[noise::FRAME_HEADER_SIZE + 1 + noise::MAC_SIZE];
frame[noise::FRAME_HEADER_SIZE] = byte;
/// Blocking write of a short response (at most NOISE_MAX_RESPONSE bytes) as one encrypted frame.
bool ESPHomeOTAComponent::noise_write_(const uint8_t *data, size_t len) {
uint8_t frame[noise::FRAME_HEADER_SIZE + NOISE_MAX_RESPONSE + noise::MAC_SIZE];
if (len > NOISE_MAX_RESPONSE)
return false;
memcpy(frame + noise::FRAME_HEADER_SIZE, data, len);
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, len, len + noise::MAC_SIZE);
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Encrypt: %d", err);
@@ -0,0 +1,375 @@
#include "ota_esphome.h"
#if defined(USE_OTA) && defined(ESPHOME_OTA_UDP)
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef ESPHOME_OTA_UDP_LWIP
#include "lwip/inet.h"
#ifdef USE_LIBRETINY
#include "lwip/tcpip.h"
#endif
#endif
#include <algorithm>
#include <cstring>
#include <memory>
/*
* UDP data phase: the same byte stream as TCP, cut into numbered datagrams.
* client -> device [type | COMMITTED][token u32][seq u16][device bytes received u16][payload]
* device -> client [ACK][token u32][received u16][seq that prompted it u16][stream offset u16][window u8][our bytes]
* The lwIP callback acks each datagram on arrival, so flash writes never look like loss, and keeps messages in its
* UDP_WINDOW slots until the OTA loop reads them. "window": free slots in the high nibble, and in the low nibble bit k
* set when received + k is already here (like TCP SACK).
*/
namespace esphome {
ESPHOME_LOG_TAG(TAG, "esphome.ota");
// PING (0x02) and PROBE (0x03) only prompt an ACK
static constexpr uint8_t UDP_MSG_DATA = 0x01;
static constexpr uint8_t UDP_FLAG_COMMITTED = 0x80;
static constexpr uint8_t UDP_MSG_ACK = 0x40;
static constexpr size_t UDP_HEADER_SIZE = 9;
static constexpr size_t UDP_ACK_HEADER_SIZE = 12;
// The client's 60 s wait for the token over a lossy TCP link, plus its 15 s probe
static constexpr uint32_t UDP_COMMIT_TIMEOUT = 80000;
// Like the TCP socket's SO_RCVTIMEO in handle_data_, so readall_ treats both alike
static constexpr uint32_t UDP_READ_WAIT_MS = 2000;
// How often the commit wait looks at the UDP link between reads of the TCP fallback byte
static constexpr uint32_t UDP_COMMIT_POLL_MS = 100;
static constexpr uint32_t UDP_LINGER_MS = 3000;
#ifdef ESPHOME_OTA_UDP_LWIP
#ifdef USE_LIBRETINY
// LibreTiny's LwIPLock is a no-op; take the core lock directly (the header drops UDP without one)
struct UdpLock {
UdpLock() { LOCK_TCPIP_CORE(); }
~UdpLock() { UNLOCK_TCPIP_CORE(); }
};
#else
using UdpLock = LwIPLock;
#endif
static uint16_t slot_len(struct pbuf *p) { return p->tot_len; }
static uint16_t slot_copy(struct pbuf *p, void *dst, uint16_t len, uint16_t offset) {
return pbuf_copy_partial(p, dst, len, offset);
}
static void slot_free(struct pbuf *p) { pbuf_free(p); }
#else
// The host has no lwIP thread: the OTA loop drains the socket itself. Empty bodies, not = default, so clang-tidy
// does not flag the guards as unused
struct UdpLock {
UdpLock() {}
~UdpLock() {}
};
struct OtaUdpDatagram {
uint16_t len;
uint8_t data[1500];
};
static uint16_t slot_len(OtaUdpDatagram *p) { return p->len; }
static uint16_t slot_copy(OtaUdpDatagram *p, void *dst, uint16_t len, uint16_t offset) {
const uint16_t n = std::min<uint16_t>(len, p->len - offset);
memcpy(dst, p->data + offset, n);
return n;
}
static void slot_free(OtaUdpDatagram *p) { delete p; }
#endif
static void put_u16(uint8_t *at, uint16_t value) {
at[0] = value >> 8;
at[1] = value & 0xFF;
}
ESPHomeOTAComponent::UdpLink::~UdpLink() { this->release(); }
void ESPHomeOTAComponent::UdpLink::release() {
UdpLock lock;
#ifdef ESPHOME_OTA_UDP_LWIP
if (this->pcb != nullptr)
udp_remove(this->pcb);
this->pcb = nullptr;
#else
this->sock = nullptr;
#endif
for (auto &p : this->queue) {
if (p != nullptr)
slot_free(p);
p = nullptr;
}
}
void ESPHomeOTAComponent::udp_send_ack(UdpLink &link, uint16_t prompted_by) {
const uint16_t len = UDP_ACK_HEADER_SIZE + link.out_len;
#ifdef ESPHOME_OTA_UDP_LWIP
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM);
if (p == nullptr)
return; // Like a lost ACK: the client asks again
auto *ack = static_cast<uint8_t *>(p->payload);
#else
uint8_t ack[UDP_ACK_HEADER_SIZE + UDP_OUT_SIZE];
#endif
ack[0] = UDP_MSG_ACK;
memcpy(ack + 1, link.reply, 4);
put_u16(ack + 5, link.received);
put_u16(ack + 7, prompted_by);
put_u16(ack + 9, link.out_base);
uint8_t window = (UDP_WINDOW - static_cast<uint16_t>(link.received - link.consumed)) << 4;
for (uint8_t k = 1; k < UDP_WINDOW; k++) {
if (link.queue[(link.received + k) % UDP_WINDOW] != nullptr)
window |= 1 << k;
}
ack[11] = window;
memcpy(ack + UDP_ACK_HEADER_SIZE, link.out, link.out_len);
// Best effort: a lost ACK is repeated for the client's next datagram
#ifdef ESPHOME_OTA_UDP_LWIP
udp_sendto(link.pcb, p, &link.peer_ip, link.peer_port);
pbuf_free(p);
#else
struct sockaddr_storage to = link.peer;
if (to.ss_family == AF_INET6) {
reinterpret_cast<sockaddr_in6 *>(&to)->sin6_port = htons(link.peer_port);
} else {
reinterpret_cast<sockaddr_in *>(&to)->sin_port = htons(link.peer_port);
}
link.sock->sendto(ack, len, 0, reinterpret_cast<const struct sockaddr *>(&to), link.peer_len);
#endif
}
void ESPHomeOTAComponent::udp_on_datagram(UdpLink &link, UdpSlot p, uint16_t port) {
uint8_t hdr[UDP_HEADER_SIZE];
if (slot_copy(p, hdr, UDP_HEADER_SIZE, 0) != UDP_HEADER_SIZE || memcmp(hdr + 1, link.reply, 4) != 0) {
slot_free(p);
return;
}
// The token proves the sender; answer the port it arrived from, which NAT may have rewritten
link.peer_port = port;
link.committed |= (hdr[0] & UDP_FLAG_COMMITTED) != 0;
// Drop the responses the client already has
const uint16_t acked = encode_uint16(hdr[7], hdr[8]) - link.out_base;
if (acked != 0 && acked <= link.out_len) {
link.out_len -= acked;
memmove(link.out, link.out + acked, link.out_len);
link.out_base += acked;
}
const uint16_t seq = encode_uint16(hdr[5], hdr[6]);
UdpSlot &slot = link.queue[seq % UDP_WINDOW];
if ((hdr[0] & ~UDP_FLAG_COMMITTED) == UDP_MSG_DATA && slot_len(p) > UDP_HEADER_SIZE &&
static_cast<uint16_t>(seq - link.consumed) < UDP_WINDOW && slot == nullptr) {
slot = p;
while (static_cast<uint16_t>(link.received - link.consumed) < UDP_WINDOW &&
link.queue[link.received % UDP_WINDOW] != nullptr)
link.received++;
} else {
slot_free(p);
}
udp_send_ack(link, seq);
}
#ifdef ESPHOME_OTA_UDP_LWIP
// lwIP context: the tcpip thread (ESP32, LibreTiny), SYS (ESP8266) or an IRQ (RP2040)
void ESPHomeOTAComponent::udp_recv_cb(void *arg, struct udp_pcb * /*pcb*/, struct pbuf *p, const ip_addr_t *addr,
uint16_t port) {
auto &link = *static_cast<UdpLink *>(arg);
if (!ip_addr_cmp(addr, &link.peer_ip)) {
pbuf_free(p);
return;
}
udp_on_datagram(link, p, port);
}
#else
static bool same_ip(const struct sockaddr_storage &a, const struct sockaddr_storage &b) {
uint32_t ipv4_a, ipv4_b;
const bool v4_a = socket::sockaddr_to_ipv4(reinterpret_cast<const struct sockaddr *>(&a), &ipv4_a);
const bool v4_b = socket::sockaddr_to_ipv4(reinterpret_cast<const struct sockaddr *>(&b), &ipv4_b);
if (v4_a || v4_b)
return v4_a && v4_b && ipv4_a == ipv4_b;
return a.ss_family == AF_INET6 && b.ss_family == AF_INET6 &&
memcmp(&reinterpret_cast<const sockaddr_in6 *>(&a)->sin6_addr,
&reinterpret_cast<const sockaddr_in6 *>(&b)->sin6_addr, sizeof(in6_addr)) == 0;
}
void ESPHomeOTAComponent::udp_poll_() {
UdpLink &link = *this->udp_;
for (;;) {
uint8_t buf[sizeof(OtaUdpDatagram::data)];
struct sockaddr_storage from;
socklen_t from_len = sizeof(from);
const ssize_t len = link.sock->recvfrom(buf, sizeof(buf), reinterpret_cast<struct sockaddr *>(&from), &from_len);
if (len < 0)
return;
if (!same_ip(from, link.peer))
continue;
auto *datagram = new OtaUdpDatagram; // NOLINT(cppcoreguidelines-owning-memory)
datagram->len = len;
memcpy(datagram->data, buf, len);
const uint16_t port = from.ss_family == AF_INET6 ? ntohs(reinterpret_cast<sockaddr_in6 *>(&from)->sin6_port)
: ntohs(reinterpret_cast<sockaddr_in *>(&from)->sin_port);
udp_on_datagram(link, datagram, port);
}
}
#endif
void ESPHomeOTAComponent::udp_open_(UdpLink &link) {
struct sockaddr_storage peer;
socklen_t peer_len = sizeof(peer);
if (this->client_->getpeername(reinterpret_cast<struct sockaddr *>(&peer), &peer_len) != 0)
return;
uint8_t token[4];
if (!random_bytes(token, sizeof(token)))
return;
#ifdef ESPHOME_OTA_UDP_LWIP
uint32_t ipv4;
if (socket::sockaddr_to_ipv4(reinterpret_cast<const struct sockaddr *>(&peer), &ipv4)) {
ip_addr_set_ip4_u32(&link.peer_ip, ipv4);
} else {
#if USE_NETWORK_IPV6 && LWIP_IPV6
inet6_addr_to_ip6addr(ip_2_ip6(&link.peer_ip), &reinterpret_cast<const sockaddr_in6 *>(&peer)->sin6_addr);
IP_SET_TYPE_VAL(link.peer_ip, IPADDR_TYPE_V6);
#else
return;
#endif
}
UdpLock lock;
#if LWIP_IPV6
link.pcb = udp_new_ip_type(IPADDR_TYPE_ANY);
#else
link.pcb = udp_new();
#endif
// On failure the reply stays all zero and ~UdpLink removes the pcb
if (link.pcb == nullptr || udp_bind(link.pcb, IP_ANY_TYPE, this->port_) != ERR_OK)
return;
udp_recv(link.pcb, udp_recv_cb, &link);
#else
link.peer = peer;
link.peer_len = peer_len;
// Same domain as the listener, so the peer address from getpeername fits
link.sock = socket::socket_ip(SOCK_DGRAM, IPPROTO_UDP);
struct sockaddr_storage local;
const socklen_t local_len =
socket::set_sockaddr_any(reinterpret_cast<struct sockaddr *>(&local), sizeof(local), this->port_);
if (link.sock == nullptr || local_len == 0 ||
link.sock->bind(reinterpret_cast<struct sockaddr *>(&local), local_len) != 0 ||
link.sock->setblocking(false) != 0)
return;
#endif
// Below the error codes (0x80 and up) the client checks the first byte against, and never all zero
token[0] = (token[0] & 0x7F) | 0x01;
memcpy(link.reply, token, sizeof(token));
}
bool ESPHomeOTAComponent::udp_start_() {
// TCP now only carries a fallback byte; short read timeouts let the UDP link be checked in between
struct timeval tv {
0, UDP_COMMIT_POLL_MS * 1000
};
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
const uint32_t start = millis();
while (millis() - start < UDP_COMMIT_TIMEOUT) {
this->udp_poll_();
bool committed;
{
UdpLock lock;
committed = this->udp_->committed;
}
if (committed) {
// The client heard us over UDP, so TCP is done. Outside the lock: closing a socket waits on the tcpip thread
this->client_ = nullptr;
return true;
}
// A raw byte outside any Noise frame: the client heard nothing over UDP. Forging it only forces the ordinary
// TCP path, which dropping our datagrams forces anyway
uint8_t fallback;
const ssize_t read = this->client_->read(&fallback, 1);
if (read == 1) {
this->udp_->release();
this->udp_ = nullptr;
tv = {UDP_READ_WAIT_MS / 1000, 0};
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
return true;
}
if (read == 0) {
this->remote_closed_ = true;
return false;
}
if (!this->would_block_(errno))
return false;
App.feed_wdt();
}
return false;
}
ssize_t ESPHomeOTAComponent::udp_read_(uint8_t *buf, size_t len) {
UdpLink &link = *this->udp_;
const uint32_t start = millis();
size_t got = 0;
for (;;) {
this->udp_poll_();
UdpSlot p;
{
UdpLock lock;
p = link.consumed != link.received ? link.queue[link.consumed % UDP_WINDOW] : nullptr;
}
if (p == nullptr) {
if (got != 0)
return got;
if (millis() - start >= UDP_READ_WAIT_MS)
break;
this->yield_and_feed_watchdog_();
continue;
}
// The callback never touches the slot being read, so the copy runs without the lock
const uint16_t offset = UDP_HEADER_SIZE + link.read_offset;
const uint16_t n = slot_copy(p, buf + got, std::min<size_t>(len - got, slot_len(p) - offset), offset);
got += n;
link.read_offset += n;
if (UDP_HEADER_SIZE + link.read_offset >= slot_len(p)) {
UdpLock lock;
slot_free(p);
link.queue[link.consumed % UDP_WINDOW] = nullptr;
// A full window just opened: tell the client, which is holding back
const bool was_full = static_cast<uint16_t>(link.received - link.consumed) == UDP_WINDOW;
link.consumed++;
link.read_offset = 0;
if (was_full)
udp_send_ack(link, link.received - 1);
}
if (got == len)
return got;
}
errno = EWOULDBLOCK;
return -1;
}
bool ESPHomeOTAComponent::udp_send_(const uint8_t *data, size_t len) {
UdpLink &link = *this->udp_;
UdpLock lock;
if (link.out_len + len > UDP_OUT_SIZE)
return false;
memcpy(link.out + link.out_len, data, len);
link.out_len += len;
// Prompted by nothing new, so the client takes no round trip sample from it
udp_send_ack(link, link.received - 1);
return true;
}
void ESPHomeOTAComponent::udp_linger_() {
if (this->udp_ == nullptr)
return;
const uint32_t start = millis();
while (millis() - start < UDP_LINGER_MS) {
this->udp_poll_();
{
UdpLock lock;
if (this->udp_->out_len == 0)
return;
}
this->yield_and_feed_watchdog_();
}
}
} // namespace esphome
#endif // USE_OTA && ESPHOME_OTA_UDP
+141
View File
@@ -0,0 +1,141 @@
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_DATA,
CONF_ID,
CONF_ON_MESSAGE,
CONF_PIN,
CONF_SOURCE,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_RP2,
PLATFORM_RTL87XX,
)
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@JosVanEijndhoven"]
DOMAIN = "hdmi_cec"
MAX_LOGICAL_ADDRESS = 15
MAX_PAYLOAD_LENGTH = 15
CONF_PROMISCUOUS_MODE = "promiscuous_mode"
CONF_MONITOR_MODE = "monitor_mode"
CONF_DESTINATION = "destination"
CONF_OPCODE = "opcode"
hdmi_cec_ns = cg.esphome_ns.namespace("hdmi_cec")
HDMICEC = hdmi_cec_ns.class_("HDMICEC", cg.Component)
_PAYLOAD_SPAN = cg.std_span.template(cg.uint8.operator("const"))
_PAYLOAD_VECTOR = cg.std_vector.template(cg.uint8)
_MESSAGE_ARGS = [
(cg.uint8, "source"),
(cg.uint8, "destination"),
(_PAYLOAD_SPAN, "data"),
]
_request_message_callback_slot = cg.slot_counter("HDMI_CEC_MESSAGE_CALLBACK_COUNT")
logical_address = cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS)
payload = cv.All(cv.ensure_list(cv.hex_uint8_t), cv.Length(max=MAX_PAYLOAD_LENGTH))
payload_prefix = cv.All(payload, cv.Length(min=1))
def _validate_message_filter(config: ConfigType) -> ConfigType:
# The opcode is the first payload byte: fold it into the data prefix
if (opcode := config.pop(CONF_OPCODE, None)) is not None:
data = config.setdefault(CONF_DATA, [opcode])
if data[0] != opcode:
raise cv.Invalid(
f"opcode 0x{opcode:02X} differs from the first data byte 0x{data[0]:02X}"
)
return config
def _message_filter(conf: ConfigType) -> str | None:
"""The C++ condition an on_message automation runs under, or None for every frame."""
checks = []
if (source := conf.get(CONF_SOURCE)) is not None:
checks.append(f"source == {source}")
if (destination := conf.get(CONF_DESTINATION)) is not None:
checks.append(f"destination == {destination}")
if data := conf.get(CONF_DATA):
checks.append(f"data.size() >= {len(data)}")
checks.extend(f"data[{i}] == 0x{byte:02X}" for i, byte in enumerate(data))
return " && ".join(checks) or None
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(HDMICEC),
cv.Required(CONF_PIN): pins.internal_gpio_input_pullup_pin_schema,
cv.Required(CONF_ADDRESS): logical_address,
cv.Optional(CONF_PROMISCUOUS_MODE, default=False): cv.boolean,
cv.Optional(CONF_MONITOR_MODE, default=False): cv.boolean,
cv.Optional(CONF_ON_MESSAGE): automation.validate_automation(
{
cv.Optional(CONF_SOURCE): logical_address,
cv.Optional(CONF_DESTINATION): logical_address,
cv.Optional(CONF_OPCODE): cv.hex_uint8_t,
cv.Optional(CONF_DATA): payload_prefix,
},
_validate_message_filter,
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on(
[
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2,
PLATFORM_BK72XX,
PLATFORM_RTL87XX,
PLATFORM_LN882X,
]
),
)
async def to_code(config: ConfigType) -> None:
if CORE.is_esp32:
# The acknowledge bit is released from a general purpose timer, left out of the build by default
include_builtin_idf_component("esp_driver_gptimer")
pin = await cg.gpio_pin_expression(config[CONF_PIN])
var = cg.new_Pvariable(config[CONF_ID], pin, config[CONF_ADDRESS])
await cg.register_component(var, config)
if config[CONF_PROMISCUOUS_MODE]:
cg.add(var.set_promiscuous_mode(True))
if config[CONF_MONITOR_MODE]:
cg.add(var.set_monitor_mode(True))
for conf in config.get(CONF_ON_MESSAGE, []):
_request_message_callback_slot()
await automation.build_callback_automation(
var,
"add_on_message_callback",
_MESSAGE_ARGS,
conf,
when=_message_filter(conf),
)
automation.register_apply_action(
"hdmi_cec.send",
cv.Schema(
{
cv.GenerateID(): cv.use_id(HDMICEC),
cv.Required(CONF_DESTINATION): cv.templatable(logical_address),
cv.Required(CONF_DATA): cv.templatable(payload),
}
),
automation.ApplyCall(
"send({}, {})",
((CONF_DESTINATION, cg.uint8), (CONF_DATA, _PAYLOAD_VECTOR)),
),
)
+484
View File
@@ -0,0 +1,484 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <map>
#include <array>
#include "esphome/core/defines.h"
#include "esphome/core/log.h"
#ifdef HDMI_CEC_USE_DECODER
#include "hdmi_cec.h"
#include "cec_decoder.h"
namespace esphome {
namespace hdmi_cec {
const std::array<const char *, 0x77> Decoder::UI_COMMANDS = {
/* 0x00 = */ "Select",
"Up",
"Down",
"Left",
"Right",
"Right-Up",
"Right-Down",
"Left-Up",
/* 0x08 = */ "Left-Down",
"Root Menu",
"Setup Menu",
"Contents Menu",
"Favorite Menu",
"Exit",
"Reserved",
"Reserved",
/* 0x10 = */ "Media Top Menu",
"Media Context-sensitive Menu",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
/* 0x18 = */ "Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Number Entry Mode",
"11",
"12",
/* 0x20 = */ "0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
/* 0x28 = */ "8",
"9",
"Dot",
"Enter",
"Clear",
"Reserved",
"Reserved",
"Next Favorite",
/* 0x30 = */ "Channel Up",
"Channel Down",
"Previous Channel",
"Sound Select",
"Input Select",
"Display Information",
"Help",
"Page Up",
/* 0x38 = */ "Page Down",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
/* 0x40 = */ "Power",
"Volume Up",
"Volume Down",
"Mute",
"Play",
"Stop",
"Pause",
"Record",
/* 0x48 = */ "Rewind",
"Fast forward",
"Eject",
"Forward",
"Backward",
"Stop-Record",
"Pause-Record",
"Reserved",
/* 0x50 = */ "Angle",
"Sub picture",
"Video on Demand",
"Electronic Program Guide",
"Timer Programming",
"Initial Configuration",
"Select Broadcast Type",
"Select Sound Presentation",
/* 0x58 = */ "Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
"Reserved",
/* 0x60 = */ "Play Function",
"Pause-Play Function",
"Record Function",
"Pause-Record Function",
"Stop Function",
"Mute Function",
"Restore Volume Function",
"Tune Function",
/* 0x68 = */ "Select Media Function",
"Select A/V Input Function",
"Select Audio Input Function",
"Power Toggle Function",
"Power Off Function",
"Power On Function",
"Reserved",
"Reserved",
/* 0x70 = */ "Reserved",
"F1 (Blue)",
"F2 (Red)",
"F3 (Green)",
"F4 (Yellow)",
"F5",
"Data"};
// See 'short audio descriptor' in https://en.wikipedia.org/wiki/Extended_Display_Identification_Data
const std::array<const char *, 0x11> Decoder::AUDIO_FORMATS = {
"reserved", "LPCM", "AC3", "MPEG-1", "MP3", "MPEG-2", "AAC", "DTS", "ATRAC",
"DSD", "DD+", "DTS-HD", "MAT/Dolby TrueHD", "DST Audio", "WMA Pro", "Extension?"};
const std::array<const char *, 8> Decoder::AUDIO_SAMPLERATES = {"32", "44.1", "48", "88",
"96", "176", "192", "Reserved"};
template<uint32_t OPERANDS> bool Decoder::do_operand_() {
if (OPERANDS <= 0xFF) {
// generic function called for single operand of unkown type and length
return append_operand_(".");
} else {
// generic function to handle two or more operands
return do_operand_<OPERANDS & 0xFF>() && do_operand_<(OPERANDS >> 8u)>();
}
}
#define APPEND_LINE(...) (length_ = buf_append_printf(line_, size_, length_, __VA_ARGS__))
/**
* List of specialised operand decode functions, one function per operand type.
* (Not fully complete, might be extended later)
*/
template<> bool Decoder::do_operand_<Decoder::NONE>() { return false; }
template<> bool Decoder::do_operand_<Decoder::ABORT_REASON>() {
const static std::array<const char *, 6> NAMES = {
"Unrecognized opcode", "Not in correct mode to respond", "Cannot provide source", "Invalid operand", "Refused",
"Unable to determine"};
return append_operand_<NAMES.size()>(NAMES);
}
template<> bool Decoder::do_operand_<Decoder::AUDIO_FORMAT>() {
// this type of operand comes in a sequence, until exhausted
bool ok = true;
while (ok && (offset_ < frame_.size())) {
ok = append_operand_<AUDIO_FORMATS.size()>(AUDIO_FORMATS);
}
return ok;
}
template<> bool Decoder::do_operand_<Decoder::AUDIO_STATUS>() {
char line[20];
if (offset_ < frame_.size()) {
uint8_t field = frame_[offset_];
std::snprintf(line, sizeof(line), "Mute=%d,Vol=%02X", (field >> 7), (field & 0x7f));
return append_operand_(line);
} else {
return append_operand_("?");
}
}
template<> bool Decoder::do_operand_<Decoder::DEVICE_TYPE>() {
const static std::array<const char *, 8> NAMES = {"TV", "Recording Device", "Reserved",
"Tuner", "Playback Device", "Audio System",
"CEC Switch", "Video Processor"};
return append_operand_<NAMES.size()>(NAMES);
}
template<> bool Decoder::do_operand_<Decoder::DISPLAY_CONTROL>() {
const static std::array<const char *, 4> NAMES = {"Default time", "Until cleared", "Clear previous", "Reserved"};
return append_operand_<NAMES.size()>(NAMES);
}
template<> bool Decoder::do_operand_<Decoder::FEATURE_OPCODE>() {
if (offset_ >= frame_.size()) {
return false;
}
uint8_t opcode = frame_[offset_];
return append_operand_(find_opcode_name_(opcode));
}
template<> bool Decoder::do_operand_<Decoder::OSD_STRING>() {
char line[20]; // frame size() is at most 16
unsigned int i;
for (i = 0; i < frame_.size() - offset_; i++) {
line[i] = (char) (frame_[offset_ + i]);
}
line[i] = '\0'; // append '\0' termination: that is likely NOT part of the frame
offset_ = frame_.size(); // no useful data further in this frame
return append_operand_(line);
}
template<> bool Decoder::do_operand_<Decoder::PHYSICAL_ADDRESS>() {
// Exception: if this is an operand of <System Audio Mode Request> 0x70, then this operand is
// merely optional, and its absence means 'Off'
if (frame_.at(1) == 0x70 && offset_ >= frame_.size()) {
return append_operand_("Off");
}
if (offset_ + 1 >= frame_.size()) {
return append_operand_("?", 2);
}
char line[12];
std::snprintf(line, sizeof(line), "%1x.%1x.%1x.%1x", (frame_[offset_] >> 4) & 0xF, frame_[offset_] & 0xF,
(frame_[offset_ + 1] >> 4) & 0xF, frame_[offset_ + 1] & 0xF);
return append_operand_(line, 2);
}
template<> bool Decoder::do_operand_<Decoder::POWER_STATUS>() {
const static std::array<const char *, 4> NAMES = {"On", "Standby", "Standby->On", "On->Standby"};
return append_operand_<NAMES.size()>(NAMES);
}
template<> bool Decoder::do_operand_<Decoder::SHORT_AUDIO_DESCRIPTOR>() {
// The frame can have a sequence of these operands, count is not fixed;
// each such operand takes 3 bytes in the frame.
// For a specification of this "Short Audio descriptor" format, see the Wikipedia page on
// "Extended Display Identification Data (EDID)", section "Audio Data Blocks".
std::array<char, 100> line;
uint32_t pos = 0;
bool ok = true;
while (ok && (offset_ + 2 < frame_.size())) {
const uint8_t descriptor = frame_[offset_];
uint8_t format = (descriptor >> 3) & 0x0F;
pos = buf_append_printf(&line[0], line.size(), 0, "%s", AUDIO_FORMATS[format]);
pos = buf_append_printf(&line[0], line.size(), pos, ",num_channels=%d", (descriptor & 0x07));
uint8_t rates = frame_[offset_ + 1];
for (int bit = 0; rates; bit++, rates >>= 1) {
if (rates & 0x1) {
// show support of various audio sample rates
pos = buf_append_printf(&line[0], line.size(), pos, ",%skHz", AUDIO_SAMPLERATES[bit]);
}
}
if (format == 1) {
// for LPCM format
uint8_t widths = frame_[offset_ + 2] & 0x7;
for (int i = 0; widths; i++, widths >>= 1) {
if (widths & 0x1) {
// show support of audio samble bit widths of 16, 20, and/or 24
pos = buf_append_printf(&line[0], line.size(), pos, ",%dbits", (16 + 4 * i));
}
}
}
ok = append_operand_(line.data(), 3);
}
// Note: Further descriptor 'extensions' not yet decoded
return ok;
}
template<> bool Decoder::do_operand_<Decoder::SYSTEM_AUDIO_STATUS>() {
const static std::array<const char *, 2> NAMES = {"Off", "On"};
return append_operand_<NAMES.size()>(NAMES);
}
template<> bool Decoder::do_operand_<Decoder::UI_COMMAND>() {
uint8_t command = frame_[offset_];
bool ok = append_operand_<UI_COMMANDS.size()>(UI_COMMANDS);
if (!ok) {
return false;
}
// out of the 100+ UI commands, a few exceptional UI commands have appended an extra parameter:
switch (command) {
case 0x56:
return do_operand_<UI_BROADCAST_TYPE>();
case 0x57:
return do_operand_<UI_SOUND_PRESENTATION_CONTROL>();
case 0x60:
return do_operand_<PLAY_MODE>();
case 0x67:
return do_operand_<CHANNEL_IDENTIFIER>();
case 0x68:
return do_operand_<UI_FUNCTION_MEDIA>();
case 0x69:
return do_operand_<UI_FUNCTION_SELECT_AV_INPUT>();
case 0x6A:
return do_operand_<UI_FUNCTION_SELECT_AUDIO_INPUT>();
default:
return ok;
}
}
template<> bool Decoder::do_operand_<Decoder::VENDOR_ID>() {
if (offset_ + 2 >= frame_.size()) {
return append_operand_("?", 3);
}
uint32_t id = (uint32_t) (frame_[offset_]) << 16 | (uint32_t) (frame_[offset_ + 1]) << 8 | frame_[offset_ + 2];
const char *const *name_lookup = VENDOR_IDS.find(id);
const char *vendor_name = name_lookup ? (*name_lookup) : nullptr;
if (!vendor_name) {
// if the hdmi-cec vendor id is not in our list, the id value itself is printed.
char line[12];
std::snprintf(line, sizeof(line), "ID=%06x", id);
return append_operand_(line, 3);
}
return append_operand_(vendor_name, 3);
}
template<> bool Decoder::do_operand_<Decoder::CEC_VERSION>() {
const static std::array<const char *, 9> NAMES = {"?", "1.2", "1.2a", "1.3", "1.3a", "1.4", "2.0", "2.x", "2.x"};
return append_operand_<NAMES.size()>(NAMES);
}
const Decoder::CecOpcodeTable Decoder::CEC_OPCODE_TABLE = {
{// Must be given sorted on increasing opcode:
// opcode, name, function pointer with specification of its operands
{0x00, {"Feature Abort", &Decoder::do_operand_<two(FEATURE_OPCODE, ABORT_REASON)>}},
{0x04, {"Image View On", &Decoder::do_operand_<NONE>}},
{0x05, {"Tuner Step Increment", &Decoder::do_operand_<NONE>}},
{0x06, {"Tuner Step Decrement", &Decoder::do_operand_<NONE>}},
{0x07, {"Tuner Device Status", &Decoder::do_operand_<TUNER_DEVICE_INFO>}},
{0x08, {"Give Tuner Device Status", &Decoder::do_operand_<STATUS_REQUEST>}},
{0x09, {"Record On", &Decoder::do_operand_<RECORD_SOURCE>}},
{0x0A, {"Record Status", &Decoder::do_operand_<RECORD_STATUS_INFO>}},
{0x0B, {"Record Off", &Decoder::do_operand_<NONE>}},
{0x0D, {"Text View On", &Decoder::do_operand_<NONE>}},
{0x0F, {"Record TV Screen", &Decoder::do_operand_<NONE>}},
{0x1A, {"Give Deck Status", &Decoder::do_operand_<STATUS_REQUEST>}},
{0x1B, {"Deck Status", &Decoder::do_operand_<DECK_INFO>}},
{0x32, {"Set Menu Language", &Decoder::do_operand_<LANGUAGE>}},
{0x33, {"Clear Analogue Timer", &Decoder::do_operand_<two(START_DATE_TIME, DURATION)>}},
{0x34, {"Set Analogue Timer", &Decoder::do_operand_<two(START_DATE_TIME, DURATION)>}},
{0x35, {"Timer Status", &Decoder::do_operand_<TIMER_STATUS_DATA>}},
{0x36, {"Standby", &Decoder::do_operand_<NONE>}},
{0x41, {"Play", &Decoder::do_operand_<PLAY_MODE>}},
{0x42, {"Deck Control", &Decoder::do_operand_<DECK_CONTROL_MODE>}},
{0x43, {"Timer Cleared Status", &Decoder::do_operand_<TIMER_CLEARED_STATUS_DATA>}},
{0x44, {"User Control Pressed", &Decoder::do_operand_<UI_COMMAND>}},
{0x45, {"User Control Released", &Decoder::do_operand_<NONE>}},
{0x46, {"Give OSD Name", &Decoder::do_operand_<NONE>}},
{0x47, {"Set OSD Name", &Decoder::do_operand_<OSD_NAME>}},
{0x64, {"Set OSD String", &Decoder::do_operand_<two(DISPLAY_CONTROL, OSD_STRING)>}},
{0x67, {"Set Timer Program Title", &Decoder::do_operand_<PROGRAM_TITLE_STRING>}},
{0x70, {"System Audio Mode Request", &Decoder::do_operand_<PHYSICAL_ADDRESS>}},
{0x71, {"Give Audio Status", &Decoder::do_operand_<NONE>}},
{0x72, {"Set System Audio Mode", &Decoder::do_operand_<SYSTEM_AUDIO_STATUS>}},
{0x7A, {"Report Audio Status", &Decoder::do_operand_<AUDIO_STATUS>}},
{0x7D, {"Give System Audio Mode Status", &Decoder::do_operand_<NONE>}},
{0x7E, {"System Audio Mode Status", &Decoder::do_operand_<SYSTEM_AUDIO_STATUS>}},
{0x80, {"Routing Change", &Decoder::do_operand_<two(PHYSICAL_ADDRESS, PHYSICAL_ADDRESS)>}},
{0x81, {"Routing Information", &Decoder::do_operand_<PHYSICAL_ADDRESS>}},
{0x82, {"Active Source", &Decoder::do_operand_<PHYSICAL_ADDRESS>}},
{0x83, {"Give Physical Address", &Decoder::do_operand_<NONE>}},
{0x84, {"Report Physical Address", &Decoder::do_operand_<two(PHYSICAL_ADDRESS, DEVICE_TYPE)>}},
{0x85, {"Request Active Source", &Decoder::do_operand_<NONE>}},
{0x86, {"Set Stream Path", &Decoder::do_operand_<PHYSICAL_ADDRESS>}},
{0x87, {"Device Vendor ID", &Decoder::do_operand_<VENDOR_ID>}},
{0x89, {"Vendor Command", &Decoder::do_operand_<VENDOR_SPECIFIC_DATA>}},
{0x8A, {"Vendor Remote Button Down", &Decoder::do_operand_<VENDOR_SPECIFIC_RC_CODE>}},
{0x8B, {"Vendor Remote Button Up", &Decoder::do_operand_<NONE>}},
{0x8C, {"Give Device Vendor ID", &Decoder::do_operand_<NONE>}},
{0x8D, {"Menu Request", &Decoder::do_operand_<MENU_REQUEST_TYPE>}},
{0x8E, {"Menu Status", &Decoder::do_operand_<MENU_STATE>}},
{0x8F, {"Give Device Power Status", &Decoder::do_operand_<NONE>}},
{0x90, {"Report Power Status", &Decoder::do_operand_<POWER_STATUS>}},
{0x91, {"Get Menu Language", &Decoder::do_operand_<NONE>}},
{0x92,
{"Select Analogue Service",
&Decoder::do_operand_<three(ANALOG_BROADCAST_TYPE, ANALOG_FREQUENCY, BROADCAST_SYSTEM)>}},
{0x93, {"Select Digital Service", &Decoder::do_operand_<DIGITAL_SERVICE_IDENTIFICATION>}},
{0x9A, {"Set Audio Rate", &Decoder::do_operand_<AUDIO_RATE>}},
{0x9D, {"Inactive Source", &Decoder::do_operand_<PHYSICAL_ADDRESS>}},
{0x97, {"Set Digital Timer", &Decoder::do_operand_<two(START_DATE_TIME, DURATION)>}},
{0x99, {"Clear Digital Timer", &Decoder::do_operand_<two(START_DATE_TIME, DURATION)>}},
{0x9E, {"CEC Version", &Decoder::do_operand_<CEC_VERSION>}},
{0x9F, {"Get CEC Version", &Decoder::do_operand_<NONE>}},
{0xA0, {"Vendor Command With ID", &Decoder::do_operand_<two(VENDOR_ID, VENDOR_SPECIFIC_DATA)>}},
{0xA1, {"Clear External Timer", &Decoder::do_operand_<two(START_DATE_TIME, DURATION)>}},
{0xA2, {"Set External Timer", &Decoder::do_operand_<two(START_DATE_TIME, DURATION)>}},
{0xA3, {"Report Short Audio Descriptor", &Decoder::do_operand_<SHORT_AUDIO_DESCRIPTOR>}},
{0xA4, {"Request Short Audio Descriptor", &Decoder::do_operand_<AUDIO_FORMAT>}},
{0xC0, {"Initiate ARC", &Decoder::do_operand_<NONE>}},
{0xC1, {"Report ARC Initiated", &Decoder::do_operand_<NONE>}},
{0xC2, {"Report ARC Terminated", &Decoder::do_operand_<NONE>}},
{0xC3, {"Request ARC Initiation", &Decoder::do_operand_<NONE>}},
{0xC4, {"Request ARC Termination", &Decoder::do_operand_<NONE>}},
{0xC5, {"Terminate ARC", &Decoder::do_operand_<NONE>}},
{0xF8, {"CDC Message", &Decoder::do_operand_<NONE>}},
{0xFF, {"Abort", &Decoder::do_operand_<NONE>}}}};
const Decoder::VendorIdTable Decoder::VENDOR_IDS = {
{// Must be given sorted on increasing id:
{0x000039, "Toshiba"}, {0x0000F0, "Samsung"}, {0x0005CD, "Denon"}, {0x000678, "Maranz"},
{0x000982, "Loewe"}, {0x0009B0, "Onkyo"}, {0x000CB8, "Medion"}, {0x000CE7, "Toshiba"},
{0x0010FA, "Apple"}, {0x001582, "Pulse Eight"}, {0x001950, "Harman Kardon"}, {0x001A11, "Google"},
{0x0020C7, "Akai"}, {0x002467, "AOC"}, {0x008045, "Panasonic"}, {0x00903E, "Philips"},
{0x009053, "Daewoo"}, {0x00A0DE, "Yamaha"}, {0x00D0D5, "Grundig"}, {0x00E036, "Pioneer"},
{0x00E091, "LG"}, {0x08001F, "Sharp"}, {0x080046, "Sony"}, {0x18C086, "Broadcom"},
{0x534850, "Sharp"}, {0x6B746D, "Vizio"}, {0x8065E9, "Benq"}, {0x9C645E, "Harman Kardon"}}};
void Decoder::address_decode_() {
const static std::array<const char *, 16> NAMES = {"TV", "RecordingDev1", "RecordingDev2", "Tuner1",
"PlaybackDev1", "AudioSystem", "Tuner2", "Tuner3",
"PlaybackDev2", "RecordingDev3", "Tuner4", "PlaybackDev3",
"Reserved", "Reserved", "SpecificUse", "Unregistered"};
const char *dst = (frame_.is_broadcast()) ? "All" : NAMES[frame_.destination_addr()];
const char *src = NAMES[frame_.initiator_addr()];
APPEND_LINE("%s to %s: ", src, dst);
}
const char *Decoder::find_opcode_name_(uint32_t opcode) const {
const FrameType *frametype = CEC_OPCODE_TABLE.find(opcode);
if (!frametype)
return "?";
return frametype->name_;
}
/**
* Helper function to implement the 'do_operand' methods, to gather a textual representation.
* @return true if a further operand can be decoded, false otherwise
*/
bool Decoder::append_operand_(const char *word, uint8_t offset_incr /* default 1 */) {
APPEND_LINE("[%s]", word);
offset_ += offset_incr;
return (length_ < size_) && (offset_ < frame_.size());
}
/**
* Entry function 'decode' to call for full decode of a CEC frame
*/
int Decoder::decode() {
// prepare text buffer state variables for an empty line
length_ = 0; // currently accumulated length of text of operands
line_[length_] = '\0'; // initialise output text to empty string
if (!line_ || !size_)
return 0;
// print src and dest fields into 'line_'
address_decode_();
// print opcode field
const FrameType *frametype = nullptr;
if (frame_.size() <= 1) {
// Missing frame operation field?
APPEND_LINE("Ping");
} else {
// find operation name
frametype = CEC_OPCODE_TABLE.find(frame_.opcode());
const char *opname = frametype ? frametype->name_ : "?";
APPEND_LINE("<%s>", opname);
}
// convert operand fields to text:
if (frametype) {
offset_ = 2; // location in frame of first operand, after address-byte and opcode-byte
OperandDecode_f f = frametype->decode_f_;
(this->*f)(); // call one of the above 'do_operand_<xx>' methods
}
return length_;
}
} // namespace hdmi_cec
} // namespace esphome
#endif // HDMI_CEC_USE_DECODER
+196
View File
@@ -0,0 +1,196 @@
#pragma once
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <array>
#include "hdmi_cec.h"
namespace esphome::hdmi_cec {
/**
* This LookupTable is const-initialised and only provides a simple 'find()'.
* This creates a compact (smaller code) alternative to an std::map;
*/
template<typename KeyT, typename ValueT, unsigned int SIZE> class LookupTable {
public:
struct KeyValue {
KeyT key_;
ValueT value_;
};
const ValueT *find(KeyT key) const {
int lo_inx = 0;
int hi_inx = SIZE - 1;
while (lo_inx <= hi_inx) {
// Search for 'key' in the array range lo_inx..hi_inx, bounds included.
// Rely on the array being sorted on increasing opcode!
int mid = (lo_inx + hi_inx) / 2;
if (key == table_[mid].key_) {
return &table_[mid].value_;
}
if (key < table_[mid].key_) {
hi_inx = mid - 1;
} else {
lo_inx = mid + 1;
}
}
return nullptr; // requested 'key' not found in table
}
const KeyValue table_[SIZE];
};
/**
* This Decoder class interprets a binary CEC Frame to create a textual representation.
* The information to create this decoder is mostly extracted from the HDMI 1.3a standard document,
* from its section "Supplement 1 Consumer Electronics Control (CEC)".
* Some further details were found in the Linux kernel source code of the "v4l-utils" repository,
* such as the "ARC" related functionality of HDMI-CEC 1.4, and the HDMI vendor ID names.
* Details on the digital audio format decoding came from the wikipedia page on
* "Extended Display Identification Data (EDID)"
*/
class Decoder {
public:
Decoder(const Frame &frame, char *line, unsigned int size)
: frame_(frame), line_(line), size_(size), length_(0), offset_(2) {}
/**
* Create a one-line textual representation of the Frame content for logging and debugging
* Store the text in the earlier provided 'line' buffer
* @return the length of the written text, excluding the terminating null character
*/
int decode();
protected:
const char *find_opcode_name_(uint32_t opcode) const;
void address_decode_();
/**
* Generic operand decode method, later specialised with operand-type-specific methods
* @return true if further conversions can continue, false when to stop.
*/
template<uint32_t OPERANDS> bool do_operand_();
/**
* The CecOpcodeTable is extracted from the HDMI CEC standard (1.4):
* It lists all Frame opcodes with their <name> and their expected [operand argument type(s)]
*/
using OperandDecode_f = bool (Decoder::*)();
struct FrameType {
const char *name_; // name of the operation (of the op_code)
const OperandDecode_f decode_f_; // a pointer to the corresponding 'do_operand_()' method
};
using CecOpcodeTable = LookupTable<uint8_t, FrameType, 71>;
using VendorIdTable = LookupTable<uint32_t, const char *, 28>;
const Frame &frame_;
char *const line_; // buffer to hold the text of the decoded frame
const unsigned int size_; // size of the 'line_' buffer
unsigned int length_; // currently accumulated length of output text in 'line'
unsigned int offset_; // current offset in frame to process next operand byte(s) (frame[0] and [1] are skipped)
/**
* The HDMI CEC standard specifies a set of distinct operand (parameter) types,
* used across the frame opcodes, denoted with "[operand type name]".
* These specified operand types are enumerated here for later type-specific decoding to text
*/
enum Operand : uint8_t {
NONE,
ABORT_REASON,
ANALOG_BROADCAST_TYPE,
ANALOG_FREQUENCY,
ASCII_DIGIT,
ASCII,
AUDIO_FORMAT,
AUDIO_RATE,
AUDIO_STATUS,
BOOLEAN,
BROADCAST_SYSTEM,
CEC_VERSION,
CHANNEL_IDENTIFIER,
// DayOfMonth, integrated in StartDateTime
DECK_CONTROL_MODE,
DECK_INFO,
DEVICE_TYPE,
DIGITAL_SERVICE_IDENTIFICATION,
DISPLAY_CONTROL,
DURATION,
EXTERNAL_PHYSICAL_ADDRESS,
EXTERNAL_PLUG,
EXTERNAL_SOURCE_SPECIFIER,
HOUR,
FEATURE_OPCODE,
LANGUAGE,
MENU_REQUEST_TYPE,
MENU_STATE,
MINUTE,
// MonthOfYear, integrated in StartDateTime
NEW_ADDRESS,
ORIGINAL_ADDRESS,
OSD_NAME,
OSD_STRING = OSD_NAME,
PHYSICAL_ADDRESS,
PLAY_MODE,
POWER_STATUS,
PROGRAM_TITLE_STRING,
RECORD_SOURCE,
RECORD_STATUS_INFO,
RECORDING_SEQUENCE,
SHORT_AUDIO_DESCRIPTOR,
STATUS_REQUEST,
START_DATE_TIME,
SYSTEM_AUDIO_STATUS,
TIME,
TIMER_CLEARED_STATUS_DATA,
TIMER_STATUS_DATA,
TUNER_DEVICE_INFO,
UI_BROADCAST_TYPE,
UI_COMMAND,
UI_FUNCTION_MEDIA,
UI_FUNCTION_SELECT_AV_INPUT,
UI_FUNCTION_SELECT_AUDIO_INPUT,
UI_SOUND_PRESENTATION_CONTROL,
VENDOR_ID,
VENDOR_SPECIFIC_DATA,
VENDOR_SPECIFIC_RC_CODE,
};
/**
* The plain 'operand types' are uint8.
* Further uint32 'operand type' values are used to encode a sequence of upto 4 (potentially different) operands
* in the MSB bytes of an uint32 value.
*/
constexpr static uint32_t two(uint32_t first, uint32_t secnd) { return first | (secnd << 8); }
constexpr static uint32_t three(uint32_t first, uint32_t secnd, uint32_t third) {
return first | (secnd << 8) | (third << 16);
}
/**
* Helper function to implement the 'do_operand' methods:
* Append one word
* @return true if a further operand shall be decoded, false to terminate operand decoding
*/
bool append_operand_(const char *word, uint8_t offset_incr = 1);
/**
* Append one word, determined by the operand value as index to an array of names
* @return true if a further operand shall be decoded, false to terminate operand decoding
*/
template<uint32_t N_STRINGS> bool append_operand_(const std::array<const char *, N_STRINGS> &strings) {
uint32_t operand_value = frame_[offset_];
const char *s = (operand_value < N_STRINGS) ? strings[operand_value] : "?";
return append_operand_(s);
}
/**
* String tables used in the subsequent 'do_operand' decode functions
*/
const static std::array<const char *, 0x77> UI_COMMANDS;
const static std::array<const char *, 0x11> AUDIO_FORMATS;
const static std::array<const char *, 8> AUDIO_SAMPLERATES;
const static CecOpcodeTable CEC_OPCODE_TABLE;
const static VendorIdTable VENDOR_IDS;
}; // class Decoder
} // namespace esphome::hdmi_cec
+450
View File
@@ -0,0 +1,450 @@
#include "hdmi_cec.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#ifdef USE_ESP8266
#include <Arduino.h>
#endif
#ifdef USE_RP2040
#include <pico/time.h>
#endif
namespace esphome::hdmi_cec {
ESPHOME_LOG_TAG(TAG, "hdmi_cec");
#if defined(USE_ESP8266) || defined(USE_RP2040)
// The timer interrupt of these platforms carries no argument
static HDMICEC *ack_timer_owner = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#endif
// Bit timing from the HDMI 1.4 CEC specification, in microseconds
static constexpr uint32_t START_BIT_MIN_US = 3500; // a low period at least this long is a start bit
static constexpr uint32_t START_BIT_NOM_US = 3700;
static constexpr uint32_t START_BIT_HIGH_US = 800;
static constexpr uint32_t HIGH_BIT_MIN_US = 400; // shortest low period of a logical 1
static constexpr uint32_t HIGH_BIT_US = 600; // nominal low period of a logical 1
static constexpr uint32_t LOW_BIT_US = 1500; // nominal low period of a logical 0
static constexpr uint32_t SAFE_SAMPLE_US = 1050; // the line is sampled here; a longer low period is a logical 0
static constexpr uint32_t TOTAL_BIT_US = 2400;
static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_RECEIVE = TOTAL_BIT_US * 5; // also for a new initiator
static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_FAIL = TOTAL_BIT_US * 3;
static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS = TOTAL_BIT_US * 7;
static constexpr uint32_t ABORTED_FRAME_US = TOTAL_BIT_US * 15; // no edge for this long: our frame was cut short
static constexpr uint8_t MAX_ATTEMPTS = 8; // CEC allows 5, but a busy bus counts as an attempt here too
Frame::Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span<const uint8_t> payload) {
this->data_[0] = ((initiator_addr & 0xf) << 4) | (destination_addr & 0xf);
this->size_ = 1 + payload.size();
std::copy(payload.begin(), payload.end(), this->data_.begin() + 1);
}
void HDMICEC::setup() {
// The line is open drain with a weak pull-up: driven low to send, released to read
this->pin_->setup();
this->isr_pin_ = this->pin_->to_isr();
this->set_pin_input_high_();
this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE;
if (!this->setup_ack_timer_()) {
ESP_LOGE(TAG, "Failed to set up the acknowledge timer");
this->mark_failed();
return;
}
this->pin_->attach_interrupt(HDMICEC::gpio_isr_s, this, gpio::INTERRUPT_ANY_EDGE);
// loop() only has work once the interrupt queues a frame or send() queues one
this->disable_loop();
}
void HDMICEC::dump_config() {
ESP_LOGCONFIG(TAG,
"HDMI-CEC:\n"
" Address: 0x%X\n"
" Monitor mode: %s\n"
" Promiscuous mode: %s",
this->address_, YESNO(this->monitor_mode_), YESNO(this->promiscuous_mode_));
LOG_PIN(" Pin: ", this->pin_);
}
void HDMICEC::loop() {
const uint8_t dropped = atomic_get(this->dropped_frames_);
if (dropped != this->reported_dropped_frames_) {
const uint8_t count = dropped - this->reported_dropped_frames_;
ESP_LOGW(TAG, "%u received frame%s dropped (queue full or oversized)", count,
count == 1 ? LOG_STR_LITERAL("") : LOG_STR_LITERAL("s"));
this->reported_dropped_frames_ = dropped;
}
// One inbound frame per pass keeps the time spent in the automations bounded
if (const Frame *frame = this->frames_queue_.front()) {
#ifdef ESPHOME_LOG_HAS_DEBUG
char line[format_hex_pretty_size(Frame::MAX_LENGTH)];
ESP_LOGD(TAG, "Received: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size()));
#endif
#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT
this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload());
#endif
this->frames_queue_.pop_front();
}
// A frame being sent stays at the front of the queue until its attempts are over
if (!this->send_queue_.is_empty()) {
this->transmit_message_();
}
if (this->frames_queue_.is_empty() && this->send_queue_.is_empty()) {
this->disable_loop();
}
}
bool HDMICEC::send(uint8_t source, uint8_t destination, std::span<const uint8_t> payload) {
if (this->monitor_mode_) {
ESP_LOGW(TAG, "Monitor mode, not sending 0x%X->0x%X", source, destination);
return false;
}
if (source > 0xf || destination > 0xf) {
ESP_LOGE(TAG, "Send abort: logical address 0x%X->0x%X out of range", source, destination);
return false;
}
if (payload.size() > Frame::MAX_PAYLOAD_LENGTH) {
ESP_LOGE(TAG, "Send abort 0x%X->0x%X: payload of %zu bytes exceeds %zu", source, destination, payload.size(),
Frame::MAX_PAYLOAD_LENGTH);
return false;
}
Frame *frame = this->send_queue_.back();
if (frame == nullptr) {
ESP_LOGW(TAG, "Send queue full, frame 0x%X->0x%X dropped", source, destination);
return false;
}
*frame = Frame(source, destination, payload);
this->send_queue_.push_back();
this->enable_loop();
return true;
}
void HDMICEC::transmit_message_() {
const uint32_t now = micros();
const uint32_t since_last_edge = now - atomic_get(this->last_edge_us_);
if (this->transmitting_) {
// The interrupt sees our own frame; wait for its end of message bit, or give up when the
// bus went quiet without one
if (!atomic_get(this->eom_received_) && since_last_edge < ABORTED_FRAME_US) {
return;
}
this->transmitting_ = false;
atomic_set(this->eom_received_, false); // the end of our own frame is not a foreign one
const Frame *frame = this->send_queue_.front();
// A broadcast is acknowledged by nobody; a directed frame on every byte
const bool bytes_ok = this->n_bytes_received_ == frame->size();
const bool acks_ok = this->n_acks_received_ == (frame->is_broadcast() ? 0 : frame->size());
if (bytes_ok && acks_ok) {
ESP_LOGD(TAG, "Send frame success in %u attempt%s", this->transmit_attempts_,
this->transmit_attempts_ > 1 ? LOG_STR_LITERAL("s") : LOG_STR_LITERAL(""));
} else if (!bytes_ok) {
ESP_LOGD(TAG, "Send frame incorrect on attempt %u, saw %u bytes but expected %zu", this->transmit_attempts_,
this->n_bytes_received_, frame->size());
} else {
ESP_LOGD(TAG, "Send frame not acknowledged on attempt %u", this->transmit_attempts_);
}
this->finish_attempt_(bytes_ok && acks_ok);
} else if (atomic_get(this->eom_received_)) {
// Another device finished a frame; the standard asks a new initiator to wait after it
this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE;
atomic_set(this->eom_received_, false);
}
const Frame *frame = this->send_queue_.front();
// An edge within the signal free time means the bus is in use or not yet free for us; a start
// bit refused by a held line drives no edge, so that attempt is timed on its own
if (frame == nullptr || since_last_edge < this->required_idle_period_ ||
now - this->last_attempt_us_ < this->required_idle_period_) {
return;
}
this->last_attempt_us_ = now;
this->transmit_attempts_++;
#ifdef ESPHOME_LOG_HAS_DEBUG
if (this->transmit_attempts_ == 1) {
char line[format_hex_pretty_size(Frame::MAX_LENGTH)];
ESP_LOGD(TAG, "Sending: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size()));
}
#endif
// The interrupt counts the bytes and acknowledge bits of our own frame
this->n_bytes_received_ = 0;
this->n_acks_received_ = 0;
atomic_set(this->eom_received_, false);
this->transmitting_ = true;
if (!this->transmit_frame_(frame)) {
// The line was held, or another initiator sent at the same time
this->transmitting_ = false;
this->finish_attempt_(false);
}
// Otherwise the acknowledge result arrives through the interrupt
}
void HDMICEC::finish_attempt_(bool sent_ok) {
this->required_idle_period_ = sent_ok ? SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS : SIGNAL_FREE_TIME_AFTER_XMIT_FAIL;
if (!sent_ok) {
if (this->transmit_attempts_ < MAX_ATTEMPTS) {
return; // the signal free time has to pass before the retry
}
ESP_LOGW(TAG, "Frame not sent after %u attempts, dropped", this->transmit_attempts_);
}
this->transmit_attempts_ = 0;
this->send_queue_.pop_front();
}
bool HDMICEC::transmit_frame_(const Frame *frame) {
if (!this->send_start_bit_()) {
return false;
}
const auto bytes = frame->bytes();
for (size_t byte_inx = 0; byte_inx < bytes.size(); byte_inx++) {
const uint8_t current_byte = bytes[byte_inx];
// Most significant bit first. The four initiator address bits watch for a collision with
// another initiator that started at the same moment; a lower address wins by holding the line
for (int8_t i = 7; i >= 0; i--) {
const bool bit_value = (current_byte >> i) & 0x1;
if (!this->send_bit_(bit_value) && byte_inx == 0 && i >= 4) {
ESP_LOGD(TAG, "Bus collision on sending initiator address 0x%X on attempt %u", frame->initiator_addr(),
this->transmit_attempts_);
return false;
}
}
// End of message bit, then the acknowledge bit which the receiver may pull low
this->send_bit_(byte_inx == bytes.size() - 1);
this->send_bit_(true);
}
return true;
}
bool HDMICEC::send_start_bit_() {
if (!this->isr_pin_.digital_read()) {
ESP_LOGD(TAG, "Bus occupied before start bit on attempt %u", this->transmit_attempts_);
return false;
}
this->set_pin_output_low_();
delay_microseconds_safe(START_BIT_NOM_US);
// Release the line and check that nobody else keeps it low
this->set_pin_input_high_();
delay_microseconds_safe(START_BIT_HIGH_US / 2);
bool value = this->isr_pin_.digital_read();
delay_microseconds_safe(START_BIT_HIGH_US / 2);
value &= this->isr_pin_.digital_read();
if (!value) {
ESP_LOGD(TAG, "Bus collision on sending start bit on attempt %u", this->transmit_attempts_);
}
return value;
}
bool HDMICEC::send_bit_(bool bit_value) {
const uint32_t start_us = micros();
this->set_pin_output_low_();
delay_microseconds_safe(bit_value ? HIGH_BIT_US : LOW_BIT_US);
this->set_pin_input_high_();
bool clear = true;
if (bit_value) {
// Sample in the middle of the safe sample period: a line still low means another initiator
// is sending a logical 0 at the same time
uint32_t elapsed = micros() - start_us;
if (elapsed < SAFE_SAMPLE_US) {
delay_microseconds_safe(SAFE_SAMPLE_US - elapsed);
}
clear = this->isr_pin_.digital_read();
}
const uint32_t elapsed = micros() - start_us;
if (elapsed < TOTAL_BIT_US) {
delay_microseconds_safe(TOTAL_BIT_US - elapsed);
}
return clear;
}
void IRAM_ATTR HDMICEC::send_ack_() {
// The acknowledge has to start right at this falling edge; the timer releases the line
this->set_pin_output_low_();
this->start_ack_timer_();
}
#if defined(USE_ESP32)
bool HDMICEC::setup_ack_timer_() {
gptimer_config_t config = {
.clk_src = GPTIMER_CLK_SRC_DEFAULT,
.direction = GPTIMER_COUNT_UP,
.resolution_hz = 1000000, // one tick per microsecond
};
if (gptimer_new_timer(&config, &this->ack_timer_) != ESP_OK) {
return false;
}
gptimer_event_callbacks_t callbacks = {.on_alarm = HDMICEC::ack_timer_isr_s};
// The alarm reloads the count to zero itself, so the interrupt only has to start the timer
gptimer_alarm_config_t alarm = {
.alarm_count = LOW_BIT_US, .reload_count = 0, .flags = {.auto_reload_on_alarm = true}};
return gptimer_register_event_callbacks(this->ack_timer_, &callbacks, this) == ESP_OK &&
gptimer_set_alarm_action(this->ack_timer_, &alarm) == ESP_OK && gptimer_enable(this->ack_timer_) == ESP_OK;
}
void IRAM_ATTR HDMICEC::start_ack_timer_() { gptimer_start(this->ack_timer_); }
bool IRAM_ATTR HDMICEC::ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg) {
auto *self = static_cast<HDMICEC *>(arg);
self->set_pin_input_high_();
gptimer_stop(timer);
return false; // no task to wake
}
#elif defined(USE_ESP8266)
static constexpr uint32_t TIMER1_TICKS_PER_US = 5; // TIM_DIV16 of the 80 MHz clock
bool HDMICEC::setup_ack_timer_() {
ack_timer_owner = this;
timer1_isr_init();
timer1_attachInterrupt(HDMICEC::ack_timer_isr_s);
timer1_enable(TIM_DIV16, TIM_EDGE, TIM_SINGLE); // a write arms one shot
return true;
}
void IRAM_ATTR HDMICEC::start_ack_timer_() { timer1_write(LOW_BIT_US * TIMER1_TICKS_PER_US); }
void IRAM_ATTR HDMICEC::ack_timer_isr_s() { ack_timer_owner->set_pin_input_high_(); }
#elif defined(USE_RP2040)
bool HDMICEC::setup_ack_timer_() {
this->ack_alarm_ = hardware_alarm_claim_unused(false);
if (this->ack_alarm_ < 0) {
return false; // every hardware alarm is taken
}
ack_timer_owner = this;
hardware_alarm_set_callback(this->ack_alarm_, HDMICEC::ack_timer_isr_s);
return true;
}
void IRAM_ATTR HDMICEC::start_ack_timer_() {
hardware_alarm_set_target(this->ack_alarm_, make_timeout_time_us(LOW_BIT_US));
}
void IRAM_ATTR HDMICEC::ack_timer_isr_s(uint alarm_num) { ack_timer_owner->set_pin_input_high_(); }
#else
bool HDMICEC::setup_ack_timer_() { return true; }
// Without a one-shot timer the interrupt holds the line itself
void IRAM_ATTR HDMICEC::start_ack_timer_() {
delay_microseconds_safe(LOW_BIT_US);
this->set_pin_input_high_();
}
#endif
void IRAM_ATTR HDMICEC::gpio_isr_s(HDMICEC *self) { self->gpio_isr_(); }
void IRAM_ATTR HDMICEC::gpio_isr_() {
const uint32_t now = micros();
const bool level = this->isr_pin_.digital_read();
if (level == this->prev_pin_level_) {
return; // a mode change of the pin, not a bus edge
}
this->prev_pin_level_ = level;
atomic_set(this->last_edge_us_, now);
if (!level) {
// Falling edge: the start of a low period
if (now - this->last_falling_edge_us_ > START_BIT_NOM_US + TOTAL_BIT_US) {
// The bus was quiet; whatever was gathered belongs to an aborted frame
this->reset_receiver_();
}
this->last_falling_edge_us_ = now;
if (this->recv_ack_queued_) {
// Acknowledge by stretching this low period
this->send_ack_();
this->recv_ack_queued_ = false;
}
return;
}
// Rising edge: the low period tells the bit value
const uint32_t pulse_duration = now - this->last_falling_edge_us_;
if (pulse_duration > START_BIT_MIN_US) {
this->reset_receiver_();
this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE;
// Without a free slot the frame is neither acknowledged nor delivered, so its initiator retries
this->recv_frame_ = this->frames_queue_.back();
if (this->recv_frame_ != nullptr) {
this->recv_frame_->clear();
}
return;
}
if (pulse_duration < HIGH_BIT_MIN_US / 4) {
return; // glitch
}
const bool value = pulse_duration <= SAFE_SAMPLE_US;
// Our own frame is on the bus until its end of message; send() may give it any initiator address
const bool own_frame = this->transmitting_ && !atomic_get(this->eom_received_);
switch (this->receiver_state_) {
case ReceiverState::RECEIVER_STATE_IDLE:
break;
case ReceiverState::RECEIVER_STATE_RECEIVING_BYTE:
this->recv_byte_ = (this->recv_byte_ << 1) | value;
if (++this->recv_bit_counter_ == 8) {
if (this->recv_length_++ == 0) {
this->recv_header_ = this->recv_byte_;
}
if (this->recv_frame_ != nullptr && !this->recv_frame_->push_back(this->recv_byte_)) {
this->recv_frame_ = nullptr; // longer than the bus allows: corrupt, do not deliver it
}
this->recv_bit_counter_ = 0;
this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM;
}
break;
case ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM:
this->recv_eom_ = value;
// A frame for us is acknowledged on the next bit, unless there is no room for it (so its
// initiator retries) or it is our own, which includes a polling message from a device
// claiming our logical address. A polling message for us (header only) needs no room, and
// goes unacknowledged only when nobody holds the address
if (!this->monitor_mode_ && !own_frame && (this->recv_frame_ != nullptr || (value && this->recv_length_ == 1)) &&
(this->recv_header_ & 0xf) == this->address_ && (this->recv_header_ >> 4) != this->address_) {
this->recv_ack_queued_ = true;
}
this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK;
break;
case ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK:
if (own_frame) {
// A low acknowledge bit means the addressed device took the byte
// (for a broadcast it means some device refused it)
this->n_bytes_received_++;
if (!value) {
this->n_acks_received_++;
}
}
if (!this->recv_eom_) {
this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE;
break;
}
atomic_set(this->eom_received_, true); // publishes the tally above to loop()
// The application only gets frames that are not our own and not a polling message (header
// only), which the acknowledge answered already, and that are addressed to us unless
// promiscuous
const uint8_t destination = this->recv_header_ & 0xf;
const bool wanted = this->recv_length_ > 1 && !own_frame && (this->recv_header_ >> 4) != this->address_ &&
(this->promiscuous_mode_ || destination == 0xf || destination == this->address_);
if (!wanted) {
// nothing to deliver
} else if (this->recv_frame_ == nullptr) {
atomic_set<uint8_t>(this->dropped_frames_, atomic_get(this->dropped_frames_) + 1);
} else {
this->frames_queue_.push_back();
this->enable_loop_soon_any_context();
}
this->reset_receiver_();
break;
}
}
void IRAM_ATTR HDMICEC::reset_receiver_() {
this->receiver_state_ = ReceiverState::RECEIVER_STATE_IDLE;
this->recv_bit_counter_ = 0;
this->recv_length_ = 0;
this->recv_ack_queued_ = false;
this->recv_frame_ = nullptr;
}
} // namespace esphome::hdmi_cec
+218
View File
@@ -0,0 +1,218 @@
#pragma once
#include <array>
#include <atomic>
#include <initializer_list>
#include <span>
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/gpio.h"
#include "esphome/core/helpers.h"
#ifdef USE_ESP32
#include "driver/gptimer.h"
#endif
#ifdef USE_RP2040
#include <hardware/timer.h>
#endif
namespace esphome::hdmi_cec {
// Release/acquire store and load. The read-modify-write forms of std::atomic are not available on
// every target (the ESP8266 has none), so a writer computes the new value itself; that is safe here
// because every atomic in this component has exactly one writer.
template<typename T> ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
atom.store(value, std::memory_order_release);
}
template<typename T> ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
return atom.load(std::memory_order_acquire);
}
/// One CEC bus message: a header byte with the initiator and destination addresses, then up to
/// 15 payload bytes of which the first is the opcode. Fixed storage, so the receive interrupt
/// never allocates.
class Frame {
public:
static constexpr size_t MAX_LENGTH = 16; // HDMI-CEC 1.4
static constexpr size_t MAX_PAYLOAD_LENGTH = MAX_LENGTH - 1;
Frame() = default;
/// The payload is at most MAX_PAYLOAD_LENGTH bytes; send() checks that
Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span<const uint8_t> payload);
ESPHOME_ALWAYS_INLINE uint8_t initiator_addr() const { return this->data_[0] >> 4; }
ESPHOME_ALWAYS_INLINE uint8_t destination_addr() const { return this->data_[0] & 0xf; }
ESPHOME_ALWAYS_INLINE bool is_broadcast() const { return this->destination_addr() == 0xf; }
ESPHOME_ALWAYS_INLINE size_t size() const { return this->size_; }
std::span<const uint8_t> bytes() const { return {this->data_.data(), this->size_}; }
/// The bytes after the header, starting with the opcode
std::span<const uint8_t> payload() const { return this->bytes().subspan(1); }
ESPHOME_ALWAYS_INLINE void clear() { this->size_ = 0; }
/// False when the frame is full already; the bus allows no more
ESPHOME_ALWAYS_INLINE bool push_back(uint8_t data) {
if (this->size_ == MAX_LENGTH) {
return false;
}
this->data_[this->size_++] = data;
return true;
}
protected:
std::array<uint8_t, MAX_LENGTH> data_{};
uint8_t size_{0};
};
/// A queue of frames between one producer and one consumer, for instance the receive interrupt and
/// loop(). The indices run freely and each is written by one side only, so no lock is needed.
/// back() hands out a slot to fill and push_back() commits it; front() and pop_front() do the same
/// on the reading side.
template<size_t SIZE> class FrameRingBuffer {
static_assert((SIZE & (SIZE - 1)) == 0, "SIZE must be a power of two");
public:
ESPHOME_ALWAYS_INLINE Frame *front() { return this->is_empty() ? nullptr : this->slot_(this->front_inx_); }
ESPHOME_ALWAYS_INLINE void pop_front() { this->advance_(this->front_inx_); }
ESPHOME_ALWAYS_INLINE Frame *back() { return this->count_() == SIZE ? nullptr : this->slot_(this->back_inx_); }
ESPHOME_ALWAYS_INLINE void push_back() { this->advance_(this->back_inx_); }
ESPHOME_ALWAYS_INLINE bool is_empty() const { return this->count_() == 0; }
protected:
ESPHOME_ALWAYS_INLINE uint8_t count_() const {
return static_cast<uint8_t>(atomic_get(this->back_inx_) - atomic_get(this->front_inx_));
}
ESPHOME_ALWAYS_INLINE Frame *slot_(const std::atomic<uint8_t> &inx) { return &this->store_[atomic_get(inx) % SIZE]; }
ESPHOME_ALWAYS_INLINE void advance_(std::atomic<uint8_t> &inx) {
atomic_set<uint8_t>(inx, static_cast<uint8_t>(atomic_get(inx) + 1));
}
std::array<Frame, SIZE> store_{};
std::atomic<uint8_t> front_inx_{0};
std::atomic<uint8_t> back_inx_{0};
};
/// The HDMI-CEC bus on one GPIO pin. The receive interrupt decodes the edges of every frame,
/// including those this device sends, and queues complete frames for loop(); loop() runs the
/// on_message automations and transmits queued frames, one blocking bit-banged attempt per pass.
class HDMICEC : public Component {
public:
HDMICEC(InternalGPIOPin *pin, uint8_t address) : pin_(pin), address_(address) {}
void setup() override;
void dump_config() override;
void loop() override;
void set_promiscuous_mode(bool promiscuous) { this->promiscuous_mode_ = promiscuous; }
void set_monitor_mode(bool monitor_mode) { this->monitor_mode_ = monitor_mode; }
#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT
/// Called with the initiator, the destination and the payload (opcode first) of every received
/// frame; the payload is only valid during the call
template<typename F> void add_on_message_callback(F &&callback) {
this->message_callbacks_.add(std::forward<F>(callback));
}
#endif
/// Queue a frame for transmission; false when the queue is full or in monitor mode
bool send(uint8_t source, uint8_t destination, std::span<const uint8_t> payload);
bool send(uint8_t destination, std::span<const uint8_t> payload) {
return this->send(this->address_, destination, payload);
}
/// For lambdas: id(cec).send(0x0, {0x44, 0x41});
bool send(uint8_t destination, std::initializer_list<uint8_t> payload) {
return this->send(destination, std::span<const uint8_t>(payload.begin(), payload.size()));
}
protected:
enum class ReceiverState : uint8_t {
RECEIVER_STATE_IDLE,
RECEIVER_STATE_RECEIVING_BYTE,
RECEIVER_STATE_WAITING_FOR_EOM,
RECEIVER_STATE_WAITING_FOR_ACK,
};
// Receive side, in the interrupt
static void gpio_isr_s(HDMICEC *self);
void gpio_isr_();
void reset_receiver_();
/// Pull the line low for the acknowledge bit of a frame addressed to us
void send_ack_();
// A hardware one-shot timer releases the line after the acknowledge bit (gptimer on the ESP32,
// timer 1 on the ESP8266, a hardware alarm on the RP2040), so the interrupt returns at once;
// without one the interrupt holds the line for the 1.5 ms itself
bool setup_ack_timer_();
void start_ack_timer_();
#ifdef USE_ESP32
static bool ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg);
#endif
#ifdef USE_ESP8266
static void ack_timer_isr_s();
#endif
#ifdef USE_RP2040
static void ack_timer_isr_s(uint alarm_num);
#endif
// Transmit side, in loop()
void transmit_message_();
/// Keep the frame for another attempt, or drop it once the attempts are used up
void finish_attempt_(bool sent_ok);
/// Drive the frame onto the line; false when another initiator sent at the same time
bool transmit_frame_(const Frame *frame);
bool send_start_bit_();
/// Send one bit; false when a logical 1 found the line held low by another initiator
bool send_bit_(bool bit_value);
ESPHOME_ALWAYS_INLINE void set_pin_input_high_() { this->isr_pin_.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP); }
ESPHOME_ALWAYS_INLINE void set_pin_output_low_() {
this->isr_pin_.pin_mode(gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN);
this->isr_pin_.digital_write(false);
}
static constexpr size_t QUEUE_SIZE = 4;
// Each queue is 70 bytes, so two single-byte fields follow each to fill the alignment gap
FrameRingBuffer<QUEUE_SIZE> frames_queue_; // received, interrupt to loop()
uint8_t recv_bit_counter_{0};
uint8_t recv_byte_{0};
FrameRingBuffer<QUEUE_SIZE> send_queue_; // to send, both ends on loop()
uint8_t transmit_attempts_{0};
uint8_t reported_dropped_frames_{0};
#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT
StaticCallbackManager<HDMI_CEC_MESSAGE_CALLBACK_COUNT, void(uint8_t, uint8_t, std::span<const uint8_t>)>
message_callbacks_;
#endif
InternalGPIOPin *pin_;
ISRInternalGPIOPin isr_pin_;
#ifdef USE_ESP32
gptimer_handle_t ack_timer_{nullptr};
#endif
#ifdef USE_RP2040
int ack_alarm_{-1};
#endif
// The word-sized fields come first and the single-byte ones form one run, so nothing is padded
std::atomic<uint32_t> last_edge_us_{0}; // the bus is busy until this is some time ago; interrupt writes
Frame *recv_frame_{nullptr}; // interrupt only; null when the queue was full or the frame too long
uint32_t last_falling_edge_us_{0}; // interrupt only
uint32_t last_attempt_us_{0}; // loop() only; a refused start bit drives no edge, so it is timed here
uint16_t required_idle_period_{0}; // loop() only
// Shared with the interrupt. The tally of our own frame is written by the interrupt while
// transmitting_ is set and no end of message has passed yet; loop() reads it once eom_received_
// says the frame is over
std::atomic<bool> eom_received_{false}; // set for every frame, ours or not
std::atomic<uint8_t> dropped_frames_{0};
uint8_t n_bytes_received_{0};
uint8_t n_acks_received_{0};
bool transmitting_{false};
// Receive state, interrupt only
uint8_t recv_header_{0}; // kept outside the frame so a dropped frame can still be judged
uint8_t recv_length_{0};
ReceiverState receiver_state_{ReceiverState::RECEIVER_STATE_IDLE};
bool recv_eom_{false};
bool recv_ack_queued_{false};
bool prev_pin_level_{true};
uint8_t address_; // logical address, 0xf when only listening
bool promiscuous_mode_{false};
bool monitor_mode_{false};
};
} // namespace esphome::hdmi_cec
@@ -12,14 +12,6 @@ namespace esphome::homeassistant {
ESPHOME_LOG_TAG(TAG, "homeassistant.button");
// In flash on the ESP8266; copied to the stack only while a request is built
PROGMEM_STRING_TABLE(ButtonActionStrings, "button.press", "input_button.press", "entity_id");
enum ButtonActionString : uint8_t {
BUTTON_ACTION_STRING_SERVICE,
BUTTON_ACTION_STRING_INPUT_SERVICE,
BUTTON_ACTION_STRING_ENTITY_ID,
};
void HomeassistantButton::dump_config() {
LOG_BUTTON("", "Homeassistant Button", this);
ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_);
@@ -40,18 +32,21 @@ void HomeassistantButton::press_action() {
}
#endif
ProgmemStringTableCopy<ButtonActionStrings> strings;
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper =
strncmp(this->entity_id_, strings.get(BUTTON_ACTION_STRING_INPUT_SERVICE), sizeof("input_") - 1) == 0;
// In flash on the ESP8266 and encoded from there
static const char SERVICE_BUTTON[] PROGMEM = "button.press";
static const char SERVICE_INPUT_BUTTON[] PROGMEM = "input_button.press";
static const char ENTITY_ID_KEY[] PROGMEM = "entity_id";
api::HomeassistantActionRequest resp;
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_BUTTON, sizeof("input_") - 1) == 0;
resp.service =
StringRef(strings.get(input_helper ? BUTTON_ACTION_STRING_INPUT_SERVICE : BUTTON_ACTION_STRING_SERVICE));
input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_BUTTON) : ProgmemStringRef::from_lit(SERVICE_BUTTON);
resp.data.init(1);
auto &entity_id_kv = resp.data.emplace_back();
entity_id_kv.key = StringRef(strings.get(BUTTON_ACTION_STRING_ENTITY_ID));
entity_id_kv.value = StringRef(this->entity_id_);
entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY);
entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_));
api::global_api_server->send_homeassistant_action(resp);
}
@@ -12,15 +12,6 @@ namespace esphome::homeassistant {
ESPHOME_LOG_TAG(TAG, "homeassistant.number");
// In flash on the ESP8266; copied to the stack only while a request is built
PROGMEM_STRING_TABLE(NumberActionStrings, "number.set_value", "input_number.set_value", "entity_id", "value");
enum NumberActionString : uint8_t {
NUMBER_ACTION_STRING_SERVICE,
NUMBER_ACTION_STRING_INPUT_SERVICE,
NUMBER_ACTION_STRING_ENTITY_ID,
NUMBER_ACTION_STRING_VALUE,
};
void HomeassistantNumber::state_changed_(StringRef state) {
auto number_value = parse_number<float>(state.c_str());
if (!number_value.has_value()) {
@@ -99,26 +90,29 @@ void HomeassistantNumber::control(float value) {
this->publish_state(value);
ProgmemStringTableCopy<NumberActionStrings> strings;
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper =
strncmp(this->entity_id_, strings.get(NUMBER_ACTION_STRING_INPUT_SERVICE), sizeof("input_") - 1) == 0;
// In flash on the ESP8266 and encoded from there
static const char NUMBER_SERVICE_NAME[] PROGMEM = "number.set_value";
static const char INPUT_NUMBER_SERVICE_NAME[] PROGMEM = "input_number.set_value";
static const char ENTITY_ID_KEY[] PROGMEM = "entity_id";
static const char VALUE_KEY[] PROGMEM = "value";
api::HomeassistantActionRequest resp;
// input_number entities have their own set_value action
resp.service =
StringRef(strings.get(input_helper ? NUMBER_ACTION_STRING_INPUT_SERVICE : NUMBER_ACTION_STRING_SERVICE));
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, INPUT_NUMBER_SERVICE_NAME, sizeof("input_") - 1) == 0;
resp.service = input_helper ? ProgmemStringRef::from_lit(INPUT_NUMBER_SERVICE_NAME)
: ProgmemStringRef::from_lit(NUMBER_SERVICE_NAME);
resp.data.init(2);
auto &entity_id = resp.data.emplace_back();
entity_id.key = StringRef(strings.get(NUMBER_ACTION_STRING_ENTITY_ID));
entity_id.value = StringRef(this->entity_id_);
entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY);
entity_id.value = ProgmemStringRef(StringRef(this->entity_id_));
auto &entity_value = resp.data.emplace_back();
entity_value.key = StringRef(strings.get(NUMBER_ACTION_STRING_VALUE));
entity_value.key = ProgmemStringRef::from_lit(VALUE_KEY);
// Stack buffer - no heap allocation; %g produces shortest representation
char value_buf[16];
buf_append_printf(value_buf, sizeof(value_buf), 0, "%g", value);
entity_value.value = StringRef(value_buf);
entity_value.value = ProgmemStringRef(StringRef(value_buf));
api::global_api_server->send_homeassistant_action(resp);
}
@@ -13,15 +13,6 @@ namespace esphome::homeassistant {
ESPHOME_LOG_TAG(TAG, "homeassistant.select");
// In flash on the ESP8266; copied to the stack only while a request is built
PROGMEM_STRING_TABLE(SelectActionStrings, "select.select_option", "input_select.select_option", "entity_id", "option");
enum SelectActionString : uint8_t {
SELECT_ACTION_STRING_SERVICE,
SELECT_ACTION_STRING_INPUT_SERVICE,
SELECT_ACTION_STRING_ENTITY_ID,
SELECT_ACTION_STRING_OPTION,
};
namespace {
/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early.
@@ -247,22 +238,26 @@ void HomeassistantSelect::control(size_t index) {
}
#endif
ProgmemStringTableCopy<SelectActionStrings> strings;
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper =
strncmp(this->entity_id_, strings.get(SELECT_ACTION_STRING_INPUT_SERVICE), sizeof("input_") - 1) == 0;
// In flash on the ESP8266 and encoded from there
static const char SERVICE_SELECT[] PROGMEM = "select.select_option";
static const char SERVICE_INPUT_SELECT[] PROGMEM = "input_select.select_option";
static const char ENTITY_ID_KEY[] PROGMEM = "entity_id";
static const char OPTION_KEY[] PROGMEM = "option";
api::HomeassistantActionRequest resp;
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_SELECT, sizeof("input_") - 1) == 0;
resp.service =
StringRef(strings.get(input_helper ? SELECT_ACTION_STRING_INPUT_SERVICE : SELECT_ACTION_STRING_SERVICE));
input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_SELECT) : ProgmemStringRef::from_lit(SERVICE_SELECT);
resp.data.init(2);
auto &entity_id = resp.data.emplace_back();
entity_id.key = StringRef(strings.get(SELECT_ACTION_STRING_ENTITY_ID));
entity_id.value = StringRef(this->entity_id_);
entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY);
entity_id.value = ProgmemStringRef(StringRef(this->entity_id_));
auto &option = resp.data.emplace_back();
option.key = StringRef(strings.get(SELECT_ACTION_STRING_OPTION));
option.value = StringRef(this->option_at(index));
option.key = ProgmemStringRef::from_lit(OPTION_KEY);
option.value = ProgmemStringRef(StringRef(this->option_at(index)));
api::global_api_server->send_homeassistant_action(resp);
}
@@ -8,14 +8,6 @@ namespace esphome::homeassistant {
ESPHOME_LOG_TAG(TAG, "homeassistant.switch");
// In flash on the ESP8266; copied to the stack only while a request is built
PROGMEM_STRING_TABLE(SwitchActionStrings, "homeassistant.turn_on", "homeassistant.turn_off", "entity_id");
enum SwitchActionString : uint8_t {
SWITCH_ACTION_STRING_TURN_ON,
SWITCH_ACTION_STRING_TURN_OFF,
SWITCH_ACTION_STRING_ENTITY_ID,
};
using namespace esphome::switch_;
void HomeassistantSwitch::setup() {
@@ -56,14 +48,18 @@ void HomeassistantSwitch::write_state(bool state) {
}
#endif
ProgmemStringTableCopy<SwitchActionStrings> strings;
// In flash on the ESP8266 and encoded from there
static const char SERVICE_ON[] PROGMEM = "homeassistant.turn_on";
static const char SERVICE_OFF[] PROGMEM = "homeassistant.turn_off";
static const char ENTITY_ID_KEY[] PROGMEM = "entity_id";
api::HomeassistantActionRequest resp;
resp.service = StringRef(strings.get(state ? SWITCH_ACTION_STRING_TURN_ON : SWITCH_ACTION_STRING_TURN_OFF));
resp.service = state ? ProgmemStringRef::from_lit(SERVICE_ON) : ProgmemStringRef::from_lit(SERVICE_OFF);
resp.data.init(1);
auto &entity_id_kv = resp.data.emplace_back();
entity_id_kv.key = StringRef(strings.get(SWITCH_ACTION_STRING_ENTITY_ID));
entity_id_kv.value = StringRef(this->entity_id_);
entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY);
entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_));
api::global_api_server->send_homeassistant_action(resp);
}
@@ -13,15 +13,6 @@ namespace esphome::homeassistant {
ESPHOME_LOG_TAG(TAG, "homeassistant.text");
// In flash on the ESP8266; copied to the stack only while a request is built
PROGMEM_STRING_TABLE(TextActionStrings, "text.set_value", "input_text.set_value", "entity_id", "value");
enum TextActionString : uint8_t {
TEXT_ACTION_STRING_SERVICE,
TEXT_ACTION_STRING_INPUT_SERVICE,
TEXT_ACTION_STRING_ENTITY_ID,
TEXT_ACTION_STRING_VALUE,
};
void HomeassistantText::state_changed_(StringRef state) {
if (state == this->state) {
return;
@@ -96,21 +87,26 @@ void HomeassistantText::control(const std::string &value) {
}
#endif
ProgmemStringTableCopy<TextActionStrings> strings;
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper =
strncmp(this->entity_id_, strings.get(TEXT_ACTION_STRING_INPUT_SERVICE), sizeof("input_") - 1) == 0;
// In flash on the ESP8266 and encoded from there
static const char SERVICE_TEXT[] PROGMEM = "text.set_value";
static const char SERVICE_INPUT_TEXT[] PROGMEM = "input_text.set_value";
static const char ENTITY_ID_KEY[] PROGMEM = "entity_id";
static const char VALUE_KEY[] PROGMEM = "value";
api::HomeassistantActionRequest resp;
resp.service = StringRef(strings.get(input_helper ? TEXT_ACTION_STRING_INPUT_SERVICE : TEXT_ACTION_STRING_SERVICE));
// An input helper entity id starts with the "input_" its action name starts with
const bool input_helper = ESPHOME_strncmp_P(this->entity_id_, SERVICE_INPUT_TEXT, sizeof("input_") - 1) == 0;
resp.service =
input_helper ? ProgmemStringRef::from_lit(SERVICE_INPUT_TEXT) : ProgmemStringRef::from_lit(SERVICE_TEXT);
resp.data.init(2);
auto &entity_id_kv = resp.data.emplace_back();
entity_id_kv.key = StringRef(strings.get(TEXT_ACTION_STRING_ENTITY_ID));
entity_id_kv.value = StringRef(this->entity_id_);
entity_id_kv.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY);
entity_id_kv.value = ProgmemStringRef(StringRef(this->entity_id_));
auto &value_kv = resp.data.emplace_back();
value_kv.key = StringRef(strings.get(TEXT_ACTION_STRING_VALUE));
value_kv.value = StringRef(value.data(), value.size());
value_kv.key = ProgmemStringRef::from_lit(VALUE_KEY);
value_kv.value = ProgmemStringRef(value.data(), value.size());
api::global_api_server->send_homeassistant_action(resp);
}
+36 -38
View File
@@ -1,15 +1,42 @@
#include "lsm6ds.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "esphome/core/progmem.h"
namespace esphome::lsm6ds {
ESPHOME_LOG_TAG(TAG, "lsm6ds");
static const struct {
uint8_t who_am_i;
const char *const name;
} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}};
// Indexed by the accel range field, which is not in ascending order (datasheet Table 3)
PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±16g", "±4g", "±8g");
static const LogString *gyro_range_to_str(LSM6DSGyroRange range) {
switch (range) {
case LSM6DS_GYRO_RANGE_125:
return LOG_STR("±125dps");
case LSM6DS_GYRO_RANGE_250:
return LOG_STR("±250dps");
case LSM6DS_GYRO_RANGE_500:
return LOG_STR("±500dps");
case LSM6DS_GYRO_RANGE_1000:
return LOG_STR("±1000dps");
case LSM6DS_GYRO_RANGE_2000:
return LOG_STR("±2000dps");
default:
return LOG_STR("unknown");
}
}
static const LogString *chip_name_from_who_am_i(uint8_t who_am_i) {
switch (who_am_i) {
case 0x69:
return LOG_STR("LSMDSO");
case 0x6A:
return LOG_STR("LSM6DS3");
default:
return nullptr;
}
}
void LSM6DSComponent::setup() {
MotionComponent::setup();
@@ -19,19 +46,13 @@ void LSM6DSComponent::setup() {
this->mark_failed();
return;
}
const char *chip_name = nullptr;
for (const auto &chip : CHIP_IDS) {
if (chip.who_am_i == who_am_i) {
chip_name = chip.name;
break;
}
}
const LogString *chip_name = chip_name_from_who_am_i(who_am_i);
if (chip_name == nullptr) {
ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i);
this->mark_failed(LOG_STR("Unknown WHO_AM_I value"));
return;
}
ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i);
ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", LOG_STR_ARG(chip_name), who_am_i);
this->chip_name_ = chip_name;
// 2. Software reset — clears all registers to defaults
@@ -85,34 +106,10 @@ void LSM6DSComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"LSM6DS IMU:\n"
" Chip type: %s\n",
this->chip_name_);
LOG_STR_ARG(this->chip_name_ != nullptr ? this->chip_name_ : LOG_STR("Unknown")));
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
// Accel range — index into the sensitivity table (datasheet Table 3)
static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"};
const char *gyro_str;
switch (this->gyro_range_) {
case LSM6DS_GYRO_RANGE_125:
gyro_str = "±125dps";
break;
case LSM6DS_GYRO_RANGE_250:
gyro_str = "±250dps";
break;
case LSM6DS_GYRO_RANGE_500:
gyro_str = "±500dps";
break;
case LSM6DS_GYRO_RANGE_1000:
gyro_str = "±1000dps";
break;
case LSM6DS_GYRO_RANGE_2000:
gyro_str = "±2000dps";
break;
default:
gyro_str = "unknown";
break;
}
auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1));
auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1));
ESP_LOGCONFIG(TAG,
@@ -120,7 +117,8 @@ void LSM6DSComponent::dump_config() {
" Accel data rate : %dHz\n"
" Gyro range : %s\n"
" Gyro data rate : %dHz",
ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr);
LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX)),
accel_odr, LOG_STR_ARG(gyro_range_to_str(this->gyro_range_)), gyro_odr);
}
// update_data()
+2 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/motion/motion_component.h"
@@ -97,7 +98,7 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev
template<typename F> void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward<F>(cb)); }
protected:
const char *chip_name_{"Unknown"};
const LogString *chip_name_{nullptr};
bool update_data(motion::MotionData &data) override;
LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G};
@@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip):
def transforms(self) -> set[str]:
"""
Return the set of transformations supported by this driver chip.
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY.
A model with an empty init sequence has no command interface at all, so it
cannot apply any transform in hardware.
"""
if self.initsequence is not None and len(self.initsequence) == 0:
return set()
return super().transforms - {CONF_SWAP_XY}
+9 -6
View File
@@ -1,11 +1,16 @@
#include "qmi8658.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "esphome/core/progmem.h"
namespace esphome::qmi8658 {
ESPHOME_LOG_TAG(TAG, "qmi8658");
PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±4g", "±8g", "±16g");
PROGMEM_STRING_TABLE(GyroRangeStrings, "±16°/s", "±32°/s", "±64°/s", "±128°/s", "±256°/s", "±512°/s", "±1024°/s",
"±2048°/s");
// Acceleration scale (g per LSB), indexed by accel_range_ >> 4.
// Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts).
static constexpr float ACCEL_SCALE[] = {
@@ -86,12 +91,10 @@ void QMI8658Component::dump_config() {
return;
}
static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"};
static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s",
"±256°/s", "±512°/s", "±1024°/s", "±2048°/s"};
ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]);
ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]);
ESP_LOGCONFIG(TAG, " Accel range : %s",
LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_ >> 4, AccelRangeStrings::LAST_INDEX)));
ESP_LOGCONFIG(TAG, " Gyro range : %s",
LOG_STR_ARG(GyroRangeStrings::get_log_str(this->gyro_range_ >> 4, GyroRangeStrings::LAST_INDEX)));
MotionComponent::dump_config();
}
@@ -293,7 +293,11 @@ void WiFiComponent::wifi_lazy_init_() {
#ifdef USE_WIFI_DPP
void WiFiComponent::wifi_dpp_init_() {
// With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
esp_err_t err = esp_supp_dpp_init();
#else
esp_err_t err = esp_supp_dpp_init(nullptr);
#endif
if (err != ESP_OK) {
ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err));
return;
+1 -1
View File
@@ -154,6 +154,7 @@
#define USE_LVGL_TOUCHSCREEN
#define USE_MDNS
#define USE_MDNS_STORE_SERVICES
#define HDMI_CEC_MESSAGE_CALLBACK_COUNT 2
#define I2C_PORT_FREQUENCY_COUNT 1
#define MDNS_SERVICE_COUNT 3
#define USE_MDNS_DYNAMIC_TXT
@@ -322,7 +323,6 @@
#define USE_API_USER_DEFINED_ACTION_RESPONSES
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#define API_MAX_SEND_QUEUE 8
#define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64
#define API_WIZARD_DATA_SIZE 1
#define API_WIZARD_INPUT_COUNT 1
#define MAX_API_CONNECTIONS 6
-34
View File
@@ -153,38 +153,4 @@ struct LogString;
} \
}
/// The index of the string value (len bytes, in RAM) in a PROGMEM_STRING_TABLE, or -1 when it is
/// not one of its strings
template<typename Table> int8_t progmem_string_table_find(const char *value, size_t len) {
for (uint8_t i = 0; i < Table::COUNT; i++) {
const char *entry = Table::get_(i, i);
if (ESPHOME_strlen_P(entry) == len && ESPHOME_strncmp_P(value, entry, len) == 0) {
return i;
}
}
return -1;
}
/// The strings of a PROGMEM_STRING_TABLE addressable as plain pointers, for code that hands them on
/// without reading them itself, such as an API request. On the ESP8266 this is a copy of the table on
/// the stack that lives as long as the object; elsewhere the table is read in place.
///
/// Example, with an enum that numbers the table's strings:
/// ProgmemStringTableCopy<MyStrings> strings;
/// request.service = StringRef(strings.get(MY_STRING_TURN_ON));
template<typename Name> class ProgmemStringTableCopy {
static_assert(Name::Table::INDEXED, "the table needs offsets");
public:
#ifdef USE_ESP8266
ProgmemStringTableCopy() { progmem_memcpy(this->blob_, Name::BLOB.data(), Name::BLOB_SIZE); }
const char *get(uint8_t idx) const { return this->blob_ + progmem_read_byte(&Name::OFFSETS[idx]); }
private:
char blob_[Name::BLOB_SIZE];
#else
const char *get(uint8_t idx) const { return Name::get_(idx, idx); }
#endif
};
} // namespace esphome
+44 -18
View File
@@ -1,5 +1,6 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <iterator>
@@ -189,24 +190,6 @@ inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { r
inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); }
/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen.
struct ProgmemStringRef {
PGM_P str;
size_t len;
};
inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) {
return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0);
}
inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); }
inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) {
// A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes
return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0);
}
inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); }
#endif // USE_ESP8266
inline bool operator<(const StringRef &lhs, const StringRef &rhs) {
@@ -241,6 +224,49 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) {
str.append(rhs.c_str(), rhs.size());
return str;
}
/// A string that may live in flash (on ESP8266): pointer and length, with no byte-reading operations.
/// progmem_ptr() is safe as a "%s" log argument; compare the contents with equals().
class ProgmemStringRef {
public:
constexpr ProgmemStringRef() = default;
constexpr ProgmemStringRef(const char *s, size_t len) : ref_(s, len) {}
constexpr explicit ProgmemStringRef(const StringRef &s) : ref_(s) {}
/// A PROGMEM char array, with its length known at compile time
template<size_t N> static constexpr ProgmemStringRef from_lit(const char (&s)[N]) { return {s, N - 1}; }
constexpr const char *progmem_ptr() const { return this->ref_.c_str(); }
constexpr size_t size() const { return this->ref_.size(); }
constexpr bool empty() const { return this->ref_.empty(); }
bool equals(const StringRef &other) const {
#ifdef USE_ESP8266
// A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes
return other.size() == this->ref_.size() &&
(other.empty() || memcmp_P(other.c_str(), this->ref_.c_str(), other.size()) == 0);
#else
return other == this->ref_;
#endif
}
// Remove before 2027.5.0
[[deprecated(
"May point to flash; use progmem_ptr() or the owner's accessors. Removed in 2027.5.0")]] constexpr const char *
c_str() const {
return this->ref_.c_str();
}
private:
StringRef ref_;
};
#ifdef USE_ESP8266
inline bool operator==(const StringRef &lhs, const ProgmemStringRef &rhs) { return rhs.equals(lhs); }
inline bool operator!=(const StringRef &lhs, const ProgmemStringRef &rhs) { return !rhs.equals(lhs); }
inline bool operator==(const std::string &lhs, const ProgmemStringRef &rhs) { return rhs.equals(StringRef(lhs)); }
inline bool operator!=(const std::string &lhs, const ProgmemStringRef &rhs) { return !rhs.equals(StringRef(lhs)); }
#endif
// String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef)
// Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation.
namespace internal {
+502 -103
View File
@@ -70,11 +70,13 @@ CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02
CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04
CLIENT_FEATURE_SUPPORTS_NOISE = 0x08
CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10
CLIENT_FEATURE_SUPPORTS_UDP = 0x20
SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01
SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02
SERVER_FEATURE_SUPPORTS_NOISE = 0x04
# Binding once offered: the device then expects the image size and a deflate stream
SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08
SERVER_FEATURE_SUPPORTS_UDP = 0x10
# Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE)
DEFLATE_WINDOW_BITS = 12
@@ -103,17 +105,64 @@ COMPRESS_LEVEL = 9
UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time
# to clean up a half-open connection (its handshake watchdog runs at 20s) before it
# to clean up a half-open connection (its handshake watchdog runs at 60s) before it
# accepts a new one, so wait between attempts instead of failing the upload outright.
# Every resolved address is tried once, and this many extra attempts are shared
# across the addresses on top of that.
EXTRA_UPLOAD_ATTEMPTS = 2
EXTRA_UPLOAD_ATTEMPTS = 4
# Once a connection has reached the device, a lossy link (not a dead device) is
# the likely cause, so keep trying like an API reconnect would
EXTRA_UPLOAD_ATTEMPTS_REACHED = 8
UPLOAD_RETRY_DELAY = 5.0
# A SYN getting through is the cheap step; an unreachable address still fails fast
CONNECT_TIMEOUT = 20.0
# Handshake waits; as patient as an API connect on a lossy link, and matching the
# device's OTA_SOCKET_TIMEOUT_HANDSHAKE
SETUP_TIMEOUT = 60.0
# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA
# (105 s) so a stalled session is gone before a retry, and long enough for lwIP
# to get a lost chunk ack through after the retransmit run seen in practice
DATA_PHASE_TIMEOUT = 160.0
# UDP data phase; wire format in ota_esphome_udp.cpp
UDP_MSG_DATA = 0x01
UDP_MSG_PING = 0x02
UDP_MSG_PROBE = 0x03
UDP_FLAG_COMMITTED = 0x80
UDP_MSG_ACK = 0x40
UDP_HEADER_SIZE = 9
UDP_ACK_HEADER_SIZE = 12
# Stream bytes per datagram, within one Ethernet frame
UDP_MAX_PAYLOAD = 1400
# Messages the device queues unconsumed (UDP_WINDOW in ota_esphome.h); as many
# as TCP's window holds, so UDP costs the device no more memory than TCP
UDP_WINDOW = 4
# Probe this long before falling back to TCP, enough to ride out a radio busy
# with BLE; the device waits 80 s
UDP_PROBE_TIMEOUT = 15.0
UDP_PROBE_INTERVAL = 0.1
# Resend timeout bounds around srtt + 4 * rttvar; random loss never grows it
UDP_RESEND_INITIAL = 0.5
UDP_RESEND_MIN = 0.2
UDP_RESEND_MAX = 2.0
# Acks still asking for the head after later messages arrived; resend it once
UDP_DUP_ACKS = 2
# Progress acks in a row with no round trip to time: the link is slower than
# the resend timer, so back it off (random loss still yields samples)
UDP_UNTIMED_ACKS = 8
# Refusals in a row with no ACK between: the device's UDP port is closed
UDP_REFUSALS = 3
UDP_FALLBACK = b"\x00"
# The UDP format this client speaks; devices decline values they do not know
UDP_REQUEST = b"\x01"
# A plain handshake exchange slower than this hid a retransmit (the device's
# lwIP waits about a second before resending); UDP is used from then on
UDP_SLOW_EXCHANGE = 0.75
# getsockopt(IPPROTO_TCP) option, size, offset and format of the retransmit
# count: tcp_info.tcpi_total_retrans (Linux), tcp_connection_info.
# tcpi_txretransmitbytes (macOS)
_TCP_RETRANS_OPTION = {"linux": (11, 104, 100, "I"), "darwin": (0x106, 80, 72, "Q")}
_LOGGER = logging.getLogger(__name__)
# Authentication method lookup table: response -> (hash_func, nonce_size, name)
@@ -511,6 +560,10 @@ class NoiseSocketWrapper:
self._decrypt = None
self._buffer = b""
def use_transport(self, transport: UdpChannel) -> None:
"""Continue the session over another byte stream."""
self._sock = transport
# Only harmless socket controls pass through; byte-moving methods are
# deliberately absent so plaintext cannot leak past the transport.
def settimeout(self, timeout: float | None) -> None:
@@ -613,19 +666,316 @@ class NoiseSocketWrapper:
return data
class _UdpMessage:
"""One datagram of the UDP stream and its send history."""
__slots__ = ("held", "last_send", "payload", "resend", "sends", "seq")
def __init__(self, seq: int, payload: bytes) -> None:
self.seq = seq
self.payload = payload
self.last_send = 0.0
self.sends = 0
self.resend = False # duplicate acks asked for it
self.held = False # the device keeps it, waiting for an earlier one
class UdpChannel:
"""Byte stream over numbered UDP datagrams, standing in for the socket.
The device acks every datagram on arrival and says how many it has room
for; each is resent after TCP's srtt + 4 * rttvar, so loss slows the
transfer instead of ending it. It backs off only while acks come back
untimed, i.e. the link is slower than the timer, never on random loss.
"""
def __init__(self, sock: socket.socket, token: bytes) -> None:
self._sock = sock
self._token = token
self._timeout = DATA_PHASE_TIMEOUT
self._next_seq = 0
self._queue: list[_UdpMessage] = [] # not yet received, oldest first
self._rx = bytearray()
self._device_bytes = 0
self._committed = False
self._srtt: float | None = None
self._rttvar = 0.0
# Doubles while acks come back untimed, so a link slower than the
# timer still yields messages sent once to time (Karn, RFC 6298)
self._backoff = 1
self._untimed = 0
self._dup_acks = 0
self._free = UDP_WINDOW # the device's empty slots, as of its last ACK
self._last_ping = 0.0
self._last_progress = time.monotonic()
self._sends = 0
# Last socket error, e.g. refused once the device's socket is gone
self._last_error: OSError | None = None
self._refusals = 0
def settimeout(self, timeout: float) -> None:
self._timeout = timeout
def setsockopt(self, level: int, optname: int, value: int) -> None:
"""TCP tuning does not apply."""
def close(self) -> None:
_LOGGER.debug(
"UDP: %d messages in %d datagrams, smoothed RTT %.0f ms",
self._next_seq,
self._sends,
(self._srtt or 0) * 1000,
)
self._sock.close()
def probe(self) -> bool:
"""True once the device answers over UDP within UDP_PROBE_TIMEOUT."""
deadline = time.monotonic() + UDP_PROBE_TIMEOUT
try:
while (now := time.monotonic()) < deadline:
self._send(UDP_MSG_PROBE, 0, b"")
self._receive(min(UDP_PROBE_INTERVAL, deadline - now))
if self._committed:
return True
except OTANetworkError:
pass # rejected, e.g. by a firewall
return False
def sendall(self, data: bytes) -> None:
for offset in range(0, len(data), UDP_MAX_PAYLOAD):
self._queue.append(
_UdpMessage(self._next_seq, data[offset : offset + UDP_MAX_PAYLOAD])
)
self._next_seq = (self._next_seq + 1) & 0xFFFF
# Return once everything is at least in flight, or the device spoke (an error)
while len(self._queue) > UDP_WINDOW and not self._rx:
self._pump()
def pending(self) -> bool:
"""Whether the device sent bytes nobody has read yet."""
return bool(self._rx)
def recv(self, amount: int) -> bytes:
while not self._rx:
self._pump()
data = bytes(self._rx[:amount])
del self._rx[:amount]
return data
def flush(self, timeout: float) -> None:
"""Best effort: wait until the device has every message."""
deadline = time.monotonic() + timeout
with contextlib.suppress(OTANetworkError):
while self._queue and time.monotonic() < deadline:
self._pump()
def _rto(self) -> float:
if self._srtt is None:
rto = UDP_RESEND_INITIAL
else:
rto = max(self._srtt + 4 * self._rttvar, UDP_RESEND_MIN)
return min(rto * self._backoff, UDP_RESEND_MAX)
def _send(self, msg_type: int, seq: int, payload: bytes) -> None:
flags = UDP_FLAG_COMMITTED if self._committed else 0
header = (
bytes([msg_type | flags])
+ self._token
+ seq.to_bytes(2, "big")
+ (self._device_bytes & 0xFFFF).to_bytes(2, "big")
)
self._sends += 1
try:
self._sock.send(header + payload)
except OSError as err:
# A full buffer or ICMP error is just more loss, but worth reporting
self._socket_error(err)
def _pump(self) -> None:
now = time.monotonic()
if now - self._last_progress > self._timeout:
reason = f" (last error: {self._last_error})" if self._last_error else ""
raise OTANetworkError(
f"no progress over UDP for {self._timeout:.0f}s{reason}"
)
rto = self._rto()
wake = now + rto
# At least the head, which doubles as the probe while the device is busy
in_flight = self._queue[: max(self._free, 1)]
for message in in_flight:
if message.held:
continue
if message.sends == 0 or message.resend or now - message.last_send >= rto:
message.last_send = now
message.sends += 1
message.resend = False
self._send(UDP_MSG_DATA, message.seq, message.payload)
wake = min(wake, message.last_send + rto)
if all(message.held for message in in_flight):
# Nothing to send: ask for the device's responses
if now - self._last_ping >= rto:
self._last_ping = now
self._send(UDP_MSG_PING, 0, b"")
wake = self._last_ping + rto
self._receive(wake - now)
def _receive(self, wait: float) -> None:
import select
# Wait for the first datagram, then drain whatever else arrived
while select.select([self._sock], [], [], max(wait, 0))[0]:
wait = 0
try:
data = self._sock.recv(2048)
except OSError as err:
self._socket_error(err)
continue
self._handle_ack(data)
def _socket_error(self, err: OSError) -> None:
if self._last_error is None or err.errno != self._last_error.errno:
_LOGGER.debug("UDP socket error: %s", err)
self._last_error = err
# Windows reports the ICMP port unreachable as a reset
if isinstance(err, (ConnectionRefusedError, ConnectionResetError)):
self._refusals += 1
if self._refusals >= UDP_REFUSALS:
raise OTANetworkError(f"device closed its UDP port: {err}")
def _sample_rtt(self, sample: float) -> None:
# RFC 6298 smoothing
self._backoff = 1
self._untimed = 0
if self._srtt is None:
self._srtt, self._rttvar = sample, sample / 2
else:
self._rttvar = 0.75 * self._rttvar + 0.25 * abs(self._srtt - sample)
self._srtt = 0.875 * self._srtt + 0.125 * sample
def _handle_ack(self, data: bytes) -> None:
if (
len(data) < UDP_ACK_HEADER_SIZE
or data[0] != UDP_MSG_ACK
or data[1:5] != self._token
):
return
now = time.monotonic()
self._committed = True
self._refusals = 0
received = int.from_bytes(data[5:7], "big")
prompted = int.from_bytes(data[7:9], "big")
self._free = data[11] >> 4
if self._queue:
head = self._queue[0].seq
index = (prompted - head) & 0xFFFF
# This datagram's own round trip; only from messages sent once (Karn)
timed = index < len(self._queue) and self._queue[index].sends == 1
if timed:
self._sample_rtt(now - self._queue[index].last_send)
done = (received - head) & 0xFFFF
if 0 < done <= len(self._queue):
del self._queue[:done]
self._last_progress = now
self._dup_acks = 0
if not timed:
self._untimed += 1
if self._untimed >= UDP_UNTIMED_ACKS:
self._untimed = 0
if self._rto() < UDP_RESEND_MAX:
self._backoff *= 2
elif done == 0 and self._queue[0].sends:
# A later message arrived while the head is missing; resend it once
self._dup_acks += 1
if self._dup_acks == UDP_DUP_ACKS:
self._queue[0].resend = True
# Rebuilt from every ACK, so a reordered one cannot leave a stale mark
for k, message in enumerate(self._queue[:UDP_WINDOW]):
message.held = (received + k - message.seq) & 0xFFFF == 0 and bool(
data[11] & (1 << k)
)
skip = (self._device_bytes - int.from_bytes(data[9:11], "big")) & 0xFFFF
fresh = data[UDP_ACK_HEADER_SIZE + skip :]
if skip <= len(data) - UDP_ACK_HEADER_SIZE and fresh:
self._rx += fresh
self._device_bytes += len(fresh)
self._last_progress = now
def _tcp_retransmits(sock: socket.socket) -> int | None:
"""Segments this side retransmitted, where the OS reports it."""
import struct
import sys
if (option := _TCP_RETRANS_OPTION.get(sys.platform)) is None:
return None
optname, size, offset, fmt = option
try:
info = sock.getsockopt(socket.IPPROTO_TCP, optname, size)
except (OSError, TypeError):
return None
if len(info) < offset + struct.calcsize(fmt):
return None
return struct.unpack_from(fmt, info, offset)[0]
def _link_lossy(
tcp_sock: socket.socket, slowest_exchange: float, prefer_udp: bool
) -> bool:
"""Whether the handshake showed loss, so the data phase should use UDP."""
return (
prefer_udp
or slowest_exchange > UDP_SLOW_EXCHANGE
or bool(_tcp_retransmits(tcp_sock))
)
def _start_udp(
sock: socket.socket | NoiseSocketWrapper, tcp_sock: socket.socket, lossy: bool
) -> UdpChannel | None:
"""Move the data phase to UDP; None keeps it on TCP."""
# A clean link asks to stay on TCP
send_check(sock, UDP_REQUEST if lossy else UDP_FALLBACK, "UDP request")
token = receive_exactly(sock, 4, "UDP token", None, decode=False)
if not lossy:
_LOGGER.debug("Link looks clean, staying on TCP")
return None
if token == bytes(4):
_LOGGER.debug("Device declined UDP")
return None
udp_sock = socket.socket(tcp_sock.family, socket.SOCK_DGRAM)
try:
udp_sock.connect(tcp_sock.getpeername())
except OSError as err:
udp_sock.close()
_LOGGER.info("UDP unavailable (%s), using TCP", err)
else:
channel = UdpChannel(udp_sock, token)
if channel.probe():
_LOGGER.info("Using UDP")
return channel
channel.close()
_LOGGER.info("UDP blocked, using TCP")
# Raw, outside any Noise frame: forging it only forces the TCP path
send_check(tcp_sock, UDP_FALLBACK, "UDP fallback")
return None
def _negotiate_session(
sock: socket.socket,
noise_psk: str | None,
plaintext_fallback: bool = False,
allow_plaintext_upload: bool = False,
) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool]:
) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool, float]:
"""Negotiate through the Noise handshake; returns the socket to go on with
(wrapped once encrypted), version, features, extended."""
(wrapped once encrypted), version, features, extended and the slowest
plaintext exchange in seconds."""
# Enable nodelay, we need it for phase 1
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
exchange_start = time.monotonic()
send_check(sock, MAGIC_BYTES, "magic bytes")
_, version = receive_exactly(sock, 2, "version", RESPONSE_OK)
slowest_exchange = time.monotonic() - exchange_start
_LOGGER.debug("Device supports OTA version %s", version)
supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0)
if version not in supported_versions:
@@ -639,9 +989,11 @@ def _negotiate_session(
| CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| CLIENT_FEATURE_SUPPORTS_DEFLATE
| CLIENT_FEATURE_SUPPORTS_UDP
)
if noise_psk:
features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE
exchange_start = time.monotonic()
send_check(sock, features_to_send, "features")
features = receive_exactly(
sock,
@@ -649,6 +1001,7 @@ def _negotiate_session(
"features",
None, # Accept any response
)[0]
slowest_exchange = max(slowest_exchange, time.monotonic() - exchange_start)
extended_proto = False
if features == RESPONSE_FEATURE_FLAGS:
@@ -705,7 +1058,7 @@ def _negotiate_session(
_LOGGER.debug("Encrypted connection established")
if allow_plaintext_upload:
_LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING)
return sock, version, features, extended_proto
return sock, version, features, extended_proto, slowest_exchange
def perform_ota(
@@ -718,6 +1071,7 @@ def perform_ota(
plaintext_fallback: bool = False,
allow_plaintext_upload: bool = False,
on_upload_start: Callable[[], None] | None = None,
prefer_udp: bool = False,
) -> None:
# Validate up front; an out-of-range value would only surface as a
# ValueError deep inside send_check, bypassing OTAError handling
@@ -740,7 +1094,9 @@ def perform_ota(
file_size = len(file_contents)
_LOGGER.info("Uploading %s (%s bytes)", filename, file_size)
sock, version, features, extended_proto = _negotiate_session(
# sock may become a Noise wrapper or a UDP channel; UDP needs the peer
tcp_sock = sock
sock, version, features, extended_proto, slowest_exchange = _negotiate_session(
sock, noise_psk, plaintext_fallback, allow_plaintext_upload
)
_LOGGER.info("Connection established; device supports OTA version %s", version)
@@ -832,117 +1188,153 @@ def perform_ota(
if on_upload_start is not None:
on_upload_start() # the device may commit anything sent from here
udp: UdpChannel | None = None
# Still under SETUP_TIMEOUT, which the device's commit wait is sized for
if extended_proto and features & SERVER_FEATURE_SUPPORTS_UDP:
udp = _start_udp(
sock, tcp_sock, _link_lossy(tcp_sock, slowest_exchange, prefer_udp)
)
if udp is not None:
# The device drops TCP once it hears the committed channel
if isinstance(sock, NoiseSocketWrapper):
sock.use_transport(udp)
else:
sock = udp
sock.settimeout(DATA_PHASE_TIMEOUT)
if extended_proto:
send_check(sock, ota_type, "ota type")
upload_size = len(upload_contents)
# The device erases flash between receiving the size and acking the
# prepare, so this window shows the erase cost (near zero when the
# device erases lazily during the upload)
prepare_start = time.perf_counter()
send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size")
if deflate:
# Own frame: an encrypted session carries one field per frame
send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size")
receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK)
prepare_duration = time.perf_counter() - prepare_start
_LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration)
# The device hashes what it writes: the inflated image, else the received bytes
upload_md5 = hashlib.md5(file_contents if deflate else upload_contents).hexdigest()
_LOGGER.debug("MD5 of upload is %s", upload_md5)
send_check(sock, upload_md5, "file checksum")
receive_exactly(sock, 1, "file checksum result", RESPONSE_BIN_MD5_OK)
# Disable nodelay for transfer
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)
# Limit send buffer (usually around 100kB) in order to have progress bar
# show the actual progress
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_BUFFER_SIZE)
start_time = time.perf_counter()
offset = 0
progress = ProgressBar("Uploading")
try:
while True:
chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE]
if not chunk:
break
offset += len(chunk)
if extended_proto:
send_check(sock, ota_type, "ota type")
try:
sock.sendall(chunk)
except OSError as err:
# A send failure can hide an error byte the device reported
# just before dropping the connection; surface that as the
# real, non-retryable cause when it is available
try:
sock.settimeout(1.0)
check_error(recv_decode(sock, 1), None)
except (OSError, OTANetworkError) as probe_err:
_LOGGER.debug(
"No device error behind the send failure: %s", probe_err
)
raise OTANetworkError(f"sending data: {err}") from err
upload_size = len(upload_contents)
# The device erases flash between receiving the size and acking the
# prepare, so this window shows the erase cost (near zero when the
# device erases lazily during the upload)
prepare_start = time.perf_counter()
send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size")
if deflate:
# Own frame: an encrypted session carries one field per frame
send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size")
receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK)
prepare_duration = time.perf_counter() - prepare_start
_LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration)
# The device hashes what it writes: the inflated image, else the received bytes
upload_md5 = hashlib.md5(
file_contents if deflate else upload_contents
).hexdigest()
_LOGGER.debug("MD5 of upload is %s", upload_md5)
send_check(sock, upload_md5, "file checksum")
receive_exactly(sock, 1, "file checksum result", RESPONSE_BIN_MD5_OK)
# Disable nodelay for transfer
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)
# Limit send buffer (usually around 100kB) in order to have progress bar
# show the actual progress
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_BUFFER_SIZE)
start_time = time.perf_counter()
offset = 0
progress = ProgressBar("Uploading")
try:
while True:
chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE]
if not chunk:
break
offset += len(chunk)
if version >= OTA_VERSION_2_0:
try:
receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK)
sock.sendall(chunk)
except OSError as err:
# A send failure can hide an error byte the device reported
# just before dropping the connection; surface that as the
# real, non-retryable cause when it is available
try:
sock.settimeout(1.0)
check_error(recv_decode(sock, 1), None)
except (OSError, OTANetworkError) as probe_err:
_LOGGER.debug(
"No device error behind the send failure: %s", probe_err
)
raise OTANetworkError(f"sending data: {err}") from err
except OTANetworkError as err:
if udp is None:
raise
# Over UDP: surface a device error code if one arrived, and
# treat a failure on the last chunk like a lost final ack
if udp.pending():
receive_exactly(sock, 1, "data", [])
if offset < upload_size:
raise
# The device already had the complete image when this ack
# was lost, so it may be committing; do not retry
raise _committed_error(err) from err
progress.update(offset / upload_size)
except OTAError:
# Terminate the progress bar line before the error is logged
# The UDP channel acks every message itself, so anything the device
# sends mid upload is an error code
if udp is not None and udp.pending() and offset < upload_size:
receive_exactly(sock, 1, "data", [])
if version >= OTA_VERSION_2_0 and udp is None:
try:
receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK)
except OTANetworkError as err:
if offset < upload_size:
raise
# The device already had the complete image when this ack
# was lost, so it may be committing; do not retry
raise _committed_error(err) from err
progress.update(offset / upload_size)
except OTAError:
# Terminate the progress bar line before the error is logged
progress.done()
raise
progress.done()
raise
progress.done()
# Enable nodelay for last checks
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
duration = time.perf_counter() - start_time
# Enable nodelay for last checks
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
duration = time.perf_counter() - start_time
_LOGGER.info("Upload took %.2f seconds, waiting for result...", duration)
_LOGGER.info("Upload took %.2f seconds, waiting for result...", duration)
# Once the device has the complete image it commits the update and
# reboots on its own; the exact commit point is not observable from
# here, so treat everything past the data phase as non-retryable. A
# re-upload could flash a device that already updated successfully.
commit_start = time.perf_counter()
try:
receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK)
receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK)
except OTANetworkError as err:
raise _committed_error(err) from err
commit_duration = time.perf_counter() - commit_start
# Once the device has the complete image it commits the update and
# reboots on its own; the exact commit point is not observable from
# here, so treat everything past the data phase as non-retryable. A
# re-upload could flash a device that already updated successfully.
commit_start = time.perf_counter()
try:
receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK)
receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK)
except OTANetworkError as err:
raise _committed_error(err) from err
commit_duration = time.perf_counter() - commit_start
# Sum of the named windows so the breakdown is self consistent; connect,
# handshake, auth, and the one MD5 round trip are not included
_LOGGER.info(
"Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)",
prepare_duration + duration + commit_duration,
prepare_duration,
duration,
commit_duration,
)
# Sum of the named windows so the breakdown is self consistent; connect,
# handshake, auth, and the one MD5 round trip are not included
_LOGGER.info(
"Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)",
prepare_duration + duration + commit_duration,
prepare_duration,
duration,
commit_duration,
)
try:
send_check(sock, RESPONSE_OK, "end acknowledgement")
except OTANetworkError as err:
# The device treats a missing end acknowledgement as non-fatal and is
# already rebooting into the new firmware, so the update succeeded
_LOGGER.warning("Failed sending end acknowledgement: %s", err)
_LOGGER.info("OTA successful (end acknowledgement not delivered)")
else:
_LOGGER.info("OTA successful")
try:
send_check(sock, RESPONSE_OK, "end acknowledgement")
except OTANetworkError as err:
# The device treats a missing end acknowledgement as non-fatal and is
# already rebooting into the new firmware, so the update succeeded
_LOGGER.warning("Failed sending end acknowledgement: %s", err)
_LOGGER.info("OTA successful (end acknowledgement not delivered)")
else:
_LOGGER.info("OTA successful")
if udp is not None:
# Our end acknowledgement also tells the device we have its
# responses, ending its wait before it reboots
udp.flush(2.0)
finally:
if udp is not None:
udp.close()
# Do not connect logs until it is fully on
time.sleep(1)
@@ -1121,7 +1513,7 @@ def run_ota_impl_(
# are shared across the addresses, cycling through them. Wait before an
# attempt when the previous one actually reached the device, or when
# revisiting an address, so a flaky link can recover and the device can
# clean up a half-open connection (its handshake watchdog runs at 20s);
# clean up a half-open connection (its handshake watchdog runs at 60s);
# moving on to the next address family stays immediate. Known limitation:
# a silent mid-transfer drop with no reset can wedge the device until its
# 105s data timeout, which outlasts this budget; the retries target the
@@ -1139,6 +1531,8 @@ def run_ota_impl_(
on_upload_start()
on_upload_start = None # once per upload, whatever the retries do
# A network failure means a lossy link, so later attempts send data over UDP
prefer_udp = False
while attempt < total_attempts:
af, socktype, _, _, sa = res[attempt % len(res)]
if not reconnect and (reached_device or attempt >= len(res)):
@@ -1153,7 +1547,7 @@ def run_ota_impl_(
reconnect = False
_LOGGER.info("Connecting to %s port %s...", sa[0], sa[1])
sock = socket.socket(af, socktype)
sock.settimeout(20.0)
sock.settimeout(CONNECT_TIMEOUT)
try:
sock.connect(sa)
except OSError as err:
@@ -1164,7 +1558,9 @@ def run_ota_impl_(
continue
_LOGGER.info("Connected to %s", sa[0])
sock.settimeout(SETUP_TIMEOUT)
reached_device = True
total_attempts = max(total_attempts, len(res) + EXTRA_UPLOAD_ATTEMPTS_REACHED)
with contextlib.closing(sock), Path(filename).open("rb") as file_handle:
try:
perform_ota(
@@ -1177,6 +1573,7 @@ def run_ota_impl_(
encryption.plaintext_fallback,
allow_plaintext_upload=allow_plaintext_upload,
on_upload_start=upload_started,
prefer_udp=prefer_udp,
)
except OTAKeyRejected as err:
# The device sent a reject and closed on its own, so reconnect
@@ -1196,6 +1593,7 @@ def run_ota_impl_(
raise
except OTAHandshakeNetworkError as err:
last_error = str(err)
prefer_udp = True
if encryption.handshake_fault_falls_back():
encryption.downgrade(last_error)
continue
@@ -1205,6 +1603,7 @@ def run_ota_impl_(
except OTANetworkError as err:
# Transient network failure; retry
last_error = str(err)
prefer_udp = True
_LOGGER.warning("%s", last_error)
attempt += 1
continue
+4
View File
@@ -85,6 +85,10 @@ dependencies:
version: "1.1.0"
rules:
- if: "idf_version >=6.0.0"
espressif/ksz8851snl:
version: "1.2.0"
rules:
- if: "idf_version >=6.0.0"
# Keep in sync with esphome/components/ethernet/__init__.py
espressif/ch390:
version: "0.3.0"
+43 -18
View File
@@ -536,16 +536,19 @@ def create_field_type_info(
f"track_presence on field '{field.name}' has no effect; it requires "
"a non-repeated message field in a message that is decoded"
)
if get_field_opt(field, pb.progmem, False) and (
field.label == FieldDescriptorProto.LABEL_REPEATED
or field.type != 12
or not get_field_opt(field, pb.pointer_to_buffer, False)
or get_field_opt(field, pb.fixed_array_size) is not None
):
raise ValueError(
f"progmem on field '{field.name}' requires a non-repeated bytes field "
"with pointer_to_buffer"
if get_field_opt(field, pb.progmem, False):
is_pointer_bytes = (
field.type == FieldDescriptorProto.TYPE_BYTES
and get_field_opt(field, pb.pointer_to_buffer, False)
and get_field_opt(field, pb.fixed_array_size) is None
)
if field.label == FieldDescriptorProto.LABEL_REPEATED or not (
field.type == FieldDescriptorProto.TYPE_STRING or is_pointer_bytes
):
raise ValueError(
f"progmem on field '{field.name}' requires a non-repeated string field, "
"or a bytes field with pointer_to_buffer"
)
if field.label == FieldDescriptorProto.LABEL_REPEATED:
# Check if this is a packed_buffer field (zero-copy packed repeated)
if get_field_opt(field, pb.packed_buffer, False):
@@ -1155,6 +1158,11 @@ class PointerToBufferTypeBase(TypeInfo):
"""Get the wire type for this field."""
return WireType.LENGTH_DELIMITED # Uses wire type 2
@property
def progmem(self) -> bool:
"""Whether the data is in flash, so encode and dump copy it with progmem_memcpy."""
return get_field_opt(self._field, pb.progmem, False)
def get_estimated_size(self) -> int:
# field ID + length varint + typical data (assume small for pointer fields)
return self.calculate_field_id_size() + 2 + 16
@@ -1168,11 +1176,6 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
reference_type = "const uint8_t*"
const_reference_type = "const uint8_t*"
@property
def progmem(self) -> bool:
"""Whether the data is in flash, so encode and dump copy it with progmem_memcpy."""
return get_field_opt(self._field, pb.progmem, False)
@property
def public_content(self) -> list[str]:
# Use uint16_t for length - max packet size is well below 65535
@@ -1252,6 +1255,8 @@ class PointerToStringBufferType(PointerToBufferTypeBase):
@property
def public_content(self) -> list[str]:
if self.progmem:
return [f"ProgmemStringRef {self.field_name}{{}};"]
if self._starts_null:
return [
f"StringRef {self.field_name}{{nullptr, 0}}; // null until set, encode only"
@@ -1267,9 +1272,19 @@ class PointerToStringBufferType(PointerToBufferTypeBase):
)
tag = self.calculate_tag()
if tag < 128:
return _encode_call(
"encode_short_string_force", str(tag), f"this->{self.field_name}"
encode_fn = (
"encode_short_progmem_string_force"
if self.progmem
else "encode_short_string_force"
)
return _encode_call(encode_fn, str(tag), f"this->{self.field_name}")
if self.progmem:
return _encode_call(
"encode_progmem_string",
str(self.number),
f"this->{self.field_name}",
force=self.force,
)
if result := self._encode_bytes_with_precomputed_tag(
f"this->{self.field_name}.c_str()",
f"this->{self.field_name}.size()",
@@ -1297,6 +1312,8 @@ class PointerToStringBufferType(PointerToBufferTypeBase):
@property
def dump_content(self) -> str:
if self.progmem:
return f'dump_progmem_string_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});'
return f'dump_field(out, ESPHOME_PSTR("{self.name}"), this->{self.field_name});'
def get_size_calculation(self, name: str, force: bool = False) -> str:
@@ -3246,8 +3263,9 @@ namespace esphome::api {
#ifdef USE_ESP8266
// Out-of-line to avoid inlining strlen_P/memcpy_P at every call site
void DumpBuffer::append_p_esp8266(const char *str) {
size_t len = strlen_P(str);
void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); }
void DumpBuffer::append_p_esp8266(const char *str, size_t len) {
size_t space = CAPACITY - 1 - pos_;
if (len > space)
len = space;
@@ -3368,6 +3386,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co
progmem_memcpy(data_buf, data, len);
dump_bytes_field(out, field_name, data_buf, len, indent);
}
// Helper for string fields in flash
static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value,
int indent = 2) {
append_field_prefix(out, field_name, indent);
out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\\n");
}
#pragma GCC diagnostic pop
"""
@@ -8,18 +8,13 @@ import pytest
from esphome.components.api import (
_action_strings,
_action_strings_size,
_has_action_metadata,
_validate_esp8266_action_strings,
validate_variable,
)
from esphome.config_validation import Invalid
from esphome.const import PlatformFramework
from esphome.core import CORE
from esphome.cpp_generator import safe_exp
from esphome.helpers import fnv1_hash
from tests.component_tests.helpers import get_define_value
from tests.component_tests.types import SetCoreConfigCallable
CONFIG = "tests/component_tests/api/test_action_metadata.yaml"
CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml"
@@ -45,7 +40,6 @@ def test_metadata_is_emitted_as_progmem_table(
)
assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp
assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines}
assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None
def test_esp8266_strings_are_shared_progmem_arrays(
@@ -69,15 +63,13 @@ def test_esp8266_strings_are_shared_progmem_arrays(
)
def test_esp8266_sizes_scratch_buffer_for_largest_action(
def test_esp8266_encodes_action_strings_from_flash(
generate_main: Callable[[str | Path], str],
) -> None:
"""ESP8266 gets a scratch buffer define equal to the byte total of the largest action."""
generate_main(CONFIG_ESP8266)
"""ESP8266 uses the same flash string table."""
main_cpp = generate_main(CONFIG_ESP8266)
# play_buzzer: name, description, two variable names, one description, one example,
# each with a terminator
assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") == "117"
assert "api_action0_strings[] PROGMEM" in main_cpp
def test_shorthand_variables_emit_no_metadata(
@@ -113,36 +105,8 @@ def test_variable_rejects_invalid(value: object) -> None:
validate_variable(value)
def _oversized_action_config() -> dict:
return {
"actions": [
{
"action": "big",
"description": "x" * 300,
"variables": {"a": {"type": "string", "example": "y" * 300}},
}
]
}
def test_esp8266_rejects_actions_over_string_budget(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP8266_ARDUINO)
with pytest.raises(Invalid, match="ESP8266 allows at most 384 bytes"):
_validate_esp8266_action_strings(_oversized_action_config())
def test_other_platforms_have_no_string_budget(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = _oversized_action_config()
assert _validate_esp8266_action_strings(config) is config
def test_empty_metadata_is_unset_and_not_counted() -> None:
"""An empty description or example emits nullptr and takes no scratch space."""
def test_empty_metadata_is_unset() -> None:
"""An empty description or example emits nullptr."""
conf = {
"action": "a",
"description": "",
@@ -150,8 +114,6 @@ def test_empty_metadata_is_unset_and_not_counted() -> None:
}
strings = _action_strings(conf, has_metadata=True)
assert strings == ["a", None, "b", None, "ex"]
# Every emitted string counts its terminator: "a" + "b" + "ex"
assert _action_strings_size(strings) == 2 + 2 + 3
def test_empty_metadata_does_not_enable_the_define() -> None:
@@ -93,12 +93,12 @@ def test_transform_matches_model_support(
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
# ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays
# The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays
# never support axis swapping).
model = MODELS["ESP32-8048S070"]
model = MODELS["ST7701S"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}})
# An unsupported axis may be explicitly disabled (a harmless no-op)...
@@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x(
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}})
def test_pure_rgb_model_has_no_hardware_transform(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A model with no init sequence has no command interface, so no transform
can be applied in hardware and every rotation must be done in software.
Previously such models claimed mirror_x/mirror_y support, so a rotation of
180 was handed to the (nonexistent) hardware and the display was not rotated.
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
model = MODELS["ESP32-8048S070"]
assert model.initsequence == ()
assert model.transforms == set()
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
for rotation in (90, 180, 270):
config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation})
assert not model.rotation_as_transform(config)
metadata = get_display_metadata(config["id"])
assert metadata.rotation == rotation
assert not metadata.has_hardware_rotation
with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"):
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}})
def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -0,0 +1,30 @@
#include <gtest/gtest.h>
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
namespace esphome::core::testing {
TEST(ProgmemStringRef, EqualsComparesLengthAndContent) {
ProgmemStringRef ref("Kitchen", 7);
EXPECT_TRUE(ref.equals(StringRef("Kitchen")));
EXPECT_FALSE(ref.equals(StringRef("Kitche")));
EXPECT_FALSE(ref.equals(StringRef("Kitchens")));
EXPECT_FALSE(ref.equals(StringRef("Bedroom")));
}
TEST(ProgmemStringRef, FromLitTakesTheLengthAtCompileTime) {
static const char NAME[] PROGMEM = "entity_id";
constexpr ProgmemStringRef ref = ProgmemStringRef::from_lit(NAME);
static_assert(ref.size() == 9);
EXPECT_TRUE(ref.equals(StringRef("entity_id")));
}
TEST(ProgmemStringRef, DefaultIsEmpty) {
ProgmemStringRef ref;
EXPECT_TRUE(ref.empty());
EXPECT_EQ(ref.size(), 0u);
EXPECT_TRUE(ref.equals(StringRef("")));
}
} // namespace esphome::core::testing
@@ -1,35 +0,0 @@
#include <gtest/gtest.h>
#include <cstring>
#include "esphome/core/progmem.h"
namespace esphome::testing {
PROGMEM_STRING_TABLE(Names, "light.turn_on", "entity_id", "rgb_color");
TEST(ProgmemStringTableCopy, HandsOutEveryString) {
ProgmemStringTableCopy<Names> strings;
EXPECT_STREQ(strings.get(0), "light.turn_on");
EXPECT_STREQ(strings.get(1), "entity_id");
EXPECT_STREQ(strings.get(2), "rgb_color");
}
TEST(ProgmemStringTableCopy, StringsStayValidWhileTheCopyLives) {
ProgmemStringTableCopy<Names> strings;
const char *key = strings.get(1);
const char *other = strings.get(2);
EXPECT_EQ(std::strlen(key), 9U);
EXPECT_STREQ(key, "entity_id");
EXPECT_STREQ(other, "rgb_color");
}
TEST(ProgmemStringTableFind, FindsWholeStringsOnly) {
EXPECT_EQ(progmem_string_table_find<Names>("entity_id", 9), 1);
EXPECT_EQ(progmem_string_table_find<Names>("rgb_color", 9), 2);
EXPECT_EQ(progmem_string_table_find<Names>("entity", 6), -1);
EXPECT_EQ(progmem_string_table_find<Names>("entity_id_x", 11), -1);
EXPECT_EQ(progmem_string_table_find<Names>("", 0), -1);
}
} // namespace esphome::testing
@@ -121,7 +121,7 @@ struct Rig {
DaikinClimate climate;
Rig() {
// All instances share one preference key; clear it so setup starts from defaults
// A second fixture in the same test would otherwise restore what the first saved
if (global_preferences != nullptr)
global_preferences->reset();
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
+1 -1
View File
@@ -143,7 +143,7 @@ struct Fixture {
/// Pass keep_preferences to restore what an earlier fixture saved
explicit Fixture(bool keep_preferences = false) {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
// A second fixture in the same test would otherwise restore what the first saved
if (!keep_preferences && global_preferences != nullptr)
global_preferences->reset();
// The test main does not construct App as generated code does; the boost timer needs its scheduler
@@ -8,7 +8,6 @@
#include <string>
#include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h"
#include "esphome/core/preferences.h"
#ifdef USE_HOST
#include "esphome/components/host/preferences.h"
#endif
@@ -33,11 +32,9 @@ class ExponentialMovingAverageTest : public ::testing::Test {
#ifdef USE_HOST
host::setup_preferences();
#endif
global_preferences->reset();
}
void TearDown() override {
global_preferences->reset();
if (this->saved_prefdir_.has_value()) {
setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1);
} else {
@@ -4,7 +4,6 @@
#include "esphome/components/friedrich/friedrich.h"
#include "esphome/components/remote_base/aeha_protocol.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
namespace esphome::friedrich::testing {
@@ -42,12 +41,7 @@ struct Fixture {
TestFriedrich sut;
CapturingTransmitter transmitter;
Fixture() {
// Climate tests share one preference key, so clear what an earlier test saved or setup restores it
if (global_preferences != nullptr)
global_preferences->reset();
this->sut.set_transmitter(&this->transmitter);
}
Fixture() { this->sut.set_transmitter(&this->transmitter); }
// The last transmission decoded, or an empty frame if it could not be decoded
AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); }
+18
View File
@@ -0,0 +1,18 @@
hdmi_cec:
id: cec
pin: ${cec_pin} # HDMI pin 13, with HDMI pin 17 as ground
address: 0x5
promiscuous_mode: true
on_message:
- opcode: 0x8F # Give Device Power Status
then:
- hdmi_cec.send:
destination: !lambda return source;
data: [0x90, 0x01] # Report Power Status: standby
- opcode: 0x8C # Give Device Vendor ID
then:
- lambda: |-
id(cec).send(0xf, {0x87, 0x00, 0x09, 0xb0});
- data: [0x44, 0x41] # User Control Pressed: Volume Up
then:
- logger.log: "HDMI-CEC Volume Up"
@@ -0,0 +1,5 @@
substitutions:
cec_pin: P6
packages:
hdmi_cec: !include common.yaml
@@ -0,0 +1,5 @@
substitutions:
cec_pin: GPIO4
packages:
hdmi_cec: !include common.yaml
@@ -0,0 +1,5 @@
substitutions:
cec_pin: GPIO14
packages:
hdmi_cec: !include common.yaml
@@ -0,0 +1,5 @@
substitutions:
cec_pin: GPIO14
packages:
hdmi_cec: !include common.yaml
+7 -1
View File
@@ -2,6 +2,7 @@
#include "esphome/components/logger/logger.h"
#include "esphome/core/application.h"
#include "esphome/core/preferences.h"
/*
This special main.cpp replaces the default one.
@@ -22,7 +23,12 @@ void original_setup() {
// The loop clock and current component one test moves must not leak into the next, like CORE.reset() in Python
class ResetAppListener : public ::testing::EmptyTestEventListener {
void OnTestStart(const ::testing::TestInfo &) override { esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; }
void OnTestStart(const ::testing::TestInfo &) override {
esphome::LoopBlockingGuard reset{nullptr, nullptr, 0};
// Entities of the same kind share preference keys, so a value an earlier test saved would be restored
if (esphome::global_preferences != nullptr)
esphome::global_preferences->reset();
}
};
void setup() {
@@ -81,7 +81,7 @@ struct Fixture {
MistralIR climate;
Fixture() {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
// A second fixture in the same test would otherwise restore what the first saved
if (global_preferences != nullptr)
global_preferences->reset();
// The test main does not construct App as generated code does
@@ -69,7 +69,7 @@ struct Fixture {
PDPioneerIR climate;
Fixture() {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
// A second fixture in the same test would otherwise restore what the first saved
if (global_preferences != nullptr)
global_preferences->reset();
// The test main does not construct App as generated code does
@@ -7,7 +7,6 @@
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/components/whirlpool/whirlpool.h"
#include "esphome/core/preferences.h"
namespace esphome::whirlpool::testing {
@@ -122,9 +121,6 @@ struct Fixture {
WhirlpoolClimate climate;
explicit Fixture(Model model) {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
if (global_preferences != nullptr)
global_preferences->reset();
this->climate.set_model(model);
this->climate.set_transmitter(&this->transmitter);
this->climate.setup();
+83 -11
View File
@@ -37,6 +37,59 @@ DEVICE_NAME = "host-ota-test"
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
_REAL_COMPRESS = zlib.compress
def _corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes:
"""Reserved block type in the first header: rejected by the decoder on
every build, unlike a flipped data bit that may only fail the MD5."""
out = bytearray(_REAL_COMPRESS(data, *args, **kwargs))
out[0] |= 0x06
return bytes(out)
@contextmanager
def _udp_mode(
monkeypatch: pytest.MonkeyPatch,
force: bool = True,
loss: float = 0.0,
blocked: bool = False,
) -> Generator[list[bool]]:
"""Force the UDP data phase (unless force is False) and drop datagrams both
ways; yields one entry per upload, True when it used UDP."""
import random
rng = random.Random(1)
used: list[bool] = []
real_start = espota2._start_udp
real_send = espota2.UdpChannel._send
real_ack = espota2.UdpChannel._handle_ack
def start(*args: object) -> espota2.UdpChannel | None:
channel = real_start(*args)
used.append(channel is not None)
return channel
def send(self: espota2.UdpChannel, *args: object) -> None:
if not blocked and rng.random() >= loss:
real_send(self, *args)
def ack(self: espota2.UdpChannel, data: bytes) -> None:
if rng.random() >= loss:
real_ack(self, data)
with monkeypatch.context() as m:
if force:
m.setattr(espota2, "UDP_SLOW_EXCHANGE", -1.0)
if blocked:
# Nothing answers, so do not wait the full probe
m.setattr(espota2, "UDP_PROBE_TIMEOUT", 1.0)
m.setattr(espota2, "_start_udp", start)
m.setattr(espota2.UdpChannel, "_send", send)
m.setattr(espota2.UdpChannel, "_handle_ack", ack)
yield used
@contextmanager
def _reserve_port() -> Generator[tuple[int, socket.socket]]:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -272,18 +325,9 @@ async def test_host_ota_deflate(
errors.append(line)
dev.on_log(line)
real_compress = zlib.compress
def corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes:
# Reserved block type in the first header: rejected by the decoder on
# every build, unlike a flipped data bit that may only fail the MD5
out = bytearray(real_compress(data, *args, **kwargs))
out[0] |= 0x06
return bytes(out)
def overlong_compress(data: bytes, *args: object, **kwargs: object) -> bytes:
"""A stream that inflates past the size the client announced."""
return real_compress(data + bytes(8192), *args, **kwargs)
return _REAL_COMPRESS(data + bytes(8192), *args, **kwargs)
async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines):
dev.proc = proc
@@ -301,7 +345,7 @@ async def test_host_ota_deflate(
# A corrupt stream fails the upload and leaves the device running
with monkeypatch.context() as m:
m.setattr(zlib, "compress", corrupt_compress)
m.setattr(zlib, "compress", _corrupt_compress)
await dev.refused_ota(None, None, "corrupt deflate stream was accepted")
assert errors, "device did not report the corrupt stream"
@@ -318,6 +362,29 @@ async def test_host_ota_deflate(
await dev.ota(None, None, "upload after a rejected stream failed")
assert dev.inflates == 2
# Localhost is clean, so the client stays on TCP
with _udp_mode(monkeypatch, force=False) as used:
await dev.ota(None, None, "TCP upload failed")
assert used == [False], "a clean link asked for UDP"
# A lossy link moves the data phase to UDP and completes through loss
with _udp_mode(monkeypatch, loss=0.2) as used:
await dev.ota(None, None, "UDP upload with loss failed")
assert used == [True], "the upload did not use UDP"
assert dev.inflates == 4
# Blocked UDP falls back to TCP
with _udp_mode(monkeypatch, blocked=True) as used:
await dev.ota(None, None, "fallback to TCP failed")
assert used == [False], "blocked UDP did not fall back to TCP"
# A device error mid upload surfaces at once, not as a 160 s timeout
with _udp_mode(monkeypatch) as used, monkeypatch.context() as m:
m.setattr(zlib, "compress", _corrupt_compress)
async with asyncio.timeout(30):
await dev.refused_ota(None, None, "corrupt stream over UDP accepted")
assert used == [True]
@pytest.mark.asyncio
async def test_host_ota_encrypted(
@@ -325,6 +392,7 @@ async def test_host_ota_encrypted(
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A client that leaves right after the handshake, as a key probe does,
is a clean close, not an OTA error; a plaintext upload is refused; an
@@ -348,6 +416,10 @@ async def test_host_ota_encrypted(
None, None, "plaintext upload to an encrypted device must fail"
)
await dev.ota(None, API_KEY, "encrypted OTA reported failure")
# Noise frames ride the UDP stream unchanged, through loss
with _udp_mode(monkeypatch, loss=0.2) as used:
await dev.ota(None, API_KEY, "encrypted UDP upload failed")
assert used == [True]
@pytest.mark.asyncio
@@ -408,8 +408,39 @@ def test_progmem_rejected_in_a_decoded_message() -> None:
def test_progmem_rejected_on_non_bytes_field() -> None:
"""(progmem) only applies to bytes fields."""
field = _field(9)
"""(progmem) only applies to string and bytes fields."""
field = _field(13)
field.options.Extensions[pb.progmem] = True
with pytest.raises(ValueError, match="progmem on field 'value'"):
create_field_type_info(field, needs_decode=False)
@pytest.mark.parametrize(
("force", "max_len", "encode_fn"),
[
(True, 120, "encode_short_progmem_string_force("),
(False, None, "encode_progmem_string("),
(True, None, "encode_progmem_string_force("),
],
)
def test_progmem_string_field_copies_from_flash(
force: bool, max_len: int | None, encode_fn: str
) -> None:
"""A (progmem) string field is a ProgmemStringRef that encodes and dumps through the progmem helpers."""
field = _field(9, force=force)
field.options.Extensions[pb.progmem] = True
if max_len is not None:
field.options.Extensions[pb.max_data_length] = max_len
ti = create_field_type_info(field, needs_decode=False)
assert "ProgmemStringRef value{};" in ti.public_content
assert encode_fn in ti.encode_content
assert "encode_string" not in ti.encode_content
assert "dump_progmem_string_field(" in ti.dump_content
def test_progmem_rejected_on_repeated_string() -> None:
"""(progmem) on a repeated string field fails instead of silently using memcpy."""
field = _field(9, repeated=True)
field.options.Extensions[pb.progmem] = True
with pytest.raises(ValueError, match="progmem on field 'value'"):
create_field_type_info(field, needs_decode=False)
+486 -8
View File
@@ -11,6 +11,7 @@ import logging
from pathlib import Path
import socket
import struct
from typing import Self
from unittest.mock import Mock, call, patch
import zlib
@@ -357,6 +358,7 @@ def test_perform_ota_successful_md5_auth(
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
| espota2.CLIENT_FEATURE_SUPPORTS_UDP
]
)
)
@@ -765,7 +767,7 @@ def test_run_ota_impl_successful(
assert result_host == "192.168.1.100"
# Verify socket was configured correctly
mock_socket.settimeout.assert_called_with(20.0)
mock_socket.settimeout.assert_called_with(espota2.SETUP_TIMEOUT)
mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232))
mock_socket.close.assert_called_once()
@@ -853,13 +855,22 @@ def test_run_ota_impl_network_error_retry_succeeds(
assert result_host == "192.168.1.100"
assert mock_perform_ota.call_count == 2
mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY)
# The failure marks the link lossy, so the retry moves the data to UDP
assert [c.kwargs["prefer_udp"] for c in mock_perform_ota.call_args_list] == [
False,
True,
]
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
def test_run_ota_impl_network_error_exhausts_attempts(
mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
) -> None:
"""Test run_ota_impl_ gives up after all attempts hit network errors."""
"""Test run_ota_impl_ gives up after all attempts hit network errors.
Every attempt connected, so the budget is the longer one for a reachable
device on a lossy link.
"""
mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe")
result_code, result_host = espota2.run_ota_impl_(
@@ -868,8 +879,8 @@ def test_run_ota_impl_network_error_exhausts_attempts(
assert result_code == 1
assert result_host is None
assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS_REACHED + 1
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS_REACHED
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
@@ -890,12 +901,10 @@ def test_run_ota_impl_multiple_addresses_cycle(
assert mock_socket.connect.call_args_list == [
call(DUAL_STACK_SA6),
call(DUAL_STACK_SA4),
call(DUAL_STACK_SA6),
call(DUAL_STACK_SA4),
]
] * (1 + espota2.EXTRA_UPLOAD_ATTEMPTS // 2)
# No connect ever reached the device, so the delay only applies before
# the revisits
assert mock_sleep.call_count == 2
assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual")
@@ -1079,6 +1088,7 @@ def test_perform_ota_successful_sha256_auth(
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
| espota2.CLIENT_FEATURE_SUPPORTS_UDP
]
)
)
@@ -1136,6 +1146,7 @@ def test_perform_ota_sha256_fallback_to_md5(
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
| espota2.CLIENT_FEATURE_SUPPORTS_UDP
]
)
)
@@ -1246,6 +1257,7 @@ def test_perform_ota_extended_protocol_app(
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
| espota2.CLIENT_FEATURE_SUPPORTS_UDP
]
)
)
@@ -1307,6 +1319,7 @@ def test_perform_ota_successful_partition_table(
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
| espota2.CLIENT_FEATURE_SUPPORTS_UDP
]
)
)
@@ -1572,3 +1585,468 @@ def test_perform_ota_with_deflate(mock_socket: Mock, server_features: int) -> No
assert len(payload) == sent_size < len(original_content)
assert zlib.decompress(payload, -espota2.DEFLATE_WINDOW_BITS) == original_content
assert sent[5] == hashlib.md5(original_content).hexdigest().encode()
class _LossyUdpDevice:
"""The device side of the UDP channel (ota_esphome_udp.cpp) on a lossy link."""
def __init__(self, token: bytes, loss: float, seed: int = 1) -> None:
import random
import threading
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(("127.0.0.1", 0))
self.sock.settimeout(0.05)
self.token = token
self.loss = loss
self.random = random.Random(seed)
self.next_seq = 0
self.slots: dict[int, bytes] = {} # early messages within the window
self.received: list[bytes] = []
self.out = bytearray()
self.out_base = 0
self.committed = False
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
def __enter__(self) -> Self:
self._thread.start()
return self
def __exit__(self, *exc: object) -> None:
self._stop.set()
self._thread.join()
self.sock.close()
def _run(self) -> None:
while not self._stop.is_set():
try:
data, peer = self.sock.recvfrom(2048)
except TimeoutError:
continue
if self.random.random() < self.loss:
continue
if data[1:5] != self.token:
continue
self.committed |= bool(data[0] & espota2.UDP_FLAG_COMMITTED)
acked = (int.from_bytes(data[7:9], "big") - self.out_base) & 0xFFFF
if acked <= len(self.out):
del self.out[:acked]
self.out_base += acked
seq = int.from_bytes(data[5:7], "big")
msg_type = data[0] & ~espota2.UDP_FLAG_COMMITTED
if (
msg_type == espota2.UDP_MSG_DATA
and (seq - self.next_seq) & 0xFFFF < espota2.UDP_WINDOW
):
self.slots.setdefault(seq, data[espota2.UDP_HEADER_SIZE :])
# Consumed as soon as it is next, like a device that is never busy
while self.next_seq in self.slots:
self.received.append(self.slots.pop(self.next_seq))
self.next_seq = (self.next_seq + 1) & 0xFFFF
if self.random.random() < self.loss:
continue
window = sum(
1 << k
for k in range(1, espota2.UDP_WINDOW)
if (self.next_seq + k) & 0xFFFF in self.slots
)
# Consumes at once, so every slot it does not hold is free
window |= (espota2.UDP_WINDOW - len(self.slots)) << 4
self.sock.sendto(
bytes([espota2.UDP_MSG_ACK])
+ self.token
+ self.next_seq.to_bytes(2, "big")
+ seq.to_bytes(2, "big")
+ (self.out_base & 0xFFFF).to_bytes(2, "big")
+ bytes([window])
+ bytes(self.out),
peer,
)
def _udp_client(device: _LossyUdpDevice) -> espota2.UdpChannel:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(device.sock.getsockname())
return espota2.UdpChannel(sock, device.token)
@pytest.mark.parametrize("loss", [0.0, 0.5])
def test_udp_channel_delivers_in_order(loss: float) -> None:
"""The stream arrives intact and responses come back, despite loss."""
data = bytes(range(256)) * 160
with _LossyUdpDevice(b"\x01\x02\x03\x04", loss) as device:
channel = _udp_client(device)
assert channel.probe()
channel.sendall(data)
device.out += bytes([espota2.RESPONSE_RECEIVE_OK])
assert channel.recv(1) == bytes([espota2.RESPONSE_RECEIVE_OK])
channel.flush(30.0)
channel.close()
assert b"".join(device.received) == data
assert all(len(m) <= espota2.UDP_MAX_PAYLOAD for m in device.received)
assert device.committed
def test_start_udp_falls_back_when_blocked(mock_socket: Mock) -> None:
"""No answer to the probes sends the fallback over TCP and stays on TCP."""
with _LossyUdpDevice(b"\x05\x06\x07\x08", 1.0) as device:
mock_socket.family = socket.AF_INET
mock_socket.getpeername.return_value = device.sock.getsockname()
mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"]
with patch.object(espota2, "UDP_PROBE_TIMEOUT", 0.3):
assert espota2._start_udp(mock_socket, mock_socket, True) is None
assert mock_socket.sendall.call_args_list == [
call(espota2.UDP_REQUEST),
call(espota2.UDP_FALLBACK), # raw on the TCP socket
]
def test_start_udp_declined(mock_socket: Mock) -> None:
"""A zero token means the device could not open UDP; nothing is probed."""
mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"]
assert espota2._start_udp(mock_socket, mock_socket, True) is None
assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)]
def test_start_udp_clean_link_stays_on_tcp(mock_socket: Mock) -> None:
"""A clean handshake asks to stay on TCP and opens no UDP socket."""
mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"]
with patch.object(espota2.socket, "socket") as udp_socket:
assert espota2._start_udp(mock_socket, mock_socket, False) is None
udp_socket.assert_not_called()
assert mock_socket.sendall.call_args_list == [call(espota2.UDP_FALLBACK)]
@pytest.mark.parametrize(
("slowest", "prefer_udp", "retransmits", "lossy"),
[
(0.01, False, 0, False),
(0.01, False, None, False),
(espota2.UDP_SLOW_EXCHANGE + 0.1, False, 0, True),
(0.01, True, 0, True),
(0.01, False, 2, True),
],
)
def test_link_lossy(
slowest: float, prefer_udp: bool, retransmits: int | None, lossy: bool
) -> None:
"""A slow exchange, a retransmit or an earlier failure means UDP."""
with patch.object(espota2, "_tcp_retransmits", return_value=retransmits):
assert espota2._link_lossy(Mock(), slowest, prefer_udp) is lossy
@pytest.mark.parametrize(
("platform", "info", "expected"),
[
("linux", bytes(100) + (3).to_bytes(4, "little") + bytes(100), 3),
("darwin", bytes(72) + (1460).to_bytes(8, "little") + bytes(8), 1460),
("win32", b"", None),
("linux", bytes(10), None),
],
)
def test_tcp_retransmits(platform: str, info: bytes, expected: int | None) -> None:
"""The retransmit counter is read where the OS reports it."""
sock = Mock()
sock.getsockopt.return_value = info
with patch("sys.platform", platform):
assert espota2._tcp_retransmits(sock) == expected
def test_udp_channel_reports_socket_errors() -> None:
"""A closed device port fails fast instead of waiting out the timeout."""
gone = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
gone.bind(("127.0.0.1", 0))
addr = gone.getsockname()
gone.close()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(addr)
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
channel.settimeout(10.0)
with pytest.raises(espota2.OTANetworkError, match="closed its UDP port"):
channel.sendall(bytes(espota2.UDP_MAX_PAYLOAD * 5))
# A probe that is refused falls back instead
assert not espota2.UdpChannel(sock, b"\x01\x01\x01\x01").probe()
channel.close()
def test_udp_channel_no_progress_names_last_error() -> None:
"""Without refusals, the no-progress timeout names the last socket error."""
sock = Mock()
sock.send.side_effect = BlockingIOError(35, "full")
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
channel.settimeout(0.2)
channel.sendall(b"x")
with (
patch("select.select", return_value=([], [], [])),
pytest.raises(espota2.OTANetworkError, match="no progress.*full"),
):
while True:
channel._pump()
def test_udp_channel_ack_resets_refusals() -> None:
"""Refusals only count in a row; an ACK in between resets them."""
channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01")
ack = bytes([espota2.UDP_MSG_ACK]) + b"\x01\x01\x01\x01" + bytes(6) + b"\x40"
for _ in range(espota2.UDP_REFUSALS * 2):
channel._socket_error(ConnectionRefusedError(61, "refused"))
channel._handle_ack(ack)
# Windows' form of the same refusal counts too
with pytest.raises(espota2.OTANetworkError, match="closed its UDP port"):
for _ in range(espota2.UDP_REFUSALS):
channel._socket_error(ConnectionResetError(10054, "forcibly closed"))
def _udp_handshake() -> list[bytes]:
"""TCP recv responses up to auth, from a device that offers UDP."""
return [
bytes([espota2.RESPONSE_OK]),
bytes([espota2.OTA_VERSION_2_0]),
bytes([espota2.RESPONSE_FEATURE_FLAGS]),
bytes([espota2.SERVER_FEATURE_SUPPORTS_UDP]),
bytes([espota2.RESPONSE_AUTH_OK]),
]
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_over_udp(mock_socket: Mock) -> None:
"""The data phase moves to the channel, which acks chunks itself."""
mock_socket.recv.side_effect = _udp_handshake()
udp = Mock()
udp.pending.return_value = False
udp.recv.side_effect = [
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]),
bytes([espota2.RESPONSE_BIN_MD5_OK]),
bytes([espota2.RESPONSE_RECEIVE_OK]),
bytes([espota2.RESPONSE_UPDATE_END_OK]),
]
with patch.object(espota2, "_start_udp", return_value=udp) as start:
espota2.perform_ota(
mock_socket, None, io.BytesIO(bytes(20000)), "test.bin", prefer_udp=True
)
assert start.call_args.args[2] is True # an earlier failure means lossy
assert udp.recv.call_count == 4 # no chunk acks
udp.flush.assert_called_once()
udp.close.assert_called_once()
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_over_udp_device_error(mock_socket: Mock) -> None:
"""A device error mid upload stops the upload and is not retried."""
mock_socket.recv.side_effect = _udp_handshake()
mock_socket.getsockopt.return_value = b"" # no retransmit counter
udp = Mock()
udp.pending.return_value = True
udp.recv.side_effect = [
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]),
bytes([espota2.RESPONSE_BIN_MD5_OK]),
bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]),
]
with (
patch.object(espota2, "_start_udp", return_value=udp),
pytest.raises(espota2.OTAError, match="Writing OTA data") as exc,
):
espota2.perform_ota(mock_socket, None, io.BytesIO(bytes(20000)), "test.bin")
assert not isinstance(exc.value, espota2.OTANetworkError)
udp.close.assert_called()
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_udp_declined_stays_on_tcp(mock_socket: Mock) -> None:
"""Without a channel the upload continues on TCP with chunk acks."""
mock_socket.recv.side_effect = [
*_udp_handshake(),
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]),
bytes([espota2.RESPONSE_BIN_MD5_OK]),
*_UPLOAD_TAIL,
]
with patch.object(espota2, "_start_udp", return_value=None):
espota2.perform_ota(
mock_socket, None, io.BytesIO(b"x" * 100), "test.bin", prefer_udp=True
)
assert mock_socket.sendall.call_args_list[-1] == call(bytes([espota2.RESPONSE_OK]))
def test_udp_channel_records_socket_errors() -> None:
"""Send and receive errors are recorded like loss."""
sock = Mock()
sock.send.side_effect = BlockingIOError(35, "full")
sock.recv.side_effect = ConnectionRefusedError(61, "refused")
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
channel._send(espota2.UDP_MSG_PING, 0, b"")
assert isinstance(channel._last_error, BlockingIOError)
with patch("select.select", side_effect=[([sock], [], []), ([], [], [])]):
channel._receive(0.0)
assert isinstance(channel._last_error, ConnectionRefusedError)
def test_start_udp_probe_succeeds(mock_socket: Mock) -> None:
"""A device that answers the probe gets the data phase over UDP."""
with _LossyUdpDevice(b"\x05\x06\x07\x09", 0.0) as device:
mock_socket.family = socket.AF_INET
mock_socket.getpeername.return_value = device.sock.getsockname()
mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x09"]
channel = espota2._start_udp(mock_socket, mock_socket, True)
assert channel is not None
channel.close()
assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)]
def test_udp_channel_pings_when_all_held() -> None:
"""With every message in flight already held, it asks for responses instead,
at most once per resend timeout."""
device = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
device.bind(("127.0.0.1", 0))
device.settimeout(1.0)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(device.getsockname())
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
channel.sendall(b"x")
channel._queue[0].held = True
with patch.object(channel, "_receive"):
channel._pump()
channel._pump()
assert device.recv(2048)[0] == espota2.UDP_MSG_PING
device.settimeout(0.1)
with pytest.raises(TimeoutError):
device.recv(2048)
channel.close()
device.close()
def test_udp_channel_ignores_foreign_datagrams() -> None:
"""Short, non-ACK or wrong-token datagrams change nothing."""
channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01")
for data in (b"\x40", bytes(espota2.UDP_ACK_HEADER_SIZE), b"\x40" + bytes(11)):
channel._handle_ack(data)
assert not channel.pending()
# An ACK before the head was ever sent is no duplicate
channel.sendall(b"x")
ack = bytes([espota2.UDP_MSG_ACK]) + b"\x01\x01\x01\x01" + bytes(6) + b"\x40"
channel._handle_ack(ack)
channel._handle_ack(ack)
assert not channel._queue[0].resend
def test_udp_channel_logs_each_socket_error_once(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A repeated error is recorded but logged only when it changes."""
channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01")
with caplog.at_level(logging.DEBUG, "esphome.espota2"):
channel._socket_error(ConnectionRefusedError(61, "refused"))
channel._socket_error(ConnectionRefusedError(61, "refused again"))
assert caplog.text.count("UDP socket error") == 1
assert "again" in str(channel._last_error)
def test_tcp_retransmits_unsupported_option() -> None:
"""An OS that rejects the option reports nothing."""
sock = Mock()
sock.getsockopt.side_effect = OSError("not supported")
with patch("sys.platform", "linux"):
assert espota2._tcp_retransmits(sock) is None
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
def test_run_ota_impl_handshake_failure_prefers_udp(
firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock
) -> None:
"""A handshake that dies after reaching the device moves the retry to UDP."""
mock_perform_ota.side_effect = [
espota2.OTAHandshakeNetworkError("noise handshake: timed out"),
None,
]
result_code, _ = espota2.run_ota_impl_("test.local", 3232, None, str(firmware_file))
assert result_code == 0
assert [c.kwargs["prefer_udp"] for c in mock_perform_ota.call_args_list] == [
False,
True,
]
def test_start_udp_socket_error_falls_back(mock_socket: Mock) -> None:
"""A UDP socket that cannot reach the peer stays on TCP."""
mock_socket.family = socket.AF_INET
mock_socket.getpeername.side_effect = OSError("not connected")
mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"]
assert espota2._start_udp(mock_socket, mock_socket, True) is None
assert mock_socket.sendall.call_args_list == [
call(espota2.UDP_REQUEST),
call(espota2.UDP_FALLBACK),
]
def test_udp_channel_backs_off_when_acks_come_back_untimed() -> None:
"""Progress acks that only answer resent messages mean the link is slower
than the timer: it backs off, up to its cap, until a sample resets it."""
token = b"\x01\x01\x01\x01"
channel = espota2.UdpChannel(Mock(), token)
channel._queue = [espota2._UdpMessage(seq, b"x") for seq in range(40)]
rtos = []
for received in range(1, 33):
for message in channel._queue[:1]:
message.sends = 2 # every message was resent before its ack
rtos.append(channel._rto())
ack = (
bytes([espota2.UDP_MSG_ACK])
+ token
+ received.to_bytes(2, "big")
+ (received - 1).to_bytes(2, "big")
+ bytes(2)
+ b"\x40"
)
channel._handle_ack(ack)
n = espota2.UDP_UNTIMED_ACKS
assert rtos[0] == espota2.UDP_RESEND_INITIAL
assert rtos[n] == espota2.UDP_RESEND_INITIAL * 2
assert rtos[-1] == espota2.UDP_RESEND_MAX
channel._sample_rtt(0.1)
assert channel._rto() == pytest.approx(0.3) # srtt 0.1 + 4 * rttvar 0.05
@pytest.mark.usefixtures("mock_time")
@pytest.mark.parametrize(
("size", "pending", "match", "retryable"),
[
(100, False, "may have already committed", False), # one, final chunk
(20000, False, "no progress", True), # fails on an earlier chunk
(20000, True, "Writing OTA data", False), # device error waiting
],
)
def test_perform_ota_over_udp_send_failure(
mock_socket: Mock, size: int, pending: bool, match: str, retryable: bool
) -> None:
"""A UDP send failure surfaces a waiting device error, and on the last
chunk is not retried, like a lost final chunk ack over TCP."""
mock_socket.recv.side_effect = _udp_handshake()
udp = Mock()
udp.pending.return_value = pending
udp.recv.side_effect = [
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]),
bytes([espota2.RESPONSE_BIN_MD5_OK]),
bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]),
]
# ota type, size and MD5 go through; the first data chunk fails
udp.sendall.side_effect = [None] * 3 + [espota2.OTANetworkError("no progress")]
with (
patch.object(espota2, "_start_udp", return_value=udp),
pytest.raises(espota2.OTAError, match=match) as exc,
):
espota2.perform_ota(
mock_socket, None, io.BytesIO(bytes(size)), "test.bin", prefer_udp=True
)
assert isinstance(exc.value, espota2.OTANetworkError) is retryable
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_tcp_send_network_error_unchanged(mock_socket: Mock) -> None:
"""Over TCP an OTANetworkError from a send stays retryable, even on the
last chunk."""
mock_socket.recv.side_effect = _no_auth_handshake(espota2.OTA_VERSION_2_0)
mock_socket.sendall.side_effect = [None] * 4 + [
espota2.OTANetworkError("sending frame")
]
with pytest.raises(espota2.OTANetworkError, match="sending frame"):
espota2.perform_ota(mock_socket, None, io.BytesIO(b"x" * 100), "test.bin")
+53 -1
View File
@@ -76,9 +76,11 @@ class FakeEncryptedDevice(threading.Thread):
prologue_features_override: int | None = None,
connections: int = 1,
drop_handshakes: int = 0,
udp: bool = False,
reject_reason: str | None = None,
) -> None:
super().__init__(daemon=True)
self.udp = udp # offer UDP; the data phase then has no chunk acks
self.connections = connections
self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times
self.reject_reason = reject_reason # refuse every handshake with this reason
@@ -125,6 +127,8 @@ class FakeEncryptedDevice(threading.Thread):
sock.sendall(bytes([espota2.RESPONSE_ERROR_ENCRYPTION_REQUIRED]))
return
server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0
if self.udp:
server_flags |= espota2.SERVER_FEATURE_SUPPORTS_UDP
sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags]))
if not (noise_negotiated and self.offer_noise):
# A device that does not require encryption continues in
@@ -217,7 +221,7 @@ class FakeEncryptedDevice(threading.Thread):
acked = 0
while len(received) < size:
received += recv_data(size - len(received))
if self.version >= espota2.OTA_VERSION_2_0:
if self.version >= espota2.OTA_VERSION_2_0 and not self.udp:
while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or (
len(received) == size and acked < size
):
@@ -913,6 +917,54 @@ def test_bare_block_refuses_a_device_that_cannot_encrypt(
assert not any("Retrying in plaintext" in r.message for r in caplog.records)
class _TcpAsUdp:
"""Stands in for a UdpChannel, carrying its byte stream over the TCP socket."""
def __init__(self, sock: socket.socket) -> None:
self._sock = sock
self.flushed = False
self.closed = False
def settimeout(self, timeout: float | None) -> None:
self._sock.settimeout(timeout)
def setsockopt(self, level: int, optname: int, value: int) -> None:
self._sock.setsockopt(level, optname, value)
def sendall(self, data: bytes) -> None:
self._sock.sendall(data)
def recv(self, amount: int) -> bytes:
return self._sock.recv(amount)
def pending(self) -> bool:
return False
def flush(self, timeout: float) -> None:
self.flushed = True
def close(self) -> None:
self.closed = True
def test_encrypted_upload_over_udp() -> None:
"""After the handshake, Noise frames continue on the UDP channel."""
pytest.importorskip("aioesphomeapi.noise")
firmware = bytes(range(256)) * 80
device = FakeEncryptedDevice(udp=True)
channels: list[_TcpAsUdp] = []
def start_udp(sock: Any, tcp_sock: socket.socket, lossy: bool) -> _TcpAsUdp:
channels.append(_TcpAsUdp(tcp_sock))
return channels[0]
with patch("time.sleep"), patch.object(espota2, "_start_udp", start_udp):
_upload(device, firmware, PSK)
device.join_and_check()
assert device.received == firmware
assert channels[0].flushed and channels[0].closed
def test_probe_ota_key_recomputes_the_budget_after_connect() -> None:
"""A slow connect leaves less for the handshake than the budget had."""
clock = [0.0]