Compare commits

...
28 changed files with 703 additions and 75 deletions
+100 -11
View File
@@ -1,5 +1,7 @@
from collections.abc import Callable
from ipaddress import IPv4Address, IPv6Address
import logging
import math
import re
from typing import Any
@@ -27,6 +29,7 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DEFAULT,
CONF_DELAY,
CONF_ENCRYPTION,
CONF_EVENT,
@@ -136,6 +139,7 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
CONF_BATCH_DELAY = "batch_delay"
CONF_CUSTOM_SERVICES = "custom_services"
CONF_EXAMPLE = "example"
CONF_REQUIRED = "required"
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
@@ -247,12 +251,66 @@ def _validate_supports_response(value: Any) -> str:
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
VARIABLE_SCHEMA = cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
}
def _not_boolean(value: Any) -> Any:
# bool is an int subclass, and "true" cannot be parsed as a number by the client
if isinstance(value, bool):
raise cv.Invalid("Expected a number, got a boolean")
return value
def _finite(value: float) -> float:
if not math.isfinite(value):
raise cv.Invalid("Expected a finite number")
return value
def _no_nul(value: str) -> str:
# The default is a C string in flash, so a NUL would cut it short
if "\0" in value:
raise cv.Invalid("A default cannot contain a NUL character")
return value
# The wire carries the default as text; the client parses it into ExecuteServiceArgument's
# types, so the values are kept within what int32 and float hold
FLOAT32_MAX = 3.4028235e38
_DEFAULT_VALUE_VALIDATORS: dict[str, Callable[[Any], Any]] = {
"bool": cv.boolean,
"int": cv.All(_not_boolean, cv.int_range(min=-(2**31), max=2**31 - 1)),
"float": cv.All(
_not_boolean,
cv.float_,
_finite,
cv.float_range(min=-FLOAT32_MAX, max=FLOAT32_MAX),
),
"string": cv.All(cv.string_strict, _no_nul),
}
assert _DEFAULT_VALUE_VALIDATORS.keys() == _SERVICE_ARG_SCALAR_TYPES.keys()
def _validate_variable_optionality(value: ConfigType) -> ConfigType:
"""Cross-validate required/default against the variable type."""
var_type = value[CONF_TYPE]
if _is_optional_var(value) and var_type not in _DEFAULT_VALUE_VALIDATORS:
raise cv.Invalid("Array variables cannot be optional")
if CONF_DEFAULT in value:
if value.get(CONF_REQUIRED) is True:
raise cv.Invalid("A variable with a default cannot set required: true")
value[CONF_DEFAULT] = _DEFAULT_VALUE_VALIDATORS[var_type](value[CONF_DEFAULT])
return value
VARIABLE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
cv.Optional(CONF_REQUIRED): cv.boolean,
cv.Optional(CONF_DEFAULT): cv.valid,
}
),
_validate_variable_optionality,
)
# Accepts the plain `name: type` shorthand or the full mapping form
@@ -464,7 +522,28 @@ def _has_action_metadata(actions: list[ConfigType]) -> bool:
)
def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
def _is_optional_var(var_: ConfigType) -> bool:
return CONF_DEFAULT in var_ or var_.get(CONF_REQUIRED) is False
def _has_optional_args(actions: list[ConfigType]) -> bool:
return any(
_is_optional_var(var_)
for conf in actions
for var_ in conf[CONF_VARIABLES].values()
)
def _default_to_wire(value: Any) -> str:
"""Serialize a validated default for the wire; clients coerce by arg type."""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _action_strings(
conf: ConfigType, has_metadata: bool, has_optional: bool
) -> list[str | None]:
"""Strings of one action in the table order UserServiceStatic (user_services.h) expects."""
# An empty description or example is treated as unset
strings: list[str | None] = [conf[CONF_ACTION]]
@@ -477,6 +556,15 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
var_.get(CONF_DESCRIPTION) or None,
var_.get(CONF_EXAMPLE) or None,
]
if has_optional:
# The default slot doubles as the optional flag: nullptr for a required variable,
# "" for an optional one without a default (an empty string default is the same)
if not _is_optional_var(var_):
strings.append(None)
elif (default := var_.get(CONF_DEFAULT)) is None:
strings.append("")
else:
strings.append(_default_to_wire(default))
return strings
@@ -485,9 +573,7 @@ def _validate_wizard(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_wizard,
)
FINAL_VALIDATE_SCHEMA = _validate_wizard
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
@@ -546,6 +632,9 @@ async def to_code(config: ConfigType) -> None:
has_metadata = _has_action_metadata(actions)
if has_metadata:
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
has_optional = _has_optional_args(actions)
if has_optional:
cg.add_define("USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS")
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
for index, conf in enumerate(actions):
@@ -589,7 +678,7 @@ async def to_code(config: ConfigType) -> None:
native = SERVICE_ARG_NATIVE_TYPES[var_type]
service_template_args.append(native)
func_args.append((native, name))
strings = _action_strings(conf, has_metadata)
strings = _action_strings(conf, has_metadata, has_optional)
table = _add_action_strings(index, strings)
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
+4
View File
@@ -1174,6 +1174,10 @@ message ListEntitiesServicesArgument {
ServiceArgType type = 2;
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];
// Advisory: ExecuteServiceRequest still requires every argument
bool optional = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS"];
// Always text: bool "true"/"false", numbers parseable as int/float; empty means no default
string default_value = 6 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS", (progmem) = true];
}
message ListEntitiesServicesResponse {
option (id) = 41;
+12
View File
@@ -1369,6 +1369,12 @@ uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self,
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.default_value);
#endif
return pos;
}
@@ -1382,6 +1388,12 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
size += ProtoSize::calc_length(1, msg.example.size());
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
size += ProtoSize::calc_bool(1, msg.optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
size += ProtoSize::calc_length(1, msg.default_value.size());
#endif
return size;
}
+6
View File
@@ -1593,6 +1593,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
ProgmemStringRef example{};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
bool optional{false};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
ProgmemStringRef default_value{};
#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 {
+6
View File
@@ -1643,6 +1643,12 @@ const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
dump_field(out, ESPHOME_PSTR("optional"), this->optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
dump_progmem_string_field(out, ESPHOME_PSTR("default_value"), this->default_value);
#endif
return out.c_str();
}
+7
View File
@@ -31,6 +31,13 @@ ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_(
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
arg.description = this->str_(base + 1);
arg.example = this->str_(base + 2);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
// The default slot doubles as the optional flag: nullptr is required, "" optional without a default
const char *d = progmem_read_ptr(&this->strings_[base + USER_ACTION_ARG_BASE_STRINGS]);
arg.optional = d != nullptr;
if (d != nullptr)
arg.default_value = ProgmemStringRef(d, ESPHOME_strlen_P(d));
#endif
}
return msg;
+10 -3
View File
@@ -40,13 +40,20 @@ template<typename T> enums::ServiceArgType to_service_arg_type();
// 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
// each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows
// the name and every argument is (name, description, example). Unset metadata is nullptr.
// the name and every argument is (name, description, example); with
// USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS every argument ends with its default: nullptr for a
// required argument, "" for an optional one without a default. Unset is nullptr.
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
static constexpr size_t USER_ACTION_HEADER_STRINGS = 2;
static constexpr size_t USER_ACTION_ARG_STRINGS = 3;
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 3;
#else
static constexpr size_t USER_ACTION_HEADER_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 1;
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS + 1;
#else
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS;
#endif
class UserServiceStatic : public UserServiceDescriptor {
public:
@@ -35,7 +35,7 @@ async def to_code(config: ConfigType) -> None:
await cg.add_variable(var.set_ota_parent, config[CONF_OTA_ID])
await cg.add_variable(var.set_request_parent, config[CONF_HTTP_REQUEST_ID])
cg.add(var.set_source_url(config[CONF_SOURCE]))
cg.add(var.set_source_url_static(config[CONF_SOURCE]))
ota.request_ota_state_listeners()
@@ -24,12 +24,6 @@ namespace esphome::http_request {
ESPHOME_LOG_TAG(TAG, "http_request.update");
// Wraps UpdateInfo + error for the task→main-loop handoff.
struct TaskResult {
update::UpdateInfo info;
const LogString *error_str{nullptr};
};
static const size_t MAX_READ_SIZE = 256;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000;
@@ -68,6 +62,37 @@ void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_
}
}
void HttpRequestUpdate::set_source_url(const char *source_url, size_t len) {
// Copy before releasing, so a caller passing the current URL back in stays valid.
char *copy = new char[len + 1];
memcpy(copy, source_url, len);
copy[len] = '\0';
this->release_source_url_();
this->source_url_ = copy;
this->source_url_owned_ = true;
}
void HttpRequestUpdate::release_source_url_() {
if (this->source_url_owned_)
delete[] this->source_url_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::begin_check_(TaskResult *result) {
result->update = this;
result->source_url = this->source_url_;
result->owns_source_url = this->source_url_owned_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::end_check_(TaskResult *result) {
if (this->source_url_ == result->source_url) {
this->source_url_owned_ = result->owns_source_url;
} else if (result->owns_source_url) {
delete[] result->source_url; // replaced while the check ran
}
}
void HttpRequestUpdate::update() {
if (!network::is_connected()) {
ESP_LOGD(TAG, "Network not connected, skipping update check");
@@ -75,28 +100,37 @@ void HttpRequestUpdate::update() {
}
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
#ifdef USE_ESP32
if (this->update_task_handle_ != nullptr) {
if (this->task_result_ != nullptr) {
ESP_LOGW(TAG, "Update check already in progress");
return;
}
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
#endif
// Allocated once per check; every path in the task returns via its single defer, which frees it.
auto *result = new TaskResult();
this->begin_check_(result);
#ifdef USE_ESP32
this->task_result_ = result;
if (xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, result, 1, nullptr) != pdPASS) {
this->task_result_ = nullptr;
this->end_check_(result);
delete result;
}
#else
this->update_task(this);
this->update_task(result);
this->end_check_(result);
#endif
}
void HttpRequestUpdate::update_task(void *params) {
HttpRequestUpdate *this_update = (HttpRequestUpdate *) params;
// Allocate once — every path below returns via the single defer at the end.
// On failure, error_str is set; on success it is nullptr.
auto *result = new TaskResult();
auto *result = static_cast<TaskResult *>(params);
HttpRequestUpdate *this_update = result->update;
auto *info = &result->info;
const char *source_url = result->source_url;
auto container = this_update->request_parent_->get(this_update->source_url_);
auto container = this_update->request_parent_->get(source_url);
if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_);
ESP_LOGE(TAG, "Failed to fetch manifest from %s", source_url);
if (container != nullptr)
container->end();
result->error_str = LOG_STR("Failed to fetch manifest");
@@ -176,14 +210,14 @@ void HttpRequestUpdate::update_task(void *params) {
allocator.deallocate(data, content_length);
if (!valid) {
ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_);
ESP_LOGE(TAG, "Failed to parse JSON from %s", source_url);
result->error_str = LOG_STR("Failed to parse manifest JSON");
goto defer; // NOLINT(cppcoreguidelines-avoid-goto)
}
// Merge source_url_ and firmware_url
if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) {
const char *source = this_update->source_url_;
const char *source = source_url;
const size_t source_len = strlen(source);
size_t prefix_len;
if (info->firmware_url[0] == '/') {
@@ -215,7 +249,8 @@ defer:
// Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains.
this_update->defer([this_update, result]() {
#ifdef USE_ESP32
this_update->update_task_handle_ = nullptr;
this_update->task_result_ = nullptr;
this_update->end_check_(result);
#endif
if (result->error_str != nullptr) {
this_update->status_set_error(result->error_str);
@@ -13,6 +13,21 @@
namespace esphome::http_request {
namespace testing {
class HttpRequestUpdateTest;
} // namespace testing
class HttpRequestUpdate;
// One manifest check: handed from update() to the task and back to the main loop.
struct TaskResult {
HttpRequestUpdate *update{nullptr};
const char *source_url{nullptr}; // the URL this check reads, fixed before it starts
update::UpdateInfo info;
const LogString *error_str{nullptr}; // set on failure, nullptr on success
bool owns_source_url{false}; // the check holds the buffer until it ends, see begin_check_()
};
class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener {
public:
void setup() override;
@@ -21,7 +36,18 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
void perform(bool force) override;
void check() override { this->update(); }
void set_source_url(const char *source_url) { this->source_url_ = source_url; }
~HttpRequestUpdate() override { this->release_source_url_(); }
/// Codegen only: the config literal lives in flash for the life of the program, so it is kept by pointer.
void set_source_url_static(const char *source_url) {
this->release_source_url_();
this->source_url_ = source_url;
}
/// Set the manifest URL at run time; the string is copied, so any storage may be passed.
void set_source_url(const char *source_url) { this->set_source_url(source_url, strlen(source_url)); }
void set_source_url(const std::string &source_url) { this->set_source_url(source_url.data(), source_url.size()); }
void set_source_url(const char *source_url, size_t len);
const char *get_source_url() const { return this->source_url_; }
void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; }
void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; }
@@ -36,12 +62,21 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
static void update_task(void *params);
#ifdef USE_ESP32
TaskHandle_t update_task_handle_{nullptr};
TaskResult *task_result_{nullptr}; // set while a check runs on the update task
#endif
uint8_t initial_check_remaining_{0};
private:
const char *source_url_{nullptr}; // literal from codegen
friend class testing::HttpRequestUpdateTest;
void release_source_url_();
// A check reads the URL it started with until it ends, on the update task or inline through an
// automation it triggers. Ownership of that buffer moves to the check, so a set_source_url in the
// meantime cannot free it; end_check_() hands it back or frees it on the main loop.
void begin_check_(TaskResult *result);
void end_check_(TaskResult *result);
bool source_url_owned_{false}; // source_url_ was copied by a run-time setter; sits in the padding above
const char *source_url_{nullptr};
};
} // namespace esphome::http_request
@@ -52,6 +52,7 @@ ST7796.extend(
height=480,
offset_width=49,
draw_rounding=1,
data_rate="40MHz",
cs_pin=39,
reset_pin=47,
dc_pin=9,
+1
View File
@@ -351,6 +351,7 @@
#define USE_API_USER_DEFINED_ACTIONS
#define USE_API_CUSTOM_SERVICES
#define USE_API_USER_DEFINED_ACTION_METADATA
#define USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
#define USE_API_USER_DEFINED_ACTION_RESPONSES
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#define API_MAX_SEND_QUEUE 8
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.6.0
click==8.5.0
aioesphomeapi==46.20.0
aioesphomeapi==46.21.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
@@ -112,7 +112,7 @@ def test_empty_metadata_is_unset() -> None:
"description": "",
"variables": {"b": {"type": "int", "description": "", "example": "ex"}},
}
strings = _action_strings(conf, has_metadata=True)
strings = _action_strings(conf, has_metadata=True, has_optional=False)
assert strings == ["a", None, "b", None, "ex"]
@@ -0,0 +1,158 @@
"""Tests for optional user-defined action variables (required/default)."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.api import (
_action_strings,
_has_optional_args,
validate_variable,
)
from esphome.config_validation import Invalid
from esphome.core import CORE
CONFIG = "tests/component_tests/api/test_action_optional_args.yaml"
CONFIG_ESP8266 = "tests/component_tests/api/test_action_optional_args_esp8266.yaml"
CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml"
def test_defaults_are_emitted_in_the_string_table(
generate_main: Callable[[str | Path], str],
) -> None:
"""Each argument gains a trailing default slot: "" marks optional without a default."""
main_cpp = generate_main(CONFIG)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = {"
'"play_buzzer", "song_str", "", "notes", "", "octave", "5", '
'"bpm", "100", "loud", "true"};' in main_cpp
)
# An all-required action still carries the default slots (as nullptr)
assert (
"static constexpr const char * api_action1_strings[] PROGMEM = {"
'"plain_action", "value", nullptr};' in main_cpp
)
assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" in {d.name for d in CORE.defines}
def test_esp8266_defaults_are_progmem_arrays(
generate_main: Callable[[str | Path], str],
) -> None:
"""On ESP8266 each default is its own PROGMEM array, referenced from the table."""
main_cpp = generate_main(CONFIG_ESP8266)
def array_for(text: str) -> str:
match = re.search(
rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp
)
assert match, text
return match.group(1)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = {"
f"{array_for('play_buzzer')}, {array_for('song_str')}, {array_for('')}, "
f"{array_for('notes')}, {array_for('')}, {array_for('octave')}, {array_for('5')}, "
f"{array_for('bpm')}, {array_for('100')}, {array_for('loud')}, "
f"{array_for('true')}}};" in main_cpp
)
def test_required_variables_emit_no_define(
generate_main: Callable[[str | Path], str],
) -> None:
generate_main(CONFIG_SHORTHAND)
assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" not in {
d.name for d in CORE.defines
}
def test_action_strings_orders_default_after_metadata() -> None:
conf = {
"action": "a",
"description": "d",
"variables": {
"b": {"type": "int", "example": "1", "default": 5},
"c": {"type": "bool", "default": False},
},
}
assert _action_strings(conf, has_metadata=True, has_optional=True) == [
"a",
"d",
"b",
None,
"1",
"5",
"c",
None,
None,
"false",
]
@pytest.mark.parametrize(
("var", "expected"),
[
({"type": "int", "required": False}, ""),
({"type": "string", "default": ""}, ""),
({"type": "float", "default": 5}, "5.0"),
],
)
def test_default_slot_marks_optional_and_keeps_the_type(
var: dict, expected: str
) -> None:
actions = [{"action": "a", "variables": {"b": validate_variable(var)}}]
assert _has_optional_args(actions)
assert _action_strings(actions[0], has_metadata=False, has_optional=True) == [
"a",
"b",
expected,
]
@pytest.mark.parametrize(
("var", "expected"),
[
({"type": "int", "default": "5"}, 5),
({"type": "int", "default": -(2**31)}, -(2**31)),
({"type": "int", "default": 2**31 - 1}, 2**31 - 1),
({"type": "bool", "default": "true"}, True),
({"type": "float", "default": "1.5"}, 1.5),
({"type": "float", "default": 3.4028235e38}, 3.4028235e38),
({"type": "string", "default": "0123"}, "0123"),
],
)
def test_default_is_validated_against_type(var: dict, expected: object) -> None:
assert validate_variable(var)["default"] == expected
@pytest.mark.parametrize(
"var",
[
{"type": "int[]", "required": False},
{"type": "string[]", "default": "x"},
{"type": "int", "default": 5, "required": True},
{"type": "int", "default": "not_a_number"},
# ExecuteServiceArgument.int_ is a signed 32-bit value
{"type": "int", "default": 2**31},
{"type": "int", "default": -(2**31) - 1},
# A boolean is an int in Python but "true" is not a number on the wire
{"type": "int", "default": True},
{"type": "float", "default": False},
# ExecuteServiceArgument.float_ is a 32-bit float
{"type": "float", "default": 1e40},
{"type": "float", "default": "nan"},
{"type": "float", "default": "inf"},
# A string default is what the user typed, and a C string in flash
{"type": "string", "default": 123},
{"type": "string", "default": "a\0b"},
{"type": "string", "default": "\0a"},
{"type": "float", "default": -1e40},
],
)
def test_variable_rejects_invalid_optionality(var: dict) -> None:
with pytest.raises(Invalid):
validate_variable(var)
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_optional_args_common.yaml
@@ -0,0 +1,26 @@
api:
actions:
- action: play_buzzer
variables:
song_str:
type: string
required: false
notes:
type: string
required: false
octave:
type: int
default: 5
bpm:
type: int
default: 100
loud:
type: bool
default: true
then:
- logger.log: Action Called
- action: plain_action
variables:
value: int
then:
- logger.log: Action Called
@@ -0,0 +1,14 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_optional_args_common.yaml
+1
View File
@@ -78,6 +78,7 @@ api:
type: string
description: Name to greet
example: World
default: World
then:
- logger.log:
format: Hello World %s!
+15
View File
@@ -57,6 +57,21 @@ button:
url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.bin");'
- logger.log: "This message should be not displayed (reboot)"
# Device firmware switches the manifest from lambdas; every shape below must compile and
# the stored URL must outlive the lambda (EverythingSmartHome/everything-presence-one#341).
- platform: template
name: Switch manifest
on_press:
then:
- lambda: |-
id(ota_update).set_source_url("http://my.ha.net:8123/local/esphome/beta-manifest.json");
std::string url = "http://my.ha.net:8123/local/esphome/";
url += "manifest.json";
id(ota_update).set_source_url(url);
id(ota_update).set_source_url(url.c_str());
id(ota_update).set_source_url(std::string("http://my.ha.net:8123/local/esphome/manifest.json"));
ESP_LOGI("test", "Manifest is now %s", id(ota_update).get_source_url());
- component.update: ota_update
update:
- platform: http_request
@@ -0,0 +1,8 @@
import esphome.config_validation as cv
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The real schema rejects the host target by framework version; the update platform's
# unit tests only need this platform's sources on the host.
manifest.config_schema = cv.Schema({}, extra=cv.ALLOW_EXTRA)
@@ -0,0 +1,141 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/components/http_request/update/http_request_update.h"
#if defined(__SANITIZE_ADDRESS__)
#define HAS_ASAN
#elif defined(__has_feature)
#if __has_feature(address_sanitizer)
#define HAS_ASAN
#endif
#endif
#ifdef HAS_ASAN
#include <sanitizer/asan_interface.h>
#endif
namespace esphome::http_request::testing {
namespace {
constexpr char LITERAL[] = "http://example.invalid/manifest.json";
// Freed heap memory is poisoned under AddressSanitizer, which CI builds with; without it the
// freed-exactly-once checks cannot be observed and are skipped.
void expect_freed(const char *buffer) {
#ifdef HAS_ASAN
EXPECT_TRUE(__asan_address_is_poisoned(buffer));
#endif
}
void expect_live(const char *buffer, const char *content) {
#ifdef HAS_ASAN
EXPECT_FALSE(__asan_address_is_poisoned(buffer));
#endif
EXPECT_STREQ(buffer, content);
}
} // namespace
// Friend of HttpRequestUpdate, so a check can be begun and ended without an HTTP fetch.
class HttpRequestUpdateTest : public ::testing::Test {
protected:
static void begin_check(HttpRequestUpdate &update, TaskResult &result) { update.begin_check_(&result); }
static void end_check(HttpRequestUpdate &update, TaskResult &result) { update.end_check_(&result); }
};
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralIsKeptByPointer) {
HttpRequestUpdate update;
update.set_source_url_static(LITERAL);
EXPECT_EQ(update.get_source_url(), LITERAL);
}
TEST(HttpRequestUpdateSourceUrl, RuntimeStringIsCopied) {
HttpRequestUpdate update;
std::string url = "http://example.invalid/from-string-manifest.json";
update.set_source_url(url);
EXPECT_NE(update.get_source_url(), url.c_str());
EXPECT_STREQ(update.get_source_url(), url.c_str());
}
// Device firmware passes base_url.c_str() of a local and runs the update after the lambda returned.
TEST(HttpRequestUpdateSourceUrl, PointerIntoDeadLocalStaysReadable) {
HttpRequestUpdate update;
{
std::string local = "http://example.invalid/from-local-manifest.json";
update.set_source_url(local.c_str());
}
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/from-local-manifest.json");
}
TEST(HttpRequestUpdateSourceUrl, PassingTheCurrentUrlBackIsSafe) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/current-manifest.json");
const char *previous = update.get_source_url();
update.set_source_url(update.get_source_url());
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/current-manifest.json");
expect_freed(previous);
}
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralAfterRuntimeUrlDropsTheCopy) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/runtime-manifest.json");
const char *copy = update.get_source_url();
update.set_source_url_static(LITERAL);
EXPECT_EQ(update.get_source_url(), LITERAL);
expect_freed(copy);
}
// The URL a check started with stays readable until the check ends, however often it is replaced in
// the meantime; the replacements it never saw are freed at once and the original exactly once.
TEST_F(HttpRequestUpdateTest, UrlReplacedDuringACheckOutlivesIt) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/first-manifest.json");
const char *first = update.get_source_url();
TaskResult result;
begin_check(update, result);
EXPECT_EQ(result.source_url, first);
update.set_source_url("http://example.invalid/second-manifest.json");
const char *second = update.get_source_url();
expect_live(first, "http://example.invalid/first-manifest.json");
update.set_source_url("http://example.invalid/third-manifest.json");
const char *third = update.get_source_url();
expect_live(first, "http://example.invalid/first-manifest.json");
expect_freed(second);
end_check(update, result);
expect_freed(first);
expect_live(third, "http://example.invalid/third-manifest.json");
// The entity owns the URL set during the check: switching away frees it.
update.set_source_url_static(LITERAL);
expect_freed(third);
}
TEST_F(HttpRequestUpdateTest, UrlUnchangedDuringACheckReturnsToTheEntity) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/kept-manifest.json");
const char *kept = update.get_source_url();
TaskResult result;
begin_check(update, result);
end_check(update, result);
expect_live(kept, "http://example.invalid/kept-manifest.json");
// Ownership came back, so the next change frees it instead of leaking it.
update.set_source_url_static(LITERAL);
expect_freed(kept);
}
TEST_F(HttpRequestUpdateTest, ConfigLiteralDuringACheckIsNeverFreed) {
HttpRequestUpdate update;
update.set_source_url_static(LITERAL);
TaskResult result;
begin_check(update, result);
update.set_source_url("http://example.invalid/runtime-manifest.json");
end_check(update, result);
EXPECT_STREQ(LITERAL, "http://example.invalid/manifest.json");
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/runtime-manifest.json");
}
} // namespace esphome::http_request::testing
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# setup() registers the entity as an OTA state listener, which the real to_code
# requests through ota.request_ota_state_listeners().
async def to_code_testing(config):
cg.add_define("USE_OTA_STATE_LISTENER")
manifest.to_code = to_code_testing
@@ -22,5 +22,18 @@ api:
value: int
then:
- logger.log: "Plain action called"
- action: optional_args_action
variables:
song_str:
type: string
required: false
octave:
type: int
default: 5
loud:
type: bool
default: true
then:
- logger.log: "Optional action called"
logger:
+31
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
from collections import Counter
import socket
import struct
from typing import Self
from aioesphomeapi import api_pb2
@@ -49,6 +50,36 @@ def decode_varint(buf: bytearray, pos: int) -> tuple[int, int] | None:
return None
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
def tag(field: int, wire_type: int) -> bytes:
return encode_varint((field << 3) | wire_type)
def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]:
"""Split a protobuf payload into field number -> raw values; a test reads fields the client lacks."""
fields: dict[int, list[int | bytes]] = {}
buf = bytearray(payload)
pos = 0
while pos < len(buf):
key, pos = decode_varint(buf, pos)
field, wire_type = key >> 3, key & 0x07
if wire_type == WIRE_VARINT:
value, pos = decode_varint(buf, pos)
elif wire_type == WIRE_LENGTH:
length, pos = decode_varint(buf, pos)
value = bytes(buf[pos : pos + length])
pos += length
elif wire_type == WIRE_FIXED32:
value = struct.unpack_from("<I", buf, pos)[0]
pos += 4
else:
raise AssertionError(f"unexpected wire type {wire_type} for field {field}")
fields.setdefault(field, []).append(value)
return fields
def encode_frame(msg_type: int, payload: bytes) -> bytes:
"""Encode one plaintext api frame: 0x00, payload length, message type."""
return b"\x00" + encode_varint(len(payload)) + encode_varint(msg_type) + payload
+12 -1
View File
@@ -38,7 +38,7 @@ async def test_api_action_metadata(
_, services = await client.list_entities_services()
by_name = {service.name: service for service in services}
assert set(by_name) == {"play_buzzer", "plain_action"}
assert set(by_name) == {"play_buzzer", "plain_action", "optional_args_action"}
# Keys are hashed at codegen time and must match what the client expects
for name, service in by_name.items():
assert service.key == fnv1_hash(name), name
@@ -57,6 +57,17 @@ async def test_api_action_metadata(
assert plain.description == ""
assert plain.args[0].description == ""
# Optional flags and defaults, as (optional, default) per argument
def flags(service) -> dict[str, tuple[bool, str]]:
return {arg.name: (arg.optional, arg.default_value) for arg in service.args}
assert flags(plain) == {"value": (False, "")}
assert flags(by_name["optional_args_action"]) == {
"song_str": (True, ""),
"octave": (True, "5"),
"loud": (True, "true"),
}
await client.execute_service(
buzzer, {"song_str": "two_short:d=4,o=5,b=100:16e6,16e6", "volume": 3}
)
+8 -29
View File
@@ -10,47 +10,26 @@ from aioesphomeapi import CoverInfo, CoverState, EntityState, api_pb2
import pytest
from .log_utils import LineWaiter
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, decode_varint, encode_varint
from .raw_api_client import (
MESSAGE_TYPE_OF,
WIRE_FIXED32,
WIRE_VARINT,
RawApiClient,
decode_fields,
tag,
)
from .state_utils import InitialStateHelper, StateWaiter, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
COVER_COMMAND = MESSAGE_TYPE_OF[api_pb2.CoverCommandRequest]
LIST_COVER = MESSAGE_TYPE_OF[api_pb2.ListEntitiesCoverResponse]
LIST_DONE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
# ListEntitiesCoverResponse fields; the installed client library does not know supports_toggle yet
FIELD_ENTITY_KEY, FIELD_SUPPORTS_TOGGLE = 2, 15
# CoverCommandRequest fields
FIELD_KEY, FIELD_STOP, FIELD_TOGGLE = 1, 8, 11
def tag(field: int, wire_type: int) -> bytes:
return encode_varint((field << 3) | wire_type)
def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]:
"""Split a protobuf payload into field number -> raw values."""
fields: dict[int, list[int | bytes]] = {}
buf = bytearray(payload)
pos = 0
while pos < len(buf):
key, pos = decode_varint(buf, pos)
field, wire_type = key >> 3, key & 0x07
if wire_type == WIRE_VARINT:
value, pos = decode_varint(buf, pos)
elif wire_type == WIRE_LENGTH:
length, pos = decode_varint(buf, pos)
value = bytes(buf[pos : pos + length])
pos += length
elif wire_type == WIRE_FIXED32:
value = struct.unpack_from("<I", buf, pos)[0]
pos += 4
else:
raise AssertionError(f"unexpected wire type {wire_type} for field {field}")
fields.setdefault(field, []).append(value)
return fields
@pytest.mark.asyncio
async def test_api_cover_toggle(
yaml_config: str,
@@ -16,16 +16,19 @@ from aioesphomeapi import (
)
import pytest
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, encode_varint
from .raw_api_client import (
MESSAGE_TYPE_OF,
WIRE_FIXED32,
WIRE_LENGTH,
WIRE_VARINT,
RawApiClient,
encode_varint,
tag,
)
from .state_utils import InitialStateHelper, StateWaiter, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
SWITCH_COMMAND = MESSAGE_TYPE_OF[api_pb2.SwitchCommandRequest]
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
def tag(field: int, wire_type: int) -> bytes:
return encode_varint((field << 3) | wire_type)
@pytest.mark.asyncio