Compare commits

..
44 Commits
Author SHA1 Message Date
J. Nick Koston e0385fae4a Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/core/config.py
2026-10-21 18:49:23 -10:00
J. Nick Koston 163a0d388b Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	tests/unit_tests/test_config_helpers.py
2026-10-21 16:39:59 -10:00
J. Nick Koston aa8e032081 Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-10-14 10:39:22 -10:00
J. Nick Koston efbee480dc Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/components/esp32/__init__.py
#	esphome/core/defines.h
2026-10-09 16:08:43 -10:00
J. Nick Koston 1e72cdb8a0 Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/writer.py
2026-10-09 13:09:48 -10:00
J. Nick Koston f117264eda Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-10-07 10:29:23 -10:00
J. Nick Koston 6ea7651a5c Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-10-06 21:09:14 -10:00
J. Nick Koston d253f6af4e Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/core/config.py
2026-10-02 09:20:38 -05:00
J. Nick Koston bbfdf02903 Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/core/config.py
2026-10-02 08:15:46 -05:00
J. Nick Koston 20ff795229 Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-10-01 15:23:04 -05:00
J. Nick Koston c9fbc2f046 Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/components/esp8266/__init__.py
#	esphome/writer.py
2026-09-28 19:52:32 +02:00
J. Nick Koston 7c420ec6ae [core] Use a component key in the keep_float_scanf test 2026-09-26 01:14:48 +01:00
J. Nick Koston feb139fd20 [core] Pass the config through to the external scan and scan once 2026-09-26 01:14:11 +01:00
J. Nick Koston 5af8b8a972 Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/writer.py
#	tests/component_tests/esp32/test_esp32.py
2026-09-26 00:12:06 +01:00
J. Nick Koston 39fd5b6a87 Merge remote-tracking branch 'upstream/dev' into HEAD 2026-09-17 08:48:11 -05:00
J. Nick Koston 1167d95312 Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-09-16 09:22:35 -05:00
J. Nick Koston 9828037ff1 [core] Let the scanf buffer argument contain a call with commas 2026-09-11 14:18:53 -05:00
J. Nick Koston c3546f8320 [core] Find the scanf format literal with one regex instead of a call walker 2026-09-11 14:03:01 -05:00
J. Nick Koston 9d400ab787 Revert "[core] Treat a non-literal scanf format as a float scan"
This reverts commit 4f0fec3e50.
2026-09-11 14:00:05 -05:00
J. Nick Koston 4f0fec3e50 [core] Treat a non-literal scanf format as a float scan 2026-09-11 13:57:20 -05:00
J. Nick Koston f73dc0e7ce [core] Move the scanf scan into code_scan.py on the loader's component list and trim the tests 2026-09-11 13:40:28 -05:00
J. Nick Koston cd214cc98b [core] Cover an external component without a float scanf 2026-09-11 13:28:37 -05:00
J. Nick Koston 3d71e9050f [core] Cover the esphome core domain in the external scan test 2026-09-11 13:22:23 -05:00
J. Nick Koston 55bf8c4e0c [core] Treat everything in the esphome package as in-tree for the float scanf scan 2026-09-11 13:21:54 -05:00
J. Nick Koston 7e089621d1 [core] Scan external component sources for float scanf 2026-09-11 12:59:13 -05:00
J. Nick Koston 1d1758c508 [core] Share the include extension set and cover the scanf call walker 2026-09-11 12:19:49 -05:00
J. Nick Koston 2606c88f3c [core] Match newlib for %%, support %p, and find float scans past a semicolon 2026-09-11 11:27:59 -05:00
J. Nick Koston 5c0607fc6e [esp32] Keep the sscanf wrap independent of enable_full_printf and say the override aborts 2026-09-11 10:33:23 -05:00
J. Nick Koston 2b3921b565 [core] Test include sorting and add_includes 2026-09-11 10:13:12 -05:00
J. Nick Koston 6020b76587 [core] Compare include basenames as posix in the test 2026-09-11 10:12:14 -05:00
J. Nick Koston 6f1d7d78f6 [core] Reuse SOURCE_FILE_EXTENSIONS for the include scan 2026-09-11 10:07:23 -05:00
J. Nick Koston aef04162d9 [esp32] Share include iteration and the float scanf resolver, register the sscanf job with the other wraps 2026-09-11 10:06:23 -05:00
J. Nick Koston e92530a0d5 [esp32] Scan includes for float scanf and name the format in the abort 2026-09-11 09:56:41 -05:00
J. Nick Koston 3c8c511e12 [esp32] Trim the sscanf wrap comments and docstrings 2026-09-11 09:53:38 -05:00
J. Nick Koston c6062a73c3 [esp32] Name the newlib wrap predicate for what it decides and test width with hh 2026-09-11 09:21:09 -05:00
J. Nick Koston bd193c0aff [core] Use fixed-width types in the sscanf scanner tests for clang-tidy 2026-09-11 08:45:48 -05:00
J. Nick Koston 79754065cd [esp32] Guard the sscanf wrap explicitly on Arduino and warn on a forced float lambda 2026-09-11 08:24:32 -05:00
J. Nick Koston 0e800b2bd8 [core] Mark the va_list parameter of the sscanf scanner for clang-tidy 2026-09-11 08:12:06 -05:00
J. Nick Koston 2c7b2e6cb1 [core] Use fixed-width types in the sscanf scanner for clang-tidy 2026-09-11 08:05:59 -05:00
J. Nick Koston ac4abd7f1f [core] Pass the sscanf destination pointer instead of a va_list reference 2026-09-11 08:01:06 -05:00
J. Nick Koston da70e10832 [core] Move the sscanf scanner into a header and add unit tests 2026-09-11 07:50:20 -05:00
J. Nick Koston 8aa1424ae4 [esp32] Share the wrap stub helpers and trim the sscanf scanner 2026-09-11 07:44:14 -05:00
J. Nick Koston 7d8f4b60e1 [esp32] Keep the libc sscanf when a lambda scans a float 2026-09-11 07:33:23 -05:00
J. Nick Koston d1943fe835 [esp32] Wrap sscanf to drop newlib's scanf engine from Bluetooth builds 2026-09-11 07:28:40 -05:00
79 changed files with 1183 additions and 5572 deletions
-1
View File
@@ -700,7 +700,6 @@ esphome/components/veml7700/* @latonita
esphome/components/version/* @esphome/core
esphome/components/vevor_dtx/* @Bofr5555
esphome/components/vl53l1x/* @will-tm
esphome/components/vnc/* @MindFreeze
esphome/components/voice_assistant/* @jesserockz @kahrendt
esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
esphome/components/watchdog/* @oarcher
+126
View File
@@ -0,0 +1,126 @@
"""Build-time scans of the user's C++ sources: lambdas, ``includes:`` files and
external components.
The only scan today finds scanf calls with float conversions, which decides
whether float scanf support (or, on ESP-IDF Bluetooth builds, the libc sscanf)
must stay linked. Other source heuristics belong here too.
"""
import logging
from pathlib import Path
import re
from esphome.config_helpers import iter_include_files
from esphome.const import (
CONF_ESPHOME,
CONF_INCLUDES,
CONF_INCLUDES_C,
SOURCE_FILE_EXTENSIONS,
)
from esphome.core import Lambda
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# The string literal handed to a scanf family call as its format: an optional
# first argument (sscanf/fscanf; a literal or a call with its own commas is
# fine) then the literal.
_SCANF_FORMAT_RE = re.compile(
r'scanf\s*\((?:(?:"(?:[^"\\]|\\.)*"|\([^()]*\)|[^,;"()])*,)?\s*"((?:[^"\\]|\\.)*)"'
)
# Standard scanf float conversions %f %F %e %E %g %G %a %A with optional
# suppression, width and length; also the invalid %.2f users write by analogy
# with printf.
_SCANF_FLOAT_SPEC_RE = re.compile(r"%[*\d.]*[hlL]*[feEgGaAF]")
def source_uses_scanf_float(src: str) -> bool:
"""Heuristic: does C++ source call a scanf family function with a float conversion?"""
if "scanf" not in src:
return False
src = Lambda.comment_remover(src)
return any(
_SCANF_FLOAT_SPEC_RE.search(match.group(1))
for match in _SCANF_FORMAT_RE.finditer(src)
)
def lambdas_use_scanf_float(config: ConfigType) -> bool:
"""Check if any lambda in the config uses scanf with a float format specifier."""
stack: list = [config]
while stack:
obj = stack.pop()
if isinstance(obj, Lambda):
if source_uses_scanf_float(obj.value):
return True
elif isinstance(obj, dict):
stack.extend(obj.values())
elif isinstance(obj, list):
stack.extend(obj)
return False
def includes_use_scanf_float(config: ConfigType) -> bool:
"""Check if an ``includes:`` or ``includes_c:`` file uses scanf with a float format specifier."""
esphome_config = config.get(CONF_ESPHOME, {})
includes = esphome_config.get(CONF_INCLUDES, []) + esphome_config.get(
CONF_INCLUDES_C, []
)
for path, _ in iter_include_files(includes):
if path.suffix not in SOURCE_FILE_EXTENSIONS:
continue
if source_uses_scanf_float(path.read_text(encoding="utf-8", errors="replace")):
return True
return False
def external_components_use_scanf_float(config: ConfigType) -> bool:
"""Whether a component from outside the esphome package scans a float."""
from esphome.config import iter_components
from esphome.loader import CORE_COMPONENTS_PATH
package_root = CORE_COMPONENTS_PATH.parent
for _, component in iter_components(config):
package_dir = Path(component.module.__file__).resolve().parent
if package_dir.is_relative_to(package_root):
continue
for resource in component.resources:
src = (package_dir / resource.resource).read_text(
encoding="utf-8", errors="replace"
)
if source_uses_scanf_float(src):
return True
return False
def user_code_uses_scanf_float(config: ConfigType) -> bool:
"""Whether a lambda, an ``includes:`` file or an external component scans a float."""
return (
lambdas_use_scanf_float(config)
or includes_use_scanf_float(config)
or external_components_use_scanf_float(config)
)
def keep_float_scanf(
option: bool | None, config: ConfigType, flash_note: str, override_note: str
) -> bool:
"""Resolve a tri-state float-scanf option: unset means "only if user code scans a float"."""
if option:
return True
found = user_code_uses_scanf_float(config)
if option is None:
if found:
_LOGGER.warning(
"Lambda, include or external component uses scanf with a float format "
"specifier; keeping float scanf support (%s)",
flash_note,
)
return found
if found:
_LOGGER.warning(
"Float scanf support is disabled but a lambda, include or external "
"component uses scanf with a float format specifier; %s",
override_note,
)
return False
+11 -100
View File
@@ -1,7 +1,5 @@
from collections.abc import Callable
from ipaddress import IPv4Address, IPv6Address
import logging
import math
import re
from typing import Any
@@ -29,7 +27,6 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DEFAULT,
CONF_DELAY,
CONF_ENCRYPTION,
CONF_EVENT,
@@ -139,7 +136,6 @@ 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"
@@ -251,66 +247,12 @@ def _validate_supports_response(value: Any) -> str:
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
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,
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,
}
)
# Accepts the plain `name: type` shorthand or the full mapping form
@@ -522,28 +464,7 @@ def _has_action_metadata(actions: list[ConfigType]) -> bool:
)
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]:
def _action_strings(conf: ConfigType, has_metadata: 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]]
@@ -556,15 +477,6 @@ def _action_strings(
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
@@ -573,7 +485,9 @@ def _validate_wizard(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = _validate_wizard
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_wizard,
)
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
@@ -632,9 +546,6 @@ 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):
@@ -678,7 +589,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, has_optional)
strings = _action_strings(conf, has_metadata)
table = _add_action_strings(index, strings)
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
-6
View File
@@ -1174,10 +1174,6 @@ 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;
@@ -1789,8 +1785,6 @@ message MediaPlayerSupportedFormat {
uint32 num_channels = 3;
MediaPlayerFormatPurpose purpose = 4;
uint32 sample_bytes = 5;
// Constant bitrate in kbps for lossy formats; 0 means no preference
uint32 bitrate = 6;
}
message ListEntitiesMediaPlayerResponse {
option (id) = 63;
@@ -1131,7 +1131,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec
media_format.num_channels = supported_format.num_channels;
media_format.purpose = static_cast<enums::MediaPlayerFormatPurpose>(supported_format.purpose);
media_format.sample_bytes = supported_format.sample_bytes;
media_format.bitrate = supported_format.bitrate;
}
return fill_and_encode_entity_info(media_player, msg, conn, remaining_size);
}
-14
View File
@@ -1369,12 +1369,6 @@ 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;
}
@@ -1388,12 +1382,6 @@ 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;
}
@@ -2385,7 +2373,6 @@ uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, uint8_t *__res
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.num_channels);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast<uint32_t>(msg.purpose));
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.sample_bytes);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.bitrate);
return pos;
}
uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) {
@@ -2396,7 +2383,6 @@ uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.num_channels);
size += msg.purpose ? 2 : 0;
size += ProtoSize::calc_uint32(1, msg.sample_bytes);
size += ProtoSize::calc_uint32(1, msg.bitrate);
return size;
}
uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self,
-7
View File
@@ -1593,12 +1593,6 @@ 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 {
@@ -2269,7 +2263,6 @@ class MediaPlayerSupportedFormat final : public ProtoMessage {
uint32_t num_channels{0};
enums::MediaPlayerFormatPurpose purpose{};
uint32_t sample_bytes{0};
uint32_t bitrate{0};
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);
-7
View File
@@ -1643,12 +1643,6 @@ 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();
}
@@ -2105,7 +2099,6 @@ const char *MediaPlayerSupportedFormat::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("num_channels"), this->num_channels);
dump_field(out, ESPHOME_PSTR("purpose"), static_cast<enums::MediaPlayerFormatPurpose>(this->purpose));
dump_field(out, ESPHOME_PSTR("sample_bytes"), this->sample_bytes);
dump_field(out, ESPHOME_PSTR("bitrate"), this->bitrate);
return out.c_str();
}
const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const {
-7
View File
@@ -31,13 +31,6 @@ 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;
+3 -10
View File
@@ -40,20 +40,13 @@ 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); 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.
// the name and every argument is (name, description, example). Unset metadata 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_BASE_STRINGS = 3;
static constexpr size_t USER_ACTION_ARG_STRINGS = 3;
#else
static constexpr size_t USER_ACTION_HEADER_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;
static constexpr size_t USER_ACTION_ARG_STRINGS = 1;
#endif
class UserServiceStatic : public UserServiceDescriptor {
public:
+52 -10
View File
@@ -10,11 +10,13 @@ import subprocess
from typing import Any
from esphome import yaml_util
from esphome.code_scan import keep_float_scanf
import esphome.codegen as cg
from esphome.components.const import (
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION,
CONF_IGNORE_NOT_FOUND,
)
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_ADVANCED,
@@ -204,6 +206,10 @@ PSRAM_XIP_VARIANTS = {
# neither, so the engine is already in the image and the wrap saves nothing.
ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6}
# Variants whose ROM exports sscanf with the newlib format in use (C6 normal
# format, C2 nano format by IDF default), so the sscanf wrap saves nothing.
ROM_SSCANF_VARIANTS = {VARIANT_ESP32C2, VARIANT_ESP32C6}
# NVS encryption (HMAC peripheral scheme) is only available on variants that
# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original
# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral
@@ -2052,6 +2058,7 @@ CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram"
CONF_HEAP_IN_IRAM = "heap_in_iram"
CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size"
CONF_USE_FULL_CERTIFICATE_BUNDLE = "use_full_certificate_bundle"
CONF_ENABLE_FULL_SCANF = "enable_full_scanf"
CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs"
CONF_DISABLE_OCD_AWARE = "disable_ocd_aware"
CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary"
@@ -2347,6 +2354,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, default=[]
): cv.ensure_list(cv.string_strict),
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
cv.Optional(CONF_ENABLE_FULL_SCANF): cv.boolean,
cv.Optional(CONF_DISABLE_DEBUG_STUBS, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_OCD_AWARE, default=True): cv.boolean,
cv.Optional(
@@ -2649,6 +2657,32 @@ async def _set_libc_picolibc_newlib_compat() -> None:
)
def _add_wrap_stub(symbol: str, define: str) -> None:
"""Emit a linker wrap for ``symbol`` served by a stub compiled under ``define``.
--undefined pulls the stub from libsrc.a, scanned before the IDF library that
references the symbol.
"""
cg.add_define(define)
cg.add_build_flag(f"-Wl,--wrap={symbol}")
cg.add_build_flag(f"-Wl,--undefined=__wrap_{symbol}")
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_sscanf_stub(enable_full_scanf: bool | None) -> None:
"""Wrap sscanf when bluedroid is the only reason newlib's scanf engine links.
FINAL priority so every request_bluetooth() call has happened.
"""
if get_esp32_variant() in ROM_SSCANF_VARIANTS or not _network_sdkconfig().bluetooth:
return
if keep_float_scanf(
enable_full_scanf, CORE.config, "~13KB flash", "that call will abort the device"
):
return
_add_wrap_stub("sscanf", "USE_ESP32_SSCANF_STUB")
@coroutine_with_priority(CoroPriority.FINAL)
async def _apply_nvs_cache_in_psram(explicit: bool) -> None:
"""Keep the NVS cache in PSRAM unless NVS encryption is on, however it was enabled."""
@@ -3090,9 +3124,8 @@ async def to_code(config):
# saves nothing while costing ~170 B of shim. IDF 5.x defaults to
# newlib on every variant; IDF 6.0+ switches to picolibc on every
# variant.
if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or idf_version() >= cv.Version(
6, 0, 0
):
uses_newlib = idf_version() < cv.Version(6, 0, 0)
if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or not uses_newlib:
cg.add_define("USE_FULL_PRINTF")
else:
for symbol in ("vprintf", "printf", "fprintf", "vfprintf"):
@@ -3100,14 +3133,14 @@ async def to_code(config):
# esp_http_client calls vasprintf, which on the ESP32-C6 is the only
# reference to newlib's full printf engine (~20 KB: _svfprintf_r,
# _dtoa_r and their helpers); every other caller resolves to the
# ROM. See vasprintf_stubs.cpp. The --undefined flag is needed
# because libsrc.a is scanned before the IDF libraries that
# reference the symbol, so the stub would otherwise never be pulled
# from the archive.
# ROM. See vasprintf_stubs.cpp.
if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS:
cg.add_define("USE_ESP32_VASPRINTF_STUB")
cg.add_build_flag("-Wl,--wrap=vasprintf")
cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf")
_add_wrap_stub("vasprintf", "USE_ESP32_VASPRINTF_STUB")
if uses_newlib:
# bluedroid's sscanf calls; see sscanf_stubs.cpp
CORE.add_job(
_add_sscanf_stub, conf[CONF_ADVANCED].get(CONF_ENABLE_FULL_SCANF)
)
else:
cg.add_build_flag("-DUSE_ARDUINO")
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO")
@@ -4097,3 +4130,12 @@ def process_stacktrace(config, line, backtrace_state):
_decode_pc(config, addr.group())
return backtrace_state
# Each stub only compiles under its define, so keep the sources out of other builds
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{
"sscanf_stubs.cpp": "USE_ESP32_SSCANF_STUB",
"vasprintf_stubs.cpp": "USE_ESP32_VASPRINTF_STUB",
}
)
+43
View File
@@ -0,0 +1,43 @@
/*
* Linker wrap for sscanf on ESP-IDF with newlib.
*
* bluedroid's two "%02x" parses are the only sscanf callers in an ESPHome
* image and link newlib's whole scanf engine (~13 KB with _strtod_l). Routing
* them through core/sscanf_no_float.h drops it; floats would pull _strtod_l
* back, so those abort naming enable_full_scanf. See _add_sscanf_stub in
* esp32/__init__.py for when this is emitted.
*/
#include "esphome/core/defines.h"
#if defined(USE_ESP_IDF) && defined(USE_ESP32_SSCANF_STUB)
#include <cstdarg>
#include <cstdio>
#include "esp_system.h"
#include "esphome/core/sscanf_no_float.h"
namespace esphome::esp32 {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
int __wrap_sscanf(const char *str, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int result = esphome::vsscanf_no_float(str, fmt, ap);
va_end(ap);
if (result == esphome::SSCANF_UNSUPPORTED) {
char msg[160];
snprintf(msg, sizeof(msg),
"sscanf: unsupported conversion in \"%s\"; set enable_full_scanf: true in esp32 framework", fmt);
esp_system_abort(msg);
}
return result;
}
} // extern "C"
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_ESP_IDF && USE_ESP32_SSCANF_STUB
+8 -43
View File
@@ -6,6 +6,7 @@ import subprocess
from typing import Any
from esphome.build_helpers.native import native_backend
from esphome.code_scan import keep_float_scanf
import esphome.codegen as cg
from esphome.components import network
import esphome.config_validation as cv
@@ -26,13 +27,7 @@ from esphome.const import (
ThreadModel,
Toolchain,
)
from esphome.core import (
CORE,
CoroPriority,
EsphomeError,
Lambda,
coroutine_with_priority,
)
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import IS_MACOS, copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script
@@ -67,15 +62,6 @@ from .const import (
from .gpio import PinInitialState, add_pin_initial_states_array
CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float"
# Heuristically matches scanf/sscanf calls with float format specifiers.
# Standard scanf float conversions: %f %F %e %E %g %G %a %A
# With optional modifiers: %*f (suppression), %8f (width), %lf %Lf (length)
# Also matches non-standard patterns like %.2f as a heuristic — these are
# invalid in scanf but users may write them by analogy with printf.
# Uses [^;]*? to stay within a single statement, preventing false positives
# from e.g. sscanf(buf, "%d", &x); printf("%f", val);
_SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]")
CODEOWNERS = ["@esphome/core"]
DOMAIN = "esp8266"
_LOGGER = logging.getLogger(__name__)
@@ -83,26 +69,6 @@ AUTO_LOAD = ["preferences"]
IS_TARGET_PLATFORM = True
def lambdas_use_scanf_float(config: ConfigType) -> bool:
"""Check if any lambda in the config uses scanf with a float format specifier.
Comments are stripped before matching to avoid false positives from
commented-out code. The cost of a false positive is only ~8KB flash.
"""
stack: list = [config]
while stack:
obj = stack.pop()
if isinstance(obj, Lambda):
src = obj.comment_remover(obj.value)
if _SCANF_FLOAT_RE.search(src):
return True
elif isinstance(obj, dict):
stack.extend(obj.values())
elif isinstance(obj, list):
stack.extend(obj)
return False
def set_core_data(config: ConfigType) -> ConfigType:
CORE.data[KEY_ESP8266] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266
@@ -425,13 +391,12 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_ESP8266_CRASH_HANDLER")
cg.add_define("USE_CRASH_HANDLER")
enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT)
if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config):
enable_scanf_float = True
_LOGGER.warning(
"Lambda uses scanf with a float format specifier; "
"enabling scanf float support (~8KB flash)"
)
enable_scanf_float = keep_float_scanf(
config.get(CONF_ENABLE_SCANF_FLOAT),
CORE.config,
"~8KB flash",
"that call will not parse",
)
# The native generator reads the same decision (KEY_SCANF_FLOAT)
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
+16 -21
View File
@@ -46,7 +46,6 @@ from esphome.const import (
CONF_TYPE,
CONF_USE_ADDRESS,
CONF_VALUE,
CONF_WIFI,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
Platform,
@@ -563,25 +562,6 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate_wifi(config: ConfigType) -> None:
"""A wifi station competes with ethernet for the primary interface, so both must be in network: priority:."""
full_config = fv.full_config.get()
if (wifi_config := full_config.get(CONF_WIFI)) is None:
return
from esphome.components.wifi import wifi_stays_ap_only
if wifi_stays_ap_only(wifi_config, full_config):
return # an access point does not claim the primary interface
priority_ifaces = get_priority_interfaces_from_full_config(full_config)
if missing := [i for i in (DOMAIN, CONF_WIFI) if i not in priority_ifaces]:
raise cv.Invalid(
"When ethernet and a wifi station are used together, 'network: priority:' "
f"must list both interfaces; missing: {', '.join(missing)}"
)
def _final_validate_spi(config: ConfigType) -> None:
if not CORE.is_esp32:
return # SPI interface validation is ESP32-only
@@ -866,7 +846,22 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> None:
"""Final validation for Ethernet component."""
_final_validate_wifi(config)
# Allow ethernet + wifi coexistence only when both are declared in network: priority:.
if "wifi" in fv.full_config.get():
priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get())
missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces]
if missing and priority_ifaces:
# A priority list exists but is incomplete: point at what to add.
raise cv.Invalid(
"When ethernet and wifi are used together, 'network: priority:' must "
f"list both interfaces; missing: {', '.join(missing)}"
)
if missing:
raise cv.Invalid(
"Component ethernet cannot be used together with component wifi "
"unless both are listed under 'network: priority:'"
)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
-3
View File
@@ -1,7 +1,6 @@
#pragma once
#include "light_color_values.h"
#include "esphome/core/string_ref.h"
namespace esphome {
@@ -134,8 +133,6 @@ 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).
@@ -3,7 +3,6 @@ from collections.abc import Callable
from esphome import automation
import esphome.codegen as cg
from esphome.components import audio
from esphome.components.const import CONF_BITRATE
import esphome.config_validation as cv
from esphome.const import (
CONF_ENTITY_CATEGORY,
@@ -56,30 +55,6 @@ FORMAT_MAPPING = {
}
# MP3 constant bitrates in kbps; the encoder rounds anything else to the nearest one
MP3_BITRATES = (
8,
16,
24,
32,
40,
48,
56,
64,
80,
96,
112,
128,
144, # MPEG-2 only, at 16, 22.05 and 24 kHz
160,
192,
224,
256,
320,
)
validate_mp3_bitrate = cv.one_of(*MP3_BITRATES, int=True)
def build_supported_format_struct(
format_config: ConfigType, purpose: MockObj
) -> cg.StructInitializer:
@@ -100,10 +75,6 @@ def build_supported_format_struct(
if format_config[CONF_FORMAT] != "MP3":
args.append(("sample_bytes", 2))
# Omitted means no preference, and the client keeps encoding as it always has.
if bitrate := format_config.get(CONF_BITRATE):
args.append(("bitrate", bitrate))
return cg.StructInitializer(*args)
@@ -120,14 +91,6 @@ def validate_preferred_format(
inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config)
inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config)
# A constant bitrate is only meaningful for the one lossy format whose
# encoder Home Assistant can be told to fix
if CONF_BITRATE in config and config.get(CONF_FORMAT) != "MP3":
raise cv.Invalid(
f"{CONF_BITRATE} is only supported for the MP3 format",
path=[CONF_BITRATE],
)
# Opus only supports 48 kHz
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
@@ -77,7 +77,6 @@ struct MediaPlayerSupportedFormat {
uint32_t num_channels;
MediaPlayerFormatPurpose purpose;
uint32_t sample_bytes;
uint32_t bitrate;
};
// Base features always reported for all media players
@@ -52,7 +52,6 @@ ST7796.extend(
height=480,
offset_width=49,
draw_rounding=1,
data_rate="40MHz",
cs_pin=39,
reset_pin=47,
dc_pin=9,
@@ -13,7 +13,6 @@ from esphome.components import (
)
from esphome.components.const import (
CONF_ANNOUNCEMENT_VOLUME,
CONF_BITRATE,
CONF_VOLUME_INCREMENT,
CONF_VOLUME_INITIAL,
CONF_VOLUME_MAX,
@@ -108,7 +107,6 @@ PIPELINE_SCHEMA = cv.Schema(
cv.Optional(CONF_FORMAT, default="FLAC"): cv.enum(audio.AUDIO_FILE_TYPE_ENUM),
cv.Optional(CONF_SAMPLE_RATE): cv.int_range(min=1),
cv.Optional(CONF_NUM_CHANNELS): cv.int_range(1, 2),
cv.Optional(CONF_BITRATE): media_player.validate_mp3_bitrate,
}
)
@@ -3,7 +3,6 @@ import esphome.codegen as cg
from esphome.components import audio, media_player, media_source, speaker
from esphome.components.const import (
CONF_ANNOUNCEMENT_VOLUME,
CONF_BITRATE,
CONF_SOURCES,
CONF_VOLUME_INCREMENT,
CONF_VOLUME_INITIAL,
@@ -79,7 +78,6 @@ PIPELINE_SCHEMA = cv.Schema(
cv.Optional(CONF_FORMAT, default="FLAC"): cv.enum(audio.AUDIO_FILE_TYPE_ENUM),
cv.Optional(CONF_SAMPLE_RATE): cv.int_range(min=1),
cv.Optional(CONF_NUM_CHANNELS): cv.int_range(1, 2),
cv.Optional(CONF_BITRATE): media_player.validate_mp3_bitrate,
}
)
-138
View File
@@ -1,138 +0,0 @@
"""VNC (RFB) client that shows a remote screen on a display."""
from esphome import automation
import esphome.codegen as cg
from esphome.components import display, network, socket
from esphome.components.const import (
BYTE_ORDER_LITTLE,
CONF_HOST,
CONF_RECONNECT_INTERVAL,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_DISPLAY_ID,
CONF_ID,
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_PLATFORM,
CONF_PORT,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@MindFreeze"]
DEPENDENCIES = ["display", "network"]
AUTO_LOAD = ["socket"]
DOMAIN = "vnc"
MULTI_CONF = True
CONF_MAX_UPDATE_DELAY = "max_update_delay"
CONF_UPDATE_DELAY = "update_delay"
vnc_ns = cg.esphome_ns.namespace("vnc")
VncClient = vnc_ns.class_("VncClient", cg.Component)
# Drivers that cannot take another component's pixels yet.
UNSUPPORTED_DISPLAYS = dict.fromkeys(
("mipi_dsi", "mipi_rgb", "mipi_spi"), "it is not supported by vnc yet"
) | {
"waveshare_epaper": "it draws every colour except pure black as ink; use epaper_spi"
}
def _register_writer(config: ConfigType) -> ConfigType:
display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(VncClient),
cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display),
cv.Required(CONF_HOST): socket.ipv4_host,
cv.Optional(CONF_PORT, default=5900): cv.port,
cv.Optional(CONF_RECONNECT_INTERVAL, default="5s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1)),
),
cv.Optional(
CONF_UPDATE_DELAY, default="200ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_UPDATE_DELAY, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA),
_register_writer,
socket.consume_sockets(1, DOMAIN),
network.require_ipv4(DOMAIN),
)
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
disp_id = config[CONF_DISPLAY_ID]
disp_conf = fv.get_declaring_config(disp_id)
if (platform := disp_conf.get(CONF_PLATFORM)) in UNSUPPORTED_DISPLAYS:
raise cv.Invalid(
f"Display '{disp_id}' uses the {platform} platform: "
f"{UNSUPPORTED_DISPLAYS[platform]}",
path=[CONF_DISPLAY_ID],
)
if (
display.get_display_metadata(disp_id).has_writer
or disp_conf.get(CONF_AUTO_CLEAR_ENABLED) is True
):
raise cv.Invalid(
f"vnc draws the whole of display '{disp_id}'; remove lambda, pages, "
"auto_clear_enabled and show_test_card from it",
path=[CONF_DISPLAY_ID],
)
if len(writers := display.get_external_writers(disp_id)) > 1:
raise cv.Invalid(
f"Display '{disp_id}' is drawn by more than one component: {', '.join(writers)}",
path=[CONF_DISPLAY_ID],
)
lvgl_configs = full_config.get("lvgl") or []
if isinstance(lvgl_configs, dict):
lvgl_configs = [lvgl_configs]
for lvgl_config in lvgl_configs:
if any(d.id == disp_id.id for d in lvgl_config.get("displays", [])):
raise cv.Invalid(
f"Display '{disp_id}' is also used by LVGL", path=[CONF_DISPLAY_ID]
)
FINAL_VALIDATE_SCHEMA = _final_validate
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
)
async def to_code(config: ConfigType) -> None:
disp = await cg.get_variable(config[CONF_DISPLAY_ID])
# Ask the server for the display's own byte order, so block-copying drivers skip the per-pixel path
meta = display.get_display_metadata(config[CONF_DISPLAY_ID])
var = cg.new_Pvariable(config[CONF_ID], disp, meta.byte_order != BYTE_ORDER_LITTLE)
await cg.register_component(var, config)
socket.require_tcp_client_link()
cg.add(var.set_host(config[CONF_HOST]))
cg.add(var.set_port(config[CONF_PORT]))
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
cg.add(var.set_update_delay(config[CONF_UPDATE_DELAY]))
cg.add(var.set_max_update_delay(config[CONF_MAX_UPDATE_DELAY]))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
VNC_ID_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(VncClient)},
)
automation.register_apply_condition("vnc.is_connected", VNC_ID_SCHEMA, "is_connected()")
-702
View File
@@ -1,702 +0,0 @@
#include "vnc_client.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cinttypes>
#include <cstring>
namespace esphome::vnc {
ESPHOME_LOG_TAG(TAG, "vnc");
// Below the 50 ms blocking warning, and the same slice it8951 uses for its transfers.
static constexpr uint32_t LOOP_BUDGET_MS = 20;
static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 15000;
static constexpr uint32_t STABLE_SESSION_MS = 60000;
static constexpr uint32_t MAX_BACKOFF_MS = 60000;
static constexpr uint32_t CONFIG_RETRY_MS = 300000;
static constexpr uint32_t HEARTBEAT_MS = 60000;
static constexpr uint32_t MIN_REQUEST_SPACING_MS = 50;
// The longest the first refresh waits for a display with a loop to park once after boot.
static constexpr uint32_t DISPLAY_START_MS = 5000;
// Longer than any driver's own busy timeout: a panel that stays busy this long is stuck
static constexpr uint32_t DISPLAY_BUSY_TIMEOUT_MS = 120000;
static constexpr uint16_t MAX_CURSOR_SIDE = 1024;
static constexpr uint32_t MAX_TEXT_LEN = 63;
static constexpr uint8_t SECURITY_NONE = 1;
static constexpr uint8_t SECURITY_VNC_AUTH = 2;
static constexpr int32_t ENCODING_RAW = 0;
static constexpr int32_t ENCODING_RICH_CURSOR = -239;
static constexpr uint8_t MSG_FRAMEBUFFER_UPDATE = 0;
static constexpr uint8_t MSG_SET_COLOUR_MAP = 1;
static constexpr uint8_t MSG_BELL = 2;
static constexpr uint8_t MSG_SERVER_CUT_TEXT = 3;
static inline uint16_t read_be16(const uint8_t *p) { return encode_uint16(p[0], p[1]); }
static inline uint32_t read_be32(const uint8_t *p) { return encode_uint32(p[0], p[1], p[2], p[3]); }
static inline void write_be16(uint8_t *p, uint16_t v) {
p[0] = v >> 8;
p[1] = v;
}
static inline void write_be32(uint8_t *p, uint32_t v) {
p[0] = v >> 24;
p[1] = v >> 16;
p[2] = v >> 8;
p[3] = v;
}
static int parse_three_digits(const uint8_t *p) {
int value = 0;
for (int i = 0; i < 3; i++) {
if (p[i] < '0' || p[i] > '9') {
return -1;
}
value = value * 10 + (p[i] - '0');
}
return value;
}
void VncClient::setup() {
this->setup_ms_ = App.get_loop_component_start_time();
this->link_.begin(TAG);
}
void VncClient::dump_config() {
ESP_LOGCONFIG(TAG,
"VNC:\n"
" Server: %s:%u\n"
" Reconnect Interval: %" PRIu32 "ms\n"
" Update Delay: %" PRIu32 "ms\n"
" Max Update Delay: %" PRIu32 "ms",
this->link_.host(), this->link_.port(), this->link_.reconnect_interval(), this->update_delay_ms_,
this->max_update_delay_ms_);
}
void VncClient::on_shutdown() { this->link_.close(); }
void VncClient::loop() {
const uint32_t now = App.get_loop_component_start_time();
this->pass_start_ms_ = millis();
if (this->display_->is_failed()) {
this->link_.close(); // a connect still in flight would otherwise keep its socket for good
if (this->state_ != VncState::VNC_STATE_IDLE) {
this->session_end_(now, VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Display failed"));
}
this->status_set_error(LOG_STR("Display failed"));
this->disable_loop();
return;
}
// A display that parks its loop when idle is busy while that loop runs. Displays that never
// park (no loop, or one that always runs) update synchronously, so they are never busy.
if (this->display_->is_idle()) {
this->display_parks_ = true;
this->display_busy_ = false;
} else {
this->display_busy_ = this->display_parks_;
}
// A stuck panel, or another owner of the display's loop, would otherwise freeze the screen silently
if (!this->display_busy_) {
this->busy_since_ms_ = now;
if (this->busy_warned_) {
this->busy_warned_ = false;
// The connection path owns the warning while no session runs
if (this->state_ == VncState::VNC_STATE_RUNNING) {
this->status_clear_warning();
}
}
} else if (!this->busy_warned_ && now - this->busy_since_ms_ >= DISPLAY_BUSY_TIMEOUT_MS) {
this->busy_warned_ = true;
this->status_set_warning(LOG_STR("Display busy too long"));
}
if (!this->link_.connected()) {
if (this->state_ != VncState::VNC_STATE_IDLE) {
this->session_lost_(now);
}
this->connect_step_(now);
if (!this->link_.connected()) {
return;
}
this->start_handshake_(now);
}
if (this->state_ == VncState::VNC_STATE_HANDSHAKE && now - this->session_start_ms_ >= HANDSHAKE_TIMEOUT_MS) {
this->fail_(this->lost_failure_(), LOG_STR("Handshake timed out"));
return;
}
const bool blocked = this->in_update_ && this->display_busy_;
// A display still starting up (it8951) can go from its start-up straight into a refresh without parking.
const bool display_starting =
!this->display_parks_ && this->display_->has_overridden_loop() && now - this->setup_ms_ < DISPLAY_START_MS;
// Wait until the screen stops changing, so an animation becomes one refresh showing where it ended.
if (this->refresh_pending_ && !this->in_update_ && !this->display_busy_ && !display_starting &&
(now - this->last_change_ms_ >= this->update_delay_ms_ ||
now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
// Synchronous displays flush inside update(), so this pass does nothing else.
this->refresh_pending_ = false;
this->display_->update();
this->display_busy_ = this->display_parks_ && !this->display_->is_idle();
} else if (!blocked && (this->rx_pending_ || this->link_.ready())) {
this->service_rx_(now);
}
if (this->state_ == VncState::VNC_STATE_RUNNING) {
// Mid-frame there is nothing to send, so a vanished server would never fail a write.
if (this->in_update_ && !blocked && !this->rx_pending_ && now - this->last_rx_ms_ >= HEARTBEAT_MS) {
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Server stopped sending"));
return;
}
this->pace_(now);
}
this->link_.flush_tx();
if (this->rx_pending_ && !(this->in_update_ && this->display_busy_)) {
this->high_freq_.start();
} else {
this->high_freq_.stop();
}
}
void VncClient::connect_step_(uint32_t now) {
const bool up = network::is_connected();
if (up && !this->network_was_up_) {
this->backoff_ms_ = 0;
this->hold_ms_ = 0;
}
this->network_was_up_ = up;
if (!up) {
return;
}
if (!this->link_.connecting() && (now - this->hold_start_ms_ < this->hold_ms_ || this->link_.in_backoff())) {
return;
}
this->link_.poll();
// DNS failures, refused connects and connect timeouts all leave no socket and start the link's backoff.
if (!this->link_.connected() && !this->link_.connecting() && this->link_.in_backoff()) {
this->session_end_(now, VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Cannot reach the server"));
}
}
void VncClient::reset_session_() {
this->rx_pos_ = 0;
this->rx_len_ = 0;
this->rx_pending_ = false;
this->in_update_ = false;
this->request_outstanding_ = false;
this->refresh_pending_ = false;
this->frame_drawn_ = false;
this->step_ = VncStep::VNC_STEP_VERSION;
this->reason_failure_ = VncFailure::VNC_FAILURE_TRANSIENT;
this->high_freq_.stop();
}
void VncClient::start_handshake_(uint32_t now) {
this->reset_session_();
this->state_ = VncState::VNC_STATE_HANDSHAKE;
this->session_start_ms_ = now;
this->last_rx_ms_ = now;
}
VncFailure VncClient::lost_failure_() const {
if (this->state_ == VncState::VNC_STATE_HANDSHAKE) {
if (this->step_ == VncStep::VNC_STEP_REASON_LENGTH || this->step_ == VncStep::VNC_STEP_REASON) {
return this->reason_failure_;
}
}
return VncFailure::VNC_FAILURE_TRANSIENT;
}
void VncClient::session_lost_(uint32_t now) {
this->session_end_(now, this->lost_failure_(), LOG_STR("Connection lost"));
}
VncParse VncClient::fail_(VncFailure failure, const LogString *reason) {
this->link_.close();
this->link_.note_attempt();
this->session_end_(App.get_loop_component_start_time(), failure, reason);
return VncParse::VNC_PARSE_STOP;
}
void VncClient::session_end_(uint32_t now, VncFailure failure, const LogString *reason) {
const bool was_running = this->state_ == VncState::VNC_STATE_RUNNING;
this->reset_session_();
this->state_ = VncState::VNC_STATE_IDLE;
this->hold_start_ms_ = now;
if (was_running) {
const uint32_t lasted = now - this->session_start_ms_;
ESP_LOGW(TAG, "%s after %" PRIu32 "s", LOG_STR_ARG(reason), lasted / 1000);
this->disconnect_callback_.call();
this->status_set_warning();
if (lasted >= STABLE_SESSION_MS) {
this->backoff_ms_ = 0;
this->hold_ms_ = 0;
return;
}
failure = VncFailure::VNC_FAILURE_TRANSIENT;
}
switch (failure) {
case VncFailure::VNC_FAILURE_CONFIG:
this->hold_ms_ = CONFIG_RETRY_MS;
ESP_LOGE(TAG, "%s; retrying in %" PRIu32 "s", LOG_STR_ARG(reason), this->hold_ms_ / 1000);
this->status_set_error(reason);
return;
default: {
const uint32_t base = this->link_.reconnect_interval();
this->backoff_ms_ =
this->backoff_ms_ == 0 ? base : std::min(this->backoff_ms_ * 2, std::max(MAX_BACKOFF_MS, base));
this->hold_ms_ = this->backoff_ms_;
if (!was_running) {
ESP_LOGW(TAG, "%s; retrying in %" PRIu32 "s", LOG_STR_ARG(reason), this->hold_ms_ / 1000);
}
this->status_set_warning();
return;
}
}
}
bool VncClient::budget_exhausted_() const { return millis() - this->pass_start_ms_ >= LOOP_BUDGET_MS; }
void VncClient::service_rx_(uint32_t now) {
for (;;) {
VncParse result = this->parse_();
if (result == VncParse::VNC_PARSE_STOP) {
return;
}
if (result == VncParse::VNC_PARSE_PAUSE) {
this->rx_pending_ = true;
return;
}
// Less than one item is left, so the buffer always has room after compacting.
if (this->rx_pos_ != 0) {
this->rx_len_ -= this->rx_pos_;
std::memmove(this->rx_, this->rx_ + this->rx_pos_, this->rx_len_);
this->rx_pos_ = 0;
}
if (this->budget_exhausted_()) {
this->rx_pending_ = true;
return;
}
ssize_t count = this->link_.read(this->rx_ + this->rx_len_, RX_BUFFER_SIZE - this->rx_len_);
if (count < 0) {
this->session_lost_(now);
return;
}
if (count == 0) {
this->rx_pending_ = false;
return;
}
this->rx_len_ += static_cast<uint16_t>(count);
this->rx_total_ += count;
this->last_rx_ms_ = now;
}
}
void VncClient::skip_(uint32_t count, VncStep next) {
this->skip_left_ = count;
this->after_skip_ = next;
this->step_ = VncStep::VNC_STEP_SKIP;
}
bool VncClient::send_client_init_() {
const uint8_t shared = 1;
return this->send_(&shared, 1);
}
VncParse VncClient::parse_() {
for (;;) {
const uint8_t *p = this->rx_ + this->rx_pos_;
const size_t avail = this->rx_len_ - this->rx_pos_;
switch (this->step_) {
case VncStep::VNC_STEP_VERSION: {
if (avail < 12) {
return VncParse::VNC_PARSE_MORE;
}
const int major = parse_three_digits(p + 4);
const int minor = parse_three_digits(p + 8);
if (std::memcmp(p, "RFB ", 4) != 0 || p[7] != '.' || p[11] != '\n' || major < 0 || minor < 0) {
return this->fail_(VncFailure::VNC_FAILURE_CONFIG, LOG_STR("Not a VNC server"));
}
if (major < 3) {
return this->fail_(VncFailure::VNC_FAILURE_CONFIG, LOG_STR("Unsupported RFB version"));
}
this->minor_ = (major > 3 || minor >= 8) ? 8 : (minor == 7 ? 7 : 3);
this->rx_pos_ += 12;
uint8_t reply[12] = {'R', 'F', 'B', ' ', '0', '0', '3', '.', '0', '0', '8', '\n'};
reply[10] = '0' + this->minor_;
if (!this->send_(reply, sizeof(reply))) {
return this->fail_send_();
}
this->step_ = this->minor_ == 3 ? VncStep::VNC_STEP_SECURITY_TYPE_33 : VncStep::VNC_STEP_SECURITY_COUNT;
break;
}
case VncStep::VNC_STEP_SECURITY_COUNT: {
if (avail < 1) {
return VncParse::VNC_PARSE_MORE;
}
this->sec_count_ = p[0];
this->rx_pos_ += 1;
if (this->sec_count_ == 0) {
// A refusal before authentication, such as a lockout caused by another client.
this->step_ = VncStep::VNC_STEP_REASON_LENGTH;
} else {
this->step_ = VncStep::VNC_STEP_SECURITY_LIST;
}
break;
}
case VncStep::VNC_STEP_SECURITY_LIST: {
if (avail < this->sec_count_) {
return VncParse::VNC_PARSE_MORE;
}
uint8_t chosen = 0;
bool wants_password = false;
for (uint8_t i = 0; i < this->sec_count_; i++) {
if (p[i] == SECURITY_NONE) {
chosen = p[i];
break;
}
wants_password |= p[i] == SECURITY_VNC_AUTH;
}
this->rx_pos_ += this->sec_count_;
if (chosen == 0) {
return this->fail_(VncFailure::VNC_FAILURE_CONFIG, wants_password ? LOG_STR("The server requires a password")
: LOG_STR("No supported security type"));
}
if (!this->send_(&chosen, 1)) {
return this->fail_send_();
}
if (this->minor_ == 8) {
this->step_ = VncStep::VNC_STEP_SECURITY_RESULT;
} else {
if (!this->send_client_init_()) {
return this->fail_send_();
}
this->step_ = VncStep::VNC_STEP_SERVER_INIT;
}
break;
}
case VncStep::VNC_STEP_SECURITY_TYPE_33: {
if (avail < 4) {
return VncParse::VNC_PARSE_MORE;
}
const uint32_t type = read_be32(p);
this->rx_pos_ += 4;
if (type == 0) {
this->step_ = VncStep::VNC_STEP_REASON_LENGTH;
} else if (type == SECURITY_NONE) {
if (!this->send_client_init_()) {
return this->fail_send_();
}
this->step_ = VncStep::VNC_STEP_SERVER_INIT;
} else {
return this->fail_(VncFailure::VNC_FAILURE_CONFIG, type == SECURITY_VNC_AUTH
? LOG_STR("The server requires a password")
: LOG_STR("No supported security type"));
}
break;
}
case VncStep::VNC_STEP_SECURITY_RESULT: {
if (avail < 4) {
return VncParse::VNC_PARSE_MORE;
}
const uint32_t result = read_be32(p);
this->rx_pos_ += 4;
if (result == 0) {
if (!this->send_client_init_()) {
return this->fail_send_();
}
this->step_ = VncStep::VNC_STEP_SERVER_INIT;
break;
}
this->reason_failure_ = VncFailure::VNC_FAILURE_CONFIG;
this->step_ = VncStep::VNC_STEP_REASON_LENGTH;
break;
}
case VncStep::VNC_STEP_REASON_LENGTH: {
if (avail < 4) {
return VncParse::VNC_PARSE_MORE;
}
this->text_len_ = static_cast<uint8_t>(std::min(read_be32(p), MAX_TEXT_LEN));
this->rx_pos_ += 4;
this->step_ = VncStep::VNC_STEP_REASON;
break;
}
case VncStep::VNC_STEP_REASON: {
if (avail < this->text_len_) {
return VncParse::VNC_PARSE_MORE;
}
ESP_LOGE(TAG, "Server: %.*s", static_cast<int>(this->text_len_), reinterpret_cast<const char *>(p));
return this->fail_(this->reason_failure_, LOG_STR("Connection refused"));
}
case VncStep::VNC_STEP_SERVER_INIT: {
if (avail < 24) {
return VncParse::VNC_PARSE_MORE;
}
this->fb_w_ = read_be16(p);
this->fb_h_ = read_be16(p + 2);
// Reused for the length of the name that follows.
this->skip_left_ = read_be32(p + 20);
this->rx_pos_ += 24;
if (this->fb_w_ == 0 || this->fb_h_ == 0) {
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("The server screen is empty"));
}
this->text_len_ = static_cast<uint8_t>(std::min(this->skip_left_, MAX_TEXT_LEN));
this->step_ = VncStep::VNC_STEP_SERVER_NAME;
break;
}
case VncStep::VNC_STEP_SERVER_NAME: {
if (avail < this->text_len_) {
return VncParse::VNC_PARSE_MORE;
}
ESP_LOGI(TAG, "Server '%.*s', %ux%u, RFB 3.%u", static_cast<int>(this->text_len_),
reinterpret_cast<const char *>(p), this->fb_w_, this->fb_h_, this->minor_);
this->rx_pos_ += this->text_len_;
this->skip_(this->skip_left_ - this->text_len_, VncStep::VNC_STEP_SESSION_START);
break;
}
case VncStep::VNC_STEP_SESSION_START:
if (!this->start_session_(App.get_loop_component_start_time())) {
return this->fail_send_();
}
this->step_ = VncStep::VNC_STEP_MESSAGE;
break;
case VncStep::VNC_STEP_MESSAGE: {
if (avail < 1) {
return VncParse::VNC_PARSE_MORE;
}
switch (p[0]) {
case MSG_FRAMEBUFFER_UPDATE:
if (avail < 4) {
return VncParse::VNC_PARSE_MORE;
}
this->rects_left_ = read_be16(p + 2);
this->rx_pos_ += 4;
this->in_update_ = true;
this->update_start_ms_ = millis();
this->update_start_bytes_ = this->rx_total_ - (this->rx_len_ - this->rx_pos_);
this->rect_done_();
break;
case MSG_SET_COLOUR_MAP:
if (avail < 6) {
return VncParse::VNC_PARSE_MORE;
}
this->rx_pos_ += 6;
this->skip_(6u * read_be16(p + 4), VncStep::VNC_STEP_MESSAGE);
break;
case MSG_BELL:
this->rx_pos_ += 1;
break;
case MSG_SERVER_CUT_TEXT:
if (avail < 8) {
return VncParse::VNC_PARSE_MORE;
}
this->rx_pos_ += 8;
this->skip_(read_be32(p + 4), VncStep::VNC_STEP_MESSAGE);
break;
default:
ESP_LOGW(TAG, "Unknown message type %u", p[0]);
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error"));
}
break;
}
case VncStep::VNC_STEP_RECT: {
if (this->display_busy_) {
return VncParse::VNC_PARSE_PAUSE;
}
if (avail < 12) {
return VncParse::VNC_PARSE_MORE;
}
this->rect_x_ = read_be16(p);
this->rect_y_ = read_be16(p + 2);
this->rect_w_ = read_be16(p + 4);
this->rect_h_ = read_be16(p + 6);
const auto encoding = static_cast<int32_t>(read_be32(p + 8));
this->rx_pos_ += 12;
this->rects_left_--;
if (encoding == ENCODING_RAW) {
if (static_cast<uint32_t>(this->rect_x_) + this->rect_w_ > this->fb_w_ ||
static_cast<uint32_t>(this->rect_y_) + this->rect_h_ > this->fb_h_) {
ESP_LOGW(TAG, "Rectangle %ux%u at %u,%u is outside the %ux%u screen", this->rect_w_, this->rect_h_,
this->rect_x_, this->rect_y_, this->fb_w_, this->fb_h_);
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error"));
}
if (this->rect_w_ == 0 || this->rect_h_ == 0) {
this->rect_done_();
} else {
this->pos_x_ = 0;
this->pos_y_ = 0;
this->step_ = VncStep::VNC_STEP_RAW;
}
} else if (encoding == ENCODING_RICH_CURSOR) {
if (this->rect_w_ > MAX_CURSOR_SIDE || this->rect_h_ > MAX_CURSOR_SIDE) {
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error"));
}
const uint32_t size = static_cast<uint32_t>(this->rect_w_) * this->rect_h_ * this->bpp_ +
((this->rect_w_ + 7u) / 8u) * this->rect_h_;
this->skip_(size, VncStep::VNC_STEP_RECT_DONE);
} else {
ESP_LOGW(TAG, "Unsupported encoding %" PRId32, encoding);
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error"));
}
break;
}
case VncStep::VNC_STEP_RECT_DONE:
this->rect_done_();
break;
case VncStep::VNC_STEP_RAW: {
if (this->display_busy_) {
return VncParse::VNC_PARSE_PAUSE;
}
const size_t pixels = avail / this->bpp_;
if (pixels == 0) {
return VncParse::VNC_PARSE_MORE;
}
if (this->budget_exhausted_()) {
return VncParse::VNC_PARSE_PAUSE;
}
const auto count = static_cast<uint16_t>(std::min<size_t>(pixels, this->rect_w_ - this->pos_x_));
this->draw_row_(this->rect_x_ + this->pos_x_, this->rect_y_ + this->pos_y_, count, p);
this->rx_pos_ += count * this->bpp_;
this->pos_x_ += count;
if (this->pos_x_ == this->rect_w_) {
this->pos_x_ = 0;
if (++this->pos_y_ == this->rect_h_) {
this->rect_done_();
}
}
break;
}
case VncStep::VNC_STEP_SKIP: {
const auto count = static_cast<uint32_t>(std::min<size_t>(avail, this->skip_left_));
this->rx_pos_ += count;
this->skip_left_ -= count;
if (this->skip_left_ != 0) {
return VncParse::VNC_PARSE_MORE;
}
this->step_ = this->after_skip_;
break;
}
}
}
}
void VncClient::rect_done_() {
if (this->rects_left_ == 0) {
this->frame_done_();
} else {
this->step_ = VncStep::VNC_STEP_RECT;
}
}
void VncClient::frame_done_() {
this->in_update_ = false;
this->request_outstanding_ = false;
this->step_ = VncStep::VNC_STEP_MESSAGE;
if (this->frame_drawn_) {
const uint32_t now = App.get_loop_component_start_time();
this->frame_drawn_ = false;
if (!this->refresh_pending_) {
this->first_change_ms_ = now;
}
this->last_change_ms_ = now;
this->refresh_pending_ = true;
ESP_LOGD(TAG, "Update: %" PRIu32 " bytes in %" PRIu32 "ms",
this->rx_total_ - (this->rx_len_ - this->rx_pos_) - this->update_start_bytes_,
millis() - this->update_start_ms_);
}
}
void VncClient::draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t *pixels) {
if (y >= this->vis_h_ || x >= this->vis_w_) {
return;
}
const int visible = std::min<int>(count, this->vis_w_ - x);
this->display_->draw_pixels_at(x, y, visible, 1, pixels, this->bitness_, this->big_endian_);
this->frame_drawn_ = true;
}
bool VncClient::start_session_(uint32_t now) {
const bool rgb565 = this->display_->get_display_type() != display::DISPLAY_TYPE_BINARY;
this->bpp_ = rgb565 ? 2 : 1;
this->bitness_ = rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_332;
const int disp_w = this->display_->get_width();
const int disp_h = this->display_->get_height();
this->vis_w_ = std::min<int>(this->fb_w_, disp_w);
this->vis_h_ = std::min<int>(this->fb_h_, disp_h);
if (this->fb_w_ != disp_w || this->fb_h_ != disp_h) {
ESP_LOGW(TAG, "Server screen is %ux%u but the display is %dx%d; showing the top-left %ux%u", this->fb_w_,
this->fb_h_, disp_w, disp_h, this->vis_w_, this->vis_h_);
}
// SetPixelFormat: true colour, in the byte order the display takes
uint8_t pixel_format[20] = {};
pixel_format[4] = rgb565 ? 16 : 8;
pixel_format[5] = rgb565 ? 16 : 8;
pixel_format[6] = this->big_endian_;
pixel_format[7] = 1;
write_be16(pixel_format + 8, rgb565 ? 31 : 7);
write_be16(pixel_format + 10, rgb565 ? 63 : 7);
write_be16(pixel_format + 12, rgb565 ? 31 : 3);
pixel_format[14] = rgb565 ? 11 : 5;
pixel_format[15] = rgb565 ? 5 : 2;
if (!this->send_(pixel_format, sizeof(pixel_format))) {
return false;
}
// SetEncodings: TightVNC uses the first real encoding it supports for every rectangle.
uint8_t encodings[12] = {2, 0, 0, 2};
write_be32(encodings + 4, ENCODING_RAW);
write_be32(encodings + 8, static_cast<uint32_t>(ENCODING_RICH_CURSOR));
if (!this->send_(encodings, sizeof(encodings)) || !this->send_update_request_(false, now)) {
return false;
}
this->state_ = VncState::VNC_STATE_RUNNING;
this->session_start_ms_ = now;
if (!this->busy_warned_) {
this->status_clear_warning(); // a stuck display keeps its warning through a reconnect
}
this->status_clear_error();
this->connect_callback_.call();
return true;
}
void VncClient::pace_(uint32_t now) {
if (this->display_busy_ || this->in_update_ || now - this->last_request_ms_ < MIN_REQUEST_SPACING_MS) {
return;
}
// Repeating a parked request costs nothing on the server and detects a dead link.
if (!this->request_outstanding_ ||
(now - this->last_rx_ms_ >= HEARTBEAT_MS && now - this->last_request_ms_ >= HEARTBEAT_MS)) {
this->send_update_request_(true, now);
}
}
bool VncClient::send_(const uint8_t *data, size_t len) {
if (this->link_.tx_free() < len) {
this->link_.flush_tx();
if (this->link_.tx_free() < len) {
return false;
}
}
this->link_.queue(data, len);
return true;
}
bool VncClient::send_update_request_(bool incremental, uint32_t now) {
uint8_t msg[10] = {3, static_cast<uint8_t>(incremental ? 1 : 0)};
write_be16(msg + 6, this->vis_w_);
write_be16(msg + 8, this->vis_h_);
if (!this->send_(msg, sizeof(msg))) {
return false;
}
this->request_outstanding_ = true;
this->last_request_ms_ = now;
return true;
}
} // namespace esphome::vnc
-162
View File
@@ -1,162 +0,0 @@
#pragma once
#include "esphome/components/display/display.h"
#include "esphome/components/socket/tcp_client_link.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstddef>
#include <cstdint>
namespace esphome::vnc {
enum class VncState : uint8_t {
VNC_STATE_IDLE,
VNC_STATE_HANDSHAKE,
VNC_STATE_RUNNING,
};
enum class VncStep : uint8_t {
VNC_STEP_VERSION,
VNC_STEP_SECURITY_COUNT,
VNC_STEP_SECURITY_LIST,
VNC_STEP_SECURITY_TYPE_33,
VNC_STEP_SECURITY_RESULT,
VNC_STEP_REASON_LENGTH,
VNC_STEP_REASON,
VNC_STEP_SERVER_INIT,
VNC_STEP_SERVER_NAME,
VNC_STEP_SESSION_START,
VNC_STEP_MESSAGE,
VNC_STEP_RECT,
VNC_STEP_RECT_DONE,
VNC_STEP_RAW,
VNC_STEP_SKIP,
};
enum class VncParse : uint8_t {
VNC_PARSE_MORE, // the next step needs bytes that have not arrived
VNC_PARSE_PAUSE, // out of time, or the display is busy; resume without new data
VNC_PARSE_STOP, // the session ended
};
enum class VncFailure : uint8_t {
VNC_FAILURE_TRANSIENT,
VNC_FAILURE_CONFIG,
};
/// RFB (VNC) client that draws the remote framebuffer into a display.
class VncClient : public Component {
public:
explicit VncClient(display::Display *display, bool big_endian = false) : display_(display), big_endian_(big_endian) {}
void set_host(const char *host) { this->link_.set_host(host); }
void set_port(uint16_t port) { this->link_.set_port(port); }
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
void set_update_delay(uint32_t ms) { this->update_delay_ms_ = ms; }
void set_max_update_delay(uint32_t ms) { this->max_update_delay_ms_ = ms; }
void setup() override;
void loop() override;
void dump_config() override;
void on_shutdown() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
bool is_connected() const { return this->state_ == VncState::VNC_STATE_RUNNING; }
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callback_.add(std::forward<F>(callback));
}
protected:
static constexpr size_t RX_BUFFER_SIZE = 512;
void connect_step_(uint32_t now);
void start_handshake_(uint32_t now);
void service_rx_(uint32_t now);
VncParse parse_();
void pace_(uint32_t now);
bool start_session_(uint32_t now);
bool send_client_init_();
void frame_done_();
void rect_done_();
void draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t *pixels);
void skip_(uint32_t count, VncStep next);
bool budget_exhausted_() const;
bool send_(const uint8_t *data, size_t len);
bool send_update_request_(bool incremental, uint32_t now);
VncParse fail_(VncFailure failure, const LogString *reason);
VncParse fail_send_() { return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Send failed")); }
VncFailure lost_failure_() const;
void session_lost_(uint32_t now);
void session_end_(uint32_t now, VncFailure failure, const LogString *reason);
void reset_session_();
display::Display *display_;
socket::TcpClientLink link_;
LazyCallbackManager<void()> connect_callback_;
LazyCallbackManager<void()> disconnect_callback_;
HighFrequencyLoopRequester high_freq_;
uint32_t update_delay_ms_{200};
uint32_t max_update_delay_ms_{1000};
uint32_t backoff_ms_{0};
uint32_t hold_ms_{0};
uint32_t hold_start_ms_{0};
uint32_t setup_ms_{0};
uint32_t busy_since_ms_{0};
uint32_t session_start_ms_{0};
uint32_t update_start_ms_{0};
// The first and the latest frame drawn since the display last refreshed.
uint32_t first_change_ms_{0};
uint32_t last_change_ms_{0};
uint32_t rx_total_{0};
uint32_t update_start_bytes_{0};
uint32_t last_request_ms_{0};
uint32_t last_rx_ms_{0};
uint32_t pass_start_ms_{0};
uint32_t skip_left_{0};
uint16_t rx_pos_{0};
uint16_t rx_len_{0};
uint16_t fb_w_{0};
uint16_t fb_h_{0};
uint16_t vis_w_{0};
uint16_t vis_h_{0};
uint16_t rects_left_{0};
uint16_t rect_x_{0};
uint16_t rect_y_{0};
uint16_t rect_w_{0};
uint16_t rect_h_{0};
// Raw: column and row inside the rect.
uint16_t pos_x_{0};
uint16_t pos_y_{0};
uint8_t bpp_{2};
uint8_t minor_{8};
uint8_t sec_count_{0};
uint8_t text_len_{0}; // clamped to MAX_TEXT_LEN
display::ColorBitness bitness_{display::COLOR_BITNESS_565};
VncState state_{VncState::VNC_STATE_IDLE};
VncStep step_{VncStep::VNC_STEP_VERSION};
VncStep after_skip_{VncStep::VNC_STEP_MESSAGE};
VncFailure reason_failure_{VncFailure::VNC_FAILURE_TRANSIENT};
bool rx_pending_{false};
bool in_update_{false};
bool request_outstanding_{false};
bool refresh_pending_{false};
bool frame_drawn_{false};
bool display_parks_{false};
bool display_busy_{false};
bool busy_warned_{false};
bool network_was_up_{false};
bool big_endian_; // the pixel order the display takes
uint8_t rx_[RX_BUFFER_SIZE];
};
} // namespace esphome::vnc
+9 -43
View File
@@ -39,7 +39,6 @@ from esphome.const import (
CONF_ENABLE_BTM,
CONF_ENABLE_ON_BOOT,
CONF_ENABLE_RRM,
CONF_ETHERNET,
CONF_FAST_CONNECT,
CONF_GATEWAY,
CONF_HIDDEN,
@@ -432,26 +431,8 @@ def _apply_min_auth_mode_default(config):
return config
def wifi_has_sta(config: ConfigType) -> bool:
"""Whether the validated config has at least one station network."""
return bool(config.get(CONF_NETWORKS))
# Components that save station credentials at run time
_PROVISIONERS = ("captive_portal", "improv_ble", "improv_serial")
def wifi_stays_ap_only(config: ConfigType, full_config: ConfigType) -> bool:
"""Whether no station can appear at run time: no networks, no DPP, no provisioner."""
return not (
wifi_has_sta(config)
or CONF_DPP in config
or any(c in full_config for c in _PROVISIONERS)
)
def final_validate(config):
has_sta = wifi_has_sta(config)
has_sta = bool(config.get(CONF_NETWORKS, True))
has_ap = CONF_AP in config
has_dpp = CONF_DPP in config
full_config = fv.full_config.get()
@@ -466,11 +447,9 @@ def final_validate(config):
ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP
if ap_is_wisp:
# The clients are routed through the station, or through ethernet
if not has_sta and CONF_ETHERNET not in full_config:
if not has_sta:
raise cv.Invalid(
"WISP mode needs a station network (wifi.ssid or wifi.networks), "
"or ethernet as the upstream interface"
"WISP mode requires at least one STA network (wifi.ssid or wifi.networks)"
)
if has_captive_portal:
raise cv.Invalid(
@@ -479,27 +458,14 @@ def final_validate(config):
# Every client packet passes through the WiFi driver, none through the device's own sockets
require_high_performance_wifi()
if CONF_DNS_SERVERS not in config[CONF_AP]:
# The access point passes the upstream's resolvers on, which a static address has to name
upstreams = [
([], config),
*(
([CONF_NETWORKS, i], network)
for i, network in enumerate(config.get(CONF_NETWORKS, []))
),
(None, full_config.get(CONF_ETHERNET, {})),
]
for path, network in upstreams:
# The access point passes the station's resolvers on, which a static address has to name
for network in [config, *config.get(CONF_NETWORKS, [])]:
static = network.get(CONF_MANUAL_IP)
if not static or not static[CONF_DNS1].is_unspecified:
continue
if path is None:
if static and static[CONF_DNS1].is_unspecified:
raise cv.Invalid(
"ethernet manual_ip needs dns1 in WISP mode unless ap.dns_servers is set"
"manual_ip needs dns1 in WISP mode unless ap.dns_servers is set",
path=[CONF_MANUAL_IP, CONF_DNS1],
)
raise cv.Invalid(
"manual_ip needs dns1 in WISP mode unless ap.dns_servers is set",
path=[*path, CONF_MANUAL_IP, CONF_DNS1],
)
if has_ap and not has_captive_portal and not has_web_server and not ap_is_wisp:
_LOGGER.warning(
@@ -599,7 +565,7 @@ def _report_provisioning_credentials(config):
"""
from esphome.components import provisioning
if wifi_has_sta(config):
if config.get(CONF_NETWORKS):
provisioning.report_hardcoded_credentials("wifi")
elif CONF_AP in config:
# An access point with no station credentials: the AP shuts down when the
+16 -20
View File
@@ -783,9 +783,12 @@ void WiFiComponent::start() {
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
this->start_initial_connection_();
#endif
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
} else if (this->has_ap()) { // a bridge waits in loop() for the upstream's resolver
#ifdef USE_WIFI_AP
} else if (this->has_ap()) {
this->setup_ap_config_();
if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
ESP_LOGV(TAG, "Setting Output Power Option failed");
}
#ifdef USE_CAPTIVE_PORTAL
// Where the radio scans alongside the AP, the portal can list networks.
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
@@ -794,7 +797,7 @@ void WiFiComponent::start() {
}
#endif
this->start_ap_portal_();
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
#endif // USE_WIFI_AP
}
#ifdef USE_WIFI_DPP
// Without networks there is nothing to try first, so listen for a configurator right away
@@ -934,7 +937,15 @@ void WiFiComponent::loop() {
return;
}
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
#ifdef USE_WIFI_AP_WISP
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
if (!this->ap_setup_ && this->ap_dns_known_() &&
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
ESP_LOGI(TAG, "Starting AP");
this->ap_changed_ = now;
this->setup_ap_config_();
}
#elif defined(USE_WIFI_AP)
// Once the provisioning window has closed, don't bring up the fallback AP (or
// the captive portal on it) - the device must stay unprovisionable until it is
// power-cycled.
@@ -964,7 +975,7 @@ void WiFiComponent::loop() {
this->start_ap_portal_();
}
}
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
#endif // USE_WIFI_AP
#ifdef USE_IMPROV_BLE
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
@@ -1002,16 +1013,6 @@ void WiFiComponent::loop() {
}
}
#ifdef USE_WIFI_AP_WISP
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && this->ap_dns_known_() &&
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
ESP_LOGI(TAG, "Starting AP");
this->ap_changed_ = now;
this->setup_ap_config_();
}
#endif
#ifdef USE_WIFI_DPP
// Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again
// here, at most once per timeout
@@ -1097,11 +1098,6 @@ void WiFiComponent::setup_ap_config_() {
// Reset before the AP exists: afterwards only the platform's event path writes the counter
this->ap_clients_ = 0;
this->ap_setup_ = this->wifi_start_ap_(this->ap_);
// The station path applied it in start(); without a station the radio only exists from here
if (this->ap_setup_ && !this->has_sta() && !std::isnan(this->output_power_) &&
!this->wifi_apply_output_power_(this->output_power_)) {
ESP_LOGV(TAG, "Setting Output Power Option failed");
}
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
// A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
// the STA attempts until the next try, and a bridge would take clients it cannot serve.
@@ -127,10 +127,6 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi
#if USE_NETWORK_IPV6
} else if (event_base == IP_EVENT && event_id == IP_EVENT_GOT_IP6) {
memcpy(&event.data.ip_got_ip6, event_data, sizeof(ip_event_got_ip6_t));
#endif
#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS)
} else if (event_base == IP_EVENT && event_id == IP_EVENT_ETH_GOT_IP) { // NOLINT(bugprone-branch-clone)
// no data: the bridge rereads the resolvers an ethernet upstream just received
#endif
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) { // NOLINT(bugprone-branch-clone)
// no data
@@ -1127,10 +1123,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
this->notify_disconnect_state_listeners_();
#endif
#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS)
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ETH_GOT_IP) {
this->apply_ap_dns_();
#endif
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) {
const auto &it = data->data.ip_got_ip;
ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw));
@@ -1405,6 +1397,10 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
esp_err_t err;
// enable AP
if (!this->wifi_mode_({}, true))
return false;
// Check if the AP interface is initialized before using it
if (s_ap_netif == nullptr) {
ESP_LOGW(TAG, "AP interface not initialized");
@@ -1482,8 +1478,8 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
#endif
err = esp_netif_dhcps_start(s_ap_netif);
// Before the access point is up, its start event brings the server up with these settings
if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_IF_NOT_READY) {
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_netif_dhcps_start failed: %d", err);
return false;
}
@@ -1492,17 +1488,6 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
}
bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) {
// The address and DHCP settings go in before the access point starts: its start event starts the DHCP
// server on the event task, and settings written after that race it.
#ifdef USE_WIFI_MANUAL_IP
if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) {
#else
if (!this->wifi_ap_ip_config_({})) {
#endif
ESP_LOGE(TAG, "wifi_ap_ip_config_ failed:");
return false;
}
// enable AP
if (!this->wifi_mode_({}, true))
return false;
@@ -1539,6 +1524,19 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) {
ESP_LOGE(TAG, "esp_wifi_set_config failed: %d", err);
return false;
}
#ifdef USE_WIFI_MANUAL_IP
if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) {
ESP_LOGE(TAG, "wifi_ap_ip_config_ failed:");
return false;
}
#else
if (!this->wifi_ap_ip_config_({})) {
ESP_LOGE(TAG, "wifi_ap_ip_config_ failed:");
return false;
}
#endif
return true;
}
@@ -1683,7 +1681,7 @@ bool WiFiComponent::write_ap_dns_() {
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
void WiFiComponent::apply_ap_dns_() {
if (!this->ap_setup_)
return; // loop() brings the bridge up once the upstream has a resolver, and its setup writes them
return; // loop() brings the bridge up once the station has a resolver, and its setup writes them
// The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the
// next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address.
bool changed = false;
+19 -1
View File
@@ -1,4 +1,6 @@
from collections.abc import Callable, Collection
from collections.abc import Callable, Collection, Iterator
import logging
from pathlib import Path
from esphome.const import (
CONF_LEVEL,
@@ -9,8 +11,11 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE
from esphome.helpers import walk_files
from esphome.util import OrderedDict
_LOGGER = logging.getLogger(__name__)
# Pre-build lookup map from (platform, framework) tuples to PlatformFramework enum
_PLATFORM_FRAMEWORK_LOOKUP = {
(pf.value[0].value, pf.value[1].value): pf for pf in PlatformFramework
@@ -191,3 +196,16 @@ def get_logger_level() -> str:
logger_config = CORE.config[CONF_LOGGER]
return logger_config.get(CONF_LEVEL, "DEBUG")
def iter_include_files(includes: list[str]) -> Iterator[tuple[Path, Path]]:
"""Yield ``(path, basename)`` per local ``includes:`` file; directory members keep the directory prefix."""
for include in includes:
if include.startswith("<") and include.endswith(">"):
continue
path = CORE.relative_config_path(include)
if path.is_dir():
for file in walk_files(path):
yield file, file.relative_to(path.parent)
else:
yield path, Path(path.name)
+5 -13
View File
@@ -10,6 +10,7 @@ import esphome.codegen as cg
from esphome.config_helpers import (
filter_source_files_from_defines,
filter_source_files_from_platform,
iter_include_files,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -48,6 +49,7 @@ from esphome.const import (
PLATFORM_ESP32,
PLATFORM_HOST,
PLATFORM_RP2,
SOURCE_FILE_EXTENSIONS,
PlatformFramework,
__version__ as ESPHOME_VERSION,
)
@@ -64,7 +66,6 @@ from esphome.helpers import (
fnv1a_32bit_hash,
get_str_env,
get_usable_cpu_count,
walk_files,
)
from esphome.types import ConfigType
@@ -132,7 +133,7 @@ SHARED_DEVICE_RELATION_DEFINES: dict[str, str | None] = {
}
Area = cg.esphome_ns.class_("Area")
VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"}
VALID_INCLUDE_EXTS = SOURCE_FILE_EXTENSIONS
def validate_hostname(config):
@@ -592,17 +593,8 @@ async def add_arduino_global_workaround():
async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
# After the id declarations, so the headers can use id(x); the generated function bodies
# and flash tables come later still, so their lambdas can use the headers.
for include in includes:
path = CORE.relative_config_path(include)
if path.is_dir():
# Directory, copy tree
for p in walk_files(path):
basename = p.relative_to(path.parent)
include_file(p, basename, is_c_header)
else:
# Copy file
basename = Path(path.name)
include_file(path, basename, is_c_header)
for path, basename in iter_include_files(includes):
include_file(path, basename, is_c_header)
def _add_library_str(lib: str) -> None:
+1 -1
View File
@@ -351,7 +351,6 @@
#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
@@ -435,6 +434,7 @@
#ifdef USE_ESP32
#define USE_ESP32_CRASH_HANDLER
#define USE_ESP32_VASPRINTF_STUB
#define USE_ESP32_SSCANF_STUB
#define USE_MQTT_IDF_ENQUEUE
#define USE_MQTT_TLS
#define USE_MQTT_CERT_BUNDLE
+277
View File
@@ -0,0 +1,277 @@
#pragma once
// sscanf without floating-point conversions, backing the linker wrap in
// components/esp32/sscanf_stubs.cpp. Integer, character, string and scanset
// conversions behave like libc; anything else returns SSCANF_UNSUPPORTED.
// Header only so the host unit tests can cover it.
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include "esphome/core/helpers.h"
namespace esphome {
/// Returned by vsscanf_no_float() for a conversion it does not implement.
static constexpr int SSCANF_UNSUPPORTED = -2;
namespace sscanf_no_float_internal {
enum class SscanfLength : uint8_t {
SSCANF_LENGTH_NONE,
SSCANF_LENGTH_HH,
SSCANF_LENGTH_H,
SSCANF_LENGTH_L,
SSCANF_LENGTH_LL,
SSCANF_LENGTH_Z,
SSCANF_LENGTH_T,
SSCANF_LENGTH_PTR, // %p
};
inline bool is_space(char c) { return c == ' ' || (c >= '\t' && c <= '\r'); }
// The unsigned bit pattern is what the signed conversions want as well
inline void store_int(void *dest, SscanfLength length, uint64_t value) {
switch (length) {
case SscanfLength::SSCANF_LENGTH_HH:
*static_cast<uint8_t *>(dest) = static_cast<uint8_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_H:
*static_cast<uint16_t *>(dest) = static_cast<uint16_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_L:
// %l is defined in terms of long, whatever its width on the target
*static_cast<unsigned long *>(dest) = static_cast<unsigned long>(value); // NOLINT(google-runtime-int)
break;
case SscanfLength::SSCANF_LENGTH_LL:
*static_cast<uint64_t *>(dest) = value;
break;
case SscanfLength::SSCANF_LENGTH_Z:
*static_cast<size_t *>(dest) = static_cast<size_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_T:
*static_cast<ptrdiff_t *>(dest) = static_cast<ptrdiff_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_PTR:
*static_cast<uintptr_t *>(dest) = static_cast<uintptr_t>(value);
break;
default:
*static_cast<unsigned *>(dest) = static_cast<unsigned>(value);
break;
}
}
struct Scanset {
uint8_t bits[32]{};
bool negate{false};
bool contains(char c) const {
auto u = static_cast<uint8_t>(c);
return ((this->bits[u >> 3] >> (u & 7)) & 1u) != this->negate;
}
};
// Parses "[set]" from just after the '[' and returns the position after ']'
inline const char *parse_scanset(const char *f, Scanset &set) {
if (*f == '^') {
set.negate = true;
f++;
}
// A ']' right after '[' or '[^' is a member, not the terminator.
bool first = true;
for (; *f != '\0' && (first || *f != ']'); f++) {
first = false;
auto lo = static_cast<uint8_t>(*f);
auto hi = lo;
if (f[1] == '-' && f[2] != ']' && f[2] != '\0') {
hi = static_cast<uint8_t>(f[2]);
f += 2;
}
for (unsigned c = lo; c <= hi; c++)
set.bits[c >> 3] |= static_cast<uint8_t>(1u << (c & 7));
}
return *f == ']' ? f + 1 : f;
}
// Radix of an integer conversion: 0 detects from the prefix (%i), 0xFF is unsupported
inline unsigned int_base(char conv) {
switch (conv) {
case 'd':
case 'u':
return 10;
case 'i':
return 0;
case 'o':
return 8;
case 'x':
case 'X':
case 'p':
return 16;
default:
return 0xFF;
}
}
} // namespace sscanf_no_float_internal
/// sscanf semantics for the integer, character, string and scanset
/// conversions; returns SSCANF_UNSUPPORTED on any other conversion.
inline int vsscanf_no_float(const char *str, const char *fmt, va_list ap) { // NOLINT(readability-non-const-parameter)
using namespace sscanf_no_float_internal;
const char *in = str;
int assigned = 0;
bool input_failure = false;
for (const char *f = fmt; *f != '\0'; f++) {
if (is_space(*f)) {
while (is_space(*in))
in++;
continue;
}
bool literal = *f != '%';
if (!literal && f[1] == '%') {
// "%%" matches one '%' with no whitespace skip, like newlib
f++;
literal = true;
}
if (literal) {
if (*in != *f) {
input_failure = *in == '\0';
break;
}
in++;
continue;
}
f++;
bool suppress = false;
if (*f == '*') {
suppress = true;
f++;
}
size_t width = 0;
while (*f >= '0' && *f <= '9') {
width = width * 10 + static_cast<size_t>(*f - '0');
f++;
}
if (width == 0)
width = SIZE_MAX;
auto length = SscanfLength::SSCANF_LENGTH_NONE;
switch (*f) {
case 'h':
f++;
if (*f == 'h') {
length = SscanfLength::SSCANF_LENGTH_HH;
f++;
} else {
length = SscanfLength::SSCANF_LENGTH_H;
}
break;
case 'l':
f++;
if (*f == 'l') {
length = SscanfLength::SSCANF_LENGTH_LL;
f++;
} else {
length = SscanfLength::SSCANF_LENGTH_L;
}
break;
case 'j':
length = SscanfLength::SSCANF_LENGTH_LL;
f++;
break;
case 'z':
length = SscanfLength::SSCANF_LENGTH_Z;
f++;
break;
case 't':
length = SscanfLength::SSCANF_LENGTH_T;
f++;
break;
default:
break;
}
const char conv = *f;
if (conv == '\0')
break;
if (conv == 'n') {
if (!suppress)
store_int(va_arg(ap, void *), length, static_cast<uint64_t>(in - str));
continue;
}
const bool string_conv = conv == 'c' || conv == 's' || conv == '[';
if (string_conv && length != SscanfLength::SSCANF_LENGTH_NONE)
return SSCANF_UNSUPPORTED; // wide characters
if (conv == 'p')
length = SscanfLength::SSCANF_LENGTH_PTR;
unsigned base = string_conv ? 0 : int_base(conv);
if (base == 0xFF)
return SSCANF_UNSUPPORTED;
if (conv != 'c' && conv != '[') {
while (is_space(*in))
in++;
}
if (*in == '\0') {
input_failure = true;
break;
}
if (!string_conv) {
const char *p = in;
size_t n = 0;
bool negative = false;
if (*p == '+' || *p == '-') {
negative = *p == '-';
p++;
n++;
}
if ((base == 0 || base == 16) && p[0] == '0' && (p[1] == 'x' || p[1] == 'X') && n + 2 < width &&
parse_hex_char(p[2]) != INVALID_HEX_CHAR) {
p += 2;
n += 2;
base = 16;
}
if (base == 0)
base = *p == '0' ? 8 : 10;
uint64_t value = 0;
bool any_digit = false;
for (uint8_t digit; n < width && (digit = parse_hex_char(*p)) < base; p++, n++) {
value = value * base + digit;
any_digit = true;
}
if (!any_digit)
break;
in = p;
if (!suppress) {
store_int(va_arg(ap, void *), length, negative ? 0 - value : value);
assigned++;
}
continue;
}
char *dest = suppress ? nullptr : va_arg(ap, char *);
Scanset set;
if (conv == '[')
f = parse_scanset(f + 1, set) - 1; // the loop's f++ steps past the ']'
// %c reads width characters (default 1); a short read still assigns, like newlib
const size_t limit = conv == 'c' && width == SIZE_MAX ? 1 : width;
size_t n = 0;
while (n < limit && in[n] != '\0' && (conv == 'c' || (conv == '[' ? set.contains(in[n]) : !is_space(in[n])))) {
if (dest != nullptr)
dest[n] = in[n];
n++;
}
if (conv != 'c') {
if (n == 0)
break;
if (dest != nullptr)
dest[n] = '\0';
}
in += n;
if (!suppress)
assigned++;
}
if (input_failure && assigned == 0)
return EOF;
return assigned;
}
} // namespace esphome
+2 -3
View File
@@ -151,10 +151,9 @@ 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. 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.
# Split build_flags list into private and public lists
private_build_flags, public_build_flags = split_list_by_condition(
build_flags, lambda a: a if a.startswith(("-W", "-O")) else None
build_flags, lambda a: a if a.startswith("-W") else None
)
# Generate the component
+2
View File
@@ -219,6 +219,8 @@ ESPHOME_H_EXCLUDE = {
Path(ENTITY_TYPES_H_TARGET),
# main.cpp includes it after defining esphome_controllers()
Path("esphome/core/controller_dispatch.h"),
# only the esp32 sscanf wrap includes it
Path("esphome/core/sscanf_no_float.h"),
# build machinery, not user API
Path("esphome/core/pch_prefix.h"),
}
+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.21.0
aioesphomeapi==46.19.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, has_optional=False)
strings = _action_strings(conf, has_metadata=True)
assert strings == ["a", None, "b", None, "ex"]
@@ -1,158 +0,0 @@
"""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)
@@ -1,14 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_optional_args_common.yaml
@@ -1,26 +0,0 @@
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
@@ -1,14 +0,0 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_optional_args_common.yaml
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
esp32_ble_tracker:
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
enable_full_printf: true
esp32_ble_tracker:
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
enable_full_scanf: true
esp32_ble_tracker:
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
esp32_ble_tracker:
sensor:
- platform: template
name: Parsed
lambda: |-
float value = 0;
sscanf("1.5", "%f", &value);
return value;
+34 -11
View File
@@ -2260,28 +2260,51 @@ def test_mbedtls_tls_zigbee_requires_extras(
assert mbedtls.tls_extras_required == _CCM_EXTRAS
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
@pytest.mark.parametrize(
("config_file", "expected"),
("config_file", "symbol", "expected"),
[
pytest.param("vasprintf_stub_c6.yaml", True, id="c6"),
pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"),
pytest.param("exclusion_reincludes.yaml", False, id="esp32"),
# vasprintf: only where the ROM lacks vasprintf but has vsnprintf
pytest.param("vasprintf_stub_c6.yaml", "vasprintf", True, id="vasprintf_c6"),
pytest.param(
"vasprintf_stub_c6_full_printf.yaml",
"vasprintf",
False,
id="vasprintf_c6_full_printf",
),
pytest.param(
"exclusion_reincludes.yaml", "vasprintf", False, id="vasprintf_esp32"
),
# sscanf: only for Bluetooth builds on variants whose ROM lacks sscanf,
# and not when a lambda scans a float
pytest.param(
"exclusion_reincludes_wifi_ble.yaml", "sscanf", True, id="sscanf_esp32_ble"
),
pytest.param("sscanf_stub_ble_c6.yaml", "sscanf", False, id="sscanf_c6_rom"),
pytest.param(
"sscanf_stub_ble_full_scanf.yaml", "sscanf", False, id="sscanf_full_scanf"
),
pytest.param(
"sscanf_stub_ble_lambda.yaml", "sscanf", False, id="sscanf_lambda_float"
),
pytest.param(
"sscanf_stub_ble_full_printf.yaml", "sscanf", True, id="sscanf_full_printf"
),
pytest.param("exclusion_reincludes.yaml", "sscanf", False, id="sscanf_no_ble"),
],
)
def test_vasprintf_stub_only_on_rom_vsnprintf_variants(
def test_wrap_stub_emission(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
symbol: str,
expected: bool,
) -> None:
"""The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf."""
"""A linker wrap stub is emitted as a define plus --wrap/--undefined flag pair."""
generate_main(component_config_path(config_file))
assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected
flags = {f"-Wl,--wrap={symbol}", f"-Wl,--undefined=__wrap_{symbol}"}
assert (CORE.build_flags >= flags) is expected
defines = {define.name for define in CORE.defines}
assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected
assert (f"USE_ESP32_{symbol.upper()}_STUB" in defines) is expected
@pytest.mark.parametrize(
+18 -35
View File
@@ -11,13 +11,9 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import (
CONF_CLOCK_SPEED,
CONFIG_SCHEMA,
_final_validate_wifi,
)
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate
from esphome.components.network import _validate_priority_list
from esphome.const import CONF_AP, CONF_NETWORKS, CONF_PRIORITY, PlatformFramework
from esphome.const import CONF_PRIORITY, PlatformFramework
from esphome.core import CORE
import esphome.final_validate as fv
@@ -32,37 +28,24 @@ _CH390_CONFIG = {
}
_WIFI_STA_CONFIG = {CONF_NETWORKS: [{"ssid": "MySSID", "password": "password1"}]}
_WIFI_AP_CONFIG = {CONF_AP: {"ssid": "espap", "password": "mypassword"}}
def test_rejects_wifi_and_ethernet_without_priority() -> None:
"""Wi-Fi + ethernet without a network: priority: list must be rejected."""
fv.full_config.set({"wifi": {}, "ethernet": {}})
with pytest.raises(Invalid, match="cannot be used together with component wifi"):
_final_validate({})
@pytest.mark.parametrize(
("wifi", "priority", "extra", "match"),
[
(_WIFI_STA_CONFIG, None, {}, "missing: ethernet, wifi"),
(_WIFI_STA_CONFIG, ["ethernet"], {}, "missing: wifi"),
(_WIFI_STA_CONFIG, ["wifi", "ethernet"], {}, None),
(_WIFI_AP_CONFIG, None, {}, None),
# A station can still appear at run time: DPP or a provisioner saves credentials
({"dpp": {}}, None, {}, "missing: ethernet, wifi"),
({"dpp": {}, **_WIFI_AP_CONFIG}, None, {}, "missing: ethernet, wifi"),
(_WIFI_AP_CONFIG, None, {"captive_portal": {}}, "missing: ethernet, wifi"),
(_WIFI_AP_CONFIG, None, {"improv_serial": {}}, "missing: ethernet, wifi"),
],
)
def test_wifi_station_needs_a_full_priority_list_but_an_access_point_does_not(
wifi: dict, priority: list[str] | None, extra: dict, match: str | None
) -> None:
"""A station, configured or provisioned later, competes with ethernet; an access point does not."""
full = {"wifi": wifi, "ethernet": {}, **extra}
if priority:
full["network"] = {CONF_PRIORITY: _validate_priority_list(priority)}
fv.full_config.set(full)
if match is None:
_final_validate_wifi({})
else:
with pytest.raises(Invalid, match=match):
_final_validate_wifi({})
def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None:
"""A priority list missing an interface is rejected and names what's missing."""
fv.full_config.set(
{
"wifi": {},
"ethernet": {},
"network": {CONF_PRIORITY: _validate_priority_list(["ethernet"])},
}
)
with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"):
_final_validate({})
@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"])
@@ -1,22 +1,14 @@
"""Tests for the speaker_source media player pipeline validation."""
from typing import Any
"""Tests for the speaker_source media player announcement volume validation."""
import pytest
from esphome import config_validation as cv
from esphome.components import audio
from esphome.components.const import CONF_ANNOUNCEMENT_VOLUME, CONF_BITRATE
from esphome.components.const import CONF_ANNOUNCEMENT_VOLUME
from esphome.components.speaker_source.media_player import (
CONF_ANNOUNCEMENT_PIPELINE,
CONF_MEDIA_PIPELINE,
PIPELINE_SCHEMA,
_validate_announcement_volume,
_validate_pipeline,
)
from esphome.const import CONF_FORMAT, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
PIPELINE = {"speaker": "spk", "sources": ["src"]}
def test_announcement_volume_with_both_pipelines() -> None:
@@ -37,49 +29,3 @@ def test_no_announcement_volume_with_one_pipeline() -> None:
def test_announcement_volume_needs_both_pipelines(pipeline: str) -> None:
with pytest.raises(cv.Invalid, match="requires both"):
_validate_announcement_volume({CONF_ANNOUNCEMENT_VOLUME: 0.6, pipeline: {}})
@pytest.mark.parametrize("bitrate", [8, 48, 144, 320])
def test_pipeline_accepts_mp3_bitrates(bitrate: int) -> None:
config = PIPELINE_SCHEMA({**PIPELINE, CONF_FORMAT: "MP3", CONF_BITRATE: bitrate})
assert config[CONF_BITRATE] == bitrate
def test_pipeline_without_bitrate() -> None:
assert CONF_BITRATE not in PIPELINE_SCHEMA(PIPELINE)
@pytest.mark.parametrize("bitrate", [0, -48, 50, 48000])
def test_pipeline_rejects_bitrates_the_encoder_cannot_use(bitrate: int) -> None:
with pytest.raises(cv.Invalid, match="Unknown value"):
PIPELINE_SCHEMA({**PIPELINE, CONF_FORMAT: "MP3", CONF_BITRATE: bitrate})
def _final_validate(monkeypatch: pytest.MonkeyPatch, **overrides: Any) -> dict:
"""Run the pipeline's final validator with the speaker checks isolated."""
monkeypatch.setattr(
audio, "final_validate_audio_schema", lambda *args, **kwargs: lambda c: c
)
# Both audio properties are set, so nothing is inherited from the speaker.
config = PIPELINE_SCHEMA(
{**PIPELINE, CONF_SAMPLE_RATE: 48000, CONF_NUM_CHANNELS: 1, **overrides}
)
return _validate_pipeline(config)
def test_final_validation_keeps_mp3_bitrate(monkeypatch: pytest.MonkeyPatch) -> None:
config = _final_validate(monkeypatch, **{CONF_FORMAT: "MP3", CONF_BITRATE: 48})
assert config[CONF_BITRATE] == 48
def test_final_validation_without_bitrate(monkeypatch: pytest.MonkeyPatch) -> None:
assert CONF_BITRATE not in _final_validate(monkeypatch, **{CONF_FORMAT: "FLAC"})
@pytest.mark.parametrize("format_", ["FLAC", "NONE", "WAV"])
def test_final_validation_rejects_bitrate_for_other_formats(
format_: str, monkeypatch: pytest.MonkeyPatch
) -> None:
with pytest.raises(cv.Invalid, match="only supported for the MP3 format") as exc:
_final_validate(monkeypatch, **{CONF_FORMAT: format_, CONF_BITRATE: 48})
assert exc.value.path == [CONF_BITRATE]
-406
View File
@@ -1,406 +0,0 @@
"""Code generation and validation tests for the vnc component."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.config import read_config
from esphome.core import CORE
TEST_CARD = "show_test_card()"
UPDATE_NEVER = "set_update_interval(4294967295UL)"
BASE = """
esphome:
name: vnc-test
esp32:
board: esp32dev
logger:
wifi:
ssid: MySSID
password: password1
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
"""
IT8951 = """
display:
- platform: it8951
id: epd
model: m5stack-m5paper
"""
VNC = """
vnc:
- host: 192.168.1.20
"""
EPAPER_SPI = """
- platform: epaper_spi
id: other
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
"""
S3_BASE = """
esphome:
name: vnc-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
wifi:
ssid: MySSID
password: password1
"""
RPI_DPI_RGB = """
display:
- platform: rpi_dpi_rgb
id: rgb
dimensions:
width: 800
height: 480
de_pin: 40
hsync_pin: 39
vsync_pin: 41
pclk_pin: 42
data_pins:
red: [45, 48, 47, 21, 14]
green: [5, 6, 7, 15, 16, 4]
blue: [8, 3, 46, 9, 1]
"""
OPENTHREAD_BASE = """
esphome:
name: vnc-test
esp32:
variant: esp32c6
framework:
type: esp-idf
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
spi:
clk_pin: GPIO6
mosi_pin: GPIO7
"""
C6_EPAPER = """
display:
- platform: epaper_spi
id: epd
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO4
busy_pin: GPIO3
reset_pin: GPIO2
"""
@pytest.fixture(autouse=True)
def _clear_toolchain() -> None:
# A host build earlier in the same session leaves the host toolchain selected.
CORE.toolchain = None
@pytest.fixture
def write_yaml(tmp_path: Path) -> Callable[..., Path]:
def writer(*parts: str, base: str = BASE) -> Path:
path = tmp_path / "vnc.yaml"
path.write_text(base + "".join(parts), encoding="utf-8")
return path
return writer
@pytest.fixture
def config_error(
write_yaml: Callable[..., Path], capsys: pytest.CaptureFixture[str]
) -> Callable[..., str]:
"""Validate a config that must fail and return the printed errors."""
def validate(*parts: str, base: str = BASE) -> str:
CORE.config_path = write_yaml(*parts, base=base)
assert read_config({}) is None
return capsys.readouterr().out
return validate
def test_it8951_owned_by_vnc(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(write_yaml(IT8951, VNC))
assert TEST_CARD not in main_cpp
assert f"epd->{UPDATE_NEVER}" in main_cpp
assert re.search(r"new\(\w+\) vnc::VncClient\(epd, \w+\);", main_cpp)
assert 'set_host("192.168.1.20");' in main_cpp
assert "set_port(5900);" in main_cpp
assert "set_reconnect_interval(5000);" in main_cpp
assert "set_update_delay(200);" in main_cpp
assert "set_max_update_delay(1000);" in main_cpp
def test_epaper_spi_owned_by_vnc(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml("display:", EPAPER_SPI, "\nvnc:\n - host: 192.168.1.20\n")
)
assert TEST_CARD not in main_cpp
assert f"other->{UPDATE_NEVER}" in main_cpp
assert re.search(r"new\(\w+\) vnc::VncClient\(other, \w+\);", main_cpp)
def test_display_with_generated_id(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml("display:\n - platform: it8951\n model: m5stack-m5paper\n", VNC)
)
assert TEST_CARD not in main_cpp
assert UPDATE_NEVER in main_cpp
assert "vnc::VncClient(" in main_cpp
def test_only_the_vnc_display_loses_test_card(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951, EPAPER_SPI, "\nvnc:\n - host: 192.168.1.20\n display_id: epd\n"
)
)
assert f"other->{TEST_CARD}" in main_cpp
assert f"epd->{TEST_CARD}" not in main_cpp
assert f"epd->{UPDATE_NEVER}" in main_cpp
def test_options(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951,
"""
vnc:
- host: vnc.local
port: 5901
reconnect_interval: 2s
update_delay: 0ms
max_update_delay: 3s
on_connect:
- logger.log: up
on_disconnect:
- logger.log: down
""",
)
)
assert 'set_host("vnc.local");' in main_cpp
assert "set_port(5901);" in main_cpp
assert "set_reconnect_interval(2000);" in main_cpp
assert "set_update_delay(0);" in main_cpp
assert "set_max_update_delay(3000);" in main_cpp
assert "add_on_connect_callback(" in main_cpp
assert "add_on_disconnect_callback(" in main_cpp
def test_two_clients_on_two_displays(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951,
EPAPER_SPI,
"""
vnc:
- host: 192.168.1.20
display_id: epd
- host: 192.168.1.21
display_id: other
""",
)
)
assert re.search(r"vnc::VncClient\(epd, \w+\);", main_cpp)
assert re.search(r"vnc::VncClient\(other, \w+\);", main_cpp)
assert TEST_CARD not in main_cpp
@pytest.mark.parametrize(
("display_options", "error"),
[
pytest.param(
" lambda: |-\n it.fill(Color::BLACK);\n",
"vnc draws the whole of display 'epd'",
id="lambda",
),
pytest.param(
" pages:\n - id: page1\n lambda: |-\n"
" it.fill(Color::BLACK);\n",
"vnc draws the whole of display 'epd'",
id="pages",
),
pytest.param(
" show_test_card: true\n",
"vnc draws the whole of display 'epd'",
id="show_test_card",
),
pytest.param(
" auto_clear_enabled: true\n",
"vnc draws the whole of display 'epd'",
id="auto_clear_enabled",
),
],
)
def test_display_with_own_writer_rejected(
display_options: str, error: str, config_error: Callable[..., str]
) -> None:
assert error in config_error(IT8951, display_options, VNC)
def test_lvgl_on_same_display_rejected(config_error: Callable[..., str]) -> None:
out = config_error(IT8951, VNC, "\nlvgl:\n displays: [epd]\n")
assert "Display 'epd' is also used by LVGL" in out
def test_lvgl_on_other_display_accepted(
write_yaml: Callable[..., Path],
) -> None:
CORE.config_path = write_yaml(
IT8951,
EPAPER_SPI,
"\nvnc:\n - host: 192.168.1.20\n display_id: epd\n",
"\nlvgl:\n displays: [other]\n",
)
assert read_config({}) is not None
def test_two_clients_on_one_display_rejected(
config_error: Callable[..., str],
) -> None:
out = config_error(
IT8951, "\nvnc:\n - host: 192.168.1.20\n - host: 192.168.1.21\n"
)
assert "Display 'epd' is drawn by more than one component: vnc, vnc" in out
def test_mipi_spi_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"""
display:
- platform: mipi_spi
id: epd
model: st7735
cs_pin: GPIO15
dc_pin: GPIO27
reset_pin: GPIO26
""",
VNC,
)
assert "Display 'epd' uses the mipi_spi platform" in out
def test_rotated_rpi_dpi_rgb_accepted(write_yaml: Callable[..., Path]) -> None:
CORE.config_path = write_yaml(RPI_DPI_RGB, " rotation: 90\n", VNC, base=S3_BASE)
assert read_config({}) is not None
def test_ipv4_kept_on_with_openthread(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
generate_main(write_yaml(C6_EPAPER, VNC, base=OPENTHREAD_BASE))
define = next(d for d in CORE.defines if d.name == "USE_NETWORK_IPV4")
assert str(define.value) == "true"
def test_ipv4_disabled_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"network:\n enable_ipv4: false\n", C6_EPAPER, VNC, base=OPENTHREAD_BASE
)
assert "IPv4 is required by vnc" in out
def test_waveshare_epaper_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"""
display:
- platform: waveshare_epaper
id: epd
model: 1.54in
cs_pin: GPIO15
dc_pin: GPIO27
busy_pin: GPIO26
reset_pin: GPIO25
""",
VNC,
)
assert "Display 'epd' uses the waveshare_epaper platform" in out
assert "use epaper_spi" in out
@pytest.mark.parametrize(
("vnc_options", "error"),
[
pytest.param(
" reconnect_interval: 500ms\n",
"value must be at least 1s",
id="reconnect",
),
pytest.param(" port: 0\n", "value must be at least 1", id="port"),
],
)
def test_option_rejected(
vnc_options: str, error: str, config_error: Callable[..., str]
) -> None:
out = config_error(IT8951, VNC, vnc_options)
assert error in out
@pytest.mark.parametrize("host", ["::1", "fe80::1", "2001:db8::10"])
def test_ipv6_host_rejected(host: str, config_error: Callable[..., str]) -> None:
out = config_error(IT8951, f'\nvnc:\n - host: "{host}"\n')
assert "IPv6 addresses are not supported" in out
-1
View File
@@ -78,7 +78,6 @@ api:
type: string
description: Name to greet
example: World
default: World
then:
- logger.log:
format: Hello World %s!
@@ -0,0 +1,250 @@
#include <gtest/gtest.h>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include "esphome/core/sscanf_no_float.h"
namespace esphome::core::testing {
static int sscanf_no_float(const char *str, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int result = vsscanf_no_float(str, fmt, ap);
va_end(ap);
return result;
}
// --- The two bluedroid call sites the wrap exists for ---
TEST(SscanfNoFloat, BluedroidHexByte) {
unsigned int value = 0;
EXPECT_EQ(sscanf_no_float("ab", "%02x", &value), 1);
EXPECT_EQ(value, 0xabu);
// Width stops after two digits even when more follow
EXPECT_EQ(sscanf_no_float("ab12", "%02x", &value), 1);
EXPECT_EQ(value, 0xabu);
}
TEST(SscanfNoFloat, BluedroidAddress) {
uint32_t a[6] = {0};
EXPECT_EQ(sscanf_no_float("AA:bb:0C:dd:ee:FF", "%02x:%02x:%02x:%02x:%02x:%02x", a, a + 1, a + 2, a + 3, a + 4, a + 5),
6);
EXPECT_EQ(a[0], 0xaau);
EXPECT_EQ(a[1], 0xbbu);
EXPECT_EQ(a[2], 0x0cu);
EXPECT_EQ(a[3], 0xddu);
EXPECT_EQ(a[4], 0xeeu);
EXPECT_EQ(a[5], 0xffu);
// Short input assigns what matched, like libc
EXPECT_EQ(sscanf_no_float("AA:bb:0C", "%02x:%02x:%02x:%02x:%02x:%02x", a, a + 1, a + 2, a + 3, a + 4, a + 5), 3);
}
TEST(SscanfNoFloat, WidthWithByteLength) {
uint8_t mac[2] = {0};
EXPECT_EQ(sscanf_no_float("AA:bb", "%02hhx:%02hhx", mac, mac + 1), 2);
EXPECT_EQ(mac[0], 0xaa);
EXPECT_EQ(mac[1], 0xbb);
// The width still bounds the digits when a length modifier is present
EXPECT_EQ(sscanf_no_float("abcd", "%02hhx", mac), 1);
EXPECT_EQ(mac[0], 0xab);
}
// --- Formats bluedroid's hands-free client uses: %hu with %n ---
TEST(SscanfNoFloat, ShortWithPosition) {
uint16_t h = 0, m = 0, s = 0;
int n1 = 0, n2 = 0, n3 = 0;
EXPECT_EQ(sscanf_no_float("6:30:15", "%hu%n:%hu%n:%hu%n", &h, &n1, &m, &n2, &s, &n3), 3);
EXPECT_EQ(h, 6);
EXPECT_EQ(m, 30);
EXPECT_EQ(s, 15);
EXPECT_EQ(n1, 1);
EXPECT_EQ(n2, 4);
EXPECT_EQ(n3, 7);
EXPECT_EQ(sscanf_no_float("M3.2.0", "M%hu%n.%hu%n.%hu%n", &h, &n1, &m, &n2, &s, &n3), 3);
EXPECT_EQ(sscanf_no_float("X3.2.0", "M%hu", &h), 0);
}
// --- Integer conversions ---
TEST(SscanfNoFloat, SignedDecimalSkipsLeadingSpace) {
int value = 0;
EXPECT_EQ(sscanf_no_float(" -42 rest", "%d", &value), 1);
EXPECT_EQ(value, -42);
EXPECT_EQ(sscanf_no_float("+7", "%u", &value), 1);
EXPECT_EQ(value, 7);
}
TEST(SscanfNoFloat, AutoBase) {
int value = 0;
EXPECT_EQ(sscanf_no_float("0x1F", "%i", &value), 1);
EXPECT_EQ(value, 31);
EXPECT_EQ(sscanf_no_float("017", "%i", &value), 1);
EXPECT_EQ(value, 15);
EXPECT_EQ(sscanf_no_float("0", "%i", &value), 1);
EXPECT_EQ(value, 0);
EXPECT_EQ(sscanf_no_float("-0x10", "%i", &value), 1);
EXPECT_EQ(value, -16);
}
TEST(SscanfNoFloat, OctalAndHexPrefix) {
unsigned int value = 0;
EXPECT_EQ(sscanf_no_float("17", "%o", &value), 1);
EXPECT_EQ(value, 15u);
EXPECT_EQ(sscanf_no_float("+0x10", "%x", &value), 1);
EXPECT_EQ(value, 16u);
// "0x" with no digit after it: only the 0 is consumed
EXPECT_EQ(sscanf_no_float("0xg", "%x", &value), 1);
EXPECT_EQ(value, 0u);
EXPECT_EQ(sscanf_no_float("zz", "%x", &value), 0);
}
TEST(SscanfNoFloat, WidthSplitsDigits) {
int a = 0;
unsigned int b = 0;
EXPECT_EQ(sscanf_no_float("12345", "%3d%d", &a, &b), 2);
EXPECT_EQ(a, 123);
EXPECT_EQ(b, 45u);
EXPECT_EQ(sscanf_no_float("ff", "%1x", &b), 1);
EXPECT_EQ(b, 15u);
}
TEST(SscanfNoFloat, LengthModifiers) {
unsigned char hh = 0;
int16_t h = 0;
long l = 0; // NOLINT(google-runtime-int) %ld is defined in terms of long
uint64_t ll = 0;
size_t z = 0;
ptrdiff_t t = 0;
EXPECT_EQ(sscanf_no_float("255", "%hhu", &hh), 1);
EXPECT_EQ(hh, 255);
EXPECT_EQ(sscanf_no_float("-2", "%hd", &h), 1);
EXPECT_EQ(h, -2);
EXPECT_EQ(sscanf_no_float("-1", "%ld", &l), 1);
EXPECT_EQ(l, -1L);
EXPECT_EQ(sscanf_no_float("18446744073709551615", "%llu", &ll), 1);
EXPECT_EQ(ll, UINT64_MAX);
EXPECT_EQ(sscanf_no_float("123", "%ju", &ll), 1);
EXPECT_EQ(ll, 123u);
EXPECT_EQ(sscanf_no_float("9", "%zu", &z), 1);
EXPECT_EQ(z, 9u);
EXPECT_EQ(sscanf_no_float("-9", "%td", &t), 1);
EXPECT_EQ(t, -9);
}
TEST(SscanfNoFloat, Pointer) {
void *p = nullptr;
EXPECT_EQ(sscanf_no_float("0x1234", "%p", &p), 1);
EXPECT_EQ(reinterpret_cast<uintptr_t>(p), 0x1234u);
EXPECT_EQ(sscanf_no_float("abcd rest", "%p", &p), 1);
EXPECT_EQ(reinterpret_cast<uintptr_t>(p), 0xabcdu);
}
TEST(SscanfNoFloat, Suppression) {
int value = 0;
EXPECT_EQ(sscanf_no_float("1 2 3", "%*d %d %*d", &value), 1);
EXPECT_EQ(value, 2);
}
// --- Character, string and scanset conversions ---
TEST(SscanfNoFloat, CharDoesNotSkipSpace) {
char c[3] = {0};
EXPECT_EQ(sscanf_no_float("xyz", "%c%c", c, c + 1), 2);
EXPECT_EQ(c[0], 'x');
EXPECT_EQ(c[1], 'y');
EXPECT_EQ(sscanf_no_float(" x", "%c", c), 1);
EXPECT_EQ(c[0], ' ');
}
TEST(SscanfNoFloat, CharWidthShortRead) {
char c[4] = {0};
EXPECT_EQ(sscanf_no_float("abc", "%2c", c), 1);
EXPECT_EQ(c[0], 'a');
EXPECT_EQ(c[1], 'b');
EXPECT_EQ(c[2], '\0');
// Fewer characters than the width still count as one assignment, like newlib
EXPECT_EQ(sscanf_no_float("ab", "%3c", c), 1);
EXPECT_EQ(sscanf_no_float("", "%c", c), EOF);
}
TEST(SscanfNoFloat, StringStopsAtSpaceAndWidth) {
char buf[16] = {0};
EXPECT_EQ(sscanf_no_float("hello world", "%5s", buf), 1);
EXPECT_STREQ(buf, "hello");
EXPECT_EQ(sscanf_no_float(" x", "%1s", buf), 1);
EXPECT_STREQ(buf, "x");
EXPECT_EQ(sscanf_no_float(" ", "%s", buf), EOF);
}
TEST(SscanfNoFloat, Scansets) {
char buf[16] = {0};
char rest[16] = {0};
EXPECT_EQ(sscanf_no_float("key=val", "%[^=]=%s", buf, rest), 2);
EXPECT_STREQ(buf, "key");
EXPECT_STREQ(rest, "val");
// ']' first is a member; '-' last is literal
EXPECT_EQ(sscanf_no_float("]]ab", "%[]]", buf), 1);
EXPECT_STREQ(buf, "]]");
EXPECT_EQ(sscanf_no_float("a-z]x", "%[a-]", buf), 1);
EXPECT_STREQ(buf, "a-");
EXPECT_EQ(sscanf_no_float("abc", "%[^]]", buf), 1);
EXPECT_STREQ(buf, "abc");
// No member matched is a matching failure
EXPECT_EQ(sscanf_no_float("123", "%[a-z]", buf), 0);
}
// --- Literals, whitespace and return value ---
TEST(SscanfNoFloat, LiteralsAndPercent) {
int value = 0;
EXPECT_EQ(sscanf_no_float("50%", "%d%%", &value), 1);
EXPECT_EQ(value, 50);
// Like newlib, %% does not skip whitespace; an explicit space in the format does
EXPECT_EQ(sscanf_no_float("50 % 7", "%d%%%d", &value, &value), 1);
EXPECT_EQ(sscanf_no_float("50 % 7", "%d %%%d", &value, &value), 2);
EXPECT_EQ(value, 7);
EXPECT_EQ(sscanf_no_float("1,2", "%d,%d", &value, &value), 2);
EXPECT_EQ(sscanf_no_float("1 ,2", "%d,%d", &value, &value), 1);
EXPECT_EQ(sscanf_no_float("1, 2", "%d,%d", &value, &value), 2);
EXPECT_EQ(sscanf_no_float("12\n34", "%d%d", &value, &value), 2);
}
TEST(SscanfNoFloat, PositionOnly) {
int n = 0;
EXPECT_EQ(sscanf_no_float("ab", "ab%n", &n), 0);
EXPECT_EQ(n, 2);
EXPECT_EQ(sscanf_no_float("", "%n", &n), 0);
EXPECT_EQ(n, 0);
}
TEST(SscanfNoFloat, EofVersusMatchingFailure) {
int value = 0;
EXPECT_EQ(sscanf_no_float("", "%d", &value), EOF);
EXPECT_EQ(sscanf_no_float(" ", "%d", &value), EOF);
EXPECT_EQ(sscanf_no_float("a", "ab"), EOF);
EXPECT_EQ(sscanf_no_float("abc", "%d", &value), 0);
EXPECT_EQ(sscanf_no_float("ac", "ab"), 0);
EXPECT_EQ(sscanf_no_float("12abc", "%d%d", &value, &value), 1);
EXPECT_EQ(sscanf_no_float("12", "%d%d", &value, &value), 1);
}
// --- Conversions the scanner refuses rather than misparses ---
TEST(SscanfNoFloat, UnsupportedConversions) {
float f = 0;
double d = 0;
wchar_t w[4] = {0};
EXPECT_EQ(sscanf_no_float("1.5", "%f", &f), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("1.5", "%lf", &d), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("1e3", "%g", &d), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("0x1p3", "%a", &d), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("abc", "%ls", w), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("abc", "%lc", w), SSCANF_UNSUPPORTED);
// Earlier conversions do not hide a later unsupported one
int value = 0;
EXPECT_EQ(sscanf_no_float("1 2.5", "%d %f", &value, &f), SSCANF_UNSUPPORTED);
}
} // namespace esphome::core::testing
@@ -1,15 +0,0 @@
ethernet:
type: LAN8720
mdc_pin: 23
mdio_pin: 32
clk:
pin: 0
mode: CLK_EXT_IN
phy_addr: 0
power_pin: 33
wifi:
ap:
ssid: WISP AP
password: wisp12345678
ap_mode: wisp
-4
View File
@@ -145,10 +145,6 @@ 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
+1 -2
View File
@@ -35,8 +35,7 @@ media_player:
announcement_volume: 60%
announcement_pipeline:
speaker: announcement_mixer_speaker_id
format: MP3
bitrate: 48
format: FLAC
num_channels: 1
sources:
- announcement_audio_file_source
-15
View File
@@ -1,15 +0,0 @@
# This file's presence makes pytest treat this directory as a package named
# "vnc"; required for cpp unit testing.
from esphome.components import socket as socket_component
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# MULTI_CONF components get no config in a unit test build, so to_code would never run.
manifest.multi_conf = False
async def to_code_testing(config: ConfigType) -> None:
socket_component.require_tcp_client_link()
manifest.to_code = to_code_testing
-27
View File
@@ -1,27 +0,0 @@
vnc:
- id: vnc_client
display_id: test_display_screen
host: 192.0.2.10
port: 5901
reconnect_interval: 10s
update_delay: 300ms
max_update_delay: 2s
on_connect:
- logger.log: VNC connected
on_disconnect:
- logger.log: VNC disconnected
interval:
- interval: 60s
then:
- if:
condition:
vnc.is_connected: vnc_client
then:
- logger.log: VNC is connected
- if:
condition:
vnc.is_connected:
id: vnc_client
then:
- logger.log: VNC is still connected
@@ -1,7 +0,0 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
vnc: !include common.yaml
wifi:
ssid: MySSID
password: password1
-7
View File
@@ -1,7 +0,0 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
vnc: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -1,7 +0,0 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
vnc: !include common.yaml
wifi:
ssid: MySSID
password: password1
-3
View File
@@ -1,3 +0,0 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
vnc: !include common.yaml
@@ -1,7 +0,0 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
vnc: !include common.yaml
wifi:
ssid: MySSID
password: password1
-164
View File
@@ -1,164 +0,0 @@
#include "vnc_test_helpers.h"
#ifdef USE_HOST
namespace esphome::vnc::testing {
/// Runs one pass at time t, then lets an asynchronous connect finish before time moves on.
static void settle_at(Session &s, uint32_t t) {
set_now(t);
s.loop();
for (int i = 0; i < 10000 && s.client.link().connecting(); i++) {
usleep(100);
s.loop();
}
}
/// Times at which refused connect attempts ended, starting at the current time.
static std::vector<uint32_t> refused_attempts(Session &s, size_t count, uint32_t step = 100) {
std::vector<uint32_t> times;
uint32_t last_start = s.client.hold_start_ms();
const uint32_t start = now_ms();
for (uint32_t t = start; times.size() < count && t - start < 3600000; t += step) {
settle_at(s, t);
if (s.client.hold_start_ms() != last_start) {
last_start = s.client.hold_start_ms();
times.push_back(t);
}
}
return times;
}
static std::vector<uint32_t> gaps(const std::vector<uint32_t> &times) {
std::vector<uint32_t> out;
for (size_t i = 1; i < times.size(); i++) {
out.push_back(times[i] - times[i - 1]);
}
return out;
}
TEST(VncBackoff, RefusedConnectsBackOff) {
FakeDisplay d;
Session s(&d);
auto times = refused_attempts(s, 7);
ASSERT_EQ(times.size(), 7u);
EXPECT_EQ(times[0], 1000u);
EXPECT_EQ(gaps(times), std::vector<uint32_t>({5000, 10000, 20000, 40000, 60000, 60000}));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_TRUE(s.client.status_has_warning());
EXPECT_FALSE(s.client.status_has_error());
}
TEST(VncBackoff, ShortReconnectInterval) {
FakeDisplay d;
Session s(&d);
s.client.set_reconnect_interval(1000);
auto times = refused_attempts(s, 9);
EXPECT_EQ(gaps(times), std::vector<uint32_t>({1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000}));
}
TEST(VncBackoff, ReconnectIntervalAboveTheCap) {
FakeDisplay d;
Session s(&d);
s.client.set_reconnect_interval(90000);
auto times = refused_attempts(s, 4, 1000);
EXPECT_EQ(gaps(times), std::vector<uint32_t>({90000, 90000, 90000}));
}
TEST(VncBackoff, StableSessionResetsBackoff) {
FakeDisplay d;
Session s(&d);
auto times = refused_attempts(s, 3);
ASSERT_EQ(s.client.backoff_ms(), 20000u);
set_now(times.back() + 1000);
s.start(64, 48);
s.run_for(60000, 1000);
ASSERT_TRUE(s.client.is_connected());
int disconnects = 0;
s.client.add_on_disconnect_callback([&disconnects]() { disconnects++; });
s.close_peer();
s.loop();
EXPECT_EQ(disconnects, 1);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.backoff_ms(), 0u);
EXPECT_EQ(s.client.hold_ms(), 0u);
const uint32_t dropped_at = now_ms();
auto after = refused_attempts(s, 2);
ASSERT_EQ(after.size(), 2u);
// The link's own interval spaces the first retry; the backoff then starts over.
EXPECT_EQ(after[0] - dropped_at, 5000u);
EXPECT_EQ(after[1] - after[0], 5000u);
}
TEST(VncBackoff, ShortSessionKeepsEscalating) {
FakeDisplay d;
Session s(&d);
auto times = refused_attempts(s, 3);
ASSERT_EQ(s.client.backoff_ms(), 20000u);
set_now(times.back() + 1000);
s.start(64, 48);
s.run_for(10000, 1000);
s.close_peer();
s.loop();
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 40000u);
}
TEST(VncBackoff, PeerCloseWhileRunningRetries) {
FakeDisplay d;
Session s(&d);
int disconnects = 0;
s.client.add_on_disconnect_callback([&disconnects]() { disconnects++; });
s.start(64, 48);
s.close_peer();
s.loop();
EXPECT_FALSE(s.client.is_connected());
EXPECT_EQ(disconnects, 1);
EXPECT_EQ(s.client.hold_ms(), 5000u);
EXPECT_TRUE(s.client.status_has_warning());
const uint32_t dropped_at = now_ms();
auto after = refused_attempts(s, 1);
ASSERT_EQ(after.size(), 1u);
EXPECT_EQ(after[0] - dropped_at, 5000u);
}
TEST(VncBackoff, NextSessionStartsClean) {
FakeDisplay d;
Session s(&d);
s.start(64, 48);
set_now(now_ms() + 100);
// Leave a frame half parsed and a refresh pending, then lose the link.
s.deliver(Rfb().fbu(1).raw_rect(0, 0, 2, 1, {1, 2}));
s.deliver(Rfb().fbu(1).rect(0, 0, 4, 4, 0).u8(0x02));
ASSERT_TRUE(s.client.in_update());
s.close_peer();
s.loop();
EXPECT_FALSE(s.client.in_update());
EXPECT_FALSE(s.client.refresh_pending());
s.attach();
EXPECT_EQ(s.client.step(), VncStep::VNC_STEP_VERSION);
EXPECT_EQ(s.client.rx_buffered(), 0);
s.deliver(handshake_38(64, 48));
ASSERT_TRUE(s.client.is_connected());
s.take();
set_now(now_ms() + 100);
s.deliver(Rfb().fbu(1).raw_rect(3, 3, 1, 1, {0x7777}));
EXPECT_EQ(d.pixel(3, 3), expect_px(0x7777, 2));
EXPECT_EQ(hex(s.take()), hex(fbur(true, 64, 48)));
}
TEST(VncBackoff, ShutdownClosesTheLink) {
FakeDisplay d;
Session s(&d);
s.start(64, 48);
s.client.on_shutdown();
EXPECT_FALSE(s.client.link().connected());
}
} // namespace esphome::vnc::testing
#endif // USE_HOST
-586
View File
@@ -1,586 +0,0 @@
#include "vnc_test_helpers.h"
#include <random>
#ifdef USE_HOST
namespace esphome::vnc::testing {
static constexpr int32_t RAW = 0;
static std::vector<uint16_t> pattern(size_t count, uint16_t seed) {
std::vector<uint16_t> out(count);
for (size_t i = 0; i < count; i++) {
out[i] = static_cast<uint16_t>(seed + i * 0x0841u);
}
return out;
}
/// Checks that a w x h block at x,y shows the given wire pixels, row-major.
static void expect_block(const FakeDisplay &d, int x, int y, int w, int h, const std::vector<uint16_t> &px,
uint8_t bpp) {
for (int row = 0; row < h; row++) {
for (int col = 0; col < w; col++) {
ASSERT_EQ(d.pixel(x + col, y + row), expect_px(px[row * w + col], bpp)) << "at " << x + col << "," << y + row;
}
}
}
static void expect_solid(const FakeDisplay &d, int x, int y, int w, int h, uint16_t px, uint8_t bpp) {
expect_block(d, x, y, w, h, std::vector<uint16_t>(static_cast<size_t>(w) * h, px), bpp);
}
class VncDecode : public ::testing::Test {
protected:
void start(FakeDisplay &display, uint16_t fb_w, uint16_t fb_h) {
this->session_ = std::make_unique<Session>(&display);
this->session_->start(fb_w, fb_h);
// Past the 50 ms request spacing, so the next request goes out as soon as a frame ends.
set_now(now_ms() + 100);
}
Session &s() { return *this->session_; }
std::unique_ptr<Session> session_;
};
TEST_F(VncDecode, Raw16bpp) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
auto px = pattern(12, 0x1234);
s().deliver(Rfb().fbu(1).raw_rect(3, 2, 4, 3, px));
expect_block(d, 3, 2, 4, 3, px, 2);
EXPECT_EQ(d.drawn_count(), 12u);
EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48)));
EXPECT_EQ(d.updates(), 0);
s().run_for(300);
EXPECT_EQ(d.updates(), 1);
}
TEST_F(VncDecode, Raw8bpp) {
FakeDisplay d(64, 48, display::DISPLAY_TYPE_BINARY);
this->start(d, 64, 48);
std::vector<uint16_t> px = {0x00, 0xFF, 0xE0, 0x1C, 0x03, 0x92, 0x49, 0x24, 0xB6, 0x6D};
s().deliver(Rfb().fbu(1).raw_rect(10, 20, 5, 2, px, 1));
expect_block(d, 10, 20, 5, 2, px, 1);
EXPECT_EQ(d.drawn_count(), 10u);
EXPECT_TRUE(s().client.is_connected());
}
TEST_F(VncDecode, RawSeveralRectsInOneUpdate) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
auto a = pattern(6, 0x0101);
auto b = pattern(4, 0xF00F);
s().deliver(Rfb().fbu(2).raw_rect(0, 0, 3, 2, a).raw_rect(60, 46, 4, 1, b));
expect_block(d, 0, 0, 3, 2, a, 2);
expect_block(d, 60, 46, 4, 1, b, 2);
EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48)));
}
TEST_F(VncDecode, EmptyUpdateRequestsAgainWithoutRefresh) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb().fbu(0));
EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48)));
s().run_for(2000);
EXPECT_EQ(d.updates(), 0);
}
TEST_F(VncDecode, RichCursorIsSkippedAndNeverRefreshes) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb().fbu(1).cursor(3, 4, 5, 4));
EXPECT_EQ(d.draw_calls(), 0);
EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48)));
s().run_for(3000);
EXPECT_EQ(d.updates(), 0);
EXPECT_TRUE(s().client.is_connected());
}
class VncCursorSkip : public ::testing::TestWithParam<display::DisplayType> {};
TEST_P(VncCursorSkip, PayloadLengthMatchesThePixelFormat) {
FakeDisplay d(64, 48, GetParam());
Session s(&d);
s.start(64, 48);
const uint8_t bpp = s.client.bpp();
// 9 wide needs a 2-byte mask row; the raw rect after it only lands right if the skip was exact.
s.deliver(Rfb().fbu(3).cursor(0, 0, 9, 3, bpp).raw_rect(7, 7, 2, 1, {0x00E0, 0x001C}, bpp).cursor(1, 1, 0, 0, bpp));
EXPECT_EQ(d.pixel(7, 7), expect_px(0x00E0, bpp));
EXPECT_EQ(d.pixel(8, 7), expect_px(0x001C, bpp));
EXPECT_EQ(d.drawn_count(), 2u);
EXPECT_TRUE(s.client.is_connected());
EXPECT_FALSE(s.client.in_update());
}
INSTANTIATE_TEST_SUITE_P(Formats, VncCursorSkip,
::testing::Values(display::DISPLAY_TYPE_COLOR, display::DISPLAY_TYPE_BINARY));
TEST_F(VncDecode, ZeroSizeCursorEndsTheFrame) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb().fbu(1).cursor(0, 0, 0, 0));
EXPECT_FALSE(s().client.in_update());
EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48)));
}
TEST_F(VncDecode, OversizedCursorReconnects) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb().fbu(1).rect(0, 0, 1025, 1, -239));
EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE);
}
TEST_F(VncDecode, LargeCutTextIsSkipped) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb().cut_text(200 * 1024).fbu(1).raw_rect(1, 1, 1, 1, {0x4321}));
EXPECT_TRUE(s().client.is_connected());
EXPECT_EQ(d.pixel(1, 1), expect_px(0x4321, 2));
EXPECT_EQ(d.drawn_count(), 1u);
}
TEST_F(VncDecode, BellAndColourMapAreSkipped) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
s().deliver(Rfb().bell().colour_map(5, 3).bell().colour_map(0, 0).fbu(1).raw_rect(2, 2, 1, 1, {0x0F0F}));
EXPECT_TRUE(s().client.is_connected());
EXPECT_EQ(d.pixel(2, 2), expect_px(0x0F0F, 2));
EXPECT_EQ(d.drawn_count(), 1u);
}
TEST(VncDecodeClip, ServerLargerThanDisplay) {
FakeDisplay d(64, 48);
Session s(&d);
s.attach();
s.deliver(handshake_38(100, 80));
ASSERT_TRUE(s.client.is_connected());
Bytes tx = s.take();
EXPECT_EQ(hex(Bytes(tx.end() - 10, tx.end())), hex(fbur(false, 64, 48)));
set_now(now_ms() + 100);
auto raw = pattern(90 * 70, 0x0021);
Rfb f;
f.fbu(3).raw_rect(5, 5, 90, 70, raw);
// A raw rect straddling both edges, and one entirely off screen.
f.raw_rect(56, 40, 20, 20, std::vector<uint16_t>(20 * 20, 0x5000));
f.raw_rect(70, 50, 16, 16, pattern(256, 1));
s.deliver(f);
EXPECT_EQ(d.out_of_bounds(), 0);
for (int y = 5; y < 40; y++) {
for (int x = 5; x < 56; x++) {
ASSERT_EQ(d.pixel(x, y), expect_px(raw[(y - 5) * 90 + (x - 5)], 2)) << x << "," << y;
}
}
expect_solid(d, 56, 40, 8, 8, 0x5000, 2);
EXPECT_EQ(d.pixel(4, 4), UNSET);
EXPECT_TRUE(s.client.is_connected());
EXPECT_EQ(hex(s.take()), hex(fbur(true, 64, 48)));
}
TEST(VncDecodeClip, ServerSmallerThanDisplay) {
FakeDisplay d(64, 48);
Session s(&d);
s.start(20, 10);
EXPECT_EQ(s.client.vis_w(), 20);
EXPECT_EQ(s.client.vis_h(), 10);
s.deliver(Rfb().fbu(1).raw_rect(19, 9, 1, 1, {0x1111}));
EXPECT_EQ(d.pixel(19, 9), expect_px(0x1111, 2));
s.deliver(Rfb().fbu(1).raw_rect(20, 0, 1, 1, {0x1111}));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
}
TEST_F(VncDecode, UnknownMessageTypeReconnects) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
int disconnects = 0;
s().client.add_on_disconnect_callback([&disconnects]() { disconnects++; });
s().deliver(Rfb().u8(7));
EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE);
EXPECT_FALSE(s().client.link().connected());
EXPECT_EQ(s().client.hold_ms(), 5000u);
EXPECT_EQ(disconnects, 1);
}
TEST_F(VncDecode, UnsupportedEncodingReconnects) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
// CopyRect, which the client never asked for.
s().deliver(Rfb().fbu(1).rect(0, 0, 4, 4, 1).u16(0).u16(0));
EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE);
}
struct OutsideCase {
uint16_t x, y, w, h;
int32_t encoding;
};
class VncRectOutside : public ::testing::TestWithParam<OutsideCase> {};
TEST_P(VncRectOutside, Reconnects) {
FakeDisplay d(64, 48);
Session s(&d);
s.start(64, 48);
const auto &c = GetParam();
s.deliver(Rfb().fbu(1).rect(c.x, c.y, c.w, c.h, c.encoding).fill(64, 0));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(d.draw_calls(), 0);
}
INSTANTIATE_TEST_SUITE_P(Rects, VncRectOutside,
::testing::Values(OutsideCase{60, 0, 5, 1, RAW}, OutsideCase{0, 47, 1, 2, RAW},
OutsideCase{65535, 0, 2, 1, RAW}, OutsideCase{0, 65535, 1, 2, RAW},
OutsideCase{64, 0, 1, 1, RAW}, OutsideCase{0, 0, 65, 1, RAW}));
// --- A frame whose last step needs no bytes must still finish when its last byte arrives.
class VncZeroByteEnd : public VncDecode {
protected:
void deliver_bytewise(const Bytes &stream, size_t frame_end) {
for (size_t i = 0; i < stream.size(); i++) {
s().deliver(Bytes{stream[i]});
Bytes tx = s().take();
if (i + 1 == frame_end) {
EXPECT_EQ(hex(tx), hex(fbur(true, 64, 48))) << "no request after the final byte";
} else {
EXPECT_EQ(tx.size(), 0u) << "unexpected bytes after byte " << i;
}
}
EXPECT_FALSE(s().client.in_update());
EXPECT_EQ(s().client.step(), VncStep::VNC_STEP_MESSAGE);
EXPECT_EQ(s().client.rx_buffered(), 0);
}
};
TEST_F(VncZeroByteEnd, ZeroSizeCursor) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
Rfb f;
f.fbu(2).raw_rect(0, 0, 2, 1, {0x7001, 0x7002}).cursor(4, 4, 0, 0);
this->deliver_bytewise(f.bytes, f.bytes.size());
s().run_for(300);
EXPECT_EQ(d.updates(), 1);
}
TEST_F(VncZeroByteEnd, ZeroSizeRawRect) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
Rfb f;
f.fbu(2).raw_rect(0, 0, 1, 1, {0x7001}).rect(64, 48, 0, 0, RAW);
this->deliver_bytewise(f.bytes, f.bytes.size());
s().run_for(300);
EXPECT_EQ(d.updates(), 1);
}
TEST_F(VncZeroByteEnd, EmptyCutTextAfterFrame) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
Rfb f;
f.fbu(1).raw_rect(0, 0, 1, 1, {0x7001});
const size_t frame_end = f.bytes.size();
f.cut_text(0).colour_map(0, 0);
this->deliver_bytewise(f.bytes, frame_end);
s().run_for(300);
EXPECT_EQ(d.updates(), 1);
s().deliver(Rfb().fbu(1).raw_rect(1, 0, 1, 1, {0x7002}));
EXPECT_EQ(d.pixel(1, 0), expect_px(0x7002, 2));
}
// --- The 20 ms budget: with a slow display, one row per pass.
TEST_F(VncDecode, BudgetPausesBetweenRectsAndFinishesOnTheLastOne) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
d.set_draw_delay_us(25000);
Rfb f;
f.fbu(3)
.raw_rect(0, 0, 16, 1, std::vector<uint16_t>(16, 0x0A0A))
.raw_rect(16, 0, 16, 1, std::vector<uint16_t>(16, 0x0B0B))
.raw_rect(32, 0, 16, 1, std::vector<uint16_t>(16, 0x0C0C));
s().send(f);
s().loop();
EXPECT_EQ(d.draw_calls(), 1);
EXPECT_TRUE(s().client.in_update());
EXPECT_TRUE(s().client.rx_pending());
EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency());
s().loop();
EXPECT_EQ(d.draw_calls(), 2);
s().loop();
EXPECT_EQ(d.draw_calls(), 3);
EXPECT_FALSE(s().client.in_update());
EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48)));
d.set_draw_delay_us(0);
s().loop(2);
EXPECT_FALSE(s().client.rx_pending());
EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency());
expect_solid(d, 32, 0, 16, 1, 0x0C0C, 2);
}
TEST_F(VncDecode, BudgetPausesInsideARawRect) {
FakeDisplay d(64, 48);
this->start(d, 64, 48);
d.set_draw_delay_us(25000);
auto px = pattern(4 * 3, 0x2000);
s().send(Rfb().fbu(1).raw_rect(0, 0, 4, 3, px));
s().loop();
EXPECT_EQ(d.draw_calls(), 1);
EXPECT_TRUE(s().client.in_update());
s().loop(2);
EXPECT_EQ(d.draw_calls(), 3);
EXPECT_FALSE(s().client.in_update());
d.set_draw_delay_us(0);
expect_block(d, 0, 0, 4, 3, px, 2);
}
// --- Split-feed property: however the bytes are cut, the result is the same and matches a model.
struct Model {
int fb_w{0};
int fb_h{0};
uint8_t bpp{2};
std::vector<int32_t> px;
void set(int x, int y, uint16_t v) { this->px[static_cast<size_t>(y) * this->fb_w + x] = v; }
};
struct Generated {
int disp_w{0};
int disp_h{0};
display::DisplayType type{display::DISPLAY_TYPE_COLOR};
uint16_t vis_w{0};
uint16_t vis_h{0};
Bytes handshake;
Bytes handshake_tx;
std::vector<Bytes> frames;
Model model;
};
static Generated generate(uint32_t seed) {
std::mt19937 rng(seed);
auto rnd = [&rng](int lo, int hi) { return std::uniform_int_distribution<int>(lo, hi)(rng); };
Generated g;
g.disp_w = rnd(4, 70);
g.disp_h = rnd(4, 50);
const int type = rnd(0, 2);
g.type = type == 0 ? display::DISPLAY_TYPE_COLOR
: (type == 1 ? display::DISPLAY_TYPE_GRAYSCALE : display::DISPLAY_TYPE_BINARY);
const uint8_t bpp = g.type == display::DISPLAY_TYPE_BINARY ? 1 : 2;
const int fb_w = rnd(1, 90);
const int fb_h = rnd(1, 64);
g.vis_w = std::min(fb_w, g.disp_w);
g.vis_h = std::min(fb_h, g.disp_h);
g.model.fb_w = fb_w;
g.model.fb_h = fb_h;
g.model.bpp = bpp;
g.model.px.assign(static_cast<size_t>(fb_w) * fb_h, -1);
auto pixel = [&]() { return static_cast<uint16_t>(bpp == 2 ? rnd(0, 0xFFFF) : rnd(0, 0xFF)); };
Rfb hs;
switch (rnd(0, 2)) {
case 0:
hs.version("RFB 003.003\n").security_33(1);
g.handshake_tx = cat({version_reply('3'), {1}});
break;
case 1:
hs.version("RFB 003.007\n").security_list({1});
g.handshake_tx = cat({version_reply('7'), {1}, {1}});
break;
default:
hs.version().security_list({16, 1}).security_result(0);
g.handshake_tx = cat({version_reply('8'), {1}, {1}});
break;
}
hs.server_init(fb_w, fb_h, std::string(rnd(0, 80), 'n'));
g.handshake = hs.bytes;
g.handshake_tx = cat({g.handshake_tx, bpp == 2 ? set_pixel_format_565() : set_pixel_format_332(), set_encodings(),
fbur(false, g.vis_w, g.vis_h)});
const int frames = rnd(3, 8);
for (int frame = 0; frame < frames; frame++) {
Rfb f;
for (int extra = rnd(0, 2); extra > 0; extra--) {
switch (rnd(0, 2)) {
case 0:
f.bell();
break;
case 1:
f.cut_text(rnd(0, 700));
break;
default:
f.colour_map(rnd(0, 255), rnd(0, 4));
break;
}
}
const int rects = rnd(0, 5);
f.fbu(rects);
for (int r = 0; r < rects; r++) {
const int kind = rnd(0, 9);
if (kind == 8) {
f.cursor(rnd(0, 100), rnd(0, 100), rnd(0, 9), rnd(0, 9), bpp);
continue;
}
if (kind == 9) {
const int x = rnd(0, fb_w);
const int y = rnd(0, fb_h);
f.rect(x, y, rnd(0, 1) ? 0 : rnd(0, fb_w - x), 0, RAW);
continue;
}
const int x = rnd(0, fb_w - 1);
const int y = rnd(0, fb_h - 1);
const int w = rnd(1, std::min(fb_w - x, 48));
const int h = rnd(1, std::min(fb_h - y, 40));
f.rect(x, y, w, h, RAW);
for (int yy = 0; yy < h; yy++) {
for (int xx = 0; xx < w; xx++) {
uint16_t p = pixel();
f.px(p, bpp);
g.model.set(x + xx, y + yy, p);
}
}
}
g.frames.push_back(f.bytes);
}
return g;
}
static void expect_matches_model(const FakeDisplay &d, const Generated &g, uint32_t seed) {
for (int y = 0; y < g.disp_h; y++) {
for (int x = 0; x < g.disp_w; x++) {
uint32_t expected = UNSET;
if (x < g.vis_w && y < g.vis_h) {
const int32_t v = g.model.px[static_cast<size_t>(y) * g.model.fb_w + x];
if (v >= 0) {
expected = expect_px(static_cast<uint16_t>(v), g.model.bpp);
}
}
ASSERT_EQ(d.pixel(x, y), expected) << "seed " << seed << " at " << x << "," << y;
}
}
}
enum class Cut { WHOLE, BYTES, RANDOM };
static std::vector<Bytes> cut(const Bytes &stream, Cut how, std::mt19937 &rng) {
std::vector<Bytes> chunks;
size_t pos = 0;
while (pos < stream.size()) {
size_t len = stream.size() - pos;
if (how == Cut::BYTES) {
len = 1;
} else if (how == Cut::RANDOM) {
len = std::min<size_t>(len, std::uniform_int_distribution<size_t>(1, 700)(rng));
}
chunks.emplace_back(stream.begin() + pos, stream.begin() + pos + len);
pos += len;
}
return chunks;
}
struct FeedResult {
std::vector<uint32_t> pixels;
Bytes tx;
};
static FeedResult run_parser(const Generated &g, Cut how, uint32_t seed) {
FakeDisplay d(g.disp_w, g.disp_h, g.type);
Session s(&d);
s.attach();
Bytes stream = g.handshake;
for (const auto &frame : g.frames) {
stream.insert(stream.end(), frame.begin(), frame.end());
}
std::mt19937 rng(seed * 7919u + static_cast<uint32_t>(how));
for (const auto &chunk : cut(stream, how, rng)) {
EXPECT_EQ(s.client.feed(chunk), VncParse::VNC_PARSE_MORE) << "seed " << seed;
}
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_RUNNING) << "seed " << seed;
EXPECT_EQ(s.client.step(), VncStep::VNC_STEP_MESSAGE) << "seed " << seed;
EXPECT_EQ(s.client.rx_buffered(), 0) << "seed " << seed;
EXPECT_FALSE(s.client.in_update()) << "seed " << seed;
EXPECT_EQ(d.out_of_bounds(), 0);
expect_matches_model(d, g, seed);
return {d.pixels(), s.take()};
}
TEST(VncSplitFeed, ParserGivesTheSameResultHoweverTheBytesAreCut) {
for (uint32_t seed = 1; seed <= 50; seed++) {
const Generated g = generate(seed);
const FeedResult whole = run_parser(g, Cut::WHOLE, seed);
EXPECT_EQ(hex(whole.tx), hex(g.handshake_tx)) << "seed " << seed;
const FeedResult bytes = run_parser(g, Cut::BYTES, seed);
const FeedResult random = run_parser(g, Cut::RANDOM, seed);
EXPECT_EQ(whole.pixels, bytes.pixels) << "seed " << seed;
EXPECT_EQ(whole.pixels, random.pixels) << "seed " << seed;
EXPECT_EQ(hex(whole.tx), hex(bytes.tx)) << "seed " << seed;
EXPECT_EQ(hex(whole.tx), hex(random.tx)) << "seed " << seed;
if (::testing::Test::HasFailure()) {
break;
}
}
}
/// End to end over the socket: the server sends each frame only after the client asked for it.
static FeedResult run_socket(const Generated &g, Cut how, uint32_t seed) {
FakeDisplay d(g.disp_w, g.disp_h, g.type);
Session s(&d);
s.attach();
s.deliver(g.handshake);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_RUNNING) << "seed " << seed;
EXPECT_EQ(hex(s.take()), hex(g.handshake_tx)) << "seed " << seed;
std::mt19937 rng(seed * 104729u + static_cast<uint32_t>(how));
Bytes tx;
auto collect = [&]() {
Bytes more = s.take();
tx.insert(tx.end(), more.begin(), more.end());
};
auto wait_for_requests = [&](int count) {
for (int i = 0; i < 100 && count_fburs(tx, true) < count; i++) {
s.tick(10);
collect();
}
EXPECT_EQ(count_fburs(tx, true), count) << "seed " << seed;
};
for (size_t k = 0; k < g.frames.size(); k++) {
if (k > 0) {
wait_for_requests(static_cast<int>(k));
}
for (const auto &chunk : cut(g.frames[k], how, rng)) {
s.send(chunk);
for (int i = 0; i < 1000 && (s.server_bytes_waiting() || s.unread() != 0 || s.client.rx_pending()); i++) {
s.tick(1);
}
collect();
}
}
wait_for_requests(static_cast<int>(g.frames.size()));
s.run_for(2000);
collect();
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_RUNNING) << "seed " << seed;
EXPECT_EQ(d.out_of_bounds(), 0);
expect_matches_model(d, g, seed);
if (d.drawn_count() != 0) {
EXPECT_GE(d.updates(), 1) << "seed " << seed;
}
Bytes expected_tx;
for (size_t i = 0; i < g.frames.size(); i++) {
expected_tx = cat({expected_tx, fbur(true, g.vis_w, g.vis_h)});
}
EXPECT_EQ(hex(tx), hex(expected_tx)) << "seed " << seed;
return {d.pixels(), tx};
}
TEST(VncSplitFeed, SocketGivesTheSameResultHoweverTheBytesAreCut) {
for (uint32_t seed = 101; seed <= 130; seed++) {
const Generated g = generate(seed);
const FeedResult whole = run_socket(g, Cut::WHOLE, seed);
const FeedResult bytes = run_socket(g, Cut::BYTES, seed);
const FeedResult random = run_socket(g, Cut::RANDOM, seed);
EXPECT_EQ(whole.pixels, bytes.pixels) << "seed " << seed;
EXPECT_EQ(whole.pixels, random.pixels) << "seed " << seed;
if (::testing::Test::HasFailure()) {
break;
}
}
}
} // namespace esphome::vnc::testing
#endif // USE_HOST
@@ -1,342 +0,0 @@
#include "vnc_test_helpers.h"
#ifdef USE_HOST
namespace esphome::vnc::testing {
static Bytes running_tail(const Bytes &pixel_format, uint16_t w, uint16_t h) {
return cat({pixel_format, set_encodings(), fbur(false, w, h)});
}
TEST(VncHandshake, Rfb38NoneExactBytes) {
FakeDisplay display(64, 48);
Session s(&display);
int connects = 0;
s.client.add_on_connect_callback([&connects]() { connects++; });
s.attach();
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_HANDSHAKE);
s.deliver(Rfb().version());
EXPECT_EQ(hex(s.take()), hex(version_reply('8')));
s.deliver(Rfb().security_list({1}));
EXPECT_EQ(hex(s.take()), hex({1}));
s.deliver(Rfb().security_result(0));
EXPECT_EQ(hex(s.take()), hex({1})); // ClientInit, shared
EXPECT_FALSE(s.client.is_connected());
s.deliver(Rfb().server_init(80, 40));
// The first request covers only what the display can show.
EXPECT_EQ(hex(s.take()), hex(running_tail(set_pixel_format_565(), 64, 40)));
EXPECT_TRUE(s.client.is_connected());
EXPECT_EQ(connects, 1);
}
TEST(VncHandshake, Rfb38NonePipelined) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(handshake_38(32, 100));
EXPECT_EQ(hex(s.take()), hex(cat({version_reply('8'), {1}, {1}, running_tail(set_pixel_format_565(), 32, 48)})));
EXPECT_TRUE(s.client.is_connected());
}
struct PixelFormatCase {
display::DisplayType type;
bool rgb565;
};
class VncPixelFormatSelection : public ::testing::TestWithParam<PixelFormatCase> {};
TEST_P(VncPixelFormatSelection, SetPixelFormatMatchesTheDisplay) {
const auto &param = GetParam();
FakeDisplay display(64, 48, param.type);
Session s(&display);
s.attach();
s.deliver(handshake_38(64, 48));
Bytes tx = s.take();
const Bytes expected = running_tail(param.rgb565 ? set_pixel_format_565() : set_pixel_format_332(), 64, 48);
ASSERT_GE(tx.size(), expected.size());
EXPECT_EQ(hex(Bytes(tx.end() - expected.size(), tx.end())), hex(expected));
EXPECT_EQ(s.client.bpp(), param.rgb565 ? 2 : 1);
}
INSTANTIATE_TEST_SUITE_P(Formats, VncPixelFormatSelection,
::testing::Values(PixelFormatCase{display::DISPLAY_TYPE_COLOR, true},
PixelFormatCase{display::DISPLAY_TYPE_GRAYSCALE, true},
PixelFormatCase{display::DISPLAY_TYPE_BINARY, false}));
TEST(VncHandshake, Rfb37NoneHasNoSecurityResult) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version("RFB 003.007\n"));
EXPECT_EQ(hex(s.take()), hex(version_reply('7')));
s.deliver(Rfb().security_list({1}));
// Type and ClientInit go out together: 3.7 sends no SecurityResult for None.
EXPECT_EQ(hex(s.take()), hex({1, 1}));
s.deliver(Rfb().server_init(64, 48));
EXPECT_EQ(hex(s.take()), hex(running_tail(set_pixel_format_565(), 64, 48)));
EXPECT_TRUE(s.client.is_connected());
}
TEST(VncHandshake, Rfb33None) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version("RFB 003.003\n"));
EXPECT_EQ(hex(s.take()), hex(version_reply('3')));
s.deliver(Rfb().security_33(1));
EXPECT_EQ(hex(s.take()), hex({1}));
s.deliver(Rfb().server_init(64, 48));
EXPECT_EQ(hex(s.take()), hex(running_tail(set_pixel_format_565(), 64, 48)));
EXPECT_TRUE(s.client.is_connected());
}
TEST(VncHandshake, Rfb33RefusalIsTransient) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version("RFB 003.003\n").security_33(0).reason("Too many security failures"));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 5000u);
EXPECT_FALSE(s.client.status_has_error());
}
TEST(VncHandshake, Rfb33PasswordWithoutKeyIsConfig) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version("RFB 003.003\n"));
EXPECT_EQ(hex(s.take()), hex(version_reply('3')));
s.deliver(Rfb().security_33(2).challenge());
EXPECT_EQ(s.take().size(), 0u);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 300000u);
EXPECT_TRUE(s.client.status_has_error());
}
struct VersionCase {
const char *server;
char reply_minor;
};
class VncVersionMapping : public ::testing::TestWithParam<VersionCase> {};
TEST_P(VncVersionMapping, RepliesWithTheMappedVersion) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(text(GetParam().server));
EXPECT_EQ(hex(s.take()), hex(version_reply(GetParam().reply_minor)));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_HANDSHAKE);
EXPECT_EQ(s.client.step(),
GetParam().reply_minor == '3' ? VncStep::VNC_STEP_SECURITY_TYPE_33 : VncStep::VNC_STEP_SECURITY_COUNT);
}
INSTANTIATE_TEST_SUITE_P(Versions, VncVersionMapping,
::testing::Values(VersionCase{"RFB 003.008\n", '8'}, VersionCase{"RFB 003.889\n", '8'},
VersionCase{"RFB 004.001\n", '8'}, VersionCase{"RFB 005.000\n", '8'},
VersionCase{"RFB 003.007\n", '7'}, VersionCase{"RFB 003.005\n", '3'},
VersionCase{"RFB 003.003\n", '3'}));
class VncBadVersion : public ::testing::TestWithParam<const char *> {};
TEST_P(VncBadVersion, IsAConfigFailureHeldFor300s) {
FakeDisplay display(64, 48);
Listener listener;
Session s(&display);
s.client.set_port(listener.port());
s.attach();
s.deliver(text(GetParam()));
EXPECT_EQ(s.take().size(), 0u);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 300000u);
EXPECT_TRUE(s.client.status_has_error());
const uint32_t failed_at = now_ms();
// No attempt while the hold runs.
while (now_ms() - failed_at < 299000) {
s.tick(1000);
}
EXPECT_EQ(listener.accepted(), 0);
EXPECT_FALSE(s.client.link().connecting());
set_now(failed_at + 300000);
EXPECT_TRUE(s.wait_until([&]() { return listener.accepted() == 1; }));
}
INSTANTIATE_TEST_SUITE_P(Versions, VncBadVersion,
::testing::Values("HTTP/1.1 400", "RFB 002.000\n", "RFB 003,008\n", "RFB 0x3.008\n",
"SSH-2.0-Open"));
TEST(VncHandshake, PasswordRequiredWithoutKeyIsConfig) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version());
EXPECT_EQ(hex(s.take()), hex(version_reply('8')));
s.deliver(Rfb().security_list({2, 16}));
// Never pick 16, never answer type 2 without a key.
EXPECT_EQ(s.take().size(), 0u);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 300000u);
EXPECT_TRUE(s.client.status_has_error());
EXPECT_FALSE(s.client.link().connected());
}
TEST(VncHandshake, OnlyUnsupportedTypesIsConfig) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version());
EXPECT_EQ(hex(s.take()), hex(version_reply('8')));
s.deliver(Rfb().security_list({16}));
EXPECT_EQ(s.take().size(), 0u);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 300000u);
EXPECT_TRUE(s.client.status_has_error());
}
TEST(VncHandshake, EmptyListWithReasonIsTransient) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version());
EXPECT_EQ(hex(s.take()), hex(version_reply('8')));
s.deliver(Rfb().security_list({}).reason("Too many authentication failures"));
EXPECT_EQ(s.take().size(), 0u);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 5000u);
EXPECT_EQ(s.client.backoff_ms(), 5000u);
EXPECT_TRUE(s.client.status_has_warning());
EXPECT_FALSE(s.client.status_has_error());
}
TEST(VncHandshake, EmptyReasonEndsAtOnce) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version());
s.deliver(Rfb().security_list({}).u32(0));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 5000u);
}
TEST(VncHandshake, LongReasonIsCutWithoutWaitingForAllOfIt) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version());
// Announces 100000 bytes but sends only the first 63 the client logs.
s.deliver(Rfb().security_list({}).u32(100000).fill(63, 'r'));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 5000u);
}
TEST(VncHandshake, FullSecurityListWithoutAUsableType) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version());
Rfb list;
list.u8(255);
for (int i = 0; i < 255; i++) {
list.u8(static_cast<uint8_t>(i == 1 || i == 2 ? 16 : i));
}
s.deliver(list);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 300000u);
}
TEST(VncHandshake, DumpConfig) {
FakeDisplay display(64, 48);
Session s(&display);
s.client.dump_config();
}
TEST(VncHandshake, HandshakeTimeout) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
const uint32_t start = now_ms();
s.deliver(Rfb().version());
s.at(start + 14999);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_HANDSHAKE);
EXPECT_TRUE(s.client.link().connected());
s.at(start + 15000);
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 5000u);
EXPECT_FALSE(s.client.link().connected());
EXPECT_FALSE(s.client.status_has_error());
}
TEST(VncHandshake, NoneSecurityFailureIsConfig) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version().security_list({1, 16}));
EXPECT_EQ(hex(s.take()), hex(cat({version_reply('8'), {1}})));
s.deliver(Rfb().security_result(1).reason("Server is busy"));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 300000u);
}
TEST(VncHandshake, EmptyServerName) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version().security_list({1}).security_result(0).server_init(64, 48, ""));
EXPECT_TRUE(s.client.is_connected());
EXPECT_EQ(hex(s.take()), hex(cat({version_reply('8'), {1}, {1}, running_tail(set_pixel_format_565(), 64, 48)})));
}
TEST(VncHandshake, LongServerNameIsSkipped) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version().security_list({1}).security_result(0).server_init(64, 48, std::string(700, 'n')));
ASSERT_TRUE(s.client.is_connected());
s.take();
s.deliver(Rfb().fbu(1).raw_rect(0, 0, 1, 1, {0xF800}));
EXPECT_EQ(display.pixel(0, 0), expect_px(0xF800, 2));
EXPECT_TRUE(s.client.is_connected());
}
TEST(VncHandshake, RunningOnlyAfterTheWholeServerName) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
Bytes init = Rfb().version().security_list({1}).security_result(0).server_init(64, 48, std::string(700, 'n')).bytes;
s.deliver(Bytes(init.begin(), init.end() - 1));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_HANDSHAKE);
s.deliver(Bytes(init.end() - 1, init.end()));
EXPECT_TRUE(s.client.is_connected());
}
TEST(VncHandshake, EmptyServerScreenIsTransient) {
FakeDisplay display(64, 48);
Session s(&display);
s.attach();
s.deliver(Rfb().version().security_list({1}).security_result(0).server_init(0, 48));
EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE);
EXPECT_EQ(s.client.hold_ms(), 5000u);
}
TEST(VncHandshake, DisconnectCallbackOnlyAfterRunning) {
FakeDisplay display(64, 48);
Session s(&display);
int disconnects = 0;
s.client.add_on_disconnect_callback([&disconnects]() { disconnects++; });
s.attach();
s.deliver(Rfb().version());
s.close_peer();
s.loop(2);
EXPECT_EQ(disconnects, 0);
s.start(64, 48);
s.close_peer();
s.loop(2);
EXPECT_EQ(disconnects, 1);
EXPECT_FALSE(s.client.is_connected());
}
} // namespace esphome::vnc::testing
#endif // USE_HOST
-385
View File
@@ -1,385 +0,0 @@
#include "vnc_test_helpers.h"
#ifdef USE_HOST
namespace esphome::vnc::testing {
static Bytes frame(uint16_t x = 0, uint16_t pixel = 0x1234) {
return Rfb().fbu(1).raw_rect(x, 0, 2, 2, {pixel, pixel, pixel, pixel}).bytes;
}
static const Bytes INCREMENTAL = fbur(true, 64, 48);
// The session starts at t=1000, when the first (full) request goes out.
class VncPacing : public ::testing::Test {
protected:
void SetUp() override {
this->s_ = std::make_unique<Session>(&this->d_);
this->s_->start(64, 48);
}
Session &s() { return *this->s_; }
FakeDisplay d_;
std::unique_ptr<Session> s_;
};
TEST_F(VncPacing, UpdateOnlyAfterTheScreenStopsChanging) {
set_now(1100);
s().deliver(frame());
EXPECT_EQ(d_.updates(), 0);
s().at(1299);
EXPECT_EQ(d_.updates(), 0);
s().at(1300);
EXPECT_EQ(d_.updates(), 1);
s().run_for(5000);
EXPECT_EQ(d_.updates(), 1);
}
TEST_F(VncPacing, UpdateHasItsOwnPass) {
set_now(1100);
s().deliver(frame(0));
s().send(frame(10, 0x4321));
ASSERT_GT(s().unread(), 0);
s().at(1300);
EXPECT_EQ(d_.updates(), 1);
// Nothing was read in the pass that refreshed the display.
EXPECT_GT(s().unread(), 0);
EXPECT_EQ(d_.pixel(10, 0), UNSET);
s().loop();
EXPECT_EQ(s().unread(), 0);
EXPECT_EQ(d_.pixel(10, 0), expect_px(0x4321, 2));
}
TEST_F(VncPacing, FastFramesPostponeTheUpdateUntilTheMaximumDelay) {
// A frame every 100 ms: never quiet for 200 ms, so only the 1 s limit forces a refresh.
std::vector<uint32_t> update_times;
int last = 0;
for (uint32_t t = 1100; t <= 2500; t += 100) {
s().send(frame(t % 60));
s().at(t);
if (d_.updates() != last) {
update_times.push_back(t);
last = d_.updates();
s().loop();
}
EXPECT_EQ(s().unread(), 0);
}
EXPECT_EQ(update_times, std::vector<uint32_t>({2100}));
s().at(2699);
EXPECT_EQ(d_.updates(), 1);
s().at(2700);
EXPECT_EQ(d_.updates(), 2);
}
TEST_F(VncPacing, NoUpdateInTheMiddleOfAFrameEvenPastTheMaximumDelay) {
set_now(1100);
s().deliver(frame());
Bytes second = Rfb().fbu(1).raw_rect(0, 10, 4, 1, {1, 2, 3, 4}).bytes;
set_now(1200);
s().deliver(Bytes(second.begin(), second.end() - 3));
ASSERT_TRUE(s().client.in_update());
s().run_for(1400);
EXPECT_EQ(d_.updates(), 0);
s().deliver(Bytes(second.end() - 3, second.end()));
EXPECT_FALSE(s().client.in_update());
EXPECT_EQ(d_.updates(), 0);
s().tick(10);
EXPECT_EQ(d_.updates(), 1);
}
TEST(VncPacingDelays, CustomDelays) {
FakeDisplay d;
Session s(&d);
s.client.set_update_delay(50);
s.client.set_max_update_delay(300);
s.start(64, 48);
set_now(1100);
s.deliver(frame());
s.at(1149);
EXPECT_EQ(d.updates(), 0);
s.at(1150);
EXPECT_EQ(d.updates(), 1);
// Frames every 40 ms: the 300 ms limit applies.
int updates = 1;
uint32_t first_forced = 0;
for (uint32_t t = 1200; t < 1700; t += 40) {
s.send(frame(t % 60));
s.at(t);
if (d.updates() != updates && first_forced == 0) {
first_forced = t;
}
updates = d.updates();
s.loop();
}
EXPECT_EQ(first_forced, 1520u);
}
TEST(VncPacingDelays, ZeroDelayRefreshesOnTheNextPass) {
FakeDisplay d;
Session s(&d);
s.client.set_update_delay(0);
s.start(64, 48);
set_now(1100);
s.deliver(frame());
s.loop();
EXPECT_EQ(d.updates(), 1);
}
TEST_F(VncPacing, NextRequestRightAfterEachFrameWithA50msFloor) {
set_now(1010);
s().deliver(frame());
EXPECT_EQ(s().take().size(), 0u);
s().at(1049);
EXPECT_EQ(s().take().size(), 0u);
s().at(1050);
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
set_now(1500);
s().deliver(frame());
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
// One request stays outstanding; nothing more until the next frame or the heartbeat.
s().run_for(50000, 100);
EXPECT_EQ(s().take().size(), 0u);
}
TEST_F(VncPacing, CursorOnlyFrameNeverRefreshes) {
set_now(1100);
s().deliver(Rfb().fbu(1).cursor(1, 1, 4, 4));
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
s().run_for(5000);
EXPECT_EQ(d_.updates(), 0);
}
TEST_F(VncPacing, HeartbeatAfterAMinuteOfSilence) {
set_now(1100);
s().deliver(frame());
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
while (now_ms() + 1000 < 61099) {
s().tick(1000);
}
EXPECT_EQ(s().take().size(), 0u);
s().at(61099);
EXPECT_EQ(s().take().size(), 0u);
s().at(61100);
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
s().at(121099);
EXPECT_EQ(s().take().size(), 0u);
s().at(121100);
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
}
TEST_F(VncPacing, DataResetsTheHeartbeat) {
set_now(1100);
s().deliver(frame());
s().take();
set_now(40000);
s().deliver(Rfb().bell());
s().at(61100);
EXPECT_EQ(s().take().size(), 0u);
s().at(100000);
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
}
TEST_F(VncPacing, ServerSilentMidFrameEndsTheSession) {
set_now(1100);
s().deliver(frame());
s().take();
Bytes partial = frame();
partial.pop_back();
s().deliver(partial);
ASSERT_TRUE(s().client.in_update());
s().at(1100 + 59999);
EXPECT_EQ(s().client.state(), VncState::VNC_STATE_RUNNING);
s().at(1100 + 60000);
EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE);
EXPECT_FALSE(s().client.link().connected());
}
// --- A display that parks its loop when idle (it8951, epaper_spi).
class VncPacingParked : public ::testing::Test {
protected:
void SetUp() override {
this->s_ = std::make_unique<Session>(&this->d_);
this->s_->start(64, 48);
}
Session &s() { return *this->s_; }
FakeParkingDisplay d_;
std::unique_ptr<Session> s_;
};
TEST_F(VncPacingParked, BusyDisplayHoldsDrawsAndRequests) {
set_now(1100);
s().deliver(frame());
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
s().at(1300);
ASSERT_EQ(d_.updates(), 1);
ASSERT_TRUE(d_.busy());
// The server answers the outstanding request while the panel refreshes.
s().send(frame(20, 0x5555));
s().at(1310);
EXPECT_TRUE(s().client.display_busy());
EXPECT_TRUE(s().client.in_update());
EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency());
s().run_for(3000);
EXPECT_EQ(d_.draws_while_busy(), 0);
EXPECT_EQ(d_.pixel(20, 0), UNSET);
EXPECT_EQ(s().take().size(), 0u);
d_.finish();
s().tick(10);
EXPECT_EQ(d_.pixel(20, 0), expect_px(0x5555, 2));
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
s().run_for(300);
EXPECT_EQ(d_.updates(), 2);
}
TEST(VncPacingParkedDelay, NoRequestInTheRefreshPassWithZeroDelay) {
FakeParkingDisplay d;
Session s(&d);
s.client.set_update_delay(0);
s.start(64, 48);
// The frame ends 20 ms after the request, inside the 50 ms floor, so the next request waits.
set_now(1020);
s.deliver(frame());
EXPECT_EQ(s.take().size(), 0u);
s.at(1060);
ASSERT_EQ(d.updates(), 1);
ASSERT_TRUE(d.busy());
EXPECT_EQ(s.take().size(), 0u) << "request sent while the panel refreshes";
d.finish();
s.tick(10);
EXPECT_EQ(hex(s.take()), hex(INCREMENTAL));
}
TEST_F(VncPacingParked, FailedDisplayEndsTheSessionAndStops) {
set_now(1100);
s().deliver(frame());
s().take();
s().at(1300);
ASSERT_EQ(d_.updates(), 1);
d_.mark_failed();
s().tick(10);
EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE);
EXPECT_FALSE(s().client.link().connected());
EXPECT_TRUE(s().client.status_has_error());
EXPECT_TRUE(s().client.is_idle());
}
TEST_F(VncPacingParked, NoRequestWhileBusyBetweenFrames) {
// Inside the 50 ms floor, so the next request is still due when the panel turns busy.
set_now(1010);
s().deliver(Rfb().fbu(0));
EXPECT_EQ(s().take().size(), 0u);
d_.external_update();
s().run_for(2000);
EXPECT_EQ(s().take().size(), 0u);
d_.finish();
s().tick(10);
EXPECT_EQ(hex(s().take()), hex(INCREMENTAL));
}
TEST_F(VncPacingParked, ExternalUpdateMidFrameStopsReading) {
Bytes f = Rfb().fbu(2).raw_rect(0, 0, 2, 1, {1, 2}).raw_rect(0, 5, 2, 1, {3, 4}).bytes;
set_now(1100);
s().deliver(Bytes(f.begin(), f.begin() + 4 + 12 + 4 + 6));
ASSERT_TRUE(s().client.in_update());
d_.external_update();
s().send(Bytes(f.begin() + 4 + 12 + 4 + 6, f.end()));
s().run_for(1000);
EXPECT_GT(s().unread(), 0);
EXPECT_EQ(d_.draws_while_busy(), 0);
EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency());
d_.finish();
s().tick(10);
EXPECT_EQ(s().unread(), 0);
EXPECT_EQ(d_.pixel(1, 5), expect_px(4, 2));
EXPECT_FALSE(s().client.in_update());
}
// --- Displays that never report busy must never stall the client.
template<typename D> static void runs_frames_without_deadlock() {
D d;
Session s(&d);
s.start(64, 48);
for (int i = 0; i < 5; i++) {
set_now(now_ms() + 300);
s.deliver(frame(i * 4, 0x1000 + i));
EXPECT_EQ(hex(s.take()), hex(INCREMENTAL)) << "frame " << i;
s.run_for(300);
EXPECT_EQ(d.updates(), i + 1) << "frame " << i;
EXPECT_EQ(d.pixel(i * 4, 0), expect_px(0x1000 + i, 2));
}
}
TEST(VncPacingDisplays, PlainDisplay) { runs_frames_without_deadlock<FakeDisplay>(); }
TEST(VncPacingStart, FirstRefreshWaitsForAStartingDisplayToPark) {
FakeStartingDisplay d;
Session s(&d);
s.start(64, 48);
set_now(1100);
s.deliver(frame());
s.at(1300);
EXPECT_EQ(d.updates(), 0);
d.finish_start();
s.tick(10);
ASSERT_EQ(d.updates(), 1);
// Known to park now, so nothing is drawn until this refresh is over.
s.deliver(frame(10));
EXPECT_EQ(d.draws_while_busy(), 0);
d.finish();
s.tick(10);
EXPECT_EQ(d.pixel(10, 0), expect_px(0x1234, 2));
}
TEST(VncPacingStart, LoopingDisplayRefreshesAfterTheStartWindow) {
FakeLoopingDisplay d;
Session s(&d);
s.start(64, 48);
set_now(1100);
s.deliver(frame());
s.at(1000 + 4999);
EXPECT_EQ(d.updates(), 0);
s.at(1000 + 5000);
EXPECT_EQ(d.updates(), 1);
}
TEST(VncPacingDisplays, FreeRunningDisplay) { runs_frames_without_deadlock<FakeFreeRunDisplay>(); }
TEST(VncPacingDisplays, SynchronousParkedDisplay) { runs_frames_without_deadlock<FakeSyncParkedDisplay>(); }
TEST_F(VncPacingParked, FailedDisplayClosesAConnectStillInFlight) {
s().client.forget_session();
ASSERT_TRUE(s().client.link().connected());
d_.mark_failed();
s().tick(10);
EXPECT_FALSE(s().client.link().connected());
EXPECT_TRUE(s().client.status_has_error());
}
TEST_F(VncPacingParked, DisplayBusyTooLongSetsAWarningUntilItParks) {
d_.external_update();
s().run_for(120000, 1000);
EXPECT_TRUE(s().client.status_has_warning());
// A new session does not hide the stuck display
s().start(64, 48);
EXPECT_TRUE(s().client.status_has_warning());
d_.finish();
s().tick(10);
EXPECT_FALSE(s().client.status_has_warning());
}
TEST_F(VncPacingParked, DisplayParkingKeepsAConnectionWarning) {
d_.external_update();
s().run_for(120000, 1000);
s().close_peer();
s().tick(10);
ASSERT_EQ(s().client.state(), VncState::VNC_STATE_IDLE);
EXPECT_TRUE(s().client.status_has_warning());
d_.finish();
s().tick(10);
EXPECT_TRUE(s().client.status_has_warning());
}
} // namespace esphome::vnc::testing
#endif // USE_HOST
@@ -1,13 +0,0 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
vnc:
- host: 192.0.2.10
interval:
- interval: 60s
then:
- if:
condition: vnc.is_connected
then:
- logger.log: VNC is connected
@@ -1,18 +0,0 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
wifi:
ssid: MySSID
password: password1
display:
- platform: it8951
id: m5paper_display
model: m5stack-m5paper
cs_pin: GPIO22
reset_pin: GPIO21
busy_pin: GPIO27
sleep_when_done: false
vnc:
- host: vnc.local
-581
View File
@@ -1,581 +0,0 @@
#pragma once
#include <gtest/gtest.h>
#include <algorithm>
#include <cerrno>
#include <csignal>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <functional>
#include <initializer_list>
#include <memory>
#include <netinet/in.h>
#include <string>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
#include "esphome/components/display/display.h"
#include "esphome/components/socket/socket.h"
#include "esphome/components/vnc/vnc_client.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#ifdef USE_HOST
namespace esphome::vnc::testing {
using Bytes = std::vector<uint8_t>;
inline void set_now(uint32_t ms) { LoopBlockingGuard guard(nullptr, nullptr, ms); }
inline uint32_t now_ms() { return App.get_loop_component_start_time(); }
inline std::string hex(const Bytes &bytes) {
std::string out;
char buf[4];
for (size_t i = 0; i < bytes.size(); i++) {
snprintf(buf, sizeof(buf), i == 0 ? "%02X" : " %02X", bytes[i]);
out += buf;
}
return out;
}
inline Bytes cat(std::initializer_list<Bytes> parts) {
Bytes out;
for (const auto &part : parts) {
out.insert(out.end(), part.begin(), part.end());
}
return out;
}
inline Bytes text(const std::string &s) { return Bytes(s.begin(), s.end()); }
// ---------------------------------------------------------------------------
// Displays
inline constexpr uint32_t UNSET = 0xFF000000;
inline uint32_t pack(Color c) { return (static_cast<uint32_t>(c.r) << 16) | (static_cast<uint32_t>(c.g) << 8) | c.b; }
/// The colour a wire pixel must show as, decoded the way the display API decodes it.
inline uint32_t expect_px(uint16_t wire, uint8_t bpp) {
return pack(display::ColorUtil::to_color(wire, bpp == 2 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_332));
}
class FakeDisplay : public display::Display {
public:
FakeDisplay(int width = 64, int height = 48, display::DisplayType type = display::DISPLAY_TYPE_COLOR)
: width_(width), height_(height), type_(type), pixels_(static_cast<size_t>(width) * height, UNSET) {}
using display::Display::draw_pixels_at;
void update() override { this->updates_++; }
display::DisplayType get_display_type() override { return this->type_; }
int get_width_internal() override { return this->width_; }
int get_height_internal() override { return this->height_; }
void draw_pixel_at(int x, int y, Color color) override {
if (x < 0 || y < 0 || x >= this->width_ || y >= this->height_) {
this->out_of_bounds_++;
ADD_FAILURE() << "pixel drawn outside the display at " << x << "," << y;
return;
}
this->pixels_[static_cast<size_t>(y) * this->width_ + x] = pack(color);
}
void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness,
bool big_endian, int x_offset, int y_offset, int x_pad) override {
this->draw_calls_++;
if (w <= 0 || h <= 0 || x_start < 0 || y_start < 0 || x_start + w > this->width_ || y_start + h > this->height_) {
this->out_of_bounds_++;
ADD_FAILURE() << "block " << w << "x" << h << " at " << x_start << "," << y_start << " does not fit "
<< this->width_ << "x" << this->height_;
}
if (this->busy()) {
this->draws_while_busy_++;
ADD_FAILURE() << "drawn while the display is busy";
}
if (this->draw_delay_us_ != 0) {
usleep(this->draw_delay_us_);
}
display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad);
}
virtual bool busy() const { return false; }
uint32_t pixel(int x, int y) const { return this->pixels_[static_cast<size_t>(y) * this->width_ + x]; }
const std::vector<uint32_t> &pixels() const { return this->pixels_; }
size_t drawn_count() const {
return static_cast<size_t>(
std::count_if(this->pixels_.begin(), this->pixels_.end(), [](uint32_t p) { return p != UNSET; }));
}
int updates() const { return this->updates_; }
int draw_calls() const { return this->draw_calls_; }
int out_of_bounds() const { return this->out_of_bounds_; }
int draws_while_busy() const { return this->draws_while_busy_; }
void set_draw_delay_us(uint32_t us) { this->draw_delay_us_ = us; }
protected:
int width_;
int height_;
display::DisplayType type_;
std::vector<uint32_t> pixels_;
int updates_{0};
int draw_calls_{0};
int out_of_bounds_{0};
int draws_while_busy_{0};
uint32_t draw_delay_us_{0};
};
/// Behaves like it8951 and epaper_spi: parked (loop disabled) while idle,
/// update() starts a refresh that runs in its loop until finish().
class FakeParkingDisplay : public FakeDisplay {
public:
FakeParkingDisplay(int width = 64, int height = 48, display::DisplayType type = display::DISPLAY_TYPE_GRAYSCALE)
: FakeDisplay(width, height, type) {
this->disable_loop();
}
void update() override {
this->updates_++;
this->enable_loop();
}
/// The refresh is over and the driver parks its loop again.
void finish() { this->disable_loop(); }
/// Something other than the VNC client started a refresh.
void external_update() { this->enable_loop(); }
bool busy() const override { return !this->is_idle(); }
};
/// Starts with its loop running and parks once its start-up is done, like it8951 at boot.
class FakeStartingDisplay : public FakeParkingDisplay {
public:
FakeStartingDisplay() {
this->component_state_ |= COMPONENT_HAS_LOOP;
this->enable_loop();
}
void finish_start() {
this->started_ = true;
this->finish();
}
bool busy() const override { return this->started_ && FakeParkingDisplay::busy(); }
protected:
bool started_{false};
};
/// A display whose loop always runs (never reports idle).
class FakeFreeRunDisplay : public FakeDisplay {
public:
FakeFreeRunDisplay(int width = 64, int height = 48) : FakeDisplay(width, height) {
this->disable_loop();
this->enable_loop();
}
};
/// A display with its own loop that never parks.
class FakeLoopingDisplay : public FakeFreeRunDisplay {
public:
FakeLoopingDisplay() { this->component_state_ |= COMPONENT_HAS_LOOP; }
};
/// Parked from setup and refreshes synchronously inside update(), like headless SDL.
class FakeSyncParkedDisplay : public FakeDisplay {
public:
FakeSyncParkedDisplay(int width = 64, int height = 48) : FakeDisplay(width, height) { this->disable_loop(); }
};
// ---------------------------------------------------------------------------
// Client under test
class TestVncClient : public VncClient {
public:
using VncClient::VncClient;
~TestVncClient() {
this->high_freq_.stop();
this->link_.close();
}
/// The state a connect still in flight has: a socket, but no session yet.
void forget_session() { this->state_ = VncState::VNC_STATE_IDLE; }
/// Hand the client a connected stream, as connect_step_() does after a successful connect.
void adopt(int fd) {
// connect_step_() has seen the network up before any connect succeeds.
this->network_was_up_ = true;
this->link_.adopt(std::make_unique<socket::Socket>(fd));
this->start_handshake_(now_ms());
}
/// Parse bytes straight from memory, the way service_rx_() would, without a socket read.
VncParse feed(const uint8_t *data, size_t len) {
VncParse result = VncParse::VNC_PARSE_MORE;
size_t offset = 0;
do {
const size_t room = RX_BUFFER_SIZE - this->rx_len_;
const size_t count = std::min(room, len - offset);
std::memcpy(this->rx_ + this->rx_len_, data + offset, count);
this->rx_len_ += static_cast<uint16_t>(count);
this->rx_total_ += count;
offset += count;
for (int guard = 0;; guard++) {
this->pass_start_ms_ = millis();
result = this->parse_();
if (result != VncParse::VNC_PARSE_PAUSE || this->display_busy_ || guard > 100000) {
break;
}
}
if (result != VncParse::VNC_PARSE_MORE) {
return result;
}
if (this->rx_pos_ != 0) {
this->rx_len_ -= this->rx_pos_;
std::memmove(this->rx_, this->rx_ + this->rx_pos_, this->rx_len_);
this->rx_pos_ = 0;
} else if (count == 0) {
ADD_FAILURE() << "parser made no progress with a full buffer";
return result;
}
} while (offset < len);
return result;
}
VncParse feed(const Bytes &bytes) { return this->feed(bytes.data(), bytes.size()); }
socket::TcpClientLink &link() { return this->link_; }
VncState state() const { return this->state_; }
VncStep step() const { return this->step_; }
uint32_t hold_ms() const { return this->hold_ms_; }
uint32_t hold_start_ms() const { return this->hold_start_ms_; }
uint32_t backoff_ms() const { return this->backoff_ms_; }
bool rx_pending() const { return this->rx_pending_; }
bool in_update() const { return this->in_update_; }
bool refresh_pending() const { return this->refresh_pending_; }
bool display_busy() const { return this->display_busy_; }
uint16_t rx_buffered() const { return this->rx_len_ - this->rx_pos_; }
uint16_t vis_w() const { return this->vis_w_; }
uint16_t vis_h() const { return this->vis_h_; }
uint8_t bpp() const { return this->bpp_; }
};
// ---------------------------------------------------------------------------
// Server-side byte builder (RFB 3.x, server to client)
class Rfb {
public:
Bytes bytes;
Rfb &u8(uint8_t v) {
this->bytes.push_back(v);
return *this;
}
Rfb &u16(uint16_t v) { return this->u8(v >> 8).u8(v & 0xFF); }
Rfb &u32(uint32_t v) { return this->u16(v >> 16).u16(v & 0xFFFF); }
Rfb &s32(int32_t v) { return this->u32(static_cast<uint32_t>(v)); }
Rfb &raw(const Bytes &b) {
this->bytes.insert(this->bytes.end(), b.begin(), b.end());
return *this;
}
Rfb &str(const std::string &s) { return this->raw(text(s)); }
Rfb &fill(size_t count, uint8_t value) {
this->bytes.insert(this->bytes.end(), count, value);
return *this;
}
/// One pixel in the client's format: little-endian RGB565 (bpp 2) or RGB332 (bpp 1).
Rfb &px(uint16_t v, uint8_t bpp = 2) {
this->u8(v & 0xFF);
if (bpp == 2) {
this->u8(v >> 8);
}
return *this;
}
Rfb &version(const std::string &v = "RFB 003.008\n") { return this->str(v); }
Rfb &security_list(std::initializer_list<uint8_t> types) {
this->u8(static_cast<uint8_t>(types.size()));
for (uint8_t t : types) {
this->u8(t);
}
return *this;
}
Rfb &security_33(uint32_t type) { return this->u32(type); }
Rfb &security_result(uint32_t result) { return this->u32(result); }
Rfb &reason(const std::string &s) { return this->u32(s.size()).str(s); }
Rfb &challenge() {
for (uint8_t i = 0; i < 16; i++) {
this->u8(i);
}
return *this;
}
Rfb &server_init(uint16_t w, uint16_t h, const std::string &name = "esphome-test") {
// The server's own pixel format; the client replaces it with SetPixelFormat.
this->u16(w).u16(h).u8(32).u8(24).u8(0).u8(1).u16(255).u16(255).u16(255).u8(16).u8(8).u8(0).fill(3, 0);
return this->u32(name.size()).str(name);
}
Rfb &fbu(uint16_t rects) { return this->u8(0).u8(0).u16(rects); }
Rfb &rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, int32_t encoding) {
return this->u16(x).u16(y).u16(w).u16(h).s32(encoding);
}
Rfb &raw_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const std::vector<uint16_t> &pixels, uint8_t bpp = 2) {
this->rect(x, y, w, h, 0);
for (uint16_t p : pixels) {
this->px(p, bpp);
}
return *this;
}
Rfb &cursor(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint8_t bpp = 2) {
this->rect(x, y, w, h, -239);
for (uint32_t i = 0; i < static_cast<uint32_t>(w) * h; i++) {
this->px(0xA5A5, bpp);
}
return this->fill(((w + 7u) / 8u) * h, 0xFF);
}
Rfb &bell() { return this->u8(2); }
Rfb &cut_text(uint32_t len, uint8_t value = 'x') { return this->u8(3).fill(3, 0).u32(len).fill(len, value); }
Rfb &colour_map(uint16_t first, uint16_t count) {
this->u8(1).u8(0).u16(first).u16(count);
for (uint16_t i = 0; i < count; i++) {
this->u16(i).u16(i).u16(i);
}
return *this;
}
};
/// A full RFB 3.8 None handshake up to and including ServerInit.
inline Bytes handshake_38(uint16_t w, uint16_t h) {
return Rfb().version().security_list({1}).security_result(0).server_init(w, h).bytes;
}
// ---------------------------------------------------------------------------
// Client-side messages the tests expect
inline Bytes fbur(bool incremental, uint16_t w, uint16_t h) {
return Rfb().u8(3).u8(incremental ? 1 : 0).u16(0).u16(0).u16(w).u16(h).bytes;
}
inline Bytes set_pixel_format_565() { return {0, 0, 0, 0, 16, 16, 0, 1, 0, 31, 0, 63, 0, 31, 11, 5, 0, 0, 0, 0}; }
inline Bytes set_pixel_format_332() { return {0, 0, 0, 0, 8, 8, 0, 1, 0, 7, 0, 7, 0, 3, 5, 2, 0, 0, 0, 0}; }
inline Bytes set_encodings() { return {2, 0, 0, 2, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x11}; }
inline Bytes version_reply(char minor) { return text(std::string("RFB 003.00") + minor + "\n"); }
/// Splits a stream of client messages that are all FramebufferUpdateRequests.
inline int count_fburs(const Bytes &tx, bool incremental) {
int count = 0;
for (size_t i = 0; i + 10 <= tx.size(); i += 10) {
if (tx[i] == 3 && (tx[i + 1] != 0) == incremental) {
count++;
}
}
return count;
}
// ---------------------------------------------------------------------------
// Ports
/// A loopback port that was free a moment ago, so a connect to it is refused.
inline uint16_t refused_port() {
int probe = ::socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
socklen_t len = sizeof(addr);
EXPECT_EQ(::bind(probe, reinterpret_cast<struct sockaddr *>(&addr), len), 0);
EXPECT_EQ(::getsockname(probe, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
::close(probe);
return ntohs(addr.sin_port);
}
/// A loopback listener that counts the connects that reach it.
class Listener {
public:
Listener() {
this->fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
socklen_t len = sizeof(addr);
EXPECT_EQ(::bind(this->fd_, reinterpret_cast<struct sockaddr *>(&addr), len), 0);
EXPECT_EQ(::listen(this->fd_, 16), 0);
EXPECT_EQ(::getsockname(this->fd_, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
fcntl(this->fd_, F_SETFL, fcntl(this->fd_, F_GETFL, 0) | O_NONBLOCK);
this->port_ = ntohs(addr.sin_port);
}
~Listener() {
for (int fd : this->accepted_) {
if (fd >= 0) {
::close(fd);
}
}
::close(this->fd_);
}
uint16_t port() const { return this->port_; }
/// Connections accepted so far, including any waiting now.
int accepted() {
for (;;) {
int fd = ::accept(this->fd_, nullptr, nullptr);
if (fd >= 0) {
this->accepted_.push_back(fd);
} else if (errno == ECONNABORTED) {
// The client gave up on it already; it still was an attempt.
this->accepted_.push_back(-1);
} else if (errno != EINTR) {
break;
}
}
return static_cast<int>(this->accepted_.size());
}
protected:
int fd_{-1};
uint16_t port_{0};
std::vector<int> accepted_;
};
// ---------------------------------------------------------------------------
// Test harness: the test plays the server on the far end of a socketpair.
class Session {
public:
explicit Session(display::Display *display, uint32_t start_ms = 1000) : client(display) {
signal(SIGPIPE, SIG_IGN);
set_now(start_ms);
this->client.set_host("127.0.0.1");
this->client.set_port(refused_port());
this->client.setup();
}
~Session() { this->close_peer(); }
/// A new TCP session: the client gets one end, the test keeps the other.
void attach() {
this->close_peer();
int fds[2];
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
fcntl(fds[1], F_SETFL, fcntl(fds[1], F_GETFL, 0) | O_NONBLOCK);
this->peer_fd = fds[1];
this->client_fd = fds[0];
this->out_.clear();
this->out_pos_ = 0;
this->client.adopt(fds[0]);
}
void close_peer() {
if (this->peer_fd >= 0) {
::close(this->peer_fd);
this->peer_fd = -1;
}
}
/// Queue server bytes and write as many as the socket takes now.
void send(const Bytes &bytes) {
this->out_.insert(this->out_.end(), bytes.begin(), bytes.end());
this->pump();
}
void send(const Rfb &rfb) { this->send(rfb.bytes); }
void pump() {
while (this->out_pos_ < this->out_.size() && this->peer_fd >= 0) {
ssize_t n = ::write(this->peer_fd, this->out_.data() + this->out_pos_, this->out_.size() - this->out_pos_);
if (n <= 0) {
break;
}
this->out_pos_ += n;
}
if (this->out_pos_ == this->out_.size()) {
this->out_.clear();
this->out_pos_ = 0;
}
}
bool server_bytes_waiting() const { return this->out_pos_ < this->out_.size(); }
/// Bytes the client has not read from its socket yet.
int unread() {
int count = 0;
if (this->client.link().connected()) {
ioctl(this->client_fd, FIONREAD, &count);
}
return count;
}
/// Everything the client sent since the last call.
Bytes take() {
this->client.link().flush_tx();
Bytes out;
if (this->peer_fd < 0) {
return out;
}
uint8_t buf[4096];
for (;;) {
ssize_t n = ::read(this->peer_fd, buf, sizeof(buf));
if (n <= 0) {
break;
}
out.insert(out.end(), buf, buf + n);
}
return out;
}
void loop(int count = 1) {
for (int i = 0; i < count; i++) {
this->pump();
this->client.loop();
}
}
/// Move the virtual clock to an absolute time and run one pass.
void at(uint32_t ms) {
set_now(ms);
this->loop();
}
/// Advance the virtual clock and run one pass.
void tick(uint32_t ms) { this->at(now_ms() + ms); }
/// Advance in steps, running one pass per step.
void run_for(uint32_t ms, uint32_t step = 10) {
const uint32_t end = now_ms() + ms;
while (static_cast<int32_t>(end - now_ms()) > 0) {
this->tick(std::min(step, end - now_ms()));
}
}
bool loop_until(const std::function<bool()> &done, int max_loops = 1000) {
for (int i = 0; i < max_loops && !done(); i++) {
this->loop();
}
return done();
}
/// Runs passes at the current virtual time until done, sleeping briefly so the kernel can finish a connect.
bool wait_until(const std::function<bool()> &done, int max_ms = 2000) {
for (int i = 0; i < max_ms * 5 && !done(); i++) {
this->loop();
if (!done()) {
usleep(200);
}
}
return done();
}
/// Send and keep running passes at the current time until the client has read and parsed it all.
void deliver(const Bytes &bytes, int max_loops = 100000) {
this->send(bytes);
this->loop_until(
[this]() { return !this->server_bytes_waiting() && this->unread() == 0 && !this->client.rx_pending(); },
max_loops);
}
void deliver(const Rfb &rfb) { this->deliver(rfb.bytes); }
/// Full RFB 3.8 None handshake up to RUNNING; the client's handshake bytes are discarded.
void start(uint16_t fb_w, uint16_t fb_h) {
this->attach();
this->deliver(handshake_38(fb_w, fb_h));
ASSERT_EQ(this->client.state(), VncState::VNC_STATE_RUNNING);
this->take();
}
TestVncClient client;
int peer_fd{-1};
int client_fd{-1};
protected:
Bytes out_;
size_t out_pos_{0};
};
} // namespace esphome::vnc::testing
#endif // USE_HOST
@@ -22,18 +22,5 @@ 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:
@@ -1,40 +0,0 @@
esphome:
name: vnc-client-test
host:
api:
actions:
- action: take_snapshot
variables:
filename: string
then:
- snapshot.take:
id: disp
filename: !lambda return filename;
logger:
level: DEBUG
display:
- platform: snapshot
id: disp
update_interval: never
dimensions:
width: 64
height: 48
vnc:
id: remote
display_id: disp
host: 127.0.0.1
# Rewritten by the test to the fake server's port
port: 15900
reconnect_interval: 1s
# Longer than the time the next request may take, so the two can be told apart
update_delay: 300ms
max_update_delay: 500ms
on_connect:
- logger.log: "VNC_TEST_CONNECTED"
on_disconnect:
- logger.log: "VNC_TEST_DISCONNECTED"
-31
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import asyncio
from collections import Counter
import socket
import struct
from typing import Self
from aioesphomeapi import api_pb2
@@ -50,36 +49,6 @@ 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
-629
View File
@@ -1,629 +0,0 @@
"""A deterministic fake RFB (VNC) server for the vnc integration tests.
It behaves like the TightVNC 1.3.10 Xvnc that the Home Assistant VNC add-on runs: the same
handshake for RFB 3.3, 3.7 and 3.8, the same security lists and failure messages, and a
connection that closes on any client message type it does not know.
The framebuffer is an RGB888 image the test draws into. Updates are translated into the pixel
format the client asked for and encoded as Raw or a RichCursor shape. Every client
message is recorded, and the test decides when to answer: ``wait_for("fbur")`` then
``send_update(...)``.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterable, Sequence
import contextlib
from dataclasses import asdict, dataclass
import logging
import random
import re
import struct
from typing import Any, Literal, Self
_LOGGER = logging.getLogger(__name__)
SECURITY_NONE = 1
SECURITY_TIGHT = 16
ENCODING_RAW = 0
ENCODING_RICH_CURSOR = -239
#: ``chunk`` value that writes in random 1-7 byte pieces.
HOSTILE_CHUNK = -1
#: TightVNC closes the connection on a longer ClientCutText.
MAX_CLIENT_CUT_TEXT = 1 << 20
RGB = tuple[int, int, int]
Encoding = Literal["raw", "rich-cursor"]
Rect = tuple[int, int, int, int, Encoding]
Message = dict[str, Any]
_PIXEL_FORMAT = struct.Struct(">BBBBHHHBBB3x")
_VERSION_RE = re.compile(rb"RFB (\d{3})\.(\d{3})\n")
@dataclass(frozen=True)
class PixelFormat:
"""An RFB PIXEL_FORMAT. Field names match the keys of a set_pixel_format message."""
bpp: int
depth: int
big_endian: bool
true_colour: bool
red_max: int
green_max: int
blue_max: int
red_shift: int
green_shift: int
blue_shift: int
@classmethod
def unpack(cls, data: bytes) -> PixelFormat:
"""Decode the 16 byte wire form."""
bpp, depth, big_endian, true_colour, *rest = _PIXEL_FORMAT.unpack(data)
return cls(bpp, depth, bool(big_endian), bool(true_colour), *rest)
def pack(self) -> bytes:
"""Encode the 16 byte wire form."""
return _PIXEL_FORMAT.pack(*asdict(self).values())
@property
def bytes_per_pixel(self) -> int:
"""Bytes per pixel on the wire."""
return self.bpp // 8
def value_of(self, rgb: RGB) -> int:
"""The pixel value a real server sends for an RGB888 colour."""
r, g, b = rgb
value = (
(((r * self.red_max + 127) // 255) << self.red_shift)
| (((g * self.green_max + 127) // 255) << self.green_shift)
| (((b * self.blue_max + 127) // 255) << self.blue_shift)
)
return value & ((1 << self.bpp) - 1)
def to_bytes(self, value: int) -> bytes:
"""A pixel value in wire byte order."""
return value.to_bytes(
self.bytes_per_pixel, "big" if self.big_endian else "little"
)
#: TightVNC's native format at ``-depth 16`` on a little-endian host.
RGB565_LE = PixelFormat(16, 16, False, True, 31, 63, 31, 11, 5, 0)
RGB565_BE = PixelFormat(16, 16, True, True, 31, 63, 31, 11, 5, 0)
RGB332 = PixelFormat(8, 8, False, True, 7, 7, 3, 5, 2, 0)
def _string(text: str) -> bytes:
data = text.encode()
return struct.pack(">I", len(data)) + data
class _ProtocolError(Exception):
"""The client broke the protocol; TightVNC would close the connection."""
@dataclass(eq=False)
class _Session:
reader: asyncio.StreamReader
writer: asyncio.StreamWriter
pixel_format: PixelFormat
task: asyncio.Task[None] | None
minor: int = 8
class FakeRfbServer:
"""An RFB server with a scripted framebuffer, serving one client at a time.
A new connection closes the previous one, as the add-on's ``-nevershared`` does.
"""
def __init__(
self,
width: int,
height: int,
*,
version: bytes = b"RFB 003.008\n",
security_types: list[int] | None = None,
name: str = "fake rfb",
native_format: PixelFormat = RGB565_LE,
refuse_with_reason: str | None = None,
handshake_chunk: int | None = None,
seed: int = 0,
) -> None:
"""Set up the server; nothing listens until ``start()``.
Args:
width: Framebuffer width.
height: Framebuffer height.
version: The 12 byte ProtocolVersion the server sends.
security_types: The 3.7+ list. Defaults to TightVNC's ``[1, 16]``.
name: Desktop name in ServerInit.
native_format: Pixel format in ServerInit, used until SetPixelFormat.
refuse_with_reason: Refuse every connection before authentication with this
reason, as TightVNC does after too many failures.
handshake_chunk: ``chunk`` used for the server's handshake writes.
seed: Seed for the hostile chunk sizes.
"""
self.width = width
self.height = height
self.version = version
self.security_types = (
list(security_types)
if security_types is not None
else [SECURITY_NONE, SECURITY_TIGHT]
)
self.name = name
self.native_format = native_format
self.refuse_with_reason = refuse_with_reason
self.handshake_chunk = handshake_chunk
self.image = bytearray(width * height * 3)
#: Every parsed client message, from every connection, in arrival order.
self.messages: list[Message] = []
self.protocol_errors: list[str] = []
self.client_version: bytes | None = None
self.shared_flag: int | None = None
self.encodings: list[int] = []
self._rng = random.Random(seed)
self._server: asyncio.Server | None = None
self._session: _Session | None = None
self._open: set[_Session] = set()
self._pending: list[Message] = []
self._changed = asyncio.Event()
self._write_lock = asyncio.Lock()
self._port = 0
self._connections = 0
self._sessions = 0
async def __aenter__(self) -> Self:
"""Start on a free loopback port if not started yet."""
if self._server is None:
await self.start()
return self
async def __aexit__(self, *exc_info: object) -> None:
"""Stop the server."""
await self.stop()
@property
def port(self) -> int:
"""The bound port, 0 before ``start()``."""
return self._port
@property
def connections(self) -> int:
"""TCP connections accepted so far."""
return self._connections
@property
def sessions(self) -> int:
"""Connections that got as far as ServerInit."""
return self._sessions
@property
def client_connected(self) -> bool:
"""True while a client connection is open."""
return self._session is not None
@property
def pixel_format(self) -> PixelFormat:
"""The current client's pixel format, or the native one with no client."""
return self._session.pixel_format if self._session else self.native_format
async def start(self, host: str = "127.0.0.1", port: int = 0) -> int:
"""Listen and return the bound port."""
self._server = await asyncio.start_server(self._handle, host, port)
self._port = self._server.sockets[0].getsockname()[1]
return self._port
async def stop(self) -> None:
"""Close the listener and every connection, and wait for their handlers."""
if (server := self._server) is None:
return
self._server = None
server.close()
for session in list(self._open):
session.writer.transport.abort()
if tasks := [s.task for s in self._open if s.task is not None]:
_, pending = await asyncio.wait(tasks, timeout=5.0)
for task in pending:
task.cancel()
if pending:
await asyncio.wait(pending)
await server.wait_closed()
def set_pixel(self, x: int, y: int, rgb: RGB) -> None:
"""Set one framebuffer pixel."""
self.fill_rect(x, y, 1, 1, rgb)
def get_pixel(self, x: int, y: int) -> RGB:
"""Read one framebuffer pixel."""
self._check_rect(x, y, 1, 1)
offset = (y * self.width + x) * 3
r, g, b = self.image[offset : offset + 3]
return r, g, b
def fill_rect(self, x: int, y: int, w: int, h: int, rgb: RGB) -> None:
"""Fill a framebuffer rectangle with one colour."""
self._check_rect(x, y, w, h)
row = bytes(rgb) * w
for line in range(y, y + h):
offset = (line * self.width + x) * 3
self.image[offset : offset + w * 3] = row
async def wait_until(
self,
predicate: Callable[[], bool],
timeout: float = 5.0,
what: str = "condition",
) -> None:
"""Wait until ``predicate()`` is true; it is checked after every server event.
Raises:
AssertionError: If it is still false after ``timeout``.
"""
if not await self._wait(lambda: True if predicate() else None, timeout):
raise AssertionError(f"{what} not met within {timeout}s; {self._summary()}")
async def wait_for(
self,
type_name: str,
timeout: float = 5.0,
predicate: Callable[[Message], bool] | None = None,
) -> Message:
"""Return and consume the oldest unconsumed message of this type.
Messages of other types stay queued, so the order of calls does not matter. Only the
current connection's messages are queued: a new connection drops the old ones.
Raises:
AssertionError: If no matching message arrives within ``timeout``.
"""
def take() -> Message | None:
for index, message in enumerate(self._pending):
if message["type"] == type_name and (
predicate is None or predicate(message)
):
return self._pending.pop(index)
return None
if (message := await self._wait(take, timeout)) is None:
raise AssertionError(
f"no {type_name} message within {timeout}s; {self._summary()}"
)
return message
def pending(self) -> list[Message]:
"""Messages not consumed by ``wait_for()`` yet."""
return list(self._pending)
def discard_pending(self) -> None:
"""Drop every unconsumed message."""
self._pending.clear()
def build_update(
self,
rects: Sequence[Rect],
*,
pixel_format: PixelFormat | None = None,
) -> bytes:
"""Encode one FramebufferUpdate from the image.
Each rect is ``(x, y, w, h, encoding)``. For ``rich-cursor`` x and y are the hotspot
and the shape is a fixed pattern.
Args:
rects: The rectangles, in order.
pixel_format: Format to encode in; defaults to the current client's.
"""
fmt = pixel_format or self.pixel_format
out = bytearray(struct.pack(">BxH", 0, len(rects)))
for x, y, w, h, encoding in rects:
if encoding == "rich-cursor":
out += struct.pack(">HHHHi", x, y, w, h, ENCODING_RICH_CURSOR)
out += _cursor_shape(fmt, w, h)
continue
self._check_rect(x, y, w, h)
values = self._values(fmt, x, y, w, h)
if encoding == "raw":
out += struct.pack(">HHHHi", x, y, w, h, ENCODING_RAW)
out += _pixels(fmt, (v for row in values for v in row))
else:
raise ValueError(f"unknown encoding {encoding!r}")
return bytes(out)
async def send_update(
self,
rects: Sequence[Rect],
chunk: int | None = None,
*,
delay: float = 0.0,
) -> None:
"""Send one FramebufferUpdate built by ``build_update()``.
Args:
rects: The rectangles, in order.
chunk: Split the write into pieces of at most this many bytes; ``HOSTILE_CHUNK``
uses seeded random 1-7 byte pieces.
delay: Sleep between pieces.
"""
session = self._require_session()
data = self.build_update(rects, pixel_format=session.pixel_format)
await self._write(session, data, chunk, delay)
async def send_cut_text(
self, text: bytes, chunk: int | None = None, *, delay: float = 0.0
) -> None:
"""Send ServerCutText."""
await self.send_raw(
struct.pack(">B3xI", 3, len(text)) + text, chunk, delay=delay
)
async def send_bell(self) -> None:
"""Send Bell."""
await self.send_raw(b"\x02")
async def send_colour_map(
self,
first: int = 0,
colours: Sequence[RGB] = (),
chunk: int | None = None,
) -> None:
"""Send SetColourMapEntries; colours are 16 bit per channel."""
data = struct.pack(">BxHH", 1, first, len(colours))
data += b"".join(struct.pack(">HHH", *colour) for colour in colours)
await self.send_raw(data, chunk)
async def send_raw(
self, data: bytes, chunk: int | None = None, *, delay: float = 0.0
) -> None:
"""Send arbitrary bytes to the current client."""
await self._write(self._require_session(), data, chunk, delay)
async def close_client(self, *, abort: bool = False) -> None:
"""Close the current client connection and wait for its handler to finish."""
if (session := self._session) is None:
return
if abort:
session.writer.transport.abort()
else:
session.writer.close()
if session.task is not None:
await asyncio.wait([session.task], timeout=5.0)
def _check_rect(self, x: int, y: int, w: int, h: int) -> None:
if min(x, y, w, h) < 0 or x + w > self.width or y + h > self.height:
raise ValueError(
f"rect {w}x{h}+{x}+{y} is outside the {self.width}x{self.height} framebuffer"
)
def _values(
self, fmt: PixelFormat, x: int, y: int, w: int, h: int
) -> list[list[int]]:
cache: dict[bytes, int] = {}
rows = []
for line in range(y, y + h):
offset = (line * self.width + x) * 3
data = bytes(self.image[offset : offset + w * 3])
row = []
for i in range(0, len(data), 3):
rgb = data[i : i + 3]
if (value := cache.get(rgb)) is None:
value = cache[rgb] = fmt.value_of((rgb[0], rgb[1], rgb[2]))
row.append(value)
rows.append(row)
return rows
def _require_session(self) -> _Session:
if self._session is None:
raise RuntimeError("no RFB client is connected")
return self._session
def _summary(self) -> str:
return (
f"connections={self._connections}, sessions={self._sessions}, "
f"pending={[m['type'] for m in self._pending]}, "
f"protocol_errors={self.protocol_errors}"
)
def _bump(self) -> None:
self._changed.set()
self._changed = asyncio.Event()
async def _wait[T](self, check: Callable[[], T | None], timeout: float) -> T | None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while (result := check()) is None:
if (remaining := deadline - loop.time()) <= 0:
return None
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._changed.wait(), remaining)
return result
async def _write(
self, session: _Session, data: bytes, chunk: int | None, delay: float = 0.0
) -> None:
if chunk is not None and chunk != HOSTILE_CHUNK and chunk < 1:
raise ValueError(f"bad chunk size {chunk}")
writer = session.writer
async with self._write_lock:
if chunk is None:
writer.write(data)
await writer.drain()
return
pos = 0
while pos < len(data):
size = self._rng.randint(1, 7) if chunk == HOSTILE_CHUNK else chunk
writer.write(data[pos : pos + size])
await writer.drain()
pos += size
await asyncio.sleep(delay)
async def _reply(self, session: _Session, data: bytes) -> None:
await self._write(session, data, self.handshake_chunk)
def _record(self, session: _Session, message: Message) -> None:
self.messages.append(message)
if session is self._session:
self._pending.append(message)
self._bump()
async def _handle(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
session = _Session(reader, writer, self.native_format, asyncio.current_task())
self._connections += 1
if (previous := self._session) is not None:
previous.writer.close()
self._session = session
self._open.add(session)
self._pending.clear()
self._bump()
try:
await self._serve(session)
except (asyncio.IncompleteReadError, ConnectionError):
pass
except _ProtocolError as err:
self.protocol_errors.append(str(err))
except Exception as err: # noqa: BLE001 - reported through protocol_errors
_LOGGER.exception("Fake RFB server failed")
self.protocol_errors.append(f"internal error: {err!r}")
finally:
if self._session is session:
self._session = None
writer.close()
with contextlib.suppress(OSError):
await writer.wait_closed()
self._open.discard(session)
self._bump()
async def _serve(self, session: _Session) -> None:
await self._reply(session, self.version)
session.minor = self._negotiate_version(await session.reader.readexactly(12))
if not await self._negotiate_security(session):
return
self.shared_flag = (await session.reader.readexactly(1))[0]
server_init = (
struct.pack(">HH", self.width, self.height)
+ self.native_format.pack()
+ _string(self.name)
)
await self._reply(session, server_init)
self._sessions += 1
self._bump()
while True:
await self._read_message(session)
def _negotiate_version(self, data: bytes) -> int:
self.client_version = data
if (match := _VERSION_RE.fullmatch(data)) is None:
raise _ProtocolError(f"bad client version {data!r}")
if int(match[1]) != 3:
raise _ProtocolError(f"unsupported client version {data!r}")
minor = int(match[2])
if minor > 8:
return 8
return minor if minor in (7, 8) else 3
async def _negotiate_security(self, session: _Session) -> bool:
reason = self.refuse_with_reason
if reason is None and not self.security_types:
reason = ""
if session.minor == 3:
if reason is not None:
await self._reply(session, struct.pack(">I", 0) + _string(reason))
return False
chosen = next(
(t for t in self.security_types if t == SECURITY_NONE),
self.security_types[0],
)
await self._reply(session, struct.pack(">I", chosen))
else:
if reason is not None:
await self._reply(session, b"\0" + _string(reason))
return False
offer = bytes([len(self.security_types), *self.security_types])
await self._reply(session, offer)
chosen = (await session.reader.readexactly(1))[0]
if chosen not in self.security_types:
raise _ProtocolError(
f"client chose security type {chosen}, offered {self.security_types}"
)
if chosen == SECURITY_NONE:
if session.minor >= 8:
await self._reply(session, struct.pack(">I", 0))
return True
raise _ProtocolError(f"security type {chosen} is not emulated")
async def _read_message(self, session: _Session) -> None:
read = session.reader.readexactly
match kind := (await read(1))[0]:
case 0:
fmt = PixelFormat.unpack((await read(19))[3:])
self._record(session, {"type": "set_pixel_format", **asdict(fmt)})
if fmt.bpp not in (8, 16, 32):
raise _ProtocolError(f"unsupported bits per pixel {fmt.bpp}")
if not fmt.true_colour:
raise _ProtocolError("colour map formats are not emulated")
session.pixel_format = fmt
case 2:
(count,) = struct.unpack(">xH", await read(3))
self.encodings = list(
struct.unpack(f">{count}i", await read(4 * count))
)
self._record(
session, {"type": "set_encodings", "encodings": self.encodings}
)
case 3:
incremental, x, y, w, h = struct.unpack(">BHHHH", await read(9))
self._record(
session,
{
"type": "fbur",
"incremental": bool(incremental),
"x": x,
"y": y,
"w": w,
"h": h,
},
)
case 4:
down, keysym = struct.unpack(">B2xI", await read(7))
self._record(
session, {"type": "key", "down": bool(down), "keysym": keysym}
)
case 5:
mask, x, y = struct.unpack(">BHH", await read(5))
self._record(session, {"type": "pointer", "mask": mask, "x": x, "y": y})
case 6:
(length,) = struct.unpack(">3xI", await read(7))
if length > MAX_CLIENT_CUT_TEXT:
raise _ProtocolError(f"client cut text of {length} bytes")
self._record(session, {"type": "cut_text", "text": await read(length)})
case _:
raise _ProtocolError(f"unknown client message type {kind}")
def _pixels(fmt: PixelFormat, values: Iterable[int]) -> bytes:
cache: dict[int, bytes] = {}
out = bytearray()
for value in values:
if (data := cache.get(value)) is None:
data = cache[value] = fmt.to_bytes(value)
out += data
return bytes(out)
def _cursor_shape(fmt: PixelFormat, w: int, h: int) -> bytes:
light = fmt.to_bytes(fmt.value_of((255, 255, 255)))
dark = fmt.to_bytes(fmt.value_of((0, 0, 0)))
pixels = b"".join(
light if (x + y) % 2 else dark for y in range(h) for x in range(w)
)
return pixels + b"\x5a" * (((w + 7) // 8) * h)
+1 -12
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", "optional_args_action"}
assert set(by_name) == {"play_buzzer", "plain_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,17 +57,6 @@ 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}
)
+29 -8
View File
@@ -10,26 +10,47 @@ from aioesphomeapi import CoverInfo, CoverState, EntityState, api_pb2
import pytest
from .log_utils import LineWaiter
from .raw_api_client import (
MESSAGE_TYPE_OF,
WIRE_FIXED32,
WIRE_VARINT,
RawApiClient,
decode_fields,
tag,
)
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, decode_varint, encode_varint
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,19 +16,16 @@ from aioesphomeapi import (
)
import pytest
from .raw_api_client import (
MESSAGE_TYPE_OF,
WIRE_FIXED32,
WIRE_LENGTH,
WIRE_VARINT,
RawApiClient,
encode_varint,
tag,
)
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, encode_varint
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
-466
View File
@@ -1,466 +0,0 @@
"""Integration test for the vnc component against a fake RFB server.
One compiled device runs every step in order against one server: the first frame, request
pacing, a reconnect after the server closes the link, a stream cut into tiny pieces, and an
unknown message type that makes the client drop the link.
The client asks for the next frame only once the previous one is fully drawn, so snapshots are
taken after that request arrives. The snapshot display draws as soon as pixels arrive, so the
picture does not depend on when the client refreshes the display.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import contextlib
from dataclasses import asdict
from itertools import pairwise
from pathlib import Path
import random
import re
import struct
from aioesphomeapi import APIClient, UserService
import pytest
from .bmp_utils import Bmp, wait_for_bmp
from .rfb_fake_server import (
HOSTILE_CHUNK,
RGB,
RGB565_BE,
RGB565_LE,
FakeRfbServer,
Message,
PixelFormat,
Rect,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
DISPLAY_WIDTH = 64
DISPLAY_HEIGHT = 48
SERVER_WIDTH = 80
SERVER_HEIGHT = 60
FIXTURE_PORT = "port: 15900"
CONNECTED = "VNC_TEST_CONNECTED"
DISCONNECTED = "VNC_TEST_DISCONNECTED"
FRAME_LOG = re.compile(r"Update: (\d+) bytes in \d+ ?ms")
LONG_OPERATION = "vnc took a long time"
# Well under the fixture's 300 ms update_delay: the next request must not wait for the refresh.
QUICK_REQUEST_S = 0.25
# Requests are at least 50 ms apart; leave room for jitter in when they arrive.
REQUEST_SPACING_S = 0.035
# The fixture's reconnect_interval.
RECONNECT_S = 1.0
# Long enough for a frame that drew something to be logged.
LOG_SETTLE_S = 0.5
# Raw rects that cross the right and bottom edges of the display.
FULL_FRAME: list[Rect] = [
(0, 0, SERVER_WIDTH, 13, "raw"),
(0, 13, 37, SERVER_HEIGHT - 13, "raw"),
(37, 13, SERVER_WIDTH - 37, SERVER_HEIGHT - 13, "raw"),
]
LogMatch = str | re.Pattern[str] | Callable[[str], bool]
class LogWatch:
"""Device log lines with the time each one arrived."""
def __init__(self) -> None:
self.lines: list[tuple[float, str]] = []
self._changed = asyncio.Event()
def callback(self, line: str) -> None:
self.lines.append((asyncio.get_running_loop().time(), line))
self._changed.set()
self._changed = asyncio.Event()
def times(self, match: LogMatch) -> list[float]:
"""When each matching line came."""
if isinstance(match, str):
text = match
def test(line: str) -> bool:
return text in line
elif isinstance(match, re.Pattern):
pattern = match
def test(line: str) -> bool:
return pattern.search(line) is not None
else:
test = match
return [when for when, line in self.lines if test(line)]
def count(self, match: LogMatch) -> int:
return len(self.times(match))
def frame_sizes(self) -> list[int]:
"""The byte counts of the 'Update:' lines, in order."""
return [
int(found[1]) for _, line in self.lines if (found := FRAME_LOG.search(line))
]
async def wait_count(
self, match: LogMatch, count: int, timeout: float = 10.0
) -> float:
"""Wait until ``match`` was logged ``count`` times and return when the last one came."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while len(times := self.times(match)) < count:
if (remaining := deadline - loop.time()) <= 0:
raise AssertionError(
f"{match!r} logged {len(times)} times within {timeout}s, "
f"expected {count}"
)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._changed.wait(), remaining)
return times[count - 1]
def paint(server: FakeRfbServer, seed: int) -> None:
"""Draw solid bands, boxes and noise.
Noise also runs along the right and bottom edges of the display, so clipping errors show.
"""
rng = random.Random(seed)
def colour() -> RGB:
return rng.randrange(256), rng.randrange(256), rng.randrange(256)
width, height = server.width, server.height
for x in range(0, width, 20):
server.fill_rect(x, 0, min(20, width - x), height, colour())
accent = colour()
for _ in range(10):
server.fill_rect(
rng.randrange(width - 8),
rng.randrange(height - 8),
rng.randint(1, 8),
rng.randint(1, 8),
accent,
)
for _ in range(10):
server.fill_rect(
rng.randrange(width - 4),
rng.randrange(height - 4),
rng.randint(1, 4),
rng.randint(1, 4),
colour(),
)
noise = [
(x, y)
for x in range(DISPLAY_WIDTH - 6, min(width, DISPLAY_WIDTH + 6))
for y in range(height)
]
noise += [
(x, y)
for y in range(DISPLAY_HEIGHT - 5, min(height, DISPLAY_HEIGHT + 5))
for x in range(width)
]
noise += [(x, y) for x in range(8, 24) for y in range(16, 32)]
for x, y in noise:
server.set_pixel(x, y, colour())
def shown_colour(fmt: PixelFormat, rgb: RGB) -> RGB:
"""The colour a snapshot holds for a server pixel, after the trip through RGB565."""
value = fmt.value_of(rgb)
return (
((value >> fmt.red_shift) & fmt.red_max) * 255 // fmt.red_max,
((value >> fmt.green_shift) & fmt.green_max) * 255 // fmt.green_max,
((value >> fmt.blue_shift) & fmt.blue_max) * 255 // fmt.blue_max,
)
def assert_shows(bmp: Bmp, server: FakeRfbServer) -> None:
"""Assert the snapshot is the top-left of the server screen, pixel for pixel."""
assert (bmp.width, bmp.height, bmp.bits) == (DISPLAY_WIDTH, DISPLAY_HEIGHT, 24)
mismatches = []
for y in range(DISPLAY_HEIGHT):
row = (DISPLAY_HEIGHT - 1 - y) * DISPLAY_WIDTH * 3
for x in range(DISPLAY_WIDTH):
b, g, r = bmp.pixels[row + x * 3 : row + x * 3 + 3]
expected = shown_colour(RGB565_LE, server.get_pixel(x, y))
if (r, g, b) != expected:
mismatches.append(((x, y), (r, g, b), expected))
assert not mismatches, (
f"{len(mismatches)} pixels differ; (x, y), shown, expected: {mismatches[:10]}"
)
def full_request(incremental: bool) -> Message:
return {
"type": "fbur",
"incremental": incremental,
"x": 0,
"y": 0,
"w": DISPLAY_WIDTH,
"h": DISPLAY_HEIGHT,
}
def cut_text(text: bytes) -> bytes:
return struct.pack(">B3xI", 3, len(text)) + text
class Device:
"""The API actions of the fixture."""
def __init__(
self, client: APIClient, services: list[UserService], snapshot_dir: Path
) -> None:
self.client = client
self.actions = {service.name: service for service in services}
self.snapshot_dir = snapshot_dir
async def run(self, action: str, **data: int | str) -> None:
await self.client.execute_service(self.actions[action], data)
async def snapshot(self, name: str) -> Bmp:
await self.run("take_snapshot", filename=name)
return await wait_for_bmp(self.snapshot_dir / f"{name}.bmp")
async def next_request(
server: FakeRfbServer, timeout: float = 5.0
) -> tuple[Message, float]:
"""Wait for the next FramebufferUpdateRequest and return it and when it came."""
request = await server.wait_for("fbur", timeout=timeout)
return request, asyncio.get_running_loop().time()
async def exchange(
server: FakeRfbServer,
data: bytes,
*,
log: LogWatch | None = None,
frame: bytes | None = None,
chunk: int | None = None,
timeout: float = 5.0,
) -> tuple[float, float]:
"""Send bytes that end a frame and wait for the incremental request that follows.
With ``log``, also wait for the frame's 'Update:' line and check its byte count; ``frame``
is the FramebufferUpdate inside ``data`` when other messages surround it.
Returns how long after the write the request came, and when it came.
"""
logged = log.count(FRAME_LOG) if log is not None else 0
await server.send_raw(data, chunk)
sent = asyncio.get_running_loop().time()
request, requested = await next_request(server, timeout)
assert request == full_request(incremental=True)
if log is not None:
await expect_logged(log, data if frame is None else frame, logged)
return requested - sent, requested
async def expect_logged(log: LogWatch, frame: bytes, before: int) -> None:
"""Wait for the 'Update:' line after the first ``before`` and check its byte count."""
await log.wait_count(FRAME_LOG, before + 1)
sizes = log.frame_sizes()
assert len(sizes) == before + 1, f"expected {before + 1} logged frames: {sizes}"
# The four byte message header may or may not be counted.
assert sizes[-1] in (len(frame) - 4, len(frame)), (
f"logged {sizes[-1]} bytes for a {len(frame)} byte update"
)
@pytest.mark.asyncio
async def test_vnc_client(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The client renders, paces and recovers from drops and bad streams."""
snapshot_dir = tmp_path / "snapshots"
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
loop = asyncio.get_running_loop()
log = LogWatch()
server = FakeRfbServer(SERVER_WIDTH, SERVER_HEIGHT, name="vnc client test")
paint(server, seed=1)
async with server:
assert FIXTURE_PORT in yaml_config
yaml_config = yaml_config.replace(FIXTURE_PORT, f"port: {server.port}")
async with (
run_compiled(yaml_config, line_callback=log.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
device = Device(client, services, snapshot_dir)
# Render: the session setup is exact and the first frame is pixel-exact.
pixel_format = await server.wait_for("set_pixel_format", timeout=15.0)
# The snapshot display declares no byte order, so the client asks for big-endian pixels
assert pixel_format == {"type": "set_pixel_format", **asdict(RGB565_BE)}
encodings = await server.wait_for("set_encodings")
assert encodings["encodings"] == [0, -239]
request, _ = await next_request(server)
assert request == full_request(incremental=False)
assert [m["type"] for m in server.messages[:3]] == [
"set_pixel_format",
"set_encodings",
"fbur",
]
assert server.client_version == b"RFB 003.008\n"
assert server.shared_flag == 1
await log.wait_count(CONNECTED, 1)
assert log.count("Server 'vnc client test', 80x60, RFB 3.8") == 1
assert log.count("showing the top-left 64x48") == 1
elapsed, _ = await exchange(
server, server.build_update(FULL_FRAME), log=log
)
assert elapsed < QUICK_REQUEST_S, f"next request after {elapsed:.3f}s"
assert_shows(await device.snapshot("render"), server)
# Pacing: one request stays parked while nothing changes.
await asyncio.sleep(1.0)
assert not [m for m in server.pending() if m["type"] == "fbur"]
# The next request goes out as soon as a frame is drawn, before the refresh.
server.fill_rect(30, 10, 10, 10, (250, 10, 10))
server.fill_rect(58, 40, 22, 20, (10, 250, 10))
frame = server.build_update(
[(30, 10, 10, 10, "raw"), (58, 40, 22, 20, "raw")]
)
elapsed, _ = await exchange(server, frame, log=log)
assert elapsed < QUICK_REQUEST_S, f"next request after {elapsed:.3f}s"
assert_shows(await device.snapshot("pacing"), server)
# A frame that only changes what the display cannot show draws nothing.
logged = log.count(FRAME_LOG)
server.fill_rect(
DISPLAY_WIDTH, 0, SERVER_WIDTH - DISPLAY_WIDTH, SERVER_HEIGHT, (1, 2, 3)
)
server.fill_rect(
0,
DISPLAY_HEIGHT,
SERVER_WIDTH,
SERVER_HEIGHT - DISPLAY_HEIGHT,
(200, 100, 50),
)
hidden = server.build_update(
[
(
DISPLAY_WIDTH,
0,
SERVER_WIDTH - DISPLAY_WIDTH,
SERVER_HEIGHT,
"raw",
),
(
0,
DISPLAY_HEIGHT,
SERVER_WIDTH,
SERVER_HEIGHT - DISPLAY_HEIGHT,
"raw",
),
]
)
elapsed, requested = await exchange(server, hidden)
assert elapsed < QUICK_REQUEST_S, f"next request after {elapsed:.3f}s"
# Empty and cursor-only updates draw nothing either, and requests stay 50 ms
# apart.
requested_at = [requested]
for update in (
server.build_update([]),
*(
server.build_update([(2 + i, 3, 10, 16, "rich-cursor")])
for i in range(3)
),
):
elapsed, requested = await exchange(server, update)
assert elapsed < QUICK_REQUEST_S, f"next request after {elapsed:.3f}s"
requested_at.append(requested)
gaps = [later - earlier for earlier, later in pairwise(requested_at)]
assert min(gaps) >= REQUEST_SPACING_S, f"requests {gaps} apart"
await asyncio.sleep(LOG_SETTLE_S)
assert log.count(FRAME_LOG) == logged, (
"a frame that drew nothing was logged"
)
assert_shows(await device.snapshot("hidden"), server)
# Reconnect: the server closes the link; the client comes back after the backoff
# and sets the session up again.
server.discard_pending()
closed = loop.time()
await server.close_client()
await log.wait_count(DISCONNECTED, 1)
await server.wait_until(
lambda: server.connections == 2, timeout=10.0, what="second connection"
)
reconnected = loop.time()
assert reconnected - closed >= RECONNECT_S - 0.05, (
f"reconnected after {reconnected - closed:.3f}s"
)
await log.wait_count(CONNECTED, 2)
assert await server.wait_for("set_pixel_format") == pixel_format
assert (await server.wait_for("set_encodings"))["encodings"] == [0, -239]
request, _ = await next_request(server)
assert request == full_request(incremental=False)
assert server.sessions == 2
# Hostile stream: 1-7 byte pieces, a long cut text, bells, a colour map and
# cursor rects around a frame.
paint(server, seed=2)
frame = server.build_update(
[
(3, 4, 12, 18, "rich-cursor"),
(0, 0, DISPLAY_WIDTH, 20, "raw"),
(DISPLAY_WIDTH, 0, SERVER_WIDTH - DISPLAY_WIDTH, 60, "raw"),
(0, 0, 0, 0, "rich-cursor"),
(0, 20, 30, SERVER_HEIGHT - 20, "raw"),
(30, 20, SERVER_WIDTH - 30, SERVER_HEIGHT - 20, "raw"),
(1, 1, 7, 9, "rich-cursor"),
]
)
colour_map = struct.pack(">BxHH", 1, 0, 2) + bytes(12)
long_cut = bytes(
random.Random(3).randrange(32, 127) for _ in range(200_000)
)
payload = (
cut_text(long_cut)
+ b"\x02"
+ colour_map
+ frame
+ b"\x02"
+ cut_text(b"")
)
await exchange(
server, payload, log=log, frame=frame, chunk=HOSTILE_CHUNK, timeout=30.0
)
assert_shows(await device.snapshot("hostile"), server)
# An unknown message type drops the link. This session was too short to count as
# stable, so the backoff has doubled.
server.discard_pending()
sent = loop.time()
await server.send_raw(b"\x09")
await log.wait_count(DISCONNECTED, 2)
assert log.count("Unknown message type 9") == 1
await server.wait_until(
lambda: server.connections == 3, timeout=15.0, what="third connection"
)
assert loop.time() - sent >= 2 * RECONNECT_S - 0.1, (
f"reconnected after {loop.time() - sent:.3f}s"
)
await log.wait_count(CONNECTED, 3)
request, _ = await next_request(server)
assert request == full_request(incremental=False)
paint(server, seed=6)
await exchange(server, server.build_update(FULL_FRAME), log=log)
assert_shows(await device.snapshot("third"), server)
assert server.protocol_errors == []
assert log.count(LONG_OPERATION) == 0
+2 -58
View File
@@ -8,64 +8,8 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.components import esp8266
from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float
from esphome.core import EsphomeError, Lambda
from esphome.types import ConfigType
@pytest.mark.parametrize(
("src", "expected"),
[
# Basic float formats
('sscanf(buf, "%f", &v)', True),
('sscanf(buf, "%F", &v)', True),
('sscanf(buf, "%e", &v)', True),
('sscanf(buf, "%E", &v)', True),
('sscanf(buf, "%g", &v)', True),
('sscanf(buf, "%G", &v)', True),
('sscanf(buf, "%a", &v)', True),
('sscanf(buf, "%A", &v)', True),
# With modifiers
('sscanf(buf, "%lf", &v)', True),
('sscanf(buf, "%Lf", &v)', True),
('sscanf(buf, "%8lf", &v)', True),
('sscanf(buf, "%*f")', True),
('sscanf(buf, "%.2f", &v)', True),
# Mixed formats
('sscanf(buf, "%d,%f", &a, &b)', True),
# fscanf and std::sscanf
('fscanf(fp, "%f", &v)', True),
('std::sscanf(buf, "%f", &v)', True),
# Multi-line
('sscanf(buf,\n"%f", &v)', True),
# No float format
('sscanf(buf, "%d", &v)', False),
('sscanf(buf, "%s", s)', False),
# printf not scanf
('printf("%f", val)', False),
# %f in a different statement after scanf
('sscanf(buf, "%d", &x); printf("%f", val);', False),
# scanf %f in comment only
('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False),
('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False),
],
)
def test_lambdas_use_scanf_float(src: str, expected: bool) -> None:
"""Test scanf float detection in lambda source."""
config: ConfigType = {"test": [Lambda(src)]}
assert lambdas_use_scanf_float(config) is expected
def test_lambdas_use_scanf_float_no_lambdas() -> None:
"""Test with config containing no lambdas."""
config: ConfigType = {"key": "value", "list": [1, 2]}
assert lambdas_use_scanf_float(config) is False
def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True
from esphome.components.esp8266 import check_rosetta
from esphome.core import EsphomeError
@pytest.fixture
+4 -8
View File
@@ -327,14 +327,10 @@ def test_wisp_requires_idf_5_1(wisp_core: None) -> None:
@pytest.mark.usefixtures("wisp_core")
def test_wisp_requires_sta_network_or_ethernet() -> None:
"""WISP has nothing to share without an upstream network; ethernet is one too."""
ap_only = {CONF_NETWORKS: [], CONF_AP: _wisp_ap()}
with pytest.raises(cv.Invalid, match="ethernet as the upstream"):
_final_validate(ap_only)
_final_validate(ap_only, {"ethernet": {}})
with pytest.raises(cv.Invalid, match="ethernet manual_ip needs dns1"):
_final_validate(ap_only, {"ethernet": {CONF_MANUAL_IP: _manual_ip("0.0.0.0")}})
def test_wisp_requires_sta_network() -> None:
"""WISP has nothing to share without an upstream network."""
with pytest.raises(cv.Invalid, match="at least one STA network"):
_final_validate({CONF_NETWORKS: [], CONF_AP: _wisp_ap()})
@pytest.mark.usefixtures("wisp_core")
+162
View File
@@ -0,0 +1,162 @@
"""Unit tests for esphome.code_scan."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.code_scan import (
external_components_use_scanf_float,
includes_use_scanf_float,
keep_float_scanf,
lambdas_use_scanf_float,
user_code_uses_scanf_float,
)
from esphome.const import CONF_ESPHOME, CONF_INCLUDES, CONF_INCLUDES_C
from esphome.core import CORE, Lambda
from esphome.loader import CORE_COMPONENTS_PATH, FileResource
from esphome.types import ConfigType
@pytest.mark.parametrize(
("src", "expected"),
[
# Basic float formats
('sscanf(buf, "%f", &v)', True),
('sscanf(buf, "%F", &v)', True),
('sscanf(buf, "%e", &v)', True),
('sscanf(buf, "%E", &v)', True),
('sscanf(buf, "%g", &v)', True),
('sscanf(buf, "%G", &v)', True),
('sscanf(buf, "%a", &v)', True),
('sscanf(buf, "%A", &v)', True),
# With modifiers
('sscanf(buf, "%lf", &v)', True),
('sscanf(buf, "%Lf", &v)', True),
('sscanf(buf, "%8lf", &v)', True),
('sscanf(buf, "%*f")', True),
('sscanf(buf, "%.2f", &v)', True),
# Mixed formats, including a ';' inside the format string
('sscanf(buf, "%d,%f", &a, &b)', True),
('sscanf(buf, "%d;%f", &a, &b)', True),
('sscanf(buf, "a;b", &a); sscanf(buf, "%d", &b); printf("%f", v)', False),
# Escaped quotes, nested calls, a ')' inside the format, an unterminated call
('sscanf(buf, "\\"%f\\"", &v)', True),
('sscanf(f(x), "%f", &v)', True),
('sscanf(s.substr(0, 2).c_str(), "%f", &v)', True),
('sscanf(strtok(s, ","), "%f", &v)', True),
('sscanf(strtok(s, ","), "%d", &v)', False),
('sscanf(buf, "%d)", &a); g("%f")', False),
('sscanf(buf, "%d", &a; g("%f")', False),
('sscanf(buf, "%f"', True),
# fscanf and std::sscanf
('fscanf(fp, "%f", &v)', True),
('std::sscanf(buf, "%f", &v)', True),
# Multi-line
('sscanf(buf,\n"%f", &v)', True),
# No float format
('sscanf(buf, "%d", &v)', False),
('sscanf(buf, "%s", s)', False),
# printf not scanf
('printf("%f", val)', False),
# %f in a different statement after scanf
('sscanf(buf, "%d", &x); printf("%f", val);', False),
# scanf %f in comment only
('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False),
('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False),
],
)
def test_lambdas_use_scanf_float(src: str, expected: bool) -> None:
"""Test scanf float detection in lambda source."""
config: ConfigType = {"test": [Lambda(src)]}
assert lambdas_use_scanf_float(config) is expected
def test_lambdas_use_scanf_float_no_lambdas() -> None:
"""Test with config containing no lambdas."""
config: ConfigType = {"key": "value", "list": [1, 2]}
assert lambdas_use_scanf_float(config) is False
def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True
def test_includes_use_scanf_float(setup_core: Path) -> None:
"""A float scanf in an includes: or includes_c: file is found; comments and non-source files are ignored."""
(setup_core / "parse.h").write_text(
'static void f(const char *b) { float v; sscanf(b, "%f", &v); }'
)
(setup_core / "lib").mkdir()
(setup_core / "lib" / "int.h").write_text(
'static int g(const char *b) { int v; sscanf(b, "%d", &v); return v; }'
)
(setup_core / "lib" / "notes.txt").write_text('sscanf(b, "%f", &v)')
(setup_core / "commented.h").write_text(
'// sscanf(b, "%f", &v)\n/* sscanf(b, "%g", &v) */'
)
assert (
includes_use_scanf_float({CONF_ESPHOME: {CONF_INCLUDES: ["parse.h"]}}) is True
)
assert (
includes_use_scanf_float({CONF_ESPHOME: {CONF_INCLUDES_C: ["parse.h"]}}) is True
)
assert includes_use_scanf_float({CONF_ESPHOME: {CONF_INCLUDES: ["lib"]}}) is False
assert (
includes_use_scanf_float(
{CONF_ESPHOME: {CONF_INCLUDES: ["commented.h", "<cstdio>"]}}
)
is False
)
assert includes_use_scanf_float({}) is False
def test_keep_float_scanf() -> None:
"""Explicit values win; unset follows whether user code scans a float."""
float_config: ConfigType = {
CONF_ESPHOME: {"on_boot": [Lambda('sscanf(b, "%f", &v)')]}
}
int_config: ConfigType = {
CONF_ESPHOME: {"on_boot": [Lambda('sscanf(b, "%d", &v)')]}
}
assert keep_float_scanf(True, int_config, "x", "y") is True
assert keep_float_scanf(False, float_config, "x", "y") is False
assert keep_float_scanf(False, int_config, "x", "y") is False
assert keep_float_scanf(None, float_config, "x", "y") is True
assert keep_float_scanf(None, int_config, "x", "y") is False
def test_external_components_use_scanf_float(setup_core: Path) -> None:
"""Sources of components outside the esphome package are scanned; in-tree ones are skipped."""
def manifest(package_dir: Path, *sources: str) -> MagicMock:
m = MagicMock()
m.is_platform_component = False
m.module.__file__ = str(package_dir / "__init__.py")
m.resources = [FileResource("pkg", name) for name in sources]
return m
(setup_core / "ext_float").mkdir()
(setup_core / "ext_float" / "parse.cpp").write_text(
'void f(const char *b) { float v; sscanf(b, "%f", &v); }'
)
(setup_core / "ext_int").mkdir()
(setup_core / "ext_int" / "parse.cpp").write_text(
'int g(const char *b) { int v; sscanf(b, "%d", &v); return v; }'
)
lookup = {
"ext_float": manifest(setup_core / "ext_float", "parse.cpp"),
"ext_int": manifest(setup_core / "ext_int", "parse.cpp"),
"sensor": manifest(CORE_COMPONENTS_PATH / "sensor", "sensor.cpp"),
"esphome": manifest(CORE_COMPONENTS_PATH.parent / "core", "config.cpp"),
}
with patch("esphome.config.get_component", side_effect=lookup.get):
CORE.config = {"esphome": {}, "sensor": {}, "ext_int": {}}
assert external_components_use_scanf_float(CORE.config) is False
CORE.config = {"sensor": {}, "ext_float": {}}
assert external_components_use_scanf_float(CORE.config) is True
assert user_code_uses_scanf_float(CORE.config) is True
+14
View File
@@ -1,6 +1,7 @@
"""Unit tests for esphome.config_helpers module."""
from collections.abc import Callable
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -12,6 +13,7 @@ from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
get_logger_level,
iter_include_files,
)
from esphome.const import (
CONF_LEVEL,
@@ -209,3 +211,15 @@ def test_filter_source_files_from_platform_unknown_pair() -> None:
{"a.cpp": {PlatformFramework.ESP32_ARDUINO}}
)
assert func() == []
def test_iter_include_files(setup_core: Path) -> None:
"""Files yield their name, directory members keep the directory prefix, system headers are skipped."""
(setup_core / "one.h").write_text("")
(setup_core / "lib").mkdir()
(setup_core / "lib" / "two.h").write_text("")
files = {
basename.as_posix()
for _, basename in iter_include_files(["one.h", "lib", "<cstdio>"])
}
assert files == {"one.h", "lib/two.h"}
-14
View File
@@ -254,20 +254,6 @@ 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: