mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 22:06:12 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1154941f25 | ||
|
|
9a65dda424 | ||
|
|
5df921b4e0 | ||
|
|
fb27e003f3 | ||
|
|
f277476de0 | ||
|
|
9673e73893 | ||
|
|
b722812bd8 | ||
|
|
8fde15e789 | ||
|
|
c48d219dc0 | ||
|
|
3bed3f3d4f | ||
|
|
4220cbe63e | ||
|
|
d7bc501095 | ||
|
|
a04032b345 | ||
|
|
611d8cfb4d | ||
|
|
7dace44d61 | ||
|
|
491f98f982 | ||
|
|
619283d74a | ||
|
|
18348b8794 | ||
|
|
f7dc2ecb51 | ||
|
|
ddcc5874ed | ||
|
|
28013a60e1 | ||
|
|
966193cb09 | ||
|
|
e1077748be | ||
|
|
81191f3d5c | ||
|
|
26c852f169 | ||
|
|
ebdf95be94 | ||
|
|
29259ca74e | ||
|
|
6a1b5d517b | ||
|
|
9687c0e0a8 | ||
|
|
230c120898 |
@@ -205,15 +205,16 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
for a table of structs copy the whole entry out and return the copy, never a pointer into flash.
|
||||
On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with
|
||||
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
|
||||
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
|
||||
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
|
||||
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`. An action that only forwards a byte payload is
|
||||
registered with `automation.ApplyBytes` (see Actions below), which keeps a constant payload in flash;
|
||||
`TEMPLATABLE_BYTES` is for a `play()` that does more with the bytes.
|
||||
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
|
||||
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
|
||||
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
|
||||
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`,
|
||||
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`); pick the helper's
|
||||
arguments instead of writing a loop. Epson style 7-byte RTC calendars (one-hot weekday) use
|
||||
`time::decode_bcd_calendar`.
|
||||
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`, `assign_bits`); pick
|
||||
the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar<LAYOUT>`
|
||||
and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
|
||||
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
|
||||
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
|
||||
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
|
||||
@@ -604,7 +605,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
)
|
||||
```
|
||||
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover
|
||||
the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with
|
||||
the rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a
|
||||
byte payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload
|
||||
stays in flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with
|
||||
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
|
||||
|
||||
* **Conditions:**
|
||||
|
||||
@@ -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
|
||||
|
||||
+107
-14
@@ -59,10 +59,15 @@ async def templatable_bytes(
|
||||
if cg.is_template(value):
|
||||
fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8))
|
||||
cg.add(set_template(fn))
|
||||
elif len(value) > 0xFFFF:
|
||||
raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535")
|
||||
else:
|
||||
cg.add(set_static(progmem_bytes(table_name, value), len(value)))
|
||||
cg.add(set_static(*_static_bytes(table_name, value)))
|
||||
|
||||
|
||||
def _static_bytes(table_name: str, value: bytes | list[int]) -> tuple[MockObj, int]:
|
||||
"""The shared PROGMEM table for a constant payload and its length."""
|
||||
if (size := len(value)) > 0xFFFF:
|
||||
raise EsphomeError(f"Byte payload is {size} bytes; the maximum is 65535")
|
||||
return progmem_bytes(table_name, value), size
|
||||
|
||||
|
||||
def maybe_simple_id(*validators):
|
||||
@@ -356,6 +361,33 @@ class ApplyField:
|
||||
return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyBytes:
|
||||
"""A byte payload key forwarded as ``target``, whose ``{}`` stands for ``data, len``.
|
||||
|
||||
A constant payload is a shared PROGMEM table passed as is; on ESP8266 it is first copied
|
||||
out, to the stack up to ``stack`` bytes and to the heap beyond, unless ``progmem_target``
|
||||
names a call that reads flash itself. A lambda's ``std::vector<uint8_t>`` is passed as
|
||||
its data and size. The template may name the parent as ``{parent}``. An absent key
|
||||
emits nothing.
|
||||
"""
|
||||
|
||||
conf_key: str | tuple[str, ...]
|
||||
target: str
|
||||
stack: int = 64
|
||||
progmem_target: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for target in (self.target, self.progmem_target):
|
||||
if target is None:
|
||||
continue
|
||||
fields = [
|
||||
f for _, f, _, _ in string.Formatter().parse(target) if f is not None
|
||||
]
|
||||
if fields.count("") != 1 or any(f not in ("", "parent") for f in fields):
|
||||
raise ValueError(f"apply bytes target {target!r} needs one {{}}")
|
||||
|
||||
|
||||
def _config_lookup(config: ConfigType, key: str | tuple[str, ...]) -> Any:
|
||||
if isinstance(key, str):
|
||||
return config.get(key)
|
||||
@@ -488,7 +520,7 @@ def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> li
|
||||
def register_apply_action(
|
||||
name: str,
|
||||
schema: cv.Schema,
|
||||
*fields: ApplyField | ApplyCall,
|
||||
*fields: ApplyField | ApplyCall | ApplyBytes,
|
||||
call: str | None = None,
|
||||
id_key: str = CONF_ID,
|
||||
) -> None:
|
||||
@@ -504,7 +536,7 @@ def register_apply_action(
|
||||
def apply_action_builder(
|
||||
name: str,
|
||||
schema: cv.Schema,
|
||||
*fields: ApplyField | ApplyCall,
|
||||
*fields: ApplyField | ApplyCall | ApplyBytes,
|
||||
call: str | None = None,
|
||||
id_key: str = CONF_ID,
|
||||
) -> Callable[..., Awaitable[MockObj]]:
|
||||
@@ -517,17 +549,20 @@ def apply_action_builder(
|
||||
and ``apply_call.perform()`` is appended.
|
||||
"""
|
||||
# An action stores the value, so a std::string constant stays in flash on ESP8266.
|
||||
statements_spec = [
|
||||
(
|
||||
c.target,
|
||||
_default_std_string(c.members, flash_string),
|
||||
)
|
||||
for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields)
|
||||
]
|
||||
_check_key_in_schema(name, schema, id_key)
|
||||
for _, members in statements_spec:
|
||||
statements_spec: list[ApplyBytes | tuple[str, list[tuple[Any, Any, Any]]]] = []
|
||||
for f in fields:
|
||||
if isinstance(f, ApplyBytes):
|
||||
_check_key_in_schema(name, schema, f.conf_key)
|
||||
statements_spec.append(f)
|
||||
continue
|
||||
c = f if isinstance(f, ApplyCall) else f.call()
|
||||
members = _default_std_string(c.members, flash_string)
|
||||
for conf_key, _, _ in members:
|
||||
_check_key_in_schema(name, schema, conf_key)
|
||||
statements_spec.append((c.target, members))
|
||||
# Named like the generated function, so it cannot clash with an SDK symbol
|
||||
table_name = f"esphome__{name.replace('.', '_')}__bytes"
|
||||
|
||||
async def builder(
|
||||
config: ConfigType,
|
||||
@@ -539,7 +574,15 @@ def apply_action_builder(
|
||||
lambda_args = _apply_lambda_args(args)
|
||||
receiver = "apply_call." if call else f"{parent}->"
|
||||
statements: list[str] = []
|
||||
for target, members in statements_spec:
|
||||
for spec in statements_spec:
|
||||
if isinstance(spec, ApplyBytes):
|
||||
statements.extend(
|
||||
await _render_bytes(
|
||||
spec, config, receiver, parent, lambda_args, table_name
|
||||
)
|
||||
)
|
||||
continue
|
||||
target, members = spec
|
||||
values = _apply_values(config, members)
|
||||
if not _apply_call_active(members, values):
|
||||
continue
|
||||
@@ -560,6 +603,56 @@ def apply_action_builder(
|
||||
return builder
|
||||
|
||||
|
||||
async def _render_bytes(
|
||||
spec: ApplyBytes,
|
||||
config: ConfigType,
|
||||
receiver: str,
|
||||
parent: str,
|
||||
lambda_args: TemplateArgsType,
|
||||
table_name: str,
|
||||
) -> list[str]:
|
||||
"""The statements for one ``ApplyBytes``: what ``TemplatableBytes::visit`` does, inlined."""
|
||||
value = _config_lookup(config, spec.conf_key)
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
# The temporary carries the generated-name prefix so a trigger argument cannot shadow it
|
||||
def send(target: str, data: str) -> str:
|
||||
return f"{receiver}{target.format(data, parent=parent)};"
|
||||
|
||||
if isinstance(value, Lambda):
|
||||
inner = await cg.process_lambda(
|
||||
value, lambda_args, return_type=cg.std_vector.template(cg.uint8)
|
||||
)
|
||||
return [
|
||||
"{",
|
||||
f" const std::vector<uint8_t> esphome__bytes = ({call_lambda(inner)});",
|
||||
f" {send(spec.target, 'esphome__bytes.data(), esphome__bytes.size()')}",
|
||||
"}",
|
||||
]
|
||||
table, size = _static_bytes(table_name, value)
|
||||
# Qualified so a trigger argument cannot shadow a reused table; an empty payload is nullptr
|
||||
args = f"::{table}, {size}" if size else f"{table}, {size}"
|
||||
if CORE.is_esp8266 and spec.progmem_target is not None:
|
||||
return [send(spec.progmem_target, args)]
|
||||
if not CORE.is_esp8266:
|
||||
return [send(spec.target, args)]
|
||||
# A PROGMEM table must be read with aligned loads on ESP8266, so copy it out first;
|
||||
# the length is known here, so the copy is an exact array, on the heap past ``stack``.
|
||||
# An empty payload still gets a one byte array: the raw lwIP socket rejects a null buffer.
|
||||
if size <= spec.stack:
|
||||
buffer, data = f"uint8_t esphome__bytes[{max(size, 1)}];", "esphome__bytes"
|
||||
else:
|
||||
buffer, data = (
|
||||
f"auto esphome__bytes = std::make_unique<uint8_t[]>({size});",
|
||||
"esphome__bytes.get()",
|
||||
)
|
||||
statements = ["{", f" {buffer}"]
|
||||
if size:
|
||||
statements.append(f" esphome::progmem_memcpy({data}, {args});")
|
||||
return [*statements, f" {send(spec.target, f'{data}, {size}')}", "}"]
|
||||
|
||||
|
||||
def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool:
|
||||
"""An ``ApplyCall`` is emitted unless it has keys and none of them is set."""
|
||||
return not members or any(value is not None for value in values)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,6 +31,13 @@ ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_(
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
|
||||
arg.description = this->str_(base + 1);
|
||||
arg.example = this->str_(base + 2);
|
||||
#endif
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
|
||||
// The default slot doubles as the optional flag: nullptr is required, "" optional without a default
|
||||
const char *d = progmem_read_ptr(&this->strings_[base + USER_ACTION_ARG_BASE_STRINGS]);
|
||||
arg.optional = d != nullptr;
|
||||
if (d != nullptr)
|
||||
arg.default_value = ProgmemStringRef(d, ESPHOME_strlen_P(d));
|
||||
#endif
|
||||
}
|
||||
return msg;
|
||||
|
||||
@@ -40,13 +40,20 @@ template<typename T> enums::ServiceArgType to_service_arg_type();
|
||||
// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in
|
||||
// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then
|
||||
// each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows
|
||||
// the name and every argument is (name, description, example). Unset metadata is nullptr.
|
||||
// the name and every argument is (name, description, example); with
|
||||
// USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS every argument ends with its default: nullptr for a
|
||||
// required argument, "" for an optional one without a default. Unset is nullptr.
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
|
||||
static constexpr size_t USER_ACTION_HEADER_STRINGS = 2;
|
||||
static constexpr size_t USER_ACTION_ARG_STRINGS = 3;
|
||||
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 3;
|
||||
#else
|
||||
static constexpr size_t USER_ACTION_HEADER_STRINGS = 1;
|
||||
static constexpr size_t USER_ACTION_ARG_STRINGS = 1;
|
||||
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 1;
|
||||
#endif
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
|
||||
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS + 1;
|
||||
#else
|
||||
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS;
|
||||
#endif
|
||||
class UserServiceStatic : public UserServiceDescriptor {
|
||||
public:
|
||||
|
||||
@@ -79,6 +79,10 @@ class ClimateCall {
|
||||
ClimateCall &set_fan_mode(optional<std::string> fan_mode);
|
||||
/// Set the custom fan mode of the climate device.
|
||||
ClimateCall &set_fan_mode(const char *custom_fan_mode);
|
||||
/// Set the custom fan mode of the climate device (StringRef overload to resolve ambiguity, zero-copy).
|
||||
ClimateCall &set_fan_mode(StringRef custom_fan_mode) {
|
||||
return this->set_fan_mode(custom_fan_mode.c_str(), custom_fan_mode.size());
|
||||
}
|
||||
/// Set the custom fan mode of the climate device (zero-copy API path).
|
||||
ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len);
|
||||
/// Set the swing mode of the climate device.
|
||||
@@ -99,6 +103,10 @@ class ClimateCall {
|
||||
ClimateCall &set_preset(optional<std::string> preset);
|
||||
/// Set the custom preset of the climate device.
|
||||
ClimateCall &set_preset(const char *custom_preset);
|
||||
/// Set the custom preset of the climate device (StringRef overload to resolve ambiguity, zero-copy).
|
||||
ClimateCall &set_preset(StringRef custom_preset) {
|
||||
return this->set_preset(custom_preset.c_str(), custom_preset.size());
|
||||
}
|
||||
/// Set the custom preset of the climate device (zero-copy API path).
|
||||
ClimateCall &set_preset(const char *custom_preset, size_t len);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ds248x_one_wire_bus.h"
|
||||
#include "ds248x.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ds248x {
|
||||
@@ -151,11 +152,7 @@ uint64_t DS248xOneWireBus::search_int() {
|
||||
}
|
||||
|
||||
// Update address based on direction taken
|
||||
if (dir_taken) {
|
||||
address |= bit_mask;
|
||||
} else {
|
||||
address &= ~bit_mask;
|
||||
}
|
||||
assign_bits(address, bit_mask, dir_taken);
|
||||
}
|
||||
|
||||
// Search successful
|
||||
|
||||
@@ -126,7 +126,7 @@ void EmonTx::loop() {
|
||||
|
||||
// Read all available data to prevent UART buffer overflow
|
||||
this->read_available([this](uint8_t received) {
|
||||
size_t len;
|
||||
size_t len = 0;
|
||||
char *line = this->line_reader_.feed(received, len);
|
||||
this->line_reader_.log_if_overflowed(TAG);
|
||||
if (line == nullptr || len == 0) {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import external_files
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, update
|
||||
from esphome.components.const import CONF_SHA256
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE
|
||||
from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE, CONF_URL
|
||||
from esphome.core import CORE, ID, HexInt
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@swoboda1337"]
|
||||
AUTO_LOAD = ["sha256", "watchdog", "json"]
|
||||
@@ -40,11 +49,15 @@ BASE_SCHEMA = update.update_schema(Esp32HostedUpdate, device_class="firmware").e
|
||||
cv.polling_component_schema("6h")
|
||||
)
|
||||
|
||||
EMBEDDED_SCHEMA = BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.file_,
|
||||
cv.Required(CONF_SHA256): _validate_sha256,
|
||||
}
|
||||
EMBEDDED_SCHEMA = cv.All(
|
||||
BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(CONF_PATH): cv.file_,
|
||||
cv.Optional(CONF_URL): cv.All(cv.url, vol.Match(r"^https?://")),
|
||||
cv.Required(CONF_SHA256): _validate_sha256,
|
||||
}
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_PATH, CONF_URL),
|
||||
)
|
||||
|
||||
HTTP_SCHEMA = BASE_SCHEMA.extend(
|
||||
@@ -70,21 +83,52 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _validate_firmware(config: dict[str, Any]) -> None:
|
||||
if config[CONF_TYPE] != TYPE_EMBEDDED:
|
||||
return
|
||||
|
||||
path = CORE.relative_config_path(config[CONF_PATH])
|
||||
with path.open("rb") as f:
|
||||
firmware_data = f.read()
|
||||
calculated = hashlib.sha256(firmware_data).hexdigest()
|
||||
expected = config[CONF_SHA256].lower()
|
||||
def _check_firmware_hash(data: bytes, expected: str, source: str | Path) -> None:
|
||||
calculated = hashlib.sha256(data).hexdigest()
|
||||
if calculated != expected:
|
||||
raise cv.Invalid(
|
||||
f"SHA256 mismatch for {config[CONF_PATH]}: expected {expected}, got {calculated}"
|
||||
f"SHA256 mismatch for {source}: expected {expected}, got {calculated}"
|
||||
)
|
||||
|
||||
|
||||
def _firmware_path(config: ConfigType) -> Path:
|
||||
"""The configured image file, or the cache slot of the pinned digest for a downloaded one."""
|
||||
if CONF_URL in config:
|
||||
cache_dir = external_files.compute_local_file_dir("esp32_hosted") / "firmware"
|
||||
return cache_dir / f"{config[CONF_SHA256].lower()}.bin"
|
||||
return CORE.relative_config_path(config[CONF_PATH])
|
||||
|
||||
|
||||
def _validate_firmware(config: ConfigType) -> None:
|
||||
"""Fetch and verify the image here; codegen only reads the file this leaves behind."""
|
||||
if config[CONF_TYPE] != TYPE_EMBEDDED:
|
||||
return
|
||||
expected = config[CONF_SHA256].lower()
|
||||
target = _firmware_path(config)
|
||||
if (url := config.get(CONF_URL)) is None:
|
||||
_check_firmware_hash(target.read_bytes(), expected, target)
|
||||
return
|
||||
# A pinned digest needs no remote freshness check.
|
||||
if target.is_file():
|
||||
actual = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
if actual == expected:
|
||||
return
|
||||
# Named after its digest, so a mismatch means the file was damaged or edited
|
||||
_LOGGER.warning(
|
||||
"Cached firmware %s hashes to %s, downloading it again", target, actual
|
||||
)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Keep unverified bytes out of the shared cache, even across concurrent builds.
|
||||
with TemporaryDirectory(dir=target.parent) as temporary:
|
||||
candidate = Path(temporary) / "firmware.bin"
|
||||
data = external_files.download_content(url, candidate, allow_stale=False)
|
||||
_check_firmware_hash(data, expected, url)
|
||||
try:
|
||||
candidate.replace(target)
|
||||
except OSError as err:
|
||||
raise cv.Invalid(f"Could not cache the firmware from {url}: {err}") from err
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_firmware
|
||||
|
||||
|
||||
@@ -92,9 +136,7 @@ async def to_code(config: dict[str, Any]) -> None:
|
||||
var = await update.new_update(config)
|
||||
|
||||
if config[CONF_TYPE] == TYPE_EMBEDDED:
|
||||
path = config[CONF_PATH]
|
||||
with CORE.relative_config_path(path).open("rb") as f:
|
||||
firmware_data = f.read()
|
||||
firmware_data = _firmware_path(config).read_bytes()
|
||||
rhs = [HexInt(x) for x in firmware_data]
|
||||
arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8)
|
||||
prog_arr = cg.progmem_array(arr_id, rhs)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ namespace esphome::hdmi_cec {
|
||||
// Release/acquire store and load. The read-modify-write forms of std::atomic are not available on
|
||||
// every target (the ESP8266 has none), so a writer computes the new value itself; that is safe here
|
||||
// because every atomic in this component has exactly one writer.
|
||||
template<typename T> ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
|
||||
template<typename T> inline ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
|
||||
atom.store(value, std::memory_order_release);
|
||||
}
|
||||
template<typename T> ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
|
||||
template<typename T> inline ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
|
||||
return atom.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
|
||||
@@ -203,12 +203,8 @@ void HomeassistantLight::on_report_(bool changed) {
|
||||
this->cancel_timeout(ECHO_TIMEOUT_ID);
|
||||
}
|
||||
// Noted as the report arrives, not when the deferred apply runs: a call read from the same loop
|
||||
// pass before this report is older than it, one read after it is newer and must not pass for
|
||||
// the transition the report is held for
|
||||
this->report_in_transition_ = this->state_->is_transitioning();
|
||||
if (this->report_in_transition_) {
|
||||
this->held_hash_ = state_hash(this->state_->remote_values);
|
||||
}
|
||||
// pass before this report is older than it, one read after it is newer
|
||||
this->state_->clear_called();
|
||||
}
|
||||
this->schedule_apply_();
|
||||
}
|
||||
@@ -257,8 +253,9 @@ void HomeassistantLight::apply_imported_() {
|
||||
}
|
||||
if (!this->state_->set_external_state(values)) {
|
||||
// A report that changed something during a transition or flash is applied once it ends, in
|
||||
// place of the values it ends at; a trait or a repeated report has nothing newer to say
|
||||
if (this->report_in_transition_) {
|
||||
// place of the values it ends at, unless a call came after the report; a trait or a repeated
|
||||
// report has nothing newer to say
|
||||
if (this->state_->is_transitioning() && !this->state_->called_since_clear()) {
|
||||
this->report_held_ = true;
|
||||
return;
|
||||
}
|
||||
@@ -268,20 +265,9 @@ void HomeassistantLight::apply_imported_() {
|
||||
ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_);
|
||||
return;
|
||||
}
|
||||
this->report_in_transition_ = false;
|
||||
ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_);
|
||||
}
|
||||
|
||||
uint32_t HomeassistantLight::state_hash(const LightColorValues &values) {
|
||||
uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast<uint8_t>(values.get_color_mode()));
|
||||
for (const float channel :
|
||||
{values.get_state(), values.get_brightness(), values.get_color_brightness(), values.get_red(),
|
||||
values.get_green(), values.get_blue(), values.get_white(), values.get_cold_white(), values.get_warm_white()}) {
|
||||
hash = fnv1a_hash_extend(hash, to_uint8_scale(channel));
|
||||
}
|
||||
return fnv1a_hash_extend(hash, static_cast<uint16_t>(values.get_color_temperature())); // mireds exceed a byte
|
||||
}
|
||||
|
||||
void HomeassistantLight::update_state(LightState *state) {
|
||||
// The light core's setup performs one call for the initial state; a second one before the first
|
||||
// write (an on_boot automation) is a local change merged into that write, so it must be sent
|
||||
@@ -291,11 +277,10 @@ void HomeassistantLight::update_state(LightState *state) {
|
||||
}
|
||||
this->initial_seen_ = true;
|
||||
}
|
||||
// A call without a transition replaces whatever ran, so a report held for it, or still waiting to
|
||||
// be applied, is older than the call
|
||||
if (!state->is_transitioning()) {
|
||||
// Every call that changes the light reaches here within a loop pass (a new transformer's first
|
||||
// apply() or set_immediately_()), and a call since the held report was read is newer than it
|
||||
if (this->report_held_ && state->called_since_clear()) {
|
||||
this->report_held_ = false;
|
||||
this->report_in_transition_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,11 +323,7 @@ void HomeassistantLight::write_state(LightState *state) {
|
||||
return;
|
||||
}
|
||||
if (state->is_transitioning()) {
|
||||
// A transition or flash runs here alone; Home Assistant gets the state it ends at. A call that
|
||||
// replaces the transition a report was held for retargets it and is newer than the report
|
||||
if (this->report_held_ && state_hash(state->remote_values) != this->held_hash_) {
|
||||
this->report_held_ = false;
|
||||
}
|
||||
// A transition or flash runs here alone; Home Assistant gets the state it ends at
|
||||
this->transitioned_ = true;
|
||||
return;
|
||||
}
|
||||
@@ -350,7 +331,6 @@ void HomeassistantLight::write_state(LightState *state) {
|
||||
const bool transitioned = this->transitioned_;
|
||||
this->report_held_ = false;
|
||||
this->transitioned_ = false;
|
||||
this->report_in_transition_ = false; // this change is newer than any report so far, held or dropped
|
||||
// Home Assistant changed meanwhile; its report is newer than what the transition ends at or
|
||||
// what the flash goes back to
|
||||
if (held) {
|
||||
|
||||
@@ -48,8 +48,6 @@ class HomeassistantLight final : public light::LightOutput, public Component {
|
||||
this->on_report_(field != value);
|
||||
field = value;
|
||||
}
|
||||
/// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call
|
||||
static uint32_t state_hash(const light::LightColorValues &values);
|
||||
/// Schedules apply_imported_() once for the attributes of one update
|
||||
void schedule_apply_();
|
||||
void apply_imported_();
|
||||
@@ -63,14 +61,12 @@ class HomeassistantLight final : public light::LightOutput, public Component {
|
||||
light::LightTraits traits_{};
|
||||
Attributes imported_{};
|
||||
bool apply_scheduled_ : 1 {false};
|
||||
bool initial_write_ : 1 {true}; // the light core's own first write is not a local change
|
||||
bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state
|
||||
bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back
|
||||
bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends
|
||||
bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent
|
||||
bool report_in_transition_ : 1 {false}; // the last changed report, not yet applied, arrived during a transition
|
||||
bool initial_write_ : 1 {true}; // the light core's own first write is not a local change
|
||||
bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state
|
||||
bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back
|
||||
bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends
|
||||
bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent
|
||||
uint8_t report_timeout_s_{10};
|
||||
uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at
|
||||
};
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "htu21d.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::htu21d {
|
||||
|
||||
@@ -112,11 +113,7 @@ void HTU21DComponent::set_heater(bool status) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
if (status) {
|
||||
raw_heater |= (1 << HTU21D_REG_HTRE_BIT);
|
||||
} else {
|
||||
raw_heater &= ~(1 << HTU21D_REG_HTRE_BIT);
|
||||
}
|
||||
assign_bits(raw_heater, 1 << HTU21D_REG_HTRE_BIT, status);
|
||||
if (this->write_register(HTU21D_WRITERHT_REG_CMD, &raw_heater, 1) != i2c::ERROR_OK) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
|
||||
@@ -76,6 +76,8 @@ static void log_percent(const LogString *param, float value) {
|
||||
void LightCall::perform() {
|
||||
const char *name = LOG_STR_ARG(this->parent_->get_log_name());
|
||||
LightColorValues v = this->validate_();
|
||||
// Any call counts as newer, even one that changes nothing
|
||||
this->parent_->called_ = true;
|
||||
const bool publish = this->get_publish_();
|
||||
|
||||
if (publish) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "light_color_values.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -133,6 +135,8 @@ class LightCall {
|
||||
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
|
||||
/// Set the effect of the light by its name (const char * overload to resolve ambiguity).
|
||||
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
|
||||
/// Set the effect of the light by its name (StringRef overload to resolve ambiguity, zero-copy).
|
||||
LightCall &set_effect(StringRef effect) { return this->set_effect(effect.c_str(), effect.size()); }
|
||||
/// Set the effect of the light by its name and length (zero-copy from API).
|
||||
LightCall &set_effect(const char *effect, size_t len);
|
||||
/// Set the effect of the light by its internal index number (only for internal use).
|
||||
@@ -226,13 +230,7 @@ class LightCall {
|
||||
inline bool get_save_() { return (this->flags_ & FLAG_SAVE) != 0; }
|
||||
|
||||
// Helper to set flag - defaults to true for common case
|
||||
void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE {
|
||||
if (value) {
|
||||
this->flags_ |= flag;
|
||||
} else {
|
||||
this->flags_ &= ~flag;
|
||||
}
|
||||
}
|
||||
void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE { assign_bits(this->flags_, flag, value); }
|
||||
|
||||
// Helper to clear flag - reduces code size for common case
|
||||
void clear_flag_(FieldFlags flag) ESPHOME_ALWAYS_INLINE { this->flags_ &= ~flag; }
|
||||
|
||||
@@ -90,6 +90,11 @@ class LightState : public EntityBase, public Component {
|
||||
|
||||
/// True from the call that starts a transition or flash until it reaches its target.
|
||||
bool is_transitioning() const { return this->transformer_ != nullptr; }
|
||||
|
||||
/// True once a call is performed after clear_called(), so an output can tell whether a call came
|
||||
/// after something it noted. Every call that changes the light reaches update_state() within a loop pass.
|
||||
bool called_since_clear() const { return this->called_; }
|
||||
void clear_called() { this->called_ = false; }
|
||||
LightCall make_call();
|
||||
|
||||
/// Take over the state of a light mirrored from elsewhere: current and target values at once,
|
||||
@@ -416,6 +421,7 @@ class LightState : public EntityBase, public Component {
|
||||
bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word
|
||||
// for effects, true if a transformer (transition) is active.
|
||||
bool is_transformer_active_ : 1 {false};
|
||||
bool called_ : 1 {false}; // see called_since_clear()
|
||||
/// Whether this light persists its state to preferences at all.
|
||||
bool save_enabled_ : 1 {false};
|
||||
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "media_player.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/controller_registry.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
@@ -77,19 +78,12 @@ const char *media_player_command_to_string(MediaPlayerCommand command) {
|
||||
}
|
||||
|
||||
void MediaPlayerTraits::set_supports_pause(bool supports_pause) {
|
||||
if (supports_pause) {
|
||||
this->feature_flags_ |= MediaPlayerEntityFeature::PAUSE | MediaPlayerEntityFeature::PLAY;
|
||||
} else {
|
||||
this->feature_flags_ &= ~(MediaPlayerEntityFeature::PAUSE | MediaPlayerEntityFeature::PLAY);
|
||||
}
|
||||
assign_bits(this->feature_flags_, MediaPlayerEntityFeature::PAUSE | MediaPlayerEntityFeature::PLAY, supports_pause);
|
||||
}
|
||||
|
||||
void MediaPlayerTraits::set_supports_turn_off_on(bool supports_turn_off_on) {
|
||||
if (supports_turn_off_on) {
|
||||
this->feature_flags_ |= MediaPlayerEntityFeature::TURN_OFF | MediaPlayerEntityFeature::TURN_ON;
|
||||
} else {
|
||||
this->feature_flags_ &= ~(MediaPlayerEntityFeature::TURN_OFF | MediaPlayerEntityFeature::TURN_ON);
|
||||
}
|
||||
assign_bits(this->feature_flags_, MediaPlayerEntityFeature::TURN_OFF | MediaPlayerEntityFeature::TURN_ON,
|
||||
supports_turn_off_on);
|
||||
}
|
||||
|
||||
void MediaPlayerCall::validate_() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::mpr121 {
|
||||
@@ -84,11 +85,7 @@ bool MPR121Component::digital_read(uint8_t pin) { return (this->gpio_input_ & (1
|
||||
|
||||
void MPR121Component::digital_write(uint8_t pin, bool value) {
|
||||
const uint8_t ionum = pin - 4;
|
||||
if (value) {
|
||||
this->gpio_output_ |= (1 << ionum);
|
||||
} else {
|
||||
this->gpio_output_ &= ~(1 << ionum);
|
||||
}
|
||||
assign_bits(this->gpio_output_, 1 << ionum, value);
|
||||
this->flush_gpio_();
|
||||
}
|
||||
|
||||
|
||||
@@ -168,8 +168,7 @@ bool MQTTFanComponent::publish_state() {
|
||||
char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
|
||||
const char *state_s = this->state_->state ? "ON" : "OFF";
|
||||
ESP_LOGD(TAG, "'%s' Sending state %s.", LOG_STR_ARG(this->state_->get_log_name()), state_s);
|
||||
this->publish(this->get_state_topic_to_(topic_buf), state_s);
|
||||
bool failed = false;
|
||||
bool failed = !this->publish(this->get_state_topic_to_(topic_buf), state_s);
|
||||
if (this->state_->get_traits().supports_direction()) {
|
||||
bool success = this->publish(this->get_direction_state_topic_to(topic_buf),
|
||||
fan_direction_to_mqtt_str(this->state_->direction));
|
||||
|
||||
@@ -1030,11 +1030,22 @@ def _generate_cmake_lists(pch_on: bool) -> bool:
|
||||
'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")',
|
||||
]
|
||||
|
||||
if compile_flags:
|
||||
defines = [flag for flag in compile_flags if flag.startswith("-D")]
|
||||
options = [flag for flag in compile_flags if not flag.startswith("-D")]
|
||||
if defines:
|
||||
lines += [
|
||||
"",
|
||||
"target_compile_options(app PRIVATE",
|
||||
*[f' "{flag}"' for flag in compile_flags],
|
||||
*[f' "{flag}"' for flag in defines],
|
||||
")",
|
||||
]
|
||||
if options:
|
||||
# A target's own options come before the ones it inherits from zephyr_interface, and the
|
||||
# compiler keeps the last of two opposing warning flags. Source options come after both.
|
||||
lines += [
|
||||
"",
|
||||
"set_source_files_properties(${APP_SOURCES} PROPERTIES",
|
||||
f' COMPILE_OPTIONS "{";".join(options)}"',
|
||||
")",
|
||||
]
|
||||
|
||||
|
||||
@@ -46,11 +46,7 @@ class ProvisioningManager : public Component {
|
||||
void set_source_provisioned(uint8_t source, bool provisioned) {
|
||||
if (source >= MAX_SOURCES)
|
||||
return;
|
||||
if (provisioned) {
|
||||
this->provisioned_mask_ |= (1UL << source);
|
||||
} else {
|
||||
this->provisioned_mask_ &= ~(1UL << source);
|
||||
}
|
||||
assign_bits(this->provisioned_mask_, 1UL << source, provisioned);
|
||||
}
|
||||
|
||||
// True once every registered source is provisioned. Config validation guarantees
|
||||
|
||||
@@ -59,7 +59,7 @@ void RX8025TComponent::read_time() {
|
||||
return;
|
||||
}
|
||||
|
||||
ESPTime rtc_time = time::decode_bcd_calendar(date);
|
||||
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(date);
|
||||
if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) {
|
||||
ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock.");
|
||||
return;
|
||||
@@ -77,7 +77,7 @@ void RX8025TComponent::write_time() {
|
||||
}
|
||||
|
||||
uint8_t buff[7];
|
||||
time::encode_bcd_calendar(now, buff);
|
||||
time::encode_bcd_calendar<time::EPSON_BCD_CALENDAR>(now, buff);
|
||||
if (!this->write_bytes(RX8025T_REG_SEC, buff, sizeof(buff))) {
|
||||
ESP_LOGE(TAG, "Can't write I2C data.");
|
||||
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "rx8111.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::rx8111 {
|
||||
@@ -60,7 +61,7 @@ void RX8111Component::read_time() {
|
||||
this->status_set_warning(LOG_STR("Invalid RTC time"));
|
||||
return;
|
||||
}
|
||||
ESPTime rtc_time = time::decode_bcd_calendar(buf);
|
||||
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
|
||||
if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) {
|
||||
ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock");
|
||||
this->status_set_warning(LOG_STR("Invalid RTC time"));
|
||||
@@ -88,7 +89,7 @@ void RX8111Component::write_time() {
|
||||
}
|
||||
|
||||
uint8_t buf[7];
|
||||
time::encode_bcd_calendar(now, buf);
|
||||
time::encode_bcd_calendar<time::EPSON_BCD_CALENDAR>(now, buf);
|
||||
const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
|
||||
// Always try to restart the counter, even after a failed calendar write.
|
||||
const bool restarted = this->set_stop_bit_(false);
|
||||
@@ -106,11 +107,7 @@ bool RX8111Component::set_stop_bit_(bool stop) {
|
||||
uint8_t control;
|
||||
if (!this->read_byte(RX8111_REG_CONTROL, &control))
|
||||
return false;
|
||||
if (stop) {
|
||||
control |= RX8111_CTRL_STOP;
|
||||
} else {
|
||||
control &= ~RX8111_CTRL_STOP;
|
||||
}
|
||||
assign_bits(control, RX8111_CTRL_STOP, stop);
|
||||
return this->write_byte(RX8111_REG_CONTROL, control);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ void RX8130Component::read_time() {
|
||||
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
ESPTime rtc_time = time::decode_bcd_calendar(date);
|
||||
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(date);
|
||||
if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) {
|
||||
ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock.");
|
||||
return;
|
||||
@@ -84,7 +84,7 @@ void RX8130Component::write_time() {
|
||||
return;
|
||||
}
|
||||
uint8_t buff[7];
|
||||
time::encode_bcd_calendar(now, buff);
|
||||
time::encode_bcd_calendar<time::EPSON_BCD_CALENDAR>(now, buff);
|
||||
this->stop_(true);
|
||||
if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) {
|
||||
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
|
||||
@@ -62,11 +62,7 @@ int BSDSocketImpl::setblocking(bool blocking) {
|
||||
if (fl < 0) {
|
||||
return fl;
|
||||
}
|
||||
if (blocking) {
|
||||
fl &= ~O_NONBLOCK;
|
||||
} else {
|
||||
fl |= O_NONBLOCK;
|
||||
}
|
||||
assign_bits(fl, O_NONBLOCK, !blocking);
|
||||
return ::fcntl(this->fd_, F_SETFL, fl);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,11 +52,7 @@ int LwIPSocketImpl::setblocking(bool blocking) {
|
||||
if (fl < 0) {
|
||||
return fl;
|
||||
}
|
||||
if (blocking) {
|
||||
fl &= ~O_NONBLOCK;
|
||||
} else {
|
||||
fl |= O_NONBLOCK;
|
||||
}
|
||||
assign_bits(fl, O_NONBLOCK, !blocking);
|
||||
return lwip_fcntl(this->fd_, F_SETFL, fl);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -108,37 +108,4 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
|
||||
this->time_sync_callback_.call();
|
||||
}
|
||||
|
||||
// Bits of each calendar byte that hold the value, byte 0 lowest; byte 3 is the one-hot weekday.
|
||||
// Packed into one constant so it stays in code rather than RAM on ESP8266.
|
||||
static constexpr uint64_t BCD_CALENDAR_MASKS = 0xFF1F3F7F3F7F7FULL;
|
||||
|
||||
ESPTime decode_bcd_calendar(const uint8_t *raw) {
|
||||
uint8_t value[7];
|
||||
for (size_t i = 0; i < 7; i++)
|
||||
value[i] = bcd_to_uint8(raw[i] & static_cast<uint8_t>(BCD_CALENDAR_MASKS >> (8 * i)));
|
||||
const uint8_t weekday = raw[3] & static_cast<uint8_t>(BCD_CALENDAR_MASKS >> 24);
|
||||
ESPTime time{
|
||||
.second = value[0],
|
||||
.minute = value[1],
|
||||
.hour = value[2],
|
||||
.day_of_week = static_cast<uint8_t>(weekday ? __builtin_ctz(weekday) + 1 : 1),
|
||||
.day_of_month = value[4],
|
||||
.day_of_year = 1,
|
||||
.month = value[5],
|
||||
.year = static_cast<uint16_t>(value[6] + 2000),
|
||||
.is_dst = false,
|
||||
.timestamp = 0,
|
||||
};
|
||||
time.recalc_timestamp_utc(false);
|
||||
return time;
|
||||
}
|
||||
|
||||
void encode_bcd_calendar(const ESPTime &time, uint8_t *raw) {
|
||||
const uint8_t value[7] = {
|
||||
time.second, time.minute, time.hour, 0, time.day_of_month, time.month, static_cast<uint8_t>(time.year % 100)};
|
||||
for (size_t i = 0; i < 7; i++)
|
||||
raw[i] = uint8_to_bcd(value[i]);
|
||||
raw[3] = 1 << (time.day_of_week - 1);
|
||||
}
|
||||
|
||||
} // namespace esphome::time
|
||||
|
||||
@@ -44,10 +44,95 @@ class RealTimeClock : public PollingComponent {
|
||||
LazyCallbackManager<void()> time_sync_callback_;
|
||||
};
|
||||
|
||||
/// Decode the 7-byte BCD calendar (second, minute, hour, one-hot weekday, day, month, year since 2000)
|
||||
/// shared by Epson RTCs such as the RX8025T, RX8111 and RX8130, and fill in the UTC timestamp.
|
||||
ESPTime decode_bcd_calendar(const uint8_t *raw);
|
||||
/// Encode @p time into that 7-byte layout; @p time must be valid (is_valid()), with the year 2000 to 2099.
|
||||
void encode_bcd_calendar(const ESPTime &time, uint8_t *raw);
|
||||
/// How the weekday byte of a BCD calendar names the day, Sunday first.
|
||||
enum class BcdWeekday : uint8_t {
|
||||
ONE_HOT, ///< one bit per day, bit 0 is Sunday
|
||||
FROM_1, ///< 1 is Sunday
|
||||
FROM_0, ///< 0 is Sunday
|
||||
};
|
||||
|
||||
/// Layout of a 7-byte BCD calendar: second, minute, hour, weekday and day of the month in either order,
|
||||
/// month and year since 2000.
|
||||
struct BcdCalendarLayout {
|
||||
BcdWeekday weekday;
|
||||
bool weekday_before_day{true};
|
||||
/// Bit 7 of the month byte is set from the year 2100 on. Chips without the bit read it as 0.
|
||||
bool century_bit{false};
|
||||
/// Bit 6 of the hour byte selects 12-hour mode, with PM in bit 5; decoded only, encoding writes 24-hour mode.
|
||||
bool hour_12h{false};
|
||||
|
||||
constexpr size_t weekday_index() const { return this->weekday_before_day ? 3 : 4; }
|
||||
constexpr size_t day_index() const { return this->weekday_before_day ? 4 : 3; }
|
||||
};
|
||||
|
||||
/// Epson RX8025T, RX8111, RX8130.
|
||||
inline constexpr BcdCalendarLayout EPSON_BCD_CALENDAR{.weekday = BcdWeekday::ONE_HOT};
|
||||
/// DS1307, DS3231.
|
||||
inline constexpr BcdCalendarLayout DS_BCD_CALENDAR{
|
||||
.weekday = BcdWeekday::FROM_1, .century_bit = true, .hour_12h = true};
|
||||
/// PCF8563, PCF85063, BM8563.
|
||||
inline constexpr BcdCalendarLayout PCF_BCD_CALENDAR{.weekday = BcdWeekday::FROM_0, .weekday_before_day = false};
|
||||
|
||||
/// Decode the 7-byte BCD calendar at @p raw in the given layout and fill in the UTC timestamp. Bits each byte
|
||||
/// does not use for its value, such as a chip's flag bits, are dropped; day_of_year is not worked out.
|
||||
/// Kept out of line so that drivers sharing a layout share one copy.
|
||||
template<BcdCalendarLayout LAYOUT> __attribute__((noinline)) ESPTime decode_bcd_calendar(const uint8_t *raw) {
|
||||
uint8_t day_of_week;
|
||||
if constexpr (LAYOUT.weekday == BcdWeekday::ONE_HOT) {
|
||||
const uint8_t weekday = raw[LAYOUT.weekday_index()] & 0x7F;
|
||||
day_of_week = weekday ? __builtin_ctz(weekday) + 1 : 1;
|
||||
} else if constexpr (LAYOUT.weekday == BcdWeekday::FROM_1) {
|
||||
day_of_week = raw[LAYOUT.weekday_index()] & 0x07;
|
||||
} else {
|
||||
day_of_week = (raw[LAYOUT.weekday_index()] & 0x07) + 1;
|
||||
}
|
||||
uint8_t hour = bcd_to_uint8(raw[2] & 0x3F);
|
||||
if constexpr (LAYOUT.hour_12h) {
|
||||
if ((raw[2] & 0x40) != 0) {
|
||||
// 12-hour mode, e.g. set by another library: 12 AM reads as 12 with PM clear, 12 PM as 12 with PM set.
|
||||
hour = bcd_to_uint8(raw[2] & 0x1F) % 12 + ((raw[2] & 0x20) != 0 ? 12 : 0);
|
||||
}
|
||||
}
|
||||
uint16_t year = 2000 + bcd_to_uint8(raw[6]);
|
||||
if constexpr (LAYOUT.century_bit) {
|
||||
if ((raw[5] & 0x80) != 0)
|
||||
year += 100;
|
||||
}
|
||||
ESPTime time{
|
||||
.second = bcd_to_uint8(raw[0] & 0x7F),
|
||||
.minute = bcd_to_uint8(raw[1] & 0x7F),
|
||||
.hour = hour,
|
||||
.day_of_week = day_of_week,
|
||||
.day_of_month = bcd_to_uint8(raw[LAYOUT.day_index()] & 0x3F),
|
||||
.day_of_year = 1,
|
||||
.month = bcd_to_uint8(raw[5] & 0x1F),
|
||||
.year = year,
|
||||
.is_dst = false,
|
||||
.timestamp = 0,
|
||||
};
|
||||
time.recalc_timestamp_utc(false);
|
||||
return time;
|
||||
}
|
||||
|
||||
/// Encode @p time into the 7-byte BCD calendar at @p raw in the given layout, always in 24-hour mode.
|
||||
/// @p time must be valid (is_valid()); the year must be 2000 to 2099 unless the layout has a century bit.
|
||||
template<BcdCalendarLayout LAYOUT>
|
||||
__attribute__((noinline)) void encode_bcd_calendar(const ESPTime &time, uint8_t *raw) {
|
||||
uint8_t value[7] = {time.second, time.minute, time.hour, 0, 0, time.month, static_cast<uint8_t>(time.year % 100)};
|
||||
value[LAYOUT.day_index()] = time.day_of_month;
|
||||
for (size_t i = 0; i < 7; i++)
|
||||
raw[i] = uint8_to_bcd(value[i]);
|
||||
if constexpr (LAYOUT.weekday == BcdWeekday::ONE_HOT) {
|
||||
raw[LAYOUT.weekday_index()] = 1 << (time.day_of_week - 1);
|
||||
} else if constexpr (LAYOUT.weekday == BcdWeekday::FROM_1) {
|
||||
raw[LAYOUT.weekday_index()] = time.day_of_week;
|
||||
} else {
|
||||
raw[LAYOUT.weekday_index()] = time.day_of_week - 1;
|
||||
}
|
||||
if constexpr (LAYOUT.century_bit) {
|
||||
if (time.year >= 2100)
|
||||
raw[5] |= 0x80;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::time
|
||||
|
||||
@@ -162,11 +162,7 @@ void HostUartComponent::setup() {
|
||||
break;
|
||||
};
|
||||
// Set stop bits
|
||||
if (this->stop_bits_ == 2) {
|
||||
options.c_cflag |= CSTOPB;
|
||||
} else {
|
||||
options.c_cflag &= ~CSTOPB;
|
||||
}
|
||||
assign_bits(options.c_cflag, CSTOPB, this->stop_bits_ == 2);
|
||||
cfsetispeed(&options, baud);
|
||||
cfsetospeed(&options, baud);
|
||||
tcsetattr(this->file_descriptor_, TCSANOW, &options);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "ufire_ec.h"
|
||||
#include <cmath>
|
||||
@@ -19,11 +20,8 @@ void UFireECComponent::setup() {
|
||||
// Write option for temperature adjustments
|
||||
uint8_t config;
|
||||
this->read_byte(REGISTER_CONFIG, &config);
|
||||
if (this->temperature_sensor_ == nullptr && this->temperature_sensor_external_ == nullptr) {
|
||||
config &= ~CONFIG_TEMP_COMPENSATION;
|
||||
} else {
|
||||
config |= CONFIG_TEMP_COMPENSATION;
|
||||
}
|
||||
assign_bits(config, CONFIG_TEMP_COMPENSATION,
|
||||
this->temperature_sensor_ != nullptr || this->temperature_sensor_external_ != nullptr);
|
||||
this->write_byte(REGISTER_CONFIG, config);
|
||||
|
||||
// Update temperature compensation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "ufire_ise.h"
|
||||
|
||||
@@ -20,11 +21,8 @@ void UFireISEComponent::setup() {
|
||||
// Write option for temperature adjustments
|
||||
uint8_t config;
|
||||
this->read_byte(REGISTER_CONFIG, &config);
|
||||
if (this->temperature_sensor_ == nullptr && this->temperature_sensor_external_ == nullptr) {
|
||||
config &= ~CONFIG_TEMP_COMPENSATION;
|
||||
} else {
|
||||
config |= CONFIG_TEMP_COMPENSATION;
|
||||
}
|
||||
assign_bits(config, CONFIG_TEMP_COMPENSATION,
|
||||
this->temperature_sensor_ != nullptr || this->temperature_sensor_external_ != nullptr);
|
||||
this->write_byte(REGISTER_CONFIG, config);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/controller_registry.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
@@ -59,21 +60,13 @@ WaterHeaterCall &WaterHeaterCall::set_target_temperature_high(float temperature)
|
||||
}
|
||||
|
||||
WaterHeaterCall &WaterHeaterCall::set_away(bool away) {
|
||||
if (away) {
|
||||
this->state_ |= WATER_HEATER_STATE_AWAY;
|
||||
} else {
|
||||
this->state_ &= ~WATER_HEATER_STATE_AWAY;
|
||||
}
|
||||
assign_bits(this->state_, WATER_HEATER_STATE_AWAY, away);
|
||||
this->state_mask_ |= WATER_HEATER_STATE_AWAY;
|
||||
return *this;
|
||||
}
|
||||
|
||||
WaterHeaterCall &WaterHeaterCall::set_on(bool on) {
|
||||
if (on) {
|
||||
this->state_ |= WATER_HEATER_STATE_ON;
|
||||
} else {
|
||||
this->state_ &= ~WATER_HEATER_STATE_ON;
|
||||
}
|
||||
assign_bits(this->state_, WATER_HEATER_STATE_ON, on);
|
||||
this->state_mask_ |= WATER_HEATER_STATE_ON;
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -252,13 +252,7 @@ class WaterHeater : public EntityBase {
|
||||
/// Set the state flags. Should only be called from control().
|
||||
void set_state_(uint32_t state) { this->state_ = state; }
|
||||
/// Set or clear a state flag. Should only be called from control().
|
||||
void set_state_flag_(uint32_t flag, bool value) {
|
||||
if (value) {
|
||||
this->state_ |= flag;
|
||||
} else {
|
||||
this->state_ &= ~flag;
|
||||
}
|
||||
}
|
||||
void set_state_flag_(uint32_t flag, bool value) { assign_bits(this->state_, flag, value); }
|
||||
|
||||
WaterHeaterMode mode_{WATER_HEATER_MODE_OFF};
|
||||
float current_temperature_{NAN};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -169,6 +169,9 @@ def add_extra_script(stage: str, filename: str, path: Path) -> None:
|
||||
|
||||
def zephyr_to_code(config: ConfigType) -> None:
|
||||
cg.add_build_flag("-DUSE_ZEPHYR")
|
||||
# Zephyr's base warnings include -Wdouble-promotion, which every float passed to a log call
|
||||
# trips; the promotion is how variadic arguments work, so the warning only adds noise
|
||||
cg.add_build_flag("-Wno-double-promotion")
|
||||
cg.add_define("USE_NATIVE_64BIT_TIME")
|
||||
# The settings subsystem finds stored preferences by key, so key migration is possible
|
||||
cg.add_define("USE_PREFERENCE_KEY_LOOKUP")
|
||||
|
||||
+18
-11
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1067,6 +1067,16 @@ constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x
|
||||
/// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59.
|
||||
constexpr uint8_t uint8_to_bcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); }
|
||||
|
||||
/// Set the bits of \p mask in \p value when \p on is true, clear them otherwise.
|
||||
/// Use an unsigned mask for a register wider than int; a negative int mask is sign-extended.
|
||||
template<typename T> ESPHOME_ALWAYS_INLINE constexpr void assign_bits(T &value, std::type_identity_t<T> mask, bool on) {
|
||||
if (on) {
|
||||
value |= mask;
|
||||
} else {
|
||||
value &= ~mask;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse the order of 8 bits.
|
||||
inline uint8_t reverse_bits(uint8_t x) {
|
||||
x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1);
|
||||
|
||||
@@ -15,6 +15,8 @@ uint8_t days_in_month(uint8_t month, uint16_t year) {
|
||||
return DAYS_IN_MONTH[month];
|
||||
}
|
||||
|
||||
static uint16_t days_in_year(uint16_t year) { return days_in_month(2, year) == 29 ? 366 : 365; }
|
||||
|
||||
size_t ESPTime::strftime(char *buffer, size_t buffer_len, const char *format) {
|
||||
struct tm c_tm = this->to_c_tm();
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
@@ -252,8 +254,7 @@ void ESPTime::increment_second() {
|
||||
increment_time_value(this->month, 1, 13);
|
||||
}
|
||||
|
||||
uint16_t days_in_year = (this->year % 4 == 0) ? 366 : 365;
|
||||
if (increment_time_value(this->day_of_year, 1, days_in_year + 1)) {
|
||||
if (increment_time_value(this->day_of_year, 1, days_in_year(this->year) + 1)) {
|
||||
// day of year roll-over, increment year
|
||||
this->year++;
|
||||
}
|
||||
@@ -270,8 +271,7 @@ void ESPTime::increment_day() {
|
||||
increment_time_value(this->month, 1, 13);
|
||||
}
|
||||
|
||||
uint16_t days_in_year = (this->year % 4 == 0) ? 366 : 365;
|
||||
if (increment_time_value(this->day_of_year, 1, days_in_year + 1)) {
|
||||
if (increment_time_value(this->day_of_year, 1, days_in_year(this->year) + 1)) {
|
||||
// day of year roll-over, increment year
|
||||
this->year++;
|
||||
}
|
||||
@@ -285,7 +285,7 @@ void ESPTime::recalc_timestamp_utc(bool use_day_of_year) {
|
||||
}
|
||||
|
||||
for (int i = 1970; i < this->year; i++)
|
||||
res += (i % 4 == 0) ? 366 : 365;
|
||||
res += days_in_year(i);
|
||||
|
||||
if (use_day_of_year) {
|
||||
res += this->day_of_year - 1;
|
||||
|
||||
+20
-10
@@ -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.
|
||||
|
||||
|
||||
@@ -151,9 +151,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
str((read_path / Path(d)).resolve()) for d in build_include_dirs
|
||||
]
|
||||
|
||||
# Split build_flags list into private and public lists
|
||||
# Split build_flags list into private and public lists. Warnings and the optimisation level only concern the
|
||||
# library's own sources, so a library built with -O2 does not change how its users are compiled.
|
||||
private_build_flags, public_build_flags = split_list_by_condition(
|
||||
build_flags, lambda a: a if a.startswith("-W") else None
|
||||
build_flags, lambda a: a if a.startswith(("-W", "-O")) else None
|
||||
)
|
||||
|
||||
# Generate the component
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.2.0
|
||||
esptool==5.6.0
|
||||
click==8.5.0
|
||||
aioesphomeapi==46.19.0
|
||||
aioesphomeapi==46.21.0
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.151.5
|
||||
puremagic==2.2.0
|
||||
|
||||
@@ -112,7 +112,7 @@ def test_empty_metadata_is_unset() -> None:
|
||||
"description": "",
|
||||
"variables": {"b": {"type": "int", "description": "", "example": "ex"}},
|
||||
}
|
||||
strings = _action_strings(conf, has_metadata=True)
|
||||
strings = _action_strings(conf, has_metadata=True, has_optional=False)
|
||||
assert strings == ["a", None, "b", None, "ex"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for optional user-defined action variables (required/default)."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.api import (
|
||||
_action_strings,
|
||||
_has_optional_args,
|
||||
validate_variable,
|
||||
)
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.core import CORE
|
||||
|
||||
CONFIG = "tests/component_tests/api/test_action_optional_args.yaml"
|
||||
CONFIG_ESP8266 = "tests/component_tests/api/test_action_optional_args_esp8266.yaml"
|
||||
CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml"
|
||||
|
||||
|
||||
def test_defaults_are_emitted_in_the_string_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""Each argument gains a trailing default slot: "" marks optional without a default."""
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert (
|
||||
"static constexpr const char * api_action0_strings[] PROGMEM = {"
|
||||
'"play_buzzer", "song_str", "", "notes", "", "octave", "5", '
|
||||
'"bpm", "100", "loud", "true"};' in main_cpp
|
||||
)
|
||||
# An all-required action still carries the default slots (as nullptr)
|
||||
assert (
|
||||
"static constexpr const char * api_action1_strings[] PROGMEM = {"
|
||||
'"plain_action", "value", nullptr};' in main_cpp
|
||||
)
|
||||
assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" in {d.name for d in CORE.defines}
|
||||
|
||||
|
||||
def test_esp8266_defaults_are_progmem_arrays(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""On ESP8266 each default is its own PROGMEM array, referenced from the table."""
|
||||
main_cpp = generate_main(CONFIG_ESP8266)
|
||||
|
||||
def array_for(text: str) -> str:
|
||||
match = re.search(
|
||||
rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp
|
||||
)
|
||||
assert match, text
|
||||
return match.group(1)
|
||||
|
||||
assert (
|
||||
"static constexpr const char * api_action0_strings[] PROGMEM = {"
|
||||
f"{array_for('play_buzzer')}, {array_for('song_str')}, {array_for('')}, "
|
||||
f"{array_for('notes')}, {array_for('')}, {array_for('octave')}, {array_for('5')}, "
|
||||
f"{array_for('bpm')}, {array_for('100')}, {array_for('loud')}, "
|
||||
f"{array_for('true')}}};" in main_cpp
|
||||
)
|
||||
|
||||
|
||||
def test_required_variables_emit_no_define(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
generate_main(CONFIG_SHORTHAND)
|
||||
|
||||
assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" not in {
|
||||
d.name for d in CORE.defines
|
||||
}
|
||||
|
||||
|
||||
def test_action_strings_orders_default_after_metadata() -> None:
|
||||
conf = {
|
||||
"action": "a",
|
||||
"description": "d",
|
||||
"variables": {
|
||||
"b": {"type": "int", "example": "1", "default": 5},
|
||||
"c": {"type": "bool", "default": False},
|
||||
},
|
||||
}
|
||||
assert _action_strings(conf, has_metadata=True, has_optional=True) == [
|
||||
"a",
|
||||
"d",
|
||||
"b",
|
||||
None,
|
||||
"1",
|
||||
"5",
|
||||
"c",
|
||||
None,
|
||||
None,
|
||||
"false",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("var", "expected"),
|
||||
[
|
||||
({"type": "int", "required": False}, ""),
|
||||
({"type": "string", "default": ""}, ""),
|
||||
({"type": "float", "default": 5}, "5.0"),
|
||||
],
|
||||
)
|
||||
def test_default_slot_marks_optional_and_keeps_the_type(
|
||||
var: dict, expected: str
|
||||
) -> None:
|
||||
actions = [{"action": "a", "variables": {"b": validate_variable(var)}}]
|
||||
assert _has_optional_args(actions)
|
||||
assert _action_strings(actions[0], has_metadata=False, has_optional=True) == [
|
||||
"a",
|
||||
"b",
|
||||
expected,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("var", "expected"),
|
||||
[
|
||||
({"type": "int", "default": "5"}, 5),
|
||||
({"type": "int", "default": -(2**31)}, -(2**31)),
|
||||
({"type": "int", "default": 2**31 - 1}, 2**31 - 1),
|
||||
({"type": "bool", "default": "true"}, True),
|
||||
({"type": "float", "default": "1.5"}, 1.5),
|
||||
({"type": "float", "default": 3.4028235e38}, 3.4028235e38),
|
||||
({"type": "string", "default": "0123"}, "0123"),
|
||||
],
|
||||
)
|
||||
def test_default_is_validated_against_type(var: dict, expected: object) -> None:
|
||||
assert validate_variable(var)["default"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"var",
|
||||
[
|
||||
{"type": "int[]", "required": False},
|
||||
{"type": "string[]", "default": "x"},
|
||||
{"type": "int", "default": 5, "required": True},
|
||||
{"type": "int", "default": "not_a_number"},
|
||||
# ExecuteServiceArgument.int_ is a signed 32-bit value
|
||||
{"type": "int", "default": 2**31},
|
||||
{"type": "int", "default": -(2**31) - 1},
|
||||
# A boolean is an int in Python but "true" is not a number on the wire
|
||||
{"type": "int", "default": True},
|
||||
{"type": "float", "default": False},
|
||||
# ExecuteServiceArgument.float_ is a 32-bit float
|
||||
{"type": "float", "default": 1e40},
|
||||
{"type": "float", "default": "nan"},
|
||||
{"type": "float", "default": "inf"},
|
||||
# A string default is what the user typed, and a C string in flash
|
||||
{"type": "string", "default": 123},
|
||||
{"type": "string", "default": "a\0b"},
|
||||
{"type": "string", "default": "\0a"},
|
||||
{"type": "float", "default": -1e40},
|
||||
],
|
||||
)
|
||||
def test_variable_rejects_invalid_optionality(var: dict) -> None:
|
||||
with pytest.raises(Invalid):
|
||||
validate_variable(var)
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
logger:
|
||||
|
||||
packages:
|
||||
api: !include test_action_optional_args_common.yaml
|
||||
@@ -0,0 +1,26 @@
|
||||
api:
|
||||
actions:
|
||||
- action: play_buzzer
|
||||
variables:
|
||||
song_str:
|
||||
type: string
|
||||
required: false
|
||||
notes:
|
||||
type: string
|
||||
required: false
|
||||
octave:
|
||||
type: int
|
||||
default: 5
|
||||
bpm:
|
||||
type: int
|
||||
default: 100
|
||||
loud:
|
||||
type: bool
|
||||
default: true
|
||||
then:
|
||||
- logger.log: Action Called
|
||||
- action: plain_action
|
||||
variables:
|
||||
value: int
|
||||
then:
|
||||
- logger.log: Action Called
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
logger:
|
||||
|
||||
packages:
|
||||
api: !include test_action_optional_args_common.yaml
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -78,6 +78,7 @@ api:
|
||||
type: string
|
||||
description: Name to greet
|
||||
example: World
|
||||
default: World
|
||||
then:
|
||||
- logger.log:
|
||||
format: Hello World %s!
|
||||
|
||||
@@ -100,6 +100,14 @@ button:
|
||||
id: climate_test_thermostat
|
||||
custom_fan_mode: !lambda return "quiet";
|
||||
custom_preset: !lambda return "Default";
|
||||
# Inlined lambdas returning a StringRef, the shape an api action string variable takes (issue #21327)
|
||||
- platform: template
|
||||
name: "Climate Control StringRef Lambdas"
|
||||
on_press:
|
||||
- climate.control:
|
||||
id: climate_test_thermostat
|
||||
custom_fan_mode: !lambda return id(climate_test_thermostat).get_custom_fan_mode();
|
||||
custom_preset: !lambda return id(climate_test_thermostat).get_custom_preset();
|
||||
|
||||
# Exercise climate.control inside a trigger with non-empty Ts (number on_value
|
||||
# passes float).
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::core::testing {
|
||||
|
||||
static_assert([] {
|
||||
uint8_t v = 0;
|
||||
assign_bits(v, 1 << 3, true);
|
||||
return v;
|
||||
}() == 0x08);
|
||||
|
||||
TEST(AssignBits, SetsAndClearsOnlyTheMask) {
|
||||
uint8_t reg = 0b1010'0001;
|
||||
assign_bits(reg, 0b0000'0110, true);
|
||||
EXPECT_EQ(reg, 0b1010'0111);
|
||||
assign_bits(reg, 0b1000'0010, false);
|
||||
EXPECT_EQ(reg, 0b0010'0101);
|
||||
}
|
||||
|
||||
TEST(AssignBits, ClearsAtTheTargetWidth) {
|
||||
uint16_t reg16 = 0xFFFF;
|
||||
assign_bits(reg16, 1 << 15, false);
|
||||
EXPECT_EQ(reg16, 0x7FFF);
|
||||
uint64_t reg64 = UINT64_MAX;
|
||||
assign_bits(reg64, uint32_t{1} << 31, false);
|
||||
EXPECT_EQ(reg64, UINT64_MAX & ~(uint64_t{1} << 31));
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -145,6 +145,10 @@ esphome:
|
||||
- light.turn_on:
|
||||
id: test_monochromatic_light
|
||||
effect: !lambda 'return "Strobe";'
|
||||
# Inlined lambda returning a StringRef, the shape an api action string variable takes (issue #21327)
|
||||
- light.turn_on:
|
||||
id: test_monochromatic_light
|
||||
effect: !lambda 'return light::EFFECT_NONE_REF;'
|
||||
# Test effect lambda with non-empty args (repeat passes uint32_t iteration)
|
||||
- repeat:
|
||||
count: 3
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
|
||||
namespace esphome::time::testing {
|
||||
|
||||
// 2026-04-16 23:59:58, a Thursday (day_of_week 5, one-hot bit 4).
|
||||
static constexpr uint8_t RAW[7] = {0x58, 0x59, 0x23, 0x10, 0x16, 0x04, 0x26};
|
||||
// 2026-04-16 23:59:58, a Thursday (day_of_week 5), in each layout.
|
||||
static constexpr uint32_t TIMESTAMP = 1776383998;
|
||||
static constexpr uint8_t EPSON_RAW[7] = {0x58, 0x59, 0x23, 0x10, 0x16, 0x04, 0x26};
|
||||
static constexpr uint8_t DS_RAW[7] = {0x58, 0x59, 0x23, 0x05, 0x16, 0x04, 0x26};
|
||||
static constexpr uint8_t PCF_RAW[7] = {0x58, 0x59, 0x23, 0x16, 0x04, 0x04, 0x26};
|
||||
|
||||
static std::array<uint8_t, 7> patched(const uint8_t (&src)[7], size_t index, uint8_t value) {
|
||||
std::array<uint8_t, 7> raw;
|
||||
std::copy(src, src + 7, raw.begin());
|
||||
raw[index] = value;
|
||||
return raw;
|
||||
}
|
||||
|
||||
template<BcdCalendarLayout LAYOUT> static void expect_round_trip(const uint8_t (&raw)[7]) {
|
||||
const ESPTime time = decode_bcd_calendar<LAYOUT>(raw);
|
||||
EXPECT_EQ(time.day_of_week, 5);
|
||||
EXPECT_EQ(time.day_of_month, 16);
|
||||
EXPECT_EQ(time.timestamp, TIMESTAMP);
|
||||
uint8_t out[7];
|
||||
encode_bcd_calendar<LAYOUT>(time, out);
|
||||
for (size_t i = 0; i < 7; i++)
|
||||
EXPECT_EQ(out[i], raw[i]) << "byte " << i;
|
||||
}
|
||||
|
||||
TEST(BcdCalendar, DecodesEveryField) {
|
||||
const ESPTime time = decode_bcd_calendar(RAW);
|
||||
const ESPTime time = decode_bcd_calendar<EPSON_BCD_CALENDAR>(EPSON_RAW);
|
||||
EXPECT_EQ(time.second, 58);
|
||||
EXPECT_EQ(time.minute, 59);
|
||||
EXPECT_EQ(time.hour, 23);
|
||||
@@ -18,14 +40,14 @@ TEST(BcdCalendar, DecodesEveryField) {
|
||||
EXPECT_EQ(time.day_of_month, 16);
|
||||
EXPECT_EQ(time.month, 4);
|
||||
EXPECT_EQ(time.year, 2026);
|
||||
EXPECT_EQ(time.timestamp, 1776383998);
|
||||
EXPECT_EQ(time.timestamp, TIMESTAMP);
|
||||
EXPECT_TRUE(time.is_valid(true, false));
|
||||
}
|
||||
|
||||
TEST(BcdCalendar, MasksStatusBits) {
|
||||
// Bit 7 of seconds/minutes, the top two bits of hour and day and the top three of month are not time.
|
||||
const uint8_t raw[7] = {0xD8, 0xD9, 0xE3, 0x90, 0xD6, 0xE4, 0x26};
|
||||
const ESPTime time = decode_bcd_calendar(raw);
|
||||
const ESPTime time = decode_bcd_calendar<EPSON_BCD_CALENDAR>(raw);
|
||||
EXPECT_EQ(time.second, 58);
|
||||
EXPECT_EQ(time.minute, 59);
|
||||
EXPECT_EQ(time.hour, 23);
|
||||
@@ -35,17 +57,34 @@ TEST(BcdCalendar, MasksStatusBits) {
|
||||
}
|
||||
|
||||
TEST(BcdCalendar, EmptyWeekdayDecodesAsSunday) {
|
||||
uint8_t raw[7];
|
||||
std::copy(RAW, RAW + 7, raw);
|
||||
raw[3] = 0;
|
||||
EXPECT_EQ(decode_bcd_calendar(raw).day_of_week, 1);
|
||||
EXPECT_EQ(decode_bcd_calendar<EPSON_BCD_CALENDAR>(patched(EPSON_RAW, 3, 0).data()).day_of_week, 1);
|
||||
}
|
||||
|
||||
TEST(BcdCalendar, EncodeRoundTrips) {
|
||||
uint8_t raw[7];
|
||||
encode_bcd_calendar(decode_bcd_calendar(RAW), raw);
|
||||
for (size_t i = 0; i < 7; i++)
|
||||
EXPECT_EQ(raw[i], RAW[i]) << "byte " << i;
|
||||
TEST(BcdCalendar, EveryLayoutRoundTrips) {
|
||||
expect_round_trip<EPSON_BCD_CALENDAR>(EPSON_RAW);
|
||||
expect_round_trip<DS_BCD_CALENDAR>(DS_RAW);
|
||||
expect_round_trip<PCF_BCD_CALENDAR>(PCF_RAW);
|
||||
}
|
||||
|
||||
TEST(BcdCalendar, DsLayoutTwelveHourMode) {
|
||||
EXPECT_EQ(decode_bcd_calendar<DS_BCD_CALENDAR>(patched(DS_RAW, 2, 0x40 | 0x20 | 0x11).data()).hour, 23); // 11 PM
|
||||
EXPECT_EQ(decode_bcd_calendar<DS_BCD_CALENDAR>(patched(DS_RAW, 2, 0x40 | 0x12).data()).hour, 0); // 12 AM
|
||||
EXPECT_EQ(decode_bcd_calendar<DS_BCD_CALENDAR>(patched(DS_RAW, 2, 0x40 | 0x20 | 0x12).data()).hour, 12); // 12 PM
|
||||
}
|
||||
|
||||
TEST(BcdCalendar, DsLayoutCenturyBit) {
|
||||
const ESPTime time = decode_bcd_calendar<DS_BCD_CALENDAR>(patched(DS_RAW, 5, 0x84).data());
|
||||
EXPECT_EQ(time.year, 2126);
|
||||
// 2100 is not a leap year, so the timestamp is one day earlier than a plain every-four-years count gives.
|
||||
EXPECT_EQ(time.timestamp, 4932057598);
|
||||
uint8_t out[7];
|
||||
encode_bcd_calendar<DS_BCD_CALENDAR>(time, out);
|
||||
EXPECT_EQ(out[5], 0x84);
|
||||
EXPECT_EQ(out[6], 0x26);
|
||||
}
|
||||
|
||||
TEST(BcdCalendar, PcfLayoutIgnoresMonthBit7) {
|
||||
EXPECT_EQ(decode_bcd_calendar<PCF_BCD_CALENDAR>(patched(PCF_RAW, 5, 0x84).data()).year, 2026);
|
||||
}
|
||||
|
||||
} // namespace esphome::time::testing
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Year roll-over in ESPTime::increment_second() and increment_day(): 2100 is a common year, 2000 and 2024 are leap
|
||||
// years, so the day of the year must wrap after 365 or 366 days accordingly.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include "esphome/core/time.h"
|
||||
|
||||
namespace esphome::testing {
|
||||
|
||||
static ESPTime last_day_of(uint16_t year, uint16_t day_of_year) {
|
||||
ESPTime t{};
|
||||
t.second = 59;
|
||||
t.minute = 59;
|
||||
t.hour = 23;
|
||||
t.day_of_week = 5;
|
||||
t.day_of_month = 31;
|
||||
t.day_of_year = day_of_year;
|
||||
t.month = 12;
|
||||
t.year = year;
|
||||
return t;
|
||||
}
|
||||
|
||||
static void expect_new_year(const ESPTime &t, uint16_t year) {
|
||||
EXPECT_EQ(t.year, year);
|
||||
EXPECT_EQ(t.month, 1);
|
||||
EXPECT_EQ(t.day_of_month, 1);
|
||||
EXPECT_EQ(t.day_of_year, 1);
|
||||
}
|
||||
|
||||
TEST(ESPTimeIncrement, DayRollsOverCommonCenturyYear) {
|
||||
ESPTime t = last_day_of(2100, 365);
|
||||
t.increment_day();
|
||||
expect_new_year(t, 2101);
|
||||
}
|
||||
|
||||
TEST(ESPTimeIncrement, SecondRollsOverCommonCenturyYear) {
|
||||
ESPTime t = last_day_of(2100, 365);
|
||||
t.increment_second();
|
||||
expect_new_year(t, 2101);
|
||||
EXPECT_EQ(t.hour, 0);
|
||||
EXPECT_EQ(t.minute, 0);
|
||||
EXPECT_EQ(t.second, 0);
|
||||
}
|
||||
|
||||
TEST(ESPTimeIncrement, DayRollsOverLeapYears) {
|
||||
ESPTime t = last_day_of(2000, 366);
|
||||
t.increment_day();
|
||||
expect_new_year(t, 2001);
|
||||
t = last_day_of(2024, 366);
|
||||
t.increment_day();
|
||||
expect_new_year(t, 2025);
|
||||
}
|
||||
|
||||
TEST(ESPTimeIncrement, DayStaysInLeapYearOnDay365) {
|
||||
ESPTime t = last_day_of(2024, 365);
|
||||
t.day_of_month = 30;
|
||||
t.increment_day();
|
||||
EXPECT_EQ(t.year, 2024);
|
||||
EXPECT_EQ(t.day_of_year, 366);
|
||||
EXPECT_EQ(t.day_of_month, 31);
|
||||
}
|
||||
|
||||
} // namespace esphome::testing
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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> ×) {
|
||||
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
|
||||
@@ -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 ¶m = 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user