mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 16:32:11 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66bf06b5d3 | ||
|
|
41e82b0295 | ||
|
|
39705a65e3 | ||
|
|
a522553e82 | ||
|
|
a07a1ab728 | ||
|
|
9a5d2d9753 | ||
|
|
3188e0d36f | ||
|
|
f3eaf698d6 | ||
|
|
f7057e1270 | ||
|
|
6edfbb594d | ||
|
|
22defcd704 | ||
|
|
0f408234d0 | ||
|
|
92e0e6db6b |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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{}); }
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user