Compare commits

..
Author SHA1 Message Date
J. Nick Koston b0032f41aa Merge branch 'dev' into http-request-update-runtime-url 2026-10-21 23:58:03 -10:00
J. Nick Koston b7184d732b [http_request] Declare the test friend on every platform for clang-tidy 2026-10-21 23:43:35 -10:00
J. Nick Koston 1995456391 [http_request] Hand URL ownership to the running check and test the lifetime 2026-10-21 23:31:34 -10:00
J. Nick Koston 54c3169d92 [http_request] Free a URL replaced during an inline check on the next loop 2026-10-21 23:08:43 -10:00
J. Nick Koston 5ddb38c5cc [http_request] Keep the check's URL snapshot in its TaskResult 2026-10-21 22:49:52 -10:00
7dace44d61 [api] Add optional user-defined action variables with defaults (#18882)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 22:48:28 -10:00
J. Nick Koston e65fee63d2 [http_request] Copy a manifest URL set at run time and keep the config literal by pointer 2026-10-21 22:01:41 -10:00
esphome[bot] 491f98f982 Bump aioesphomeapi from 46.20.0 to 46.21.0 (#21371) 2026-10-22 07:43:14 +00:00
Clyde StubbsandClaude Opus 5.5 619283d74a [mipi_spi] Run the T-Display S3 Pro panel at 40 MHz (#21370)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:26:30 -10:00
18348b8794 [vnc] Add VNC client component (#20623)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-21 21:14:37 -10:00
f7dc2ecb51 [wifi] [ethernet] Bridge the WISP access point to an ethernet upstream (#7147)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 07:02:25 +00:00
ddcc5874ed [api] Let a media_player ask for a constant bitrate (#19962)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 06:17:59 +00:00
esphome[bot] 28013a60e1 Bump aioesphomeapi from 46.19.0 to 46.20.0 (#21362) 2026-10-21 19:19:35 -10:00
J. Nick Koston 966193cb09 [core] Emit generated lambda code after the user includes (#21307) 2026-10-21 18:28:04 -10:00
e1077748be [api][cover] Expose toggle support in native API (#16506)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 18:11:09 -10:00
cc89a011e2 Bump clang-tidy + clang-format from 22.1.8 to 23.1.3 (#21296)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 17:45:36 -10:00
Craig Andrews c76e9e1d62 [epaper_spi] Support Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680) (#21218) 2026-10-21 17:09:44 -10:00
esphome[bot] 7a3aac407f Bump aioesphomeapi from 46.18.0 to 46.19.0 (#21345) 2026-10-21 17:03:34 -10:00
J. Nick Koston b0a1054e8d [ld2410] Publish queried number values directly (#21346) 2026-10-21 16:56:51 -10:00
a8a8acb918 [sensor] Add Relative Humidity Correction Filter (#18287)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-10-21 16:33:11 -10:00
112 changed files with 6930 additions and 466 deletions
+8
View File
@@ -12,6 +12,7 @@ Checks: >-
-bugprone-multi-level-implicit-pointer-conversion,
-bugprone-narrowing-conversions,
-bugprone-tagged-union-member-count,
-bugprone-signed-bitwise,
-bugprone-signed-char-misuse,
-bugprone-switch-missing-default-case,
-cert-dcl50-cpp,
@@ -39,6 +40,7 @@ Checks: >-
-cppcoreguidelines-avoid-const-or-ref-data-members,
-cppcoreguidelines-avoid-do-while,
-cppcoreguidelines-avoid-magic-numbers,
-cppcoreguidelines-explicit-constructor,
-cppcoreguidelines-init-variables,
-cppcoreguidelines-macro-to-enum,
-cppcoreguidelines-macro-usage,
@@ -84,6 +86,7 @@ Checks: >-
-llvm-use-ranges,
-llvmlibc-*,
-misc-const-correctness,
-misc-explicit-constructor,
-misc-include-cleaner,
-misc-multiple-inheritance,
-misc-no-recursion,
@@ -113,6 +116,7 @@ Checks: >-
-objc-*,
-performance-enum-size,
-portability-avoid-pragma-once,
-portability-no-assembler,
-portability-template-virtual-member-function,
-readability-ambiguous-smartptr-reset-call,
-readability-avoid-nested-conditional-operator,
@@ -129,9 +133,13 @@ Checks: >-
-readability-named-parameter,
-readability-redundant-casting,
-readability-redundant-inline-specifier,
-readability-redundant-lambda-parameter-list,
-readability-redundant-member-init,
-readability-redundant-nested-if,
-readability-redundant-parentheses,
-readability-redundant-typename,
-readability-trailing-comma,
-readability-trivial-switch,
-readability-uppercase-literal-suffix,
-readability-use-anyofallof,
-readability-use-std-min-max,
+1 -1
View File
@@ -38,7 +38,7 @@ repos:
- id: yamllint
exclude: ^(\.clang-format|\.clang-tidy)$
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v22.1.8
rev: v23.1.3
hooks:
- id: clang-format
types_or: [c, c++]
+1
View File
@@ -700,6 +700,7 @@ 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
+100 -11
View File
@@ -1,5 +1,7 @@
from collections.abc import Callable
from ipaddress import IPv4Address, IPv6Address
import logging
import math
import re
from typing import Any
@@ -27,6 +29,7 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DEFAULT,
CONF_DELAY,
CONF_ENCRYPTION,
CONF_EVENT,
@@ -136,6 +139,7 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
CONF_BATCH_DELAY = "batch_delay"
CONF_CUSTOM_SERVICES = "custom_services"
CONF_EXAMPLE = "example"
CONF_REQUIRED = "required"
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
@@ -247,12 +251,66 @@ def _validate_supports_response(value: Any) -> str:
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
VARIABLE_SCHEMA = cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
}
def _not_boolean(value: Any) -> Any:
# bool is an int subclass, and "true" cannot be parsed as a number by the client
if isinstance(value, bool):
raise cv.Invalid("Expected a number, got a boolean")
return value
def _finite(value: float) -> float:
if not math.isfinite(value):
raise cv.Invalid("Expected a finite number")
return value
def _no_nul(value: str) -> str:
# The default is a C string in flash, so a NUL would cut it short
if "\0" in value:
raise cv.Invalid("A default cannot contain a NUL character")
return value
# The wire carries the default as text; the client parses it into ExecuteServiceArgument's
# types, so the values are kept within what int32 and float hold
FLOAT32_MAX = 3.4028235e38
_DEFAULT_VALUE_VALIDATORS: dict[str, Callable[[Any], Any]] = {
"bool": cv.boolean,
"int": cv.All(_not_boolean, cv.int_range(min=-(2**31), max=2**31 - 1)),
"float": cv.All(
_not_boolean,
cv.float_,
_finite,
cv.float_range(min=-FLOAT32_MAX, max=FLOAT32_MAX),
),
"string": cv.All(cv.string_strict, _no_nul),
}
assert _DEFAULT_VALUE_VALIDATORS.keys() == _SERVICE_ARG_SCALAR_TYPES.keys()
def _validate_variable_optionality(value: ConfigType) -> ConfigType:
"""Cross-validate required/default against the variable type."""
var_type = value[CONF_TYPE]
if _is_optional_var(value) and var_type not in _DEFAULT_VALUE_VALIDATORS:
raise cv.Invalid("Array variables cannot be optional")
if CONF_DEFAULT in value:
if value.get(CONF_REQUIRED) is True:
raise cv.Invalid("A variable with a default cannot set required: true")
value[CONF_DEFAULT] = _DEFAULT_VALUE_VALIDATORS[var_type](value[CONF_DEFAULT])
return value
VARIABLE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
cv.Optional(CONF_REQUIRED): cv.boolean,
cv.Optional(CONF_DEFAULT): cv.valid,
}
),
_validate_variable_optionality,
)
# Accepts the plain `name: type` shorthand or the full mapping form
@@ -464,7 +522,28 @@ def _has_action_metadata(actions: list[ConfigType]) -> bool:
)
def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
def _is_optional_var(var_: ConfigType) -> bool:
return CONF_DEFAULT in var_ or var_.get(CONF_REQUIRED) is False
def _has_optional_args(actions: list[ConfigType]) -> bool:
return any(
_is_optional_var(var_)
for conf in actions
for var_ in conf[CONF_VARIABLES].values()
)
def _default_to_wire(value: Any) -> str:
"""Serialize a validated default for the wire; clients coerce by arg type."""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _action_strings(
conf: ConfigType, has_metadata: bool, has_optional: bool
) -> list[str | None]:
"""Strings of one action in the table order UserServiceStatic (user_services.h) expects."""
# An empty description or example is treated as unset
strings: list[str | None] = [conf[CONF_ACTION]]
@@ -477,6 +556,15 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
var_.get(CONF_DESCRIPTION) or None,
var_.get(CONF_EXAMPLE) or None,
]
if has_optional:
# The default slot doubles as the optional flag: nullptr for a required variable,
# "" for an optional one without a default (an empty string default is the same)
if not _is_optional_var(var_):
strings.append(None)
elif (default := var_.get(CONF_DEFAULT)) is None:
strings.append("")
else:
strings.append(_default_to_wire(default))
return strings
@@ -485,9 +573,7 @@ def _validate_wizard(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_wizard,
)
FINAL_VALIDATE_SCHEMA = _validate_wizard
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
@@ -546,6 +632,9 @@ async def to_code(config: ConfigType) -> None:
has_metadata = _has_action_metadata(actions)
if has_metadata:
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
has_optional = _has_optional_args(actions)
if has_optional:
cg.add_define("USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS")
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
for index, conf in enumerate(actions):
@@ -589,7 +678,7 @@ async def to_code(config: ConfigType) -> None:
native = SERVICE_ARG_NATIVE_TYPES[var_type]
service_template_args.append(native)
func_args.append((native, name))
strings = _action_strings(conf, has_metadata)
strings = _action_strings(conf, has_metadata, has_optional)
table = _add_action_strings(index, strings)
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
+8
View File
@@ -608,6 +608,7 @@ message ListEntitiesCoverResponse {
bool supports_stop = 12;
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
bool supports_stop_tilt = 14;
bool supports_toggle = 15;
}
// Deprecated in API version 1.1
@@ -670,6 +671,7 @@ message CoverCommandRequest {
bool stop = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
bool stop_tilt = 10;
bool toggle = 11;
}
// ==================== FAN ====================
@@ -1172,6 +1174,10 @@ message ListEntitiesServicesArgument {
ServiceArgType type = 2;
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
// Advisory: ExecuteServiceRequest still requires every argument
bool optional = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS"];
// Always text: bool "true"/"false", numbers parseable as int/float; empty means no default
string default_value = 6 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS", (progmem) = true];
}
message ListEntitiesServicesResponse {
option (id) = 41;
@@ -1783,6 +1789,8 @@ 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;
@@ -555,6 +555,7 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c
msg.supports_tilt = traits.get_supports_tilt();
msg.supports_stop = traits.get_supports_stop();
msg.supports_stop_tilt = traits.get_supports_stop_tilt();
msg.supports_toggle = traits.get_supports_toggle();
return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, conn, remaining_size);
}
void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
@@ -567,6 +568,8 @@ void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
call.set_command_stop();
if (msg.stop_tilt)
call.set_command_stop_tilt();
if (msg.toggle)
call.set_command_toggle();
call.perform();
}
#endif
@@ -1128,6 +1131,7 @@ 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);
}
+19
View File
@@ -484,6 +484,7 @@ uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__rest
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id);
#endif
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.supports_stop_tilt);
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.supports_toggle);
return pos;
}
uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
@@ -506,6 +507,7 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.device_id);
#endif
size += ProtoSize::calc_bool(1, msg.supports_stop_tilt);
size += ProtoSize::calc_bool(1, msg.supports_toggle);
return size;
}
uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
@@ -565,6 +567,9 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *
case proto_tag(10, WIRE_TYPE_VARINT):
msg.stop_tilt = value.as_bool();
break;
case proto_tag(11, WIRE_TYPE_VARINT):
msg.toggle = value.as_bool();
break;
default:
break;
}
@@ -1364,6 +1369,12 @@ uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self,
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.default_value);
#endif
return pos;
}
@@ -1377,6 +1388,12 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
size += ProtoSize::calc_length(1, msg.example.size());
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
size += ProtoSize::calc_bool(1, msg.optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
size += ProtoSize::calc_length(1, msg.default_value.size());
#endif
return size;
}
@@ -2368,6 +2385,7 @@ 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) {
@@ -2378,6 +2396,7 @@ 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,
+11 -2
View File
@@ -911,7 +911,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage {
class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 13;
static constexpr uint8_t ESTIMATED_SIZE = 59;
static constexpr uint8_t ESTIMATED_SIZE = 61;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); }
#endif
@@ -921,6 +921,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
ProgmemStringRef device_class{};
bool supports_stop{false};
bool supports_stop_tilt{false};
bool supports_toggle{false};
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);
@@ -958,7 +959,7 @@ class CoverStateResponse final : public StateResponseProtoMessage {
class CoverCommandRequest final : public CommandProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 30;
static constexpr uint8_t ESTIMATED_SIZE = 27;
static constexpr uint8_t ESTIMATED_SIZE = 29;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("cover_command_request"); }
#endif
@@ -968,6 +969,7 @@ class CoverCommandRequest final : public CommandProtoMessage {
float tilt{0.0f};
bool stop{false};
bool stop_tilt{false};
bool toggle{false};
void decode(const uint8_t *buffer, size_t length) {
ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);
}
@@ -1591,6 +1593,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
ProgmemStringRef example{};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
bool optional{false};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
ProgmemStringRef default_value{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -2261,6 +2269,7 @@ 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);
+9
View File
@@ -1246,6 +1246,7 @@ const char *ListEntitiesCoverResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("supports_stop_tilt"), this->supports_stop_tilt);
dump_field(out, ESPHOME_PSTR("supports_toggle"), this->supports_toggle);
return out.c_str();
}
const char *CoverStateResponse::dump_to(DumpBuffer &out) const {
@@ -1271,6 +1272,7 @@ const char *CoverCommandRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("stop_tilt"), this->stop_tilt);
dump_field(out, ESPHOME_PSTR("toggle"), this->toggle);
return out.c_str();
}
#endif
@@ -1641,6 +1643,12 @@ const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
dump_field(out, ESPHOME_PSTR("optional"), this->optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
dump_progmem_string_field(out, ESPHOME_PSTR("default_value"), this->default_value);
#endif
return out.c_str();
}
@@ -2097,6 +2105,7 @@ 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 {
+1 -1
View File
@@ -301,7 +301,7 @@ constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128
constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
/// Generated encode body: writes the fields at pos, returns the cursor past them.
using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
using ProtoEncodeFn = uint8_t *(*)(const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and
/// returns it advanced, so outlined calls at -Os chain through the return register instead of a
+7
View File
@@ -31,6 +31,13 @@ ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_(
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
arg.description = this->str_(base + 1);
arg.example = this->str_(base + 2);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
// The default slot doubles as the optional flag: nullptr is required, "" optional without a default
const char *d = progmem_read_ptr(&this->strings_[base + USER_ACTION_ARG_BASE_STRINGS]);
arg.optional = d != nullptr;
if (d != nullptr)
arg.default_value = ProgmemStringRef(d, ESPHOME_strlen_P(d));
#endif
}
return msg;
+10 -3
View File
@@ -40,13 +40,20 @@ template<typename T> enums::ServiceArgType to_service_arg_type();
// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in
// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then
// each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows
// the name and every argument is (name, description, example). Unset metadata is nullptr.
// the name and every argument is (name, description, example); with
// USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS every argument ends with its default: nullptr for a
// required argument, "" for an optional one without a default. Unset is nullptr.
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
static constexpr size_t USER_ACTION_HEADER_STRINGS = 2;
static constexpr size_t USER_ACTION_ARG_STRINGS = 3;
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 3;
#else
static constexpr size_t USER_ACTION_HEADER_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 1;
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS + 1;
#else
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS;
#endif
class UserServiceStatic : public UserServiceDescriptor {
public:
+4 -5
View File
@@ -111,10 +111,9 @@ static const uint8_t BL0906_USR_WRPROT = 0x9E;
// Reset Register
static const uint8_t BL0906_SOFT_RESET = 0x9F;
const uint8_t BL0906_INIT[2][6] = {
// Reset to default
{BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52},
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
const uint8_t BL0906_INIT[2][6] = {// Reset to default
{BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52},
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
} // namespace esphome::bl0906
@@ -0,0 +1,12 @@
#include "epaper_goodisplay_3c.h"
namespace esphome::epaper_spi {
void EPaperGoodDisplay3C::power_on() {}
void EPaperGoodDisplay3C::refresh_screen(bool partial) {
EPaperWeAct3C::refresh_screen(partial);
this->next_delay_ = REFRESH_START_MS;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,28 @@
#pragma once
#include "epaper_weact_3c.h"
namespace esphome::epaper_spi {
/**
* Good Display 3-color e-paper displays (SSD1680 controller), e.g. GDEY029Z95.
*
* Driven as EPaperWeAct3C, except that there is no separate power-on: the full refresh (0xF7) powers the panel
* up itself. Busy then only goes high a little after the refresh starts, so the next state waits before
* checking it.
*/
class EPaperGoodDisplay3C : public EPaperWeAct3C {
public:
using EPaperWeAct3C::EPaperWeAct3C;
protected:
// Time from master activation (0x20) until busy can be relied on
static constexpr uint16_t REFRESH_START_MS = 50;
// With EPaperWeAct3C's power-on (0x22 0xF8) first, this panel's refresh ends almost at once and the image
// does not change.
void power_on() override;
void refresh_screen(bool partial) override;
};
} // namespace esphome::epaper_spi
@@ -4,11 +4,17 @@ Supported models:
- weact-2.13in-3c: 122x250 pixels (2.13" display)
- weact-2.9in-3c: 128x296 pixels (2.9" display)
- weact-4.2in-3c: 400x300 pixels (4.2" display)
- goodisplay-gdey029z95-2.9: 128x296 pixels (Good Display GDEY029Z95 2.9" display)
These displays use SSD1680 or SSD1683 controller and require a specific initialization
sequence. The DRV_OUT_CTL command is calculated from the display height.
The Good Display panel is driven as the WeAct ones, except that it must not get the
separate power-on before the refresh, so it has its own driver class, EPaperGoodDisplay3C.
"""
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_RESET_PIN
from . import EpaperModel
@@ -17,8 +23,10 @@ class WeActBWR(EpaperModel):
source_files = ("epaper_weact_3c.cpp",)
def __init__(self, name, **defaults):
super().__init__(name, "EPaperWeAct3C", **defaults)
def __init__(
self, name: str, class_name: str = "EPaperWeAct3C", **defaults: object
) -> None:
super().__init__(name, class_name, **defaults)
def get_init_sequence(self, config):
"""Generate initialization sequence for WeAct BWR displays.
@@ -75,3 +83,27 @@ weact_4p2in3c = WeActBWR(
data_rate="10MHz",
minimum_update_interval="10s",
)
class GoodDisplayBWR(WeActBWR):
"""Good Display Black/White/Red displays using SSD1680 controller."""
source_files = ("epaper_weact_3c.cpp", "epaper_goodisplay_3c.cpp")
def __init__(self, name: str, **defaults: object) -> None:
super().__init__(name, "EPaperGoodDisplay3C", **defaults)
def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required:
# Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy
if name in (CONF_RESET_PIN, CONF_BUSY_PIN):
return cv.Required(name)
return super().option(name, fallback)
# Model: Good Display GDEY029Z95 2.9" BWR - 128x296 pixels, SSD1680 controller
GoodDisplayBWR(
"goodisplay-gdey029z95-2.9",
width=128,
height=296,
minimum_update_interval="10s",
)
+21 -16
View File
@@ -46,6 +46,7 @@ from esphome.const import (
CONF_TYPE,
CONF_USE_ADDRESS,
CONF_VALUE,
CONF_WIFI,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
Platform,
@@ -562,6 +563,25 @@ 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
@@ -846,22 +866,7 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> None:
"""Final validation for Ethernet component."""
# 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_wifi(config)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
@@ -35,7 +35,7 @@ async def to_code(config: ConfigType) -> None:
await cg.add_variable(var.set_ota_parent, config[CONF_OTA_ID])
await cg.add_variable(var.set_request_parent, config[CONF_HTTP_REQUEST_ID])
cg.add(var.set_source_url(config[CONF_SOURCE]))
cg.add(var.set_source_url_static(config[CONF_SOURCE]))
ota.request_ota_state_listeners()
@@ -24,12 +24,6 @@ namespace esphome::http_request {
ESPHOME_LOG_TAG(TAG, "http_request.update");
// Wraps UpdateInfo + error for the task→main-loop handoff.
struct TaskResult {
update::UpdateInfo info;
const LogString *error_str{nullptr};
};
static const size_t MAX_READ_SIZE = 256;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000;
@@ -68,6 +62,37 @@ void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_
}
}
void HttpRequestUpdate::set_source_url(const char *source_url, size_t len) {
// Copy before releasing, so a caller passing the current URL back in stays valid.
char *copy = new char[len + 1];
memcpy(copy, source_url, len);
copy[len] = '\0';
this->release_source_url_();
this->source_url_ = copy;
this->source_url_owned_ = true;
}
void HttpRequestUpdate::release_source_url_() {
if (this->source_url_owned_)
delete[] this->source_url_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::begin_check_(TaskResult *result) {
result->update = this;
result->source_url = this->source_url_;
result->owns_source_url = this->source_url_owned_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::end_check_(TaskResult *result) {
if (this->source_url_ == result->source_url) {
this->source_url_owned_ = result->owns_source_url;
} else if (result->owns_source_url) {
delete[] result->source_url; // replaced while the check ran
}
}
void HttpRequestUpdate::update() {
if (!network::is_connected()) {
ESP_LOGD(TAG, "Network not connected, skipping update check");
@@ -75,28 +100,37 @@ void HttpRequestUpdate::update() {
}
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
#ifdef USE_ESP32
if (this->update_task_handle_ != nullptr) {
if (this->task_result_ != nullptr) {
ESP_LOGW(TAG, "Update check already in progress");
return;
}
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
#endif
// Allocated once per check; every path in the task returns via its single defer, which frees it.
auto *result = new TaskResult();
this->begin_check_(result);
#ifdef USE_ESP32
this->task_result_ = result;
if (xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, result, 1, nullptr) != pdPASS) {
this->task_result_ = nullptr;
this->end_check_(result);
delete result;
}
#else
this->update_task(this);
this->update_task(result);
this->end_check_(result);
#endif
}
void HttpRequestUpdate::update_task(void *params) {
HttpRequestUpdate *this_update = (HttpRequestUpdate *) params;
// Allocate once — every path below returns via the single defer at the end.
// On failure, error_str is set; on success it is nullptr.
auto *result = new TaskResult();
auto *result = static_cast<TaskResult *>(params);
HttpRequestUpdate *this_update = result->update;
auto *info = &result->info;
const char *source_url = result->source_url;
auto container = this_update->request_parent_->get(this_update->source_url_);
auto container = this_update->request_parent_->get(source_url);
if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_);
ESP_LOGE(TAG, "Failed to fetch manifest from %s", source_url);
if (container != nullptr)
container->end();
result->error_str = LOG_STR("Failed to fetch manifest");
@@ -176,14 +210,14 @@ void HttpRequestUpdate::update_task(void *params) {
allocator.deallocate(data, content_length);
if (!valid) {
ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_);
ESP_LOGE(TAG, "Failed to parse JSON from %s", source_url);
result->error_str = LOG_STR("Failed to parse manifest JSON");
goto defer; // NOLINT(cppcoreguidelines-avoid-goto)
}
// Merge source_url_ and firmware_url
if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) {
const char *source = this_update->source_url_;
const char *source = source_url;
const size_t source_len = strlen(source);
size_t prefix_len;
if (info->firmware_url[0] == '/') {
@@ -215,7 +249,8 @@ defer:
// Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains.
this_update->defer([this_update, result]() {
#ifdef USE_ESP32
this_update->update_task_handle_ = nullptr;
this_update->task_result_ = nullptr;
this_update->end_check_(result);
#endif
if (result->error_str != nullptr) {
this_update->status_set_error(result->error_str);
@@ -13,6 +13,21 @@
namespace esphome::http_request {
namespace testing {
class HttpRequestUpdateTest;
} // namespace testing
class HttpRequestUpdate;
// One manifest check: handed from update() to the task and back to the main loop.
struct TaskResult {
HttpRequestUpdate *update{nullptr};
const char *source_url{nullptr}; // the URL this check reads, fixed before it starts
update::UpdateInfo info;
const LogString *error_str{nullptr}; // set on failure, nullptr on success
bool owns_source_url{false}; // the check holds the buffer until it ends, see begin_check_()
};
class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener {
public:
void setup() override;
@@ -21,7 +36,18 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
void perform(bool force) override;
void check() override { this->update(); }
void set_source_url(const char *source_url) { this->source_url_ = source_url; }
~HttpRequestUpdate() override { this->release_source_url_(); }
/// Codegen only: the config literal lives in flash for the life of the program, so it is kept by pointer.
void set_source_url_static(const char *source_url) {
this->release_source_url_();
this->source_url_ = source_url;
}
/// Set the manifest URL at run time; the string is copied, so any storage may be passed.
void set_source_url(const char *source_url) { this->set_source_url(source_url, strlen(source_url)); }
void set_source_url(const std::string &source_url) { this->set_source_url(source_url.data(), source_url.size()); }
void set_source_url(const char *source_url, size_t len);
const char *get_source_url() const { return this->source_url_; }
void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; }
void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; }
@@ -36,12 +62,21 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
static void update_task(void *params);
#ifdef USE_ESP32
TaskHandle_t update_task_handle_{nullptr};
TaskResult *task_result_{nullptr}; // set while a check runs on the update task
#endif
uint8_t initial_check_remaining_{0};
private:
const char *source_url_{nullptr}; // literal from codegen
friend class testing::HttpRequestUpdateTest;
void release_source_url_();
// A check reads the URL it started with until it ends, on the update task or inline through an
// automation it triggers. Ownership of that buffer moves to the check, so a set_source_url in the
// meantime cannot free it; end_check_() hands it back or frees it on the main loop.
void begin_check_(TaskResult *result);
void end_check_(TaskResult *result);
bool source_url_owned_{false}; // source_url_ was copied by a run-time setter; sits in the padding above
const char *source_url_{nullptr};
};
} // namespace esphome::http_request
+9 -16
View File
@@ -407,12 +407,10 @@ void LD2410Component::handle_periodic_data_() {
}
#ifdef USE_NUMBER
std::function<void()> set_number_value(number::Number *n, float value) {
void set_number_value(number::Number *n, float value) {
if (n != nullptr && (!n->has_state() || n->state != value)) {
n->state = value;
return [n, value]() { n->publish_state(value); };
n->publish_state(value);
}
return []() {};
}
#endif
@@ -560,29 +558,24 @@ bool LD2410Component::handle_ack_data_() {
Moving distance range: 13th byte
Still distance range: 14th byte
*/
std::vector<std::function<void()>> updates;
updates.push_back(set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12]));
updates.push_back(set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13]));
set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12]);
set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13]);
/*
Moving Sensitivities: 15~23th bytes
*/
for (std::vector<number::Number *>::size_type i = 0; i != this->gate_move_threshold_numbers_.size(); i++) {
updates.push_back(set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i]));
for (size_t i = 0; i < TOTAL_GATES; i++) {
set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i]);
}
/*
Still Sensitivities: 24~32th bytes
*/
for (std::vector<number::Number *>::size_type i = 0; i != this->gate_still_threshold_numbers_.size(); i++) {
updates.push_back(set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i]));
for (size_t i = 0; i < TOTAL_GATES; i++) {
set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i]);
}
/*
None Duration: 33~34th bytes
*/
updates.push_back(
set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32])));
for (auto &update : updates) {
update();
}
set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32]));
#endif
break;
}
+101 -48
View File
@@ -9,7 +9,6 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <cmath>
@@ -18,6 +17,23 @@ namespace esphome::ld2412 {
ESPHOME_LOG_TAG(TAG, "ld2412");
enum BaudRate : uint8_t {
BAUD_RATE_9600 = 1,
BAUD_RATE_19200 = 2,
BAUD_RATE_38400 = 3,
BAUD_RATE_57600 = 4,
BAUD_RATE_115200 = 5,
BAUD_RATE_230400 = 6,
BAUD_RATE_256000 = 7,
BAUD_RATE_460800 = 8,
};
enum DistanceResolution : uint8_t {
DISTANCE_RESOLUTION_0_2 = 0x03,
DISTANCE_RESOLUTION_0_5 = 0x01,
DISTANCE_RESOLUTION_0_75 = 0x00,
};
enum LightFunction : uint8_t {
LIGHT_FUNCTION_OFF = 0x00,
LIGHT_FUNCTION_BELOW = 0x01,
@@ -66,18 +82,77 @@ enum AckData : uint8_t {
ACK_PAYLOAD = 10,
};
// Index + 1 is the BaudRate value; the select options use the same order
PROGMEM_STRING_TABLE(BaudRateStrings, "9600", "19200", "38400", "57600", "115200", "230400", "256000", "460800");
static_assert(BAUD_RATE_9600 == 1 && BAUD_RATE_460800 == BaudRateStrings::COUNT, "BaudRate is index + 1");
// Same order as DISTANCE_RESOLUTIONS
PROGMEM_STRING_TABLE(DistanceResolutionStrings, "0.2m", "0.5m", "0.75m");
// The light function select options are in enum order
static_assert(LIGHT_FUNCTION_OFF == 0 && LIGHT_FUNCTION_BELOW == 1 && LIGHT_FUNCTION_ABOVE == 2,
"LightFunction is the select option index");
// Device values of the out pin level select options, in option order
static constexpr uint8_t OUT_PIN_LEVELS[] PROGMEM = {OUT_PIN_LEVEL_LOW, OUT_PIN_LEVEL_HIGH};
// Memory-efficient lookup tables
struct StringToUint8 {
const char *str;
const uint8_t value;
};
static constexpr uint32_t BAUD_RATES[] PROGMEM = {9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800};
struct Uint8ToString {
const uint8_t value;
const char *str;
};
constexpr StringToUint8 BAUD_RATES_BY_STR[] = {
{"9600", BAUD_RATE_9600}, {"19200", BAUD_RATE_19200}, {"38400", BAUD_RATE_38400},
{"57600", BAUD_RATE_57600}, {"115200", BAUD_RATE_115200}, {"230400", BAUD_RATE_230400},
{"256000", BAUD_RATE_256000}, {"460800", BAUD_RATE_460800},
};
constexpr StringToUint8 DISTANCE_RESOLUTIONS_BY_STR[] = {
{"0.2m", DISTANCE_RESOLUTION_0_2},
{"0.5m", DISTANCE_RESOLUTION_0_5},
{"0.75m", DISTANCE_RESOLUTION_0_75},
};
constexpr Uint8ToString DISTANCE_RESOLUTIONS_BY_UINT[] = {
{DISTANCE_RESOLUTION_0_2, "0.2m"},
{DISTANCE_RESOLUTION_0_5, "0.5m"},
{DISTANCE_RESOLUTION_0_75, "0.75m"},
};
constexpr StringToUint8 LIGHT_FUNCTIONS_BY_STR[] = {
{"off", LIGHT_FUNCTION_OFF},
{"below", LIGHT_FUNCTION_BELOW},
{"above", LIGHT_FUNCTION_ABOVE},
};
constexpr Uint8ToString LIGHT_FUNCTIONS_BY_UINT[] = {
{LIGHT_FUNCTION_OFF, "off"},
{LIGHT_FUNCTION_BELOW, "below"},
{LIGHT_FUNCTION_ABOVE, "above"},
};
constexpr StringToUint8 OUT_PIN_LEVELS_BY_STR[] = {
{"low", OUT_PIN_LEVEL_LOW},
{"high", OUT_PIN_LEVEL_HIGH},
};
constexpr Uint8ToString OUT_PIN_LEVELS_BY_UINT[] = {
{OUT_PIN_LEVEL_LOW, "low"},
{OUT_PIN_LEVEL_HIGH, "high"},
};
constexpr uint32_t BAUD_RATES[] = {9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800};
// Helper functions for lookups
template<size_t N> uint8_t find_uint8(const StringToUint8 (&arr)[N], const char *str) {
for (const auto &entry : arr) {
if (strcmp(str, entry.str) == 0) {
return entry.value;
}
}
return 0xFF; // Not found
}
template<size_t N> const char *find_str(const Uint8ToString (&arr)[N], uint8_t value) {
for (const auto &entry : arr) {
if (value == entry.value) {
return entry.str;
}
}
return ""; // Not found
}
static constexpr uint8_t DEFAULT_PRESENCE_TIMEOUT = 5; // Default used when number component is not defined
// Commands
@@ -457,16 +532,11 @@ bool LD2412Component::handle_ack_data_() {
break;
}
case CMD_QUERY_DISTANCE_RESOLUTION: {
const auto index = ld24xx::find_index(DISTANCE_RESOLUTIONS, this->buffer_data_[10]);
if (!index) {
ESP_LOGW(TAG, "Unknown distance resolution %u", this->buffer_data_[10]);
break;
}
ESP_LOGV(TAG, "Distance resolution: %s",
LOG_STR_ARG(DistanceResolutionStrings::get_log_str(*index, DistanceResolutionStrings::LAST_INDEX)));
const auto *distance_resolution = find_str(DISTANCE_RESOLUTIONS_BY_UINT, this->buffer_data_[10]);
ESP_LOGV(TAG, "Distance resolution: %s", distance_resolution);
#ifdef USE_SELECT
if (this->distance_resolution_select_ != nullptr) {
this->distance_resolution_select_->publish_state(*index);
this->distance_resolution_select_->publish_state(distance_resolution);
}
#endif
break;
@@ -475,10 +545,11 @@ bool LD2412Component::handle_ack_data_() {
case CMD_QUERY_LIGHT_CONTROL: {
this->light_function_ = this->buffer_data_[10];
this->light_threshold_ = this->buffer_data_[11];
ESP_LOGV(TAG, "Light function: %u, threshold: %u", this->light_function_, this->light_threshold_);
const auto *light_function_str = find_str(LIGHT_FUNCTIONS_BY_UINT, this->light_function_);
ESP_LOGV(TAG, "Light function: %s, threshold: %u", light_function_str, this->light_threshold_);
#ifdef USE_SELECT
if (this->light_function_select_ != nullptr) {
this->light_function_select_->publish_state(static_cast<size_t>(this->light_function_));
this->light_function_select_->publish_state(light_function_str);
}
#endif
#ifdef USE_NUMBER
@@ -596,10 +667,9 @@ bool LD2412Component::handle_ack_data_() {
*/
this->out_pin_level_ = this->buffer_data_[14];
#ifdef USE_SELECT
const auto *out_pin_level_str = find_str(OUT_PIN_LEVELS_BY_UINT, this->out_pin_level_);
if (this->out_pin_level_select_ != nullptr) {
if (const auto index = ld24xx::find_index(OUT_PIN_LEVELS, this->out_pin_level_)) {
this->out_pin_level_select_->publish_state(*index);
}
this->out_pin_level_select_->publish_state(out_pin_level_str);
}
#endif
#endif
@@ -659,29 +729,15 @@ void LD2412Component::set_bluetooth(bool enable) {
}
void LD2412Component::set_distance_resolution(const char *state) {
const int16_t index = progmem_string_table_find<DistanceResolutionStrings>(state, strlen(state));
if (index >= 0) {
this->set_distance_resolution(static_cast<DistanceResolution>(progmem_read_byte(&DISTANCE_RESOLUTIONS[index])));
}
}
void LD2412Component::set_distance_resolution(DistanceResolution resolution) {
this->set_config_mode_(true);
const uint8_t cmd_value[6] = {resolution, 0x00, 0x00, 0x00, 0x00, 0x00};
const uint8_t cmd_value[6] = {find_uint8(DISTANCE_RESOLUTIONS_BY_STR, state), 0x00, 0x00, 0x00, 0x00, 0x00};
this->send_command_(CMD_SET_DISTANCE_RESOLUTION, cmd_value, sizeof(cmd_value));
this->set_timeout(200, [this]() { this->restart_and_read_all_info(); });
}
void LD2412Component::set_baud_rate(const char *state) {
const int16_t index = progmem_string_table_find<BaudRateStrings>(state, strlen(state));
if (index >= 0) {
this->set_baud_rate(static_cast<BaudRate>(index + 1));
}
}
void LD2412Component::set_baud_rate(BaudRate rate) {
this->set_config_mode_(true);
const uint8_t cmd_value[2] = {rate, 0x00};
const uint8_t cmd_value[2] = {find_uint8(BAUD_RATES_BY_STR, state), 0x00};
this->send_command_(CMD_SET_BAUD_RATE, cmd_value, sizeof(cmd_value));
this->set_timeout(200, [this]() { this->restart_(); });
}
@@ -763,10 +819,9 @@ void LD2412Component::set_basic_config() {
#endif
#ifdef USE_SELECT
if (this->out_pin_level_select_ != nullptr) {
const auto index = this->out_pin_level_select_->active_index();
if (!index)
if (!this->out_pin_level_select_->has_state())
return;
out_pin_level = progmem_read_byte(&OUT_PIN_LEVELS[*index]);
out_pin_level = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str());
}
#endif
@@ -838,10 +893,8 @@ void LD2412Component::set_light_out_control() {
}
#endif
#ifdef USE_SELECT
if (this->light_function_select_ != nullptr) {
if (const auto index = this->light_function_select_->active_index()) {
this->light_function_ = *index;
}
if (this->light_function_select_ != nullptr && this->light_function_select_->has_state()) {
this->light_function_ = find_uint8(LIGHT_FUNCTIONS_BY_STR, this->light_function_select_->current_option().c_str());
}
#endif
uint8_t value[2] = {this->light_function_, this->light_threshold_};
-23
View File
@@ -36,27 +36,6 @@ using namespace ld24xx;
static constexpr uint8_t MAX_LINE_LENGTH = 54; // Max characters for serial buffer
static constexpr uint8_t TOTAL_GATES = 14; // Total number of gates supported by the LD2412
enum BaudRate : uint8_t {
BAUD_RATE_9600 = 1,
BAUD_RATE_19200 = 2,
BAUD_RATE_38400 = 3,
BAUD_RATE_57600 = 4,
BAUD_RATE_115200 = 5,
BAUD_RATE_230400 = 6,
BAUD_RATE_256000 = 7,
BAUD_RATE_460800 = 8,
};
enum DistanceResolution : uint8_t {
DISTANCE_RESOLUTION_0_2 = 0x03,
DISTANCE_RESOLUTION_0_5 = 0x01,
DISTANCE_RESOLUTION_0_75 = 0x00,
};
// Device values of the distance resolution select options, in option order; in flash on ESP8266
static constexpr uint8_t DISTANCE_RESOLUTIONS[] PROGMEM = {DISTANCE_RESOLUTION_0_2, DISTANCE_RESOLUTION_0_5,
DISTANCE_RESOLUTION_0_75};
class LD2412Component final : public Component, public uart::UARTDevice {
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(dynamic_background_correction_status)
@@ -124,9 +103,7 @@ class LD2412Component final : public Component, public uart::UARTDevice {
void restart_and_read_all_info();
void set_bluetooth(bool enable);
void set_distance_resolution(const char *state);
void set_distance_resolution(DistanceResolution resolution);
void set_baud_rate(const char *state);
void set_baud_rate(BaudRate rate);
void factory_reset();
void start_dynamic_background_correction();
@@ -52,7 +52,6 @@ CONFIG_SCHEMA = {
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2412_ID])
selects = select.sub_selects(config, parent=hub)
# Option lists follow the order of the C++ enums and tables; the hub maps by index
await selects(
CONF_BAUD_RATE,
hub.set_baud_rate_select,
@@ -4,7 +4,7 @@ namespace esphome::ld2412 {
void BaudRateSelect::control(size_t index) {
this->publish_state(index);
this->parent_->set_baud_rate(static_cast<BaudRate>(index + 1));
this->parent_->set_baud_rate(this->option_at(index));
}
} // namespace esphome::ld2412
@@ -4,8 +4,7 @@ namespace esphome::ld2412 {
void DistanceResolutionSelect::control(size_t index) {
this->publish_state(index);
this->parent_->set_distance_resolution(
static_cast<DistanceResolution>(progmem_read_byte(&DISTANCE_RESOLUTIONS[index])));
this->parent_->set_distance_resolution(this->option_at(index));
}
} // namespace esphome::ld2412
+3 -11
View File
@@ -1,7 +1,6 @@
#pragma once
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <memory>
@@ -25,17 +24,10 @@
namespace esphome::ld24xx {
// Index of value in a constant table that may live in flash, or none
template<typename T, size_t N> optional<size_t> find_index(const T (&arr)[N], T value) {
static_assert(sizeof(T) == 1 || sizeof(T) == 4, "flash tables are read as bytes or aligned 32 bit words");
// Helper to find index of value in constexpr array
template<size_t N> optional<size_t> find_index(const uint32_t (&arr)[N], uint32_t value) {
for (size_t i = 0; i < N; i++) {
T entry;
if constexpr (sizeof(T) == 1) {
entry = progmem_read_byte(reinterpret_cast<const uint8_t *>(&arr[i]));
} else {
entry = arr[i]; // an aligned 32 bit read from flash is safe on ESP8266
}
if (entry == value)
if (arr[i] == value)
return i;
}
return {};
@@ -3,6 +3,7 @@ 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,
@@ -55,6 +56,30 @@ 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:
@@ -75,6 +100,10 @@ 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)
@@ -91,6 +120,14 @@ 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,6 +77,7 @@ struct MediaPlayerSupportedFormat {
uint32_t num_channels;
MediaPlayerFormatPurpose purpose;
uint32_t sample_bytes;
uint32_t bitrate;
};
// Base features always reported for all media players
+2 -2
View File
@@ -244,9 +244,9 @@ class MipiSpi : public display::Display,
char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)];
// Don't spam the log after setup
if (this->init_sequence_len_ == 0) {
esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_v(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
} else {
esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_d(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
}
if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
this->enable();
@@ -52,6 +52,7 @@ ST7796.extend(
height=480,
offset_width=49,
draw_rounding=1,
data_rate="40MHz",
cs_pin=39,
reset_pin=47,
dc_pin=9,
+21
View File
@@ -39,13 +39,16 @@ from esphome.const import (
CONF_REFERENCE_TEMPERATURE,
CONF_SEND_EVERY,
CONF_SEND_FIRST_AT,
CONF_SENSOR_ID,
CONF_STATE_CLASS,
CONF_TEMPERATURE,
CONF_TEMPERATURE_OFFSET,
CONF_TIMEOUT,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USE_FAHRENHEIT,
CONF_VALUE,
CONF_WEB_SERVER,
CONF_WINDOW_SIZE,
@@ -310,6 +313,7 @@ OrFilter = sensor_ns.class_("OrFilter", Filter)
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter)
ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter)
RHCorrectionFilter = sensor_ns.class_("RHCorrectionFilter", Filter)
CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter)
SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter)
ClampFilter = sensor_ns.class_("ClampFilter", Filter)
@@ -1281,6 +1285,23 @@ async def calibrate_ntc_temperature_filter_to_code(config, filter_id):
)
@FILTER_REGISTRY.register(
"rh_correction",
RHCorrectionFilter,
cv.Schema(
{
cv.Required(CONF_TEMPERATURE_OFFSET): cv.templatable(cv.float_),
cv.Required(CONF_SENSOR_ID): cv.use_id(Sensor),
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
}
),
)
async def rh_correction_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
sensor = await cg.get_variable(config[CONF_SENSOR_ID])
offset = await cg.templatable(config[CONF_TEMPERATURE_OFFSET], [], cg.float_)
return cg.new_Pvariable(filter_id, offset, sensor, config[CONF_USE_FAHRENHEIT])
def _mean(xs):
return sum(xs) / len(xs)
+42
View File
@@ -493,6 +493,48 @@ optional<float> ToNTCTemperatureFilter::new_value(float value) {
return temp;
}
// RHCorrectionFilter
static constexpr float MAGNUS_B = 17.67f, MAGNUS_C = 243.5f;
static bool rh_correction_temp_in_band(float t) { return t >= -40.0f && t <= 50.0f; }
optional<float> RHCorrectionFilter::new_value(float value) {
if (!this->temperature_sensor_->has_state()) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): no temperature yet, passing humidity through uncorrected", this);
this->warned_ = true;
}
return value;
}
float offset = this->temperature_offset_.value();
float tc = this->temperature_sensor_->get_state();
if (this->use_fahrenheit_) {
tc = fahrenheit_to_celsius(tc);
offset /= 1.8f;
}
const float tnc = tc - offset;
// A NaN temperature or offset is a faulted input: keep the last corrected value
if (std::isnan(tnc)) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): temperature or offset is NaN, dropping humidity updates", this);
this->warned_ = true;
}
return {};
}
if (!rh_correction_temp_in_band(tc) || !rh_correction_temp_in_band(tnc)) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): Temperature out of range -40..50 °C. Tc=%f Tnc=%f", this, tc, tnc);
this->warned_ = true;
}
return value;
}
this->warned_ = false;
const float rh =
clamp(value * std::exp(MAGNUS_B * MAGNUS_C * (tnc - tc) / ((tnc + MAGNUS_C) * (tc + MAGNUS_C))), 0.0f, 100.0f);
ESP_LOGVV(TAG, "RHCorrectionFilter(%p)::new_value(%f) -> %f", this, value, rh);
return rh;
}
// StreamingFilter (base class)
StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at)
: window_size_(window_size), send_first_at_(send_first_at) {}
+20
View File
@@ -647,6 +647,26 @@ class ToNTCTemperatureFilter : public Filter {
double c_;
};
/** RH Temperature correction filter
*
* Allows for Relative Humidity Correction when a temperature offset is applied to the temperature sensor.
* Uses the Magnus formula, rh = rh_nc * exp(b*(t_nc/(c+t_nc) - t/(c+t))) with b = 17.67, c = 243.5 °C;
* see https://en.wikipedia.org/wiki/Dew_point
*/
class RHCorrectionFilter : public Filter {
public:
RHCorrectionFilter(TemplatableFn<float> offset, const Sensor *temperature_sensor, bool use_fahrenheit)
: temperature_offset_(offset), temperature_sensor_(temperature_sensor), use_fahrenheit_(use_fahrenheit) {}
optional<float> new_value(float value) override;
protected:
TemplatableFn<float> temperature_offset_;
const Sensor *temperature_sensor_;
bool use_fahrenheit_;
bool warned_{false};
};
/** Base class for streaming filters (batch windows where window_size == send_every).
*
* When window_size equals send_every, we don't need a sliding window.
@@ -13,6 +13,7 @@ from esphome.components import (
)
from esphome.components.const import (
CONF_ANNOUNCEMENT_VOLUME,
CONF_BITRATE,
CONF_VOLUME_INCREMENT,
CONF_VOLUME_INITIAL,
CONF_VOLUME_MAX,
@@ -107,6 +108,7 @@ 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,6 +3,7 @@ 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,
@@ -78,6 +79,7 @@ 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
@@ -0,0 +1,138 @@
"""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
@@ -0,0 +1,702 @@
#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
@@ -0,0 +1,162 @@
#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
+13 -13
View File
@@ -1043,7 +1043,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa
if (is_on) {
parse_cstr_param_(
request, ESPHOME_F("effect"), call,
static_cast<light::LightCall &(light::LightCall::*) (const char *, size_t)>(&decltype(call)::set_effect));
static_cast<light::LightCall &(light::LightCall::*)(const char *, size_t)>(&decltype(call)::set_effect));
}
DEFER_ACTION(call, call.perform());
@@ -1404,7 +1404,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("value"), call,
static_cast<text::TextCall &(text::TextCall::*) (const char *, size_t)>(&decltype(call)::set_value));
static_cast<text::TextCall &(text::TextCall::*)(const char *, size_t)>(&decltype(call)::set_value));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1464,7 +1464,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("option"), call,
static_cast<select::SelectCall &(select::SelectCall::*) (const char *, size_t)>(&decltype(call)::set_option));
static_cast<select::SelectCall &(select::SelectCall::*)(const char *, size_t)>(&decltype(call)::set_option));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1522,26 +1522,26 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url
// Parse string mode parameters
parse_cstr_param_(
request, ESPHOME_F("mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(&decltype(call)::set_mode));
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(&decltype(call)::set_mode));
parse_cstr_param_(request, ESPHOME_F("fan_mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_fan_mode));
parse_cstr_param_(request, ESPHOME_F("swing_mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_swing_mode));
parse_cstr_param_(request, ESPHOME_F("preset"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_preset));
// Parse temperature parameters
// static_cast needed to disambiguate overloaded setters (float vs optional<float>)
using ClimateCall = decltype(call);
parse_num_param_(request, ESPHOME_F("target_temperature_high"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_high));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature_high));
parse_num_param_(request, ESPHOME_F("target_temperature_low"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_low));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature_low));
parse_num_param_(request, ESPHOME_F("target_temperature"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1818,8 +1818,8 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("code"), call,
static_cast<alarm_control_panel::AlarmControlPanelCall &(
alarm_control_panel::AlarmControlPanelCall::*) (const char *, size_t)>(&decltype(call)::set_code));
static_cast<alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*)(
const char *, size_t)>(&decltype(call)::set_code));
static constexpr ActionEntry<alarm_control_panel::AlarmControlPanelCall> ACTIONS[] PROGMEM = {
{"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm},
@@ -1893,7 +1893,7 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons
// Parse mode parameter
parse_cstr_param_(
request, ESPHOME_F("mode"), base_call,
static_cast<water_heater::WaterHeaterCall &(water_heater::WaterHeaterCall::*) (const char *, size_t)>(
static_cast<water_heater::WaterHeaterCall &(water_heater::WaterHeaterCall::*)(const char *, size_t)>(
&water_heater::WaterHeaterCall::set_mode));
// Parse temperature parameters
+43 -9
View File
@@ -39,6 +39,7 @@ from esphome.const import (
CONF_ENABLE_BTM,
CONF_ENABLE_ON_BOOT,
CONF_ENABLE_RRM,
CONF_ETHERNET,
CONF_FAST_CONNECT,
CONF_GATEWAY,
CONF_HIDDEN,
@@ -431,8 +432,26 @@ 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 = bool(config.get(CONF_NETWORKS, True))
has_sta = wifi_has_sta(config)
has_ap = CONF_AP in config
has_dpp = CONF_DPP in config
full_config = fv.full_config.get()
@@ -447,9 +466,11 @@ def final_validate(config):
ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP
if ap_is_wisp:
if not has_sta:
# The clients are routed through the station, or through ethernet
if not has_sta and CONF_ETHERNET not in full_config:
raise cv.Invalid(
"WISP mode requires at least one STA network (wifi.ssid or wifi.networks)"
"WISP mode needs a station network (wifi.ssid or wifi.networks), "
"or ethernet as the upstream interface"
)
if has_captive_portal:
raise cv.Invalid(
@@ -458,14 +479,27 @@ 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 station's resolvers on, which a static address has to name
for network in [config, *config.get(CONF_NETWORKS, [])]:
# 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:
static = network.get(CONF_MANUAL_IP)
if static and static[CONF_DNS1].is_unspecified:
if not static or not static[CONF_DNS1].is_unspecified:
continue
if path is None:
raise cv.Invalid(
"manual_ip needs dns1 in WISP mode unless ap.dns_servers is set",
path=[CONF_MANUAL_IP, CONF_DNS1],
"ethernet manual_ip needs dns1 in WISP mode unless ap.dns_servers is set"
)
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(
@@ -565,7 +599,7 @@ def _report_provisioning_credentials(config):
"""
from esphome.components import provisioning
if config.get(CONF_NETWORKS):
if wifi_has_sta(config):
provisioning.report_hardcoded_credentials("wifi")
elif CONF_AP in config:
# An access point with no station credentials: the AP shuts down when the
+20 -16
View File
@@ -783,12 +783,9 @@ void WiFiComponent::start() {
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
this->start_initial_connection_();
#endif
#ifdef USE_WIFI_AP
} else if (this->has_ap()) {
#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
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) {
@@ -797,7 +794,7 @@ void WiFiComponent::start() {
}
#endif
this->start_ap_portal_();
#endif // USE_WIFI_AP
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
}
#ifdef USE_WIFI_DPP
// Without networks there is nothing to try first, so listen for a configurator right away
@@ -937,15 +934,7 @@ void WiFiComponent::loop() {
return;
}
#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)
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
// 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.
@@ -975,7 +964,7 @@ void WiFiComponent::loop() {
this->start_ap_portal_();
}
}
#endif // USE_WIFI_AP
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
#ifdef USE_IMPROV_BLE
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
@@ -1013,6 +1002,16 @@ 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
@@ -1098,6 +1097,11 @@ 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,6 +127,10 @@ 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
@@ -1123,6 +1127,10 @@ 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));
@@ -1397,10 +1405,6 @@ 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");
@@ -1478,8 +1482,8 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
#endif
err = esp_netif_dhcps_start(s_ap_netif);
if (err != ESP_OK) {
// 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) {
ESP_LOGE(TAG, "esp_netif_dhcps_start failed: %d", err);
return false;
}
@@ -1488,6 +1492,17 @@ 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;
@@ -1524,19 +1539,6 @@ 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;
}
@@ -1681,7 +1683,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 station has a resolver, and its setup writes them
return; // loop() brings the bridge up once the upstream 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;
@@ -5,7 +5,7 @@ namespace esphome::xiaomi_hhccpot002 {
ESPHOME_LOG_TAG(TAG, "xiaomi_hhccpot002");
void XiaomiHHCCPOT002 ::dump_config() {
void XiaomiHHCCPOT002::dump_config() {
ESP_LOGCONFIG(TAG, "Xiaomi HHCCPOT002");
LOG_SENSOR(" ", "Moisture", this->moisture_);
LOG_SENSOR(" ", "Conductivity", this->conductivity_);
+18 -11
View File
@@ -658,6 +658,8 @@ class EsphomeCore:
self.main_statements: list[Statement] = []
# A list of statements to insert in the global block (includes and global variables)
self.global_statements: list[Statement] = []
# Generated code emitted after the user includes, so it can use what they declare
self.global_statements_after_includes: list[Statement] = []
# A map of platformio libraries to add to the project (shortname: (name, version, repository))
self.platformio_libraries: dict[str, Library] = {}
# A set of build flags to set in the platformio project
@@ -728,6 +730,7 @@ class EsphomeCore:
self.variables = {}
self.main_statements = []
self.global_statements = []
self.global_statements_after_includes = []
self.platformio_libraries = {}
self.build_flags = set()
self.cmake_args = {}
@@ -1045,8 +1048,8 @@ class EsphomeCore:
except RuntimeError as e:
raise EsphomeError(str(e)) from e
def add(self, expression, prepend=False) -> "Statement":
"""Add an expression or statement to the main setup() block."""
@staticmethod
def _as_statement(expression) -> "Statement":
from esphome.cpp_generator import Expression, Statement, statement
if isinstance(expression, Expression):
@@ -1055,7 +1058,11 @@ class EsphomeCore:
raise ValueError(
f"Add '{expression}' must be expression or statement, not {type(expression)}"
)
return expression
def add(self, expression, prepend=False) -> "Statement":
"""Add an expression or statement to the main setup() block."""
expression = self._as_statement(expression)
if prepend:
self.main_statements.insert(0, expression)
else:
@@ -1064,14 +1071,7 @@ class EsphomeCore:
return expression
def add_global(self, expression, prepend=False) -> "Statement":
from esphome.cpp_generator import Expression, Statement, statement
if isinstance(expression, Expression):
expression = statement(expression)
if not isinstance(expression, Statement):
raise ValueError(
f"Add '{expression}' must be expression or statement, not {type(expression)}"
)
expression = self._as_statement(expression)
if prepend:
self.global_statements.insert(0, expression)
else:
@@ -1079,6 +1079,13 @@ class EsphomeCore:
_LOGGER.debug("Adding global: %s", expression)
return expression
def add_global_after_includes(self, expression) -> "Statement":
"""Add a global after the user includes, for generated code that may use them."""
expression = self._as_statement(expression)
self.global_statements_after_includes.append(expression)
_LOGGER.debug("Adding global after includes: %s", expression)
return expression
def add_library(self, library: Library):
if not isinstance(library, Library):
raise TypeError(
@@ -1258,7 +1265,7 @@ class EsphomeCore:
from esphome.cpp_generator import statement
global_code = []
for exp in self.global_statements:
for exp in (*self.global_statements, *self.global_statements_after_includes):
text = str(statement(exp))
text = text.rstrip()
global_code.append(text)
+2 -1
View File
@@ -590,7 +590,8 @@ async def add_arduino_global_workaround():
@coroutine_with_priority(CoroPriority.FINAL)
async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
# Add includes at the very end, so that the included files can access global variables
# 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():
+1
View File
@@ -351,6 +351,7 @@
#define USE_API_USER_DEFINED_ACTIONS
#define USE_API_CUSTOM_SERVICES
#define USE_API_USER_DEFINED_ACTION_METADATA
#define USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
#define USE_API_USER_DEFINED_ACTION_RESPONSES
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#define API_MAX_SEND_QUEUE 8
+4 -2
View File
@@ -59,13 +59,15 @@ template<typename T> class PlainAtomic {
constexpr PlainAtomic(T value) : value_(value) {}
T load(std::memory_order order = std::memory_order_seq_cst) const {
T value = value_;
if (order != std::memory_order_relaxed)
if (order != std::memory_order_relaxed) {
ESPHOME_LFQ_COMPILER_BARRIER(); // acquire: later reads may not hoist above this load
}
return value;
}
void store(T value, std::memory_order order = std::memory_order_seq_cst) {
if (order != std::memory_order_relaxed)
if (order != std::memory_order_relaxed) {
ESPHOME_LFQ_COMPILER_BARRIER(); // release: earlier writes may not sink below this store
}
value_ = value;
}
T fetch_add(T amount, std::memory_order /*order*/ = std::memory_order_seq_cst) {
+20 -10
View File
@@ -536,8 +536,10 @@ def shared_progmem_array(
used |= {str(i) for i in CORE.variables}
used |= set(RESERVED_IDS) | CORE.loaded_integrations
id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_)
# Global, so any scope can use it; anything a lambda references is already declared.
CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs, constexpr))
# After the user includes, so a lambda in the table can use what their headers declare.
CORE.add_global_after_includes(
ProgmemAssignmentExpression(type_, id_, rhs, constexpr)
)
array = MockObj(id_, ".")
CORE.register_variable(id_, array)
if share:
@@ -789,15 +791,16 @@ def static_function(
"""Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an
expression naming it, for use as a template argument or a function pointer.
Every id the body names must already be declared, which holds when the statements were
rendered through ``get_variable`` or ``process_lambda``.
The declaration goes with the other globals, so a template argument can name the function
at once; the body goes after the user includes, where every id and everything those
headers declare is in scope.
"""
params = ParameterListExpression(*parameters)
add_global(
RawStatement(
f"static {safe_exp(return_type)} {name}({params}) {{\n"
f"{indent(chr(10).join(body))}\n}}"
)
signature = (
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
add_global(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}")
)
return RawExpression(name)
@@ -816,6 +819,13 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False):
CORE.add_global(expression, prepend)
def add_global_after_includes(expression: SafeExpType | Statement):
"""Add a global after the user's ``includes:`` headers, for generated code whose
lambdas may use what those headers declare.
"""
CORE.add_global_after_includes(expression)
def add_library(name: str, version: str | None, repository: str | None = None):
"""Add a library to the codegen library storage.
+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.18.0
aioesphomeapi==46.21.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
+2 -2
View File
@@ -1,4 +1,4 @@
# Useful stuff when working in a development environment
clang-format==22.1.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==22.1.8
clang-format==23.1.3 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==23.1.3
yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
+8 -14
View File
@@ -319,21 +319,15 @@ def run_tidy(
disabled_checks = []
if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")):
# MMIO peripheral access on these bare-metal platforms is all
# fixed-address.
disabled_checks.append("-clang-analyzer-core.FixedAddressDereference")
# fixed-address. clang-tidy 23 moved the checker under optin.
disabled_checks += [
"-clang-analyzer-core.FixedAddressDereference",
"-clang-analyzer-optin.core.FixedAddressDereference",
]
if args.environment.startswith("host"):
# glibc's assert() expands to a call the check mistakes for a
# constant condition.
disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"]
if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")):
# bugprone-pointer-arithmetic-on-polymorphic-object (and its
# cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher
# recursion on lvgl_esphome.h under the RP2 defines and on the
# all-include file under the host defines.
disabled_checks += [
"-bugprone-pointer-arithmetic-on-polymorphic-object",
"-cert-ctr56-cpp",
]
if os.path.normpath(path) in ANALYZER_SKIPPED_FILES:
disabled_checks.append("-clang-analyzer-*")
if disabled_checks:
@@ -523,7 +517,7 @@ def main():
failed_files = []
timings = []
try:
executable = get_binary("clang-tidy", 22)
executable = get_binary("clang-tidy", 23)
task_queue = queue.Queue(args.jobs)
lock = threading.Lock()
for _ in range(args.jobs):
@@ -571,13 +565,13 @@ def main():
try:
try:
subprocess.call(
["clang-apply-replacements-22", tmpdir], close_fds=False
["clang-apply-replacements-23", tmpdir], close_fds=False
)
except FileNotFoundError:
subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False)
except FileNotFoundError:
print(
"Error please install clang-apply-replacements-22 or clang-apply-replacements.\n",
"Error please install clang-apply-replacements-23 or clang-apply-replacements.\n",
file=sys.stderr,
)
except:
@@ -112,7 +112,7 @@ def test_empty_metadata_is_unset() -> None:
"description": "",
"variables": {"b": {"type": "int", "description": "", "example": "ex"}},
}
strings = _action_strings(conf, has_metadata=True)
strings = _action_strings(conf, has_metadata=True, has_optional=False)
assert strings == ["a", None, "b", None, "ex"]
@@ -0,0 +1,158 @@
"""Tests for optional user-defined action variables (required/default)."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.api import (
_action_strings,
_has_optional_args,
validate_variable,
)
from esphome.config_validation import Invalid
from esphome.core import CORE
CONFIG = "tests/component_tests/api/test_action_optional_args.yaml"
CONFIG_ESP8266 = "tests/component_tests/api/test_action_optional_args_esp8266.yaml"
CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml"
def test_defaults_are_emitted_in_the_string_table(
generate_main: Callable[[str | Path], str],
) -> None:
"""Each argument gains a trailing default slot: "" marks optional without a default."""
main_cpp = generate_main(CONFIG)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = {"
'"play_buzzer", "song_str", "", "notes", "", "octave", "5", '
'"bpm", "100", "loud", "true"};' in main_cpp
)
# An all-required action still carries the default slots (as nullptr)
assert (
"static constexpr const char * api_action1_strings[] PROGMEM = {"
'"plain_action", "value", nullptr};' in main_cpp
)
assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" in {d.name for d in CORE.defines}
def test_esp8266_defaults_are_progmem_arrays(
generate_main: Callable[[str | Path], str],
) -> None:
"""On ESP8266 each default is its own PROGMEM array, referenced from the table."""
main_cpp = generate_main(CONFIG_ESP8266)
def array_for(text: str) -> str:
match = re.search(
rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp
)
assert match, text
return match.group(1)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = {"
f"{array_for('play_buzzer')}, {array_for('song_str')}, {array_for('')}, "
f"{array_for('notes')}, {array_for('')}, {array_for('octave')}, {array_for('5')}, "
f"{array_for('bpm')}, {array_for('100')}, {array_for('loud')}, "
f"{array_for('true')}}};" in main_cpp
)
def test_required_variables_emit_no_define(
generate_main: Callable[[str | Path], str],
) -> None:
generate_main(CONFIG_SHORTHAND)
assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" not in {
d.name for d in CORE.defines
}
def test_action_strings_orders_default_after_metadata() -> None:
conf = {
"action": "a",
"description": "d",
"variables": {
"b": {"type": "int", "example": "1", "default": 5},
"c": {"type": "bool", "default": False},
},
}
assert _action_strings(conf, has_metadata=True, has_optional=True) == [
"a",
"d",
"b",
None,
"1",
"5",
"c",
None,
None,
"false",
]
@pytest.mark.parametrize(
("var", "expected"),
[
({"type": "int", "required": False}, ""),
({"type": "string", "default": ""}, ""),
({"type": "float", "default": 5}, "5.0"),
],
)
def test_default_slot_marks_optional_and_keeps_the_type(
var: dict, expected: str
) -> None:
actions = [{"action": "a", "variables": {"b": validate_variable(var)}}]
assert _has_optional_args(actions)
assert _action_strings(actions[0], has_metadata=False, has_optional=True) == [
"a",
"b",
expected,
]
@pytest.mark.parametrize(
("var", "expected"),
[
({"type": "int", "default": "5"}, 5),
({"type": "int", "default": -(2**31)}, -(2**31)),
({"type": "int", "default": 2**31 - 1}, 2**31 - 1),
({"type": "bool", "default": "true"}, True),
({"type": "float", "default": "1.5"}, 1.5),
({"type": "float", "default": 3.4028235e38}, 3.4028235e38),
({"type": "string", "default": "0123"}, "0123"),
],
)
def test_default_is_validated_against_type(var: dict, expected: object) -> None:
assert validate_variable(var)["default"] == expected
@pytest.mark.parametrize(
"var",
[
{"type": "int[]", "required": False},
{"type": "string[]", "default": "x"},
{"type": "int", "default": 5, "required": True},
{"type": "int", "default": "not_a_number"},
# ExecuteServiceArgument.int_ is a signed 32-bit value
{"type": "int", "default": 2**31},
{"type": "int", "default": -(2**31) - 1},
# A boolean is an int in Python but "true" is not a number on the wire
{"type": "int", "default": True},
{"type": "float", "default": False},
# ExecuteServiceArgument.float_ is a 32-bit float
{"type": "float", "default": 1e40},
{"type": "float", "default": "nan"},
{"type": "float", "default": "inf"},
# A string default is what the user typed, and a C string in flash
{"type": "string", "default": 123},
{"type": "string", "default": "a\0b"},
{"type": "string", "default": "\0a"},
{"type": "float", "default": -1e40},
],
)
def test_variable_rejects_invalid_optionality(var: dict) -> None:
with pytest.raises(Invalid):
validate_variable(var)
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_optional_args_common.yaml
@@ -0,0 +1,26 @@
api:
actions:
- action: play_buzzer
variables:
song_str:
type: string
required: false
notes:
type: string
required: false
octave:
type: int
default: 5
bpm:
type: int
default: 100
loud:
type: bool
default: true
then:
- logger.log: Action Called
- action: plain_action
variables:
value: int
then:
- logger.log: Action Called
@@ -0,0 +1,14 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_optional_args_common.yaml
@@ -621,6 +621,69 @@ def test_uc8279_width_must_be_a_multiple_of_8(
)
GDEY029Z95 = "goodisplay-gdey029z95-2.9"
def _gdey029z95_config(**overrides: Any) -> ConfigType:
config: ConfigType = {
"id": "test_display",
"model": GDEY029Z95,
CONF_DC_PIN: 21,
CONF_RESET_PIN: 22,
CONF_BUSY_PIN: 23,
}
config.update(overrides)
return config
def test_gdey029z95_uses_the_good_display_driver() -> None:
"""The GDEY029Z95 uses EPaperGoodDisplay3C with the WeAct init sequence."""
model = MODELS[GDEY029Z95.upper()]
assert model.class_name == "EPaperGoodDisplay3C"
assert model.get_init_sequence({}) == (
(0x01, 0x27, 0x01, 0x00),
(0x11, 0x03),
(0x3C, 0x05),
(0x18, 0x80),
(0x21, 0x00, 0x80),
)
@pytest.mark.parametrize("pin", [CONF_RESET_PIN, CONF_BUSY_PIN])
def test_gdey029z95_requires_reset_and_busy_pins(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
pin: str,
) -> None:
"""Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = _gdey029z95_config()
del config[pin]
with pytest.raises(cv.Invalid, match=pin):
run_schema_validation(config)
def test_gdey029z95_has_no_partial_update(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
run_schema_validation(_gdey029z95_config())
with pytest.raises(cv.Invalid, match="does not support partial update"):
run_schema_validation(_gdey029z95_config(full_update_every=2))
def test_uc8279_x4pro_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
+35 -18
View File
@@ -11,9 +11,13 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate
from esphome.components.ethernet import (
CONF_CLOCK_SPEED,
CONFIG_SCHEMA,
_final_validate_wifi,
)
from esphome.components.network import _validate_priority_list
from esphome.const import CONF_PRIORITY, PlatformFramework
from esphome.const import CONF_AP, CONF_NETWORKS, CONF_PRIORITY, PlatformFramework
from esphome.core import CORE
import esphome.final_validate as fv
@@ -28,24 +32,37 @@ _CH390_CONFIG = {
}
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({})
_WIFI_STA_CONFIG = {CONF_NETWORKS: [{"ssid": "MySSID", "password": "password1"}]}
_WIFI_AP_CONFIG = {CONF_AP: {"ssid": "espap", "password": "mypassword"}}
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(
("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({})
@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"])
@@ -0,0 +1,24 @@
esphome:
name: test
esp32:
board: esp32dev
sensor:
- platform: template
id: temperature
filters:
- offset: -7.3
- platform: template
id: constant_offset
filters:
- rh_correction:
sensor_id: temperature
temperature_offset: -7.3
- platform: template
id: templated_offset
filters:
- rh_correction:
sensor_id: temperature
temperature_offset: !lambda return -7.3;
use_fahrenheit: true
@@ -0,0 +1,60 @@
"""The rh_correction filter: schema and the constructor call it generates."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.sensor import validate_filters
import esphome.config_validation as cv
@pytest.mark.parametrize(
"filters",
[
pytest.param([{"rh_correction": {"sensor_id": "t"}}], id="no_offset"),
pytest.param([{"rh_correction": {"temperature_offset": -7.3}}], id="no_sensor"),
pytest.param(
[
{
"rh_correction": {
"sensor_id": "t",
"temperature_offset": -7.3,
"use_fahrenheit": "maybe",
}
}
],
id="bad_fahrenheit",
),
],
)
def test_rh_correction_rejects_incomplete_config(filters: list[dict]) -> None:
with pytest.raises(cv.Invalid):
validate_filters(filters)
def test_rh_correction_defaults_to_celsius() -> None:
config = validate_filters(
[{"rh_correction": {"sensor_id": "t", "temperature_offset": -7.3}}]
)
assert config[0]["rh_correction"]["use_fahrenheit"] is False
def test_rh_correction_codegen(
generate_main: Callable[[str | Path], str],
) -> None:
main_cpp = generate_main("tests/component_tests/sensor/rh_correction.yaml")
# A constant offset is wrapped in a stateless lambda so the filter stores a function pointer
assert re.search(
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{\s*return -7\.3f;\s*\}, "
r"temperature, false\);\s*constant_offset->add_filter\(",
main_cpp,
)
# A templated offset keeps the user's lambda body and passes use_fahrenheit through
assert re.search(
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{[^}]*return -7\.3;[^}]*\}, "
r"temperature, true\);\s*templated_offset->add_filter\(",
main_cpp,
)
@@ -1,14 +1,22 @@
"""Tests for the speaker_source media player announcement volume validation."""
"""Tests for the speaker_source media player pipeline validation."""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.const import CONF_ANNOUNCEMENT_VOLUME
from esphome.components import audio
from esphome.components.const import CONF_ANNOUNCEMENT_VOLUME, CONF_BITRATE
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:
@@ -29,3 +37,49 @@ 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
@@ -0,0 +1,406 @@
"""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,6 +78,7 @@ api:
type: string
description: Name to greet
example: World
default: World
then:
- logger.log:
format: Hello World %s!
@@ -0,0 +1,164 @@
#include <gtest/gtest.h>
#include <utility>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_goodisplay_3c.h"
namespace esphome::epaper_spi::testing {
namespace {
class TestableGoodDisplay3C : public EPaperGoodDisplay3C {
public:
TestableGoodDisplay3C(uint16_t width, uint16_t height) : EPaperGoodDisplay3C("test", width, height, nullptr, 0) {}
void install(spi::SPIDelegate *delegate) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
}
void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); }
/// Both planes of one push; returns how many calls it took.
int run_push() {
int calls = 1;
while (!this->transfer_data())
calls++;
return calls;
}
/// A whole update with the frame already drawn, run through loop() as the main loop would.
void run_update() {
this->set_auto_clear(false);
this->state_ = EPaperState::UPDATE;
const uint32_t deadline = millis() + 2000;
while (this->state_ != EPaperState::IDLE) {
ASSERT_LT(millis(), deadline) << "update did not finish";
this->loop();
}
}
using EPaperGoodDisplay3C::REFRESH_START_MS;
RecordingPin dc;
};
/// Also notes when each command byte was sent.
class TimedCommandDelegate : public RecordingDelegate {
public:
using RecordingDelegate::RecordingDelegate;
void write_array(const uint8_t *ptr, size_t length) override {
if (!this->dc_->level) {
for (size_t i = 0; i != length; i++)
this->sent.emplace_back(ptr[i], millis());
}
RecordingDelegate::write_array(ptr, length);
}
/// When the n-th (from 0) copy of a command was sent.
uint32_t time_of(uint8_t command, int n = 0) const {
for (const auto &[byte, time] : this->sent) {
if (byte == command && n-- == 0)
return time;
}
ADD_FAILURE() << "command " << int(command) << " not sent";
return 0;
}
std::vector<std::pair<uint8_t, uint32_t>> sent;
};
using Bytes = std::vector<uint8_t>;
const Color RED(255, 0, 0);
/// One row of 16 pixels: white, black, red, then white. The red plane goes first as 1=red, then the black/white
/// plane as 1=white with red pixels sent as 0.
TEST(EPaperGoodDisplay3CTransfer, PixelsReachThePanel) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(Color::WHITE);
display.draw(1, 0, Color::BLACK);
display.draw(2, 0, RED);
EXPECT_EQ(display.run_push(), 1);
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24}));
EXPECT_EQ(bus.data[0x44], (Bytes{0x00, 0x01}));
EXPECT_EQ(bus.data[0x45], (Bytes{0x00, 0x00, 0x00, 0x00}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x9F, 0xFF}));
EXPECT_EQ(bus.data[0x26], (Bytes{0x20, 0x00}));
}
/// A push that yields to the main loop after every chunk sends the same commands and bytes as one that runs to
/// completion in one call, and a second push starts again from the window setup.
TEST(EPaperGoodDisplay3CTransfer, ResumedPushMatches) {
// 128x20 pixels is 320 bytes per plane, so each plane takes three chunks of up to MAX_TRANSFER_SIZE.
constexpr int width = 128;
constexpr int height = 20;
const Color colors[] = {Color::WHITE, Color::BLACK, RED};
TestableGoodDisplay3C reference(width, height);
RecordingDelegate reference_bus(&reference.dc);
reference.install(&reference_bus);
TestableGoodDisplay3C display(width, height);
// Every data write takes longer than MAX_TRANSFER_TIME, so the push yields after each chunk.
RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1);
display.install(&bus);
for (int y = 0; y != height; y++) {
for (int x = 0; x != width; x++) {
reference.draw(x, y, colors[(x * 7 + y * 3) % 3]);
display.draw(x, y, colors[(x * 7 + y * 3) % 3]);
}
}
EXPECT_EQ(reference.run_push(), 1);
EXPECT_GT(display.run_push(), 2);
EXPECT_EQ(bus.commands, reference_bus.commands);
EXPECT_EQ(bus.data[0x24].size(), 320u);
EXPECT_EQ(bus.data[0x24], reference_bus.data[0x24]);
EXPECT_EQ(bus.data[0x26], reference_bus.data[0x26]);
bus.clear();
display.run_push();
EXPECT_EQ(bus.commands, reference_bus.commands);
}
/// A whole update sends no power-on (0x22 0xF8) before the refresh (0x22 0xF7): with it, this panel does not
/// refresh. Power off (0x83) and deep sleep follow.
TEST(EPaperGoodDisplay3CTransfer, UpdateHasNoSeparatePowerOn) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24, 0x4E, 0x4F, 0x22, 0x20, 0x22, 0x20, 0x10}));
EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00}));
EXPECT_EQ(bus.data[0x22], (Bytes{0xF7, 0x83}));
EXPECT_EQ(bus.data[0x10], (Bytes{0x01}));
}
/// Busy only goes high a little after master activation (0x20), so power off is not sent until REFRESH_START_MS
/// after the refresh starts.
TEST(EPaperGoodDisplay3CTransfer, WaitsForBusyAfterTheRefreshStarts) {
TestableGoodDisplay3C display(16, 1);
TimedCommandDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
// The first 0x20 starts the refresh; the second 0x22 selects power off.
EXPECT_GE(bus.time_of(0x22, 1) - bus.time_of(0x20, 0), TestableGoodDisplay3C::REFRESH_START_MS);
}
} // namespace
} // namespace esphome::epaper_spi::testing
@@ -209,6 +209,27 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
model: goodisplay-gdey029z95-2.9
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# WeAct 4.2" 3-color e-paper (400x300, SSD1683)
- platform: epaper_spi
spi_id: spi_bus
@@ -0,0 +1,15 @@
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
+15
View File
@@ -57,6 +57,21 @@ button:
url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.bin");'
- logger.log: "This message should be not displayed (reboot)"
# Device firmware switches the manifest from lambdas; every shape below must compile and
# the stored URL must outlive the lambda (EverythingSmartHome/everything-presence-one#341).
- platform: template
name: Switch manifest
on_press:
then:
- lambda: |-
id(ota_update).set_source_url("http://my.ha.net:8123/local/esphome/beta-manifest.json");
std::string url = "http://my.ha.net:8123/local/esphome/";
url += "manifest.json";
id(ota_update).set_source_url(url);
id(ota_update).set_source_url(url.c_str());
id(ota_update).set_source_url(std::string("http://my.ha.net:8123/local/esphome/manifest.json"));
ESP_LOGI("test", "Manifest is now %s", id(ota_update).get_source_url());
- component.update: ota_update
update:
- platform: http_request
@@ -0,0 +1,8 @@
import esphome.config_validation as cv
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The real schema rejects the host target by framework version; the update platform's
# unit tests only need this platform's sources on the host.
manifest.config_schema = cv.Schema({}, extra=cv.ALLOW_EXTRA)
@@ -0,0 +1,141 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/components/http_request/update/http_request_update.h"
#if defined(__SANITIZE_ADDRESS__)
#define HAS_ASAN
#elif defined(__has_feature)
#if __has_feature(address_sanitizer)
#define HAS_ASAN
#endif
#endif
#ifdef HAS_ASAN
#include <sanitizer/asan_interface.h>
#endif
namespace esphome::http_request::testing {
namespace {
constexpr char LITERAL[] = "http://example.invalid/manifest.json";
// Freed heap memory is poisoned under AddressSanitizer, which CI builds with; without it the
// freed-exactly-once checks cannot be observed and are skipped.
void expect_freed(const char *buffer) {
#ifdef HAS_ASAN
EXPECT_TRUE(__asan_address_is_poisoned(buffer));
#endif
}
void expect_live(const char *buffer, const char *content) {
#ifdef HAS_ASAN
EXPECT_FALSE(__asan_address_is_poisoned(buffer));
#endif
EXPECT_STREQ(buffer, content);
}
} // namespace
// Friend of HttpRequestUpdate, so a check can be begun and ended without an HTTP fetch.
class HttpRequestUpdateTest : public ::testing::Test {
protected:
static void begin_check(HttpRequestUpdate &update, TaskResult &result) { update.begin_check_(&result); }
static void end_check(HttpRequestUpdate &update, TaskResult &result) { update.end_check_(&result); }
};
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralIsKeptByPointer) {
HttpRequestUpdate update;
update.set_source_url_static(LITERAL);
EXPECT_EQ(update.get_source_url(), LITERAL);
}
TEST(HttpRequestUpdateSourceUrl, RuntimeStringIsCopied) {
HttpRequestUpdate update;
std::string url = "http://example.invalid/from-string-manifest.json";
update.set_source_url(url);
EXPECT_NE(update.get_source_url(), url.c_str());
EXPECT_STREQ(update.get_source_url(), url.c_str());
}
// Device firmware passes base_url.c_str() of a local and runs the update after the lambda returned.
TEST(HttpRequestUpdateSourceUrl, PointerIntoDeadLocalStaysReadable) {
HttpRequestUpdate update;
{
std::string local = "http://example.invalid/from-local-manifest.json";
update.set_source_url(local.c_str());
}
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/from-local-manifest.json");
}
TEST(HttpRequestUpdateSourceUrl, PassingTheCurrentUrlBackIsSafe) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/current-manifest.json");
const char *previous = update.get_source_url();
update.set_source_url(update.get_source_url());
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/current-manifest.json");
expect_freed(previous);
}
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralAfterRuntimeUrlDropsTheCopy) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/runtime-manifest.json");
const char *copy = update.get_source_url();
update.set_source_url_static(LITERAL);
EXPECT_EQ(update.get_source_url(), LITERAL);
expect_freed(copy);
}
// The URL a check started with stays readable until the check ends, however often it is replaced in
// the meantime; the replacements it never saw are freed at once and the original exactly once.
TEST_F(HttpRequestUpdateTest, UrlReplacedDuringACheckOutlivesIt) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/first-manifest.json");
const char *first = update.get_source_url();
TaskResult result;
begin_check(update, result);
EXPECT_EQ(result.source_url, first);
update.set_source_url("http://example.invalid/second-manifest.json");
const char *second = update.get_source_url();
expect_live(first, "http://example.invalid/first-manifest.json");
update.set_source_url("http://example.invalid/third-manifest.json");
const char *third = update.get_source_url();
expect_live(first, "http://example.invalid/first-manifest.json");
expect_freed(second);
end_check(update, result);
expect_freed(first);
expect_live(third, "http://example.invalid/third-manifest.json");
// The entity owns the URL set during the check: switching away frees it.
update.set_source_url_static(LITERAL);
expect_freed(third);
}
TEST_F(HttpRequestUpdateTest, UrlUnchangedDuringACheckReturnsToTheEntity) {
HttpRequestUpdate update;
update.set_source_url("http://example.invalid/kept-manifest.json");
const char *kept = update.get_source_url();
TaskResult result;
begin_check(update, result);
end_check(update, result);
expect_live(kept, "http://example.invalid/kept-manifest.json");
// Ownership came back, so the next change frees it instead of leaking it.
update.set_source_url_static(LITERAL);
expect_freed(kept);
}
TEST_F(HttpRequestUpdateTest, ConfigLiteralDuringACheckIsNeverFreed) {
HttpRequestUpdate update;
update.set_source_url_static(LITERAL);
TaskResult result;
begin_check(update, result);
update.set_source_url("http://example.invalid/runtime-manifest.json");
end_check(update, result);
EXPECT_STREQ(LITERAL, "http://example.invalid/manifest.json");
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/runtime-manifest.json");
}
} // namespace esphome::http_request::testing
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# setup() registers the entity as an OTA state listener, which the real to_code
# requests through ota.request_ota_state_listeners().
async def to_code_testing(config):
cg.add_define("USE_OTA_STATE_LISTENER")
manifest.to_code = to_code_testing
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# filter.cpp only compiles with the define the real to_code emits for configured filters
cg.add_define("USE_SENSOR_FILTER")
manifest.to_code = to_code_testing
+18
View File
@@ -254,3 +254,21 @@ sensor:
lambda: return 42.0;
update_interval: 1s
state_class: "measurement_angle"
# Testing rh_correction filter
- platform: copy
source_id: source_sensor
name: "RH Correction Filter Fahrenheit"
filters:
- rh_correction:
sensor_id: source_sensor
temperature_offset: -10.0
use_fahrenheit: true
- platform: copy
source_id: source_sensor
name: "RH Correction Filter Template"
filters:
- rh_correction:
sensor_id: source_sensor
temperature_offset: !lambda return -10.0;
+66
View File
@@ -0,0 +1,66 @@
#include <gtest/gtest.h>
#include <cmath>
#include "esphome/components/sensor/filter.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::sensor::testing {
// 40 % read 7.3 °C warm at a true 25 °C, in single precision Magnus arithmetic
static constexpr float CORRECTED = 61.13f;
struct RhFixture {
Sensor temperature;
RHCorrectionFilter filter;
RhFixture(float (*offset)(), bool use_fahrenheit = false) : filter(offset, &this->temperature, use_fahrenheit) {}
};
TEST(RHCorrectionFilter, CorrectsForTheOffset) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
}
TEST(RHCorrectionFilter, PositiveOffsetLowersTheHumidity) {
RhFixture f([]() -> float { return 7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_LT(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
}
TEST(RHCorrectionFilter, FahrenheitReadingsGiveTheSameResult) {
// 77 °F is 25 °C and -13.14 °F is -7.3 °C of offset
RhFixture f([]() -> float { return -13.14f; }, true);
f.temperature.publish_state(77.0f);
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
}
TEST(RHCorrectionFilter, ClampsToOneHundredPercent) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(90.0f).value_or(NAN), 100.0f);
}
TEST(RHCorrectionFilter, PassesThroughWithoutAUsableTemperature) {
RhFixture f([]() -> float { return -7.3f; });
// No temperature reading yet
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
// Out of the -40..50 °C band
f.temperature.publish_state(60.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
// The band also covers the reconstructed enclosure temperature
f.temperature.publish_state(45.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
}
TEST(RHCorrectionFilter, DropsTheUpdateOnNaN) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(NAN);
EXPECT_FALSE(f.filter.new_value(40.0f).has_value());
RhFixture nan_offset([]() -> float { return NAN; });
nan_offset.temperature.publish_state(25.0f);
EXPECT_FALSE(nan_offset.filter.new_value(40.0f).has_value());
}
} // namespace esphome::sensor::testing
+2 -1
View File
@@ -35,7 +35,8 @@ media_player:
announcement_volume: 60%
announcement_pipeline:
speaker: announcement_mixer_speaker_id
format: FLAC
format: MP3
bitrate: 48
num_channels: 1
sources:
- announcement_audio_file_source
@@ -409,6 +409,10 @@ cover:
id: template_cover_actions
position: 0.0
tilt: 0.0
toggle_action:
- cover.template.publish:
id: template_cover_actions
current_operation: IDLE
stop_action:
- cover.template.publish:
id: template_cover_actions
+15
View File
@@ -0,0 +1,15 @@
# 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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,7 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
vnc: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,3 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
vnc: !include common.yaml
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,164 @@
#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
@@ -0,0 +1,586 @@
#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
@@ -0,0 +1,342 @@
#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
@@ -0,0 +1,385 @@
#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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,581 @@
#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
+4
View File
@@ -236,6 +236,10 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
external_components_path = str(FIXTURES_DIR / "external_components")
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
# Replace the user includes path placeholder if present
if "INCLUDES_PATH" in content:
content = content.replace("INCLUDES_PATH", str(FIXTURES_DIR / "includes"))
if shared_name is not None:
# _compile verifies the marked test compiles this content unmodified
request.node._shared_yaml_content = content
@@ -22,5 +22,18 @@ api:
value: int
then:
- logger.log: "Plain action called"
- action: optional_args_action
variables:
song_str:
type: string
required: false
octave:
type: int
default: 5
loud:
type: bool
default: true
then:
- logger.log: "Optional action called"
logger:
@@ -0,0 +1,26 @@
esphome:
name: api-cover-toggle-test
host:
api:
logger:
level: DEBUG
cover:
- platform: template
name: "Toggle Cover"
id: toggle_cover
toggle_action:
- cover.template.publish:
id: toggle_cover
position: 0.5
stop_action:
- cover.template.publish:
id: toggle_cover
position: 0.25
- platform: template
name: "Plain Cover"
id: plain_cover
stop_action:
- cover.template.publish:
id: plain_cover
position: 0.25
@@ -0,0 +1,6 @@
#pragma once
static constexpr int HEADER_CONSTANT = 42;
// Names the global by its id, so the header must come after the id declarations.
inline int header_reads_global() { return header_value->value() * 2; }
@@ -0,0 +1,27 @@
esphome:
name: includes-lambda-order
includes:
- INCLUDES_PATH/lambda_order.h
on_boot:
# An apply action value is rendered at file scope.
- globals.set:
id: header_value
value: !lambda return HEADER_CONSTANT;
host:
api:
logger:
globals:
- id: header_value
type: int
initial_value: "0"
sensor:
- platform: template
name: Header Value
update_interval: 100ms
lambda: return header_reads_global();
filters:
# A lambda in a flash table.
- filter_out: !lambda return HEADER_CONSTANT - 1;
@@ -0,0 +1,102 @@
esphome:
name: test-rh-correction
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Temperature C"
id: temperature_c
accuracy_decimals: 1
- platform: template
name: "Temperature F"
id: temperature_f
accuracy_decimals: 1
- platform: template
name: "Humidity Source"
id: humidity_source
accuracy_decimals: 1
- platform: copy
source_id: humidity_source
name: "RH Celsius"
id: rh_celsius
accuracy_decimals: 2
filters:
- rh_correction:
sensor_id: temperature_c
temperature_offset: -7.3
- platform: copy
source_id: humidity_source
name: "RH Fahrenheit"
id: rh_fahrenheit
accuracy_decimals: 2
filters:
- rh_correction:
sensor_id: temperature_f
temperature_offset: -13.14
use_fahrenheit: true
script:
- id: test_rh_correction
then:
# Both temperature sensors read 25 °C after a -7.3 °C (-13.14 °F) offset
- sensor.template.publish:
id: temperature_c
state: 25.0
- sensor.template.publish:
id: temperature_f
state: 77.0
- sensor.template.publish:
id: humidity_source
state: 40.0
- delay: 20ms
# Clamped to 100 %
- sensor.template.publish:
id: humidity_source
state: 90.0
- delay: 20ms
# Out of band: passes through unchanged
- sensor.template.publish:
id: temperature_c
state: 60.0
- sensor.template.publish:
id: temperature_f
state: 140.0
- sensor.template.publish:
id: humidity_source
state: 40.0
- delay: 20ms
# NaN temperature: the update is dropped
- sensor.template.publish:
id: temperature_c
state: !lambda "return NAN;"
- sensor.template.publish:
id: temperature_f
state: !lambda "return NAN;"
- sensor.template.publish:
id: humidity_source
state: 50.0
- delay: 20ms
- sensor.template.publish:
id: temperature_c
state: 25.0
- sensor.template.publish:
id: temperature_f
state: 77.0
- sensor.template.publish:
id: humidity_source
state: 40.0
button:
- platform: template
name: "Test RH Correction"
on_press:
- script.execute: test_rh_correction
@@ -1,68 +0,0 @@
esphome:
name: uart-mock-ld2412-selects
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Answers the distance resolution query with 0x01, the module's code for 0.5m
uart_mock:
id: mock_uart
baud_rate: 256000
on_tx:
- then:
- lambda: |-
// The length and command word arrive as one write of their own
static const std::vector<uint8_t> RESOLUTION_QUERY = {0x02, 0x00, 0x11, 0x00};
if (data != RESOLUTION_QUERY) {
return;
}
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
0xFD, 0xFC, 0xFB, 0xFA, // header
0x06, 0x00, // length 6
0x11, 0x01, // answer to command 0x0011
0x00, 0x00, // status: success
0x01, 0x00, // resolution code 0x01 = 0.5m
0x04, 0x03, 0x02, 0x01, // footer
});
ld2412:
id: ld2412_dev
uart_id: mock_uart
select:
- platform: ld2412
ld2412_id: ld2412_dev
distance_resolution:
name: "Distance Resolution"
baud_rate:
name: "Baud Rate"
# Lambdas reach the name taking setters directly, which the API cannot do with an unknown name
button:
- platform: template
name: "Bogus Names"
on_press:
- lambda: |-
id(ld2412_dev).set_distance_resolution("bogus");
id(ld2412_dev).set_baud_rate("bogus");
ESP_LOGI("test", "bogus names done");
- platform: template
name: "Named Values"
on_press:
- lambda: |-
id(ld2412_dev).set_distance_resolution("0.75m");
id(ld2412_dev).set_baud_rate("57600");
@@ -0,0 +1,40 @@
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"
+43 -5
View File
@@ -1,7 +1,7 @@
"""Minimal plaintext native-api client over a raw socket.
Reads only when told to, so tests control when the TCP pipe backs up toward
the device; payloads are skipped and only message types are counted.
the device; message types are counted and each frame is kept for inspection.
"""
from __future__ import annotations
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
from collections import Counter
import socket
import struct
from typing import Self
from aioesphomeapi import api_pb2
@@ -49,6 +50,36 @@ def decode_varint(buf: bytearray, pos: int) -> tuple[int, int] | None:
return None
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
def tag(field: int, wire_type: int) -> bytes:
return encode_varint((field << 3) | wire_type)
def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]:
"""Split a protobuf payload into field number -> raw values; a test reads fields the client lacks."""
fields: dict[int, list[int | bytes]] = {}
buf = bytearray(payload)
pos = 0
while pos < len(buf):
key, pos = decode_varint(buf, pos)
field, wire_type = key >> 3, key & 0x07
if wire_type == WIRE_VARINT:
value, pos = decode_varint(buf, pos)
elif wire_type == WIRE_LENGTH:
length, pos = decode_varint(buf, pos)
value = bytes(buf[pos : pos + length])
pos += length
elif wire_type == WIRE_FIXED32:
value = struct.unpack_from("<I", buf, pos)[0]
pos += 4
else:
raise AssertionError(f"unexpected wire type {wire_type} for field {field}")
fields.setdefault(field, []).append(value)
return fields
def encode_frame(msg_type: int, payload: bytes) -> bytes:
"""Encode one plaintext api frame: 0x00, payload length, message type."""
return b"\x00" + encode_varint(len(payload)) + encode_varint(msg_type) + payload
@@ -59,15 +90,17 @@ class FrameParser:
def __init__(self) -> None:
self._buf = bytearray()
self.frames: list[tuple[int, bytes]] = []
def feed(self, data: bytes) -> list[int]:
self._buf.extend(data)
types: list[int] = []
while (msg_type := self._try_parse()) is not None:
types.append(msg_type)
while (frame := self._try_parse()) is not None:
self.frames.append(frame)
types.append(frame[0])
return types
def _try_parse(self) -> int | None:
def _try_parse(self) -> tuple[int, bytes] | None:
buf = self._buf
if not buf:
return None
@@ -80,8 +113,9 @@ class FrameParser:
msg_type, pos = type_decoded
if len(buf) - pos < size:
return None
payload = bytes(buf[pos : pos + size])
del buf[: pos + size]
return msg_type
return msg_type, payload
class RawApiClient:
@@ -145,5 +179,9 @@ class RawApiClient:
await asyncio.wait_for(_read_loop(), timeout)
def frames(self, msg_type: int) -> list[bytes]:
"""Payloads of every frame of msg_type received so far."""
return [payload for kind, payload in self._parser.frames if kind == msg_type]
def close(self) -> None:
self._sock.close()

Some files were not shown because too many files have changed in this diff Show More