mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 13:56:10 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04032b345 | ||
|
|
611d8cfb4d | ||
|
|
7dace44d61 | ||
|
|
491f98f982 | ||
|
|
619283d74a |
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "light_color_values.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -133,6 +134,8 @@ class LightCall {
|
||||
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
|
||||
/// Set the effect of the light by its name (const char * overload to resolve ambiguity).
|
||||
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
|
||||
/// Set the effect of the light by its name (StringRef overload to resolve ambiguity, zero-copy).
|
||||
LightCall &set_effect(StringRef effect) { return this->set_effect(effect.c_str(), effect.size()); }
|
||||
/// Set the effect of the light by its name and length (zero-copy from API).
|
||||
LightCall &set_effect(const char *effect, size_t len);
|
||||
/// Set the effect of the light by its internal index number (only for internal use).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -151,9 +151,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
str((read_path / Path(d)).resolve()) for d in build_include_dirs
|
||||
]
|
||||
|
||||
# Split build_flags list into private and public lists
|
||||
# Split build_flags list into private and public lists. Warnings and the optimisation level only concern the
|
||||
# library's own sources, so a library built with -O2 does not change how its users are compiled.
|
||||
private_build_flags, public_build_flags = split_list_by_condition(
|
||||
build_flags, lambda a: a if a.startswith("-W") else None
|
||||
build_flags, lambda a: a if a.startswith(("-W", "-O")) else None
|
||||
)
|
||||
|
||||
# Generate the component
|
||||
|
||||
+1
-1
@@ -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
|
||||
@@ -78,6 +78,7 @@ api:
|
||||
type: string
|
||||
description: Name to greet
|
||||
example: World
|
||||
default: World
|
||||
then:
|
||||
- logger.log:
|
||||
format: Hello World %s!
|
||||
|
||||
@@ -145,6 +145,10 @@ esphome:
|
||||
- light.turn_on:
|
||||
id: test_monochromatic_light
|
||||
effect: !lambda 'return "Strobe";'
|
||||
# Inlined lambda returning a StringRef, the shape an api action string variable takes (issue #21327)
|
||||
- light.turn_on:
|
||||
id: test_monochromatic_light
|
||||
effect: !lambda 'return light::EFFECT_NONE_REF;'
|
||||
# Test effect lambda with non-empty args (repeat passes uint32_t iteration)
|
||||
- repeat:
|
||||
count: 3
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -254,6 +254,20 @@ target_link_libraries(${COMPONENT_LIB} INTERFACE
|
||||
)
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_optimisation_flags_are_private(
|
||||
tmp_component: IDFComponent,
|
||||
) -> None:
|
||||
# A library built with -O2 must not change how the components that use it are compiled
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "main.c").write_text("int main() {}")
|
||||
tmp_component.data = {"build": {"flags": ["-O2", "-DTEST"]}}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
assert 'target_compile_options(${COMPONENT_LIB} PUBLIC\n "-DTEST"\n)' in content
|
||||
assert 'target_compile_options(${COMPONENT_LIB} PRIVATE\n "-O2"\n)' in content
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_uses_forward_slashes_on_windows(
|
||||
tmp_component, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user