Compare commits

...
Author SHA1 Message Date
J. Nick Koston 4e0b995299 Merge remote-tracking branch 'origin/dev' into assign-bits-migrate-other
# Conflicts:
#	AGENTS.md
2026-10-22 08:03:23 -10:00
Jonathan Swoboda 13683fa163 [tests] Match the format strings in test lambdas to their argument types (#21406) 2026-10-22 17:59:25 +00:00
J. Nick Koston 01d29001bb [core] Add assign_bits to set or clear register bits from a bool (#21184) 2026-10-22 07:58:37 -10:00
Jonathan Swoboda 9a5fcbce89 [esp32_hosted] Move to esp_hosted 3.0.10 (#21394) 2026-10-22 13:44:04 -04:00
Jonathan Swoboda ce83a625c1 [ms8607] Interpolate the humidity in float (#21405) 2026-10-22 07:42:32 -10:00
Jonathan Swoboda 43732e382e [tmp1075] Write the configuration register in datasheet bit order (#21399) 2026-10-22 13:40:33 -04:00
Jonathan Swoboda 6d1f68af8d [ledc] Reset the peripheral through the HAL on targets without the legacy periph API (#21397) 2026-10-22 13:39:32 -04:00
Jonathan Swoboda adeea9da98 [core] Keep the color codes of a filtered build output line (#21396) 2026-10-22 07:37:21 -10:00
Jonathan Swoboda 946de4223f [esp32] Require ESP-IDF 5.1 and set software coexistence through its Kconfig name (#21398) 2026-10-22 07:36:22 -10:00
Jonathan Swoboda 0ccac0c291 [wifi] Log the lease seconds with PRIu32 (#21403) 2026-10-22 07:33:50 -10:00
Jonathan Swoboda fc1e4bf816 [psram] Allow external task stacks through the Kconfig name ESP-IDF 5.4 uses (#21401) 2026-10-22 07:33:38 -10:00
Jonathan Swoboda 5df921b4e0 [emontx] Initialise the line length before the reader fills it (#21404) 2026-10-22 07:32:50 -10:00
J. Nick Koston fb27e003f3 [time] Make the BCD calendar helpers a template over the chip's layout (#21190) 2026-10-22 07:29:56 -10:00
J. Nick Koston f277476de0 [core] Add ApplyBytes for apply actions that forward a byte payload (#21262) 2026-10-22 07:29:28 -10:00
J. Nick Koston 9673e73893 [light][homeassistant] Drop a held light report when any call comes after it (#21144) 2026-10-22 07:28:52 -10:00
Jonathan Swoboda b722812bd8 [hdmi_cec] Declare the always_inline atomic helpers inline (#21402) 2026-10-22 13:09:43 -04:00
Jonathan Swoboda 8fde15e789 [nrf52] Silence double promotion warnings for the app's variadic log arguments (#21400) 2026-10-22 13:09:10 -04:00
J. Nick KostonandJonathan Swoboda c48d219dc0 [climate] Add StringRef overloads for ClimateCall::set_fan_mode and set_preset (#21359)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-22 16:48:44 +00:00
J. Nick Koston 3bed3f3d4f [http_request] Copy a manifest URL set at run time and keep the config literal by pointer (#21373) 2026-10-22 12:40:26 -04:00
J. Nick Koston 4220cbe63e [mqtt] Resend the fan state when its first publish fails (#21328) 2026-10-22 12:36:49 -04:00
d7bc501095 [esp32_hosted] Download and cache pinned embedded firmware (#20065)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-22 12:20:49 -04:00
J. Nick Koston a04032b345 [light] Add a StringRef overload for LightCall::set_effect (#21358) 2026-10-22 06:54:49 -04:00
Clyde Stubbs 611d8cfb4d [core] Keep a library's optimisation flags to its own component (#21375) 2026-10-22 21:11:10 +11:00
7dace44d61 [api] Add optional user-defined action variables with defaults (#18882)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 22:48:28 -10:00
esphome[bot] 491f98f982 Bump aioesphomeapi from 46.20.0 to 46.21.0 (#21371) 2026-10-22 07:43:14 +00:00
Clyde StubbsandClaude Opus 5.5 619283d74a [mipi_spi] Run the T-Display S3 Pro panel at 40 MHz (#21370)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:26:30 -10:00
18348b8794 [vnc] Add VNC client component (#20623)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-21 21:14:37 -10:00
f7dc2ecb51 [wifi] [ethernet] Bridge the WISP access point to an ethernet upstream (#7147)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 07:02:25 +00:00
ddcc5874ed [api] Let a media_player ask for a constant bitrate (#19962)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 06:17:59 +00:00
esphome[bot] 28013a60e1 Bump aioesphomeapi from 46.19.0 to 46.20.0 (#21362) 2026-10-21 19:19:35 -10:00
J. Nick Koston 7f5be74b73 Include helpers.h where assign_bits is used 2026-10-20 04:05:36 -10:00
J. Nick Koston 7531fd4240 Move the entity, socket and sensor conversions to their own PR 2026-10-20 03:55:10 -10:00
J. Nick Koston 0a830a60f6 Merge remote-tracking branch 'origin/assign-bits-helper' into assign-bits-migrate-other 2026-10-20 03:52:11 -10:00
J. Nick Koston 29259ca74e Always inline assign_bits 2026-10-20 03:52:03 -10:00
J. Nick Koston f6c1d87b1b Leave the IRAM ds2484 search unchanged 2026-10-20 03:46:26 -10:00
J. Nick Koston 829287ec28 Set or clear register bits with assign_bits
Replace the set-or-clear if/else blocks in components with assign_bits, and
drop the local write_bit (opentherm) and set_bit (hitachi_ac344, hitachi_ac424)
copies of the same helper.
2026-10-20 03:40:03 -10:00
J. Nick Koston 6a1b5d517b Note the unsigned mask for wide registers 2026-10-20 03:36:19 -10:00
J. Nick Koston 9687c0e0a8 Take the mask at the target width 2026-10-20 03:31:51 -10:00
J. Nick Koston 230c120898 Add assign_bits to set or clear register bits from a bool 2026-10-20 03:28:19 -10:00
144 changed files with 7204 additions and 846 deletions

No files matched your search

+9 -6
View File
@@ -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:**
+1
View File
@@ -700,6 +700,7 @@ esphome/components/veml7700/* @latonita
esphome/components/version/* @esphome/core
esphome/components/vevor_dtx/* @Bofr5555
esphome/components/vl53l1x/* @will-tm
esphome/components/vnc/* @MindFreeze
esphome/components/voice_assistant/* @jesserockz @kahrendt
esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
esphome/components/watchdog/* @oarcher
+107 -14
View File
@@ -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)
+3 -1
View File
@@ -111,7 +111,9 @@ class ToolOutput(RedirectText):
def _emit_line(self, line: str) -> None:
if self._progress and _PROGRESS.match(line):
if not self._is_filtered(line):
if self._is_filtered(line):
self._drop_line(line)
else:
text = _fit_terminal(line.strip("\r\n"))
self._write_color_replace(f"\r{text}\x1b[K")
self._on_progress_line = True
+100 -11
View File
@@ -1,5 +1,7 @@
from collections.abc import Callable
from ipaddress import IPv4Address, IPv6Address
import logging
import math
import re
from typing import Any
@@ -27,6 +29,7 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DEFAULT,
CONF_DELAY,
CONF_ENCRYPTION,
CONF_EVENT,
@@ -136,6 +139,7 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
CONF_BATCH_DELAY = "batch_delay"
CONF_CUSTOM_SERVICES = "custom_services"
CONF_EXAMPLE = "example"
CONF_REQUIRED = "required"
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
@@ -247,12 +251,66 @@ def _validate_supports_response(value: Any) -> str:
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
VARIABLE_SCHEMA = cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
}
def _not_boolean(value: Any) -> Any:
# bool is an int subclass, and "true" cannot be parsed as a number by the client
if isinstance(value, bool):
raise cv.Invalid("Expected a number, got a boolean")
return value
def _finite(value: float) -> float:
if not math.isfinite(value):
raise cv.Invalid("Expected a finite number")
return value
def _no_nul(value: str) -> str:
# The default is a C string in flash, so a NUL would cut it short
if "\0" in value:
raise cv.Invalid("A default cannot contain a NUL character")
return value
# The wire carries the default as text; the client parses it into ExecuteServiceArgument's
# types, so the values are kept within what int32 and float hold
FLOAT32_MAX = 3.4028235e38
_DEFAULT_VALUE_VALIDATORS: dict[str, Callable[[Any], Any]] = {
"bool": cv.boolean,
"int": cv.All(_not_boolean, cv.int_range(min=-(2**31), max=2**31 - 1)),
"float": cv.All(
_not_boolean,
cv.float_,
_finite,
cv.float_range(min=-FLOAT32_MAX, max=FLOAT32_MAX),
),
"string": cv.All(cv.string_strict, _no_nul),
}
assert _DEFAULT_VALUE_VALIDATORS.keys() == _SERVICE_ARG_SCALAR_TYPES.keys()
def _validate_variable_optionality(value: ConfigType) -> ConfigType:
"""Cross-validate required/default against the variable type."""
var_type = value[CONF_TYPE]
if _is_optional_var(value) and var_type not in _DEFAULT_VALUE_VALIDATORS:
raise cv.Invalid("Array variables cannot be optional")
if CONF_DEFAULT in value:
if value.get(CONF_REQUIRED) is True:
raise cv.Invalid("A variable with a default cannot set required: true")
value[CONF_DEFAULT] = _DEFAULT_VALUE_VALIDATORS[var_type](value[CONF_DEFAULT])
return value
VARIABLE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
cv.Optional(CONF_REQUIRED): cv.boolean,
cv.Optional(CONF_DEFAULT): cv.valid,
}
),
_validate_variable_optionality,
)
# Accepts the plain `name: type` shorthand or the full mapping form
@@ -464,7 +522,28 @@ def _has_action_metadata(actions: list[ConfigType]) -> bool:
)
def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
def _is_optional_var(var_: ConfigType) -> bool:
return CONF_DEFAULT in var_ or var_.get(CONF_REQUIRED) is False
def _has_optional_args(actions: list[ConfigType]) -> bool:
return any(
_is_optional_var(var_)
for conf in actions
for var_ in conf[CONF_VARIABLES].values()
)
def _default_to_wire(value: Any) -> str:
"""Serialize a validated default for the wire; clients coerce by arg type."""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _action_strings(
conf: ConfigType, has_metadata: bool, has_optional: bool
) -> list[str | None]:
"""Strings of one action in the table order UserServiceStatic (user_services.h) expects."""
# An empty description or example is treated as unset
strings: list[str | None] = [conf[CONF_ACTION]]
@@ -477,6 +556,15 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
var_.get(CONF_DESCRIPTION) or None,
var_.get(CONF_EXAMPLE) or None,
]
if has_optional:
# The default slot doubles as the optional flag: nullptr for a required variable,
# "" for an optional one without a default (an empty string default is the same)
if not _is_optional_var(var_):
strings.append(None)
elif (default := var_.get(CONF_DEFAULT)) is None:
strings.append("")
else:
strings.append(_default_to_wire(default))
return strings
@@ -485,9 +573,7 @@ def _validate_wizard(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_wizard,
)
FINAL_VALIDATE_SCHEMA = _validate_wizard
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
@@ -546,6 +632,9 @@ async def to_code(config: ConfigType) -> None:
has_metadata = _has_action_metadata(actions)
if has_metadata:
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
has_optional = _has_optional_args(actions)
if has_optional:
cg.add_define("USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS")
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
for index, conf in enumerate(actions):
@@ -589,7 +678,7 @@ async def to_code(config: ConfigType) -> None:
native = SERVICE_ARG_NATIVE_TYPES[var_type]
service_template_args.append(native)
func_args.append((native, name))
strings = _action_strings(conf, has_metadata)
strings = _action_strings(conf, has_metadata, has_optional)
table = _add_action_strings(index, strings)
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
+6
View File
@@ -1174,6 +1174,10 @@ message ListEntitiesServicesArgument {
ServiceArgType type = 2;
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
// Advisory: ExecuteServiceRequest still requires every argument
bool optional = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS"];
// Always text: bool "true"/"false", numbers parseable as int/float; empty means no default
string default_value = 6 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS", (progmem) = true];
}
message ListEntitiesServicesResponse {
option (id) = 41;
@@ -1785,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;
@@ -1131,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);
}
+14
View File
@@ -1369,6 +1369,12 @@ uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self,
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.default_value);
#endif
return pos;
}
@@ -1382,6 +1388,12 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
size += ProtoSize::calc_length(1, msg.example.size());
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
size += ProtoSize::calc_bool(1, msg.optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
size += ProtoSize::calc_length(1, msg.default_value.size());
#endif
return size;
}
@@ -2373,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) {
@@ -2383,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,
+7
View File
@@ -1593,6 +1593,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
ProgmemStringRef example{};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
bool optional{false};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
ProgmemStringRef default_value{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -2263,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);
+7
View File
@@ -1643,6 +1643,12 @@ const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
dump_field(out, ESPHOME_PSTR("optional"), this->optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
dump_progmem_string_field(out, ESPHOME_PSTR("default_value"), this->default_value);
#endif
return out.c_str();
}
@@ -2099,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 {
+7
View File
@@ -31,6 +31,13 @@ ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_(
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
arg.description = this->str_(base + 1);
arg.example = this->str_(base + 2);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
// The default slot doubles as the optional flag: nullptr is required, "" optional without a default
const char *d = progmem_read_ptr(&this->strings_[base + USER_ACTION_ARG_BASE_STRINGS]);
arg.optional = d != nullptr;
if (d != nullptr)
arg.default_value = ProgmemStringRef(d, ESPHOME_strlen_P(d));
#endif
}
return msg;
+10 -3
View File
@@ -40,13 +40,20 @@ template<typename T> enums::ServiceArgType to_service_arg_type();
// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in
// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then
// each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows
// the name and every argument is (name, description, example). Unset metadata is nullptr.
// the name and every argument is (name, description, example); with
// USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS every argument ends with its default: nullptr for a
// required argument, "" for an optional one without a default. Unset is nullptr.
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
static constexpr size_t USER_ACTION_HEADER_STRINGS = 2;
static constexpr size_t USER_ACTION_ARG_STRINGS = 3;
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 3;
#else
static constexpr size_t USER_ACTION_HEADER_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 1;
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS + 1;
#else
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS;
#endif
class UserServiceStatic : public UserServiceDescriptor {
public:
+8
View File
@@ -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 -1
View File
@@ -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,4 +1,5 @@
#include "epaper_waveshare_bwr.h"
#include "esphome/core/helpers.h"
#include <algorithm>
@@ -36,17 +37,9 @@ void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) {
const auto bwr = color_to_bwr(color);
if (bwr == BwrState::BWR_BLACK) {
this->buffer_[pos] |= bit;
} else {
this->buffer_[pos] &= ~bit;
}
assign_bits(this->buffer_[pos], bit, bwr == BwrState::BWR_BLACK);
if (bwr == BwrState::BWR_RED) {
this->buffer_[red_offset + pos] |= bit;
} else {
this->buffer_[red_offset + pos] &= ~bit;
}
assign_bits(this->buffer_[red_offset + pos], bit, bwr == BwrState::BWR_RED);
}
void EPaperWaveshareBWR::fill(Color color) {
+2 -5
View File
@@ -1,6 +1,7 @@
#include "es8156.h"
#include "es8156_const.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -100,11 +101,7 @@ bool ES8156::set_mute_state_(bool mute_state) {
ESP_LOGV(TAG, "Read ES8156_REG13_DAC_MUTE: %u", reg13);
if (mute_state) {
reg13 |= BIT(1) | BIT(2);
} else {
reg13 &= ~(BIT(1) | BIT(2));
}
assign_bits(reg13, BIT(1) | BIT(2), mute_state);
ESP_LOGV(TAG, "Setting ES8156_REG13_DAC_MUTE to %u (muted: %s)", reg13, YESNO(mute_state));
return this->write_byte(ES8156_REG13_DAC_MUTE, reg13);
+3 -10
View File
@@ -1,6 +1,7 @@
#include "es8311.h"
#include "es8311_const.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -154,11 +155,7 @@ bool ES8311::configure_clock_() {
// Register 0x06
uint8_t reg06;
ES8311_ERROR_CHECK(this->read_byte(ES8311_REG06_CLK_MANAGER, &reg06));
if (this->sclk_inverted_) {
reg06 |= BIT(5);
} else {
reg06 &= ~BIT(5);
}
assign_bits(reg06, BIT(5), this->sclk_inverted_);
reg06 &= 0xE0;
if (coefficient->bclk_div < 19) {
reg06 |= (coefficient->bclk_div - 1) << 0;
@@ -223,11 +220,7 @@ bool ES8311::set_mute_state_(bool mute_state) {
return false;
}
if (mute_state) {
reg31 |= BIT(6) | BIT(5);
} else {
reg31 &= ~(BIT(6) | BIT(5));
}
assign_bits(reg31, BIT(6) | BIT(5), mute_state);
return this->write_byte(ES8311_REG31_DAC, reg31);
}
+2 -5
View File
@@ -2,6 +2,7 @@
#include <cinttypes>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::es8388 {
@@ -184,11 +185,7 @@ bool ES8388::set_mute_state_(bool mute_state) {
// DAC settings that must be preserved. Previously muting overwrote the whole
// register with 0x3C and unmuting never cleared the bit, so once muted the DAC
// could not be unmuted again.
if (mute_state) {
value |= ES8388_DACCONTROL3_DAC_MUTE;
} else {
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
}
assign_bits(value, ES8388_DACCONTROL3_DAC_MUTE, mute_state);
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
return this->write_byte(ES8388_DACCONTROL3, value);
+7 -8
View File
@@ -1303,8 +1303,8 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version:
raise cv.Invalid("Only Arduino 3.0+ is supported.")
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
if version < cv.Version(5, 1, 0):
raise cv.Invalid("Only ESP-IDF 5.1+ is supported.")
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
if version != recommended:
@@ -2851,9 +2851,9 @@ async def _reconcile_network_sdkconfig() -> None:
# Software coexistence: enable when requested (the schema only allows it
# alongside WiFi). Disable only in the Ethernet-without-WiFi case.
if net.software_coexistence:
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", True)
set_idf_sdkconfig_default("CONFIG_ESP_COEX_SW_COEXIST_ENABLE", True)
elif wifi_disabled:
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", False)
set_idf_sdkconfig_default("CONFIG_ESP_COEX_SW_COEXIST_ENABLE", False)
# SoftAP support: drop it when WiFi is used without AP mode (IDF only).
if not is_arduino and net.wifi and not net.wifi_ap:
@@ -3031,10 +3031,9 @@ async def to_code(config):
# assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and
# IDF's noflash placement puts the flash driver's copies in DRAM. The
# basename keeps the panic output useful at a fraction of the size.
# __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2.
if idf_version() >= cv.Version(5, 1, 0):
cg.add_build_flag("-D__FILE__=__FILE_NAME__")
cg.add_build_flag("-Wno-builtin-macro-redefined")
# __FILE_NAME__ is a GCC 12 builtin, which every supported IDF ships.
cg.add_build_flag("-D__FILE__=__FILE_NAME__")
cg.add_build_flag("-Wno-builtin-macro-redefined")
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
CORE.add_job(_finalize_arduino_aware_flags)
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
+1 -7
View File
@@ -473,17 +473,11 @@ def final_validation(config: ConfigType) -> None:
# Check if hosted bluetooth is being used
if ESP32_HOSTED_DOMAIN in full_config:
from esphome.components.esp32_hosted import uses_esp_hosted_3x
add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False)
add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True)
if uses_esp_hosted_3x():
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True)
else:
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True)
# Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client)
has_ble_client = (
+15 -48
View File
@@ -14,15 +14,10 @@
#include <cinttypes>
extern "C" {
#include <esp_hosted.h>
#ifndef CONFIG_ESP_HOSTED_HOST_FEAT_BT
#include <esp_hosted_misc.h>
#include <esp_hosted_bluedroid.h>
#endif
}
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
#include <eh_host_bt.h>
#include <esp_hosted_bt_host_stack.h>
#endif
#endif
#include <esp_bt_device.h>
#include <esp_bt_main.h>
#include <esp_gap_ble_api.h>
@@ -246,44 +241,28 @@ bool ESP32BLE::ble_setup_() {
}
ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
esp_hosted_bt_host_stack_cfg_t bt_cfg{};
bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID;
bt_cfg.bring_up_controller = true;
bt_cfg.controller_ready_timeout_ms = EH_BT_CTRL_DEFAULT_READY_TIMEOUT_MS;
if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) {
ESP_LOGE(TAG,
"BT controller bring-up failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
#else
if (esp_hosted_bt_controller_init() != ESP_OK) {
// Not the library's bring-up: its retry loop can block for minutes on a silent co-processor
err = eh_host_bt_controller_init();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG,
"BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
if (esp_hosted_bt_controller_enable() != ESP_OK) {
ESP_LOGE(TAG,
"BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
err = eh_host_bt_controller_enable();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "BT controller enable failed: %s; BLE disabled", esp_err_to_name(err));
return false;
}
hosted_hci_bluedroid_open();
esp_bluedroid_hci_driver_operations_t operations = {
.send = hosted_hci_bluedroid_send,
.check_send_available = hosted_hci_bluedroid_check_send_available,
.register_host_callback = hosted_hci_bluedroid_register_host_callback,
};
esp_bluedroid_attach_hci_driver(&operations);
#endif
esp_hosted_bt_host_stack_cfg_t bt_cfg{};
bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID;
bt_cfg.bring_up_controller = false;
if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) {
ESP_LOGE(TAG, "BT host stack setup failed; BLE disabled");
return false;
}
#endif
err = esp_bluedroid_init();
@@ -440,23 +419,11 @@ bool ESP32BLE::ble_dismantle_() {
return false;
}
}
#elif defined(CONFIG_ESP_HOSTED_HOST_FEAT_BT)
#else
if (esp_hosted_bt_host_stack_teardown() != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_host_stack_teardown failed");
return false;
}
#else
if (esp_hosted_bt_controller_disable() != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed");
return false;
}
if (esp_hosted_bt_controller_deinit(false) != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed");
return false;
}
hosted_hci_bluedroid_close();
#endif
return true;
}
+46 -220
View File
@@ -1,6 +1,6 @@
import logging
import os
from pathlib import Path
import re
from esphome import pins
from esphome.components import esp32
@@ -8,33 +8,25 @@ from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM
import esphome.config_validation as cv
from esphome.const import (
CONF_CLK_PIN,
CONF_COMPONENTS,
CONF_CS_PIN,
CONF_FRAMEWORK,
CONF_FREQUENCY,
CONF_MISO_PIN,
CONF_MOSI_PIN,
CONF_NAME,
CONF_REF,
CONF_RESET_PIN,
CONF_TYPE,
CONF_VARIANT,
KEY_ESP32,
)
from esphome.core import CORE
from esphome.cpp_generator import add_define
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@swoboda1337"]
DEPENDENCIES = ["esp32"]
DOMAIN = "esp32_hosted"
# esp32_ble raises the task watchdog around the remote BT controller bring-up
AUTO_LOAD = ["watchdog"]
# 3.x is opt-in via a pin under esp32.framework.components (needs ESP-IDF 5.5)
ESP_HOSTED_COMPONENT = "espressif/esp_hosted"
CONF_ACTIVE_HIGH = "active_high"
CONF_BUS_WIDTH = "bus_width"
CONF_CMD_PIN = "cmd_pin"
@@ -72,7 +64,7 @@ _MAX_CUSTOM_MSG_HANDLERS = 8
BASE_SCHEMA = cv.Schema(
{
cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True),
cv.Required(CONF_ACTIVE_HIGH): cv.boolean,
cv.Optional(CONF_ACTIVE_HIGH): cv.boolean,
cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean,
}
@@ -83,7 +75,7 @@ SDIO_SCHEMA = BASE_SCHEMA.extend(
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_CMD_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_D0_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_D1_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_D1_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_D2_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_D3_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_BUS_WIDTH, default=4): cv.one_of(1, 4, int=True),
@@ -95,9 +87,26 @@ SDIO_SCHEMA = BASE_SCHEMA.extend(
)
def _validate_active_high(config: ConfigType) -> ConfigType:
# Remove before 2027.5.0
if (active_high := config.get(CONF_ACTIVE_HIGH)) is None:
return config
if not active_high:
raise cv.Invalid(
"esp_hosted always parks the reset line high with a low pulse, so "
"'active_high: false' is not supported.",
path=[CONF_ACTIVE_HIGH],
)
_LOGGER.warning(
"'active_high' is deprecated and ignored, remove it from esp32_hosted. "
"Removed in 2027.5.0"
)
return config
def _validate_sdio(config: ConfigType) -> ConfigType:
if config[CONF_BUS_WIDTH] == 4:
for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN):
for pin in (CONF_D2_PIN, CONF_D3_PIN):
if pin not in config:
raise cv.Invalid(
f"{pin} is required when bus_width is 4",
@@ -150,121 +159,26 @@ def _validate_spi(config: ConfigType) -> ConfigType:
CONFIG_SCHEMA = cv.typed_schema(
{
"sdio": cv.All(SDIO_SCHEMA, _validate_sdio),
"spi": cv.All(SPI_SCHEMA, _validate_spi),
"sdio": cv.All(SDIO_SCHEMA, _validate_sdio, _validate_active_high),
"spi": cv.All(SPI_SCHEMA, _validate_spi, _validate_active_high),
},
default_type="sdio",
)
# Version specs that stay within one major (exact, ^, ~, ~=); other ranges are 3.x
_PINNED_MAJOR = re.compile(r"^(?:==|\^|~=|~)?(\d+)(?:\.(?:\d+|\*))*$")
def user_esp_hosted_major() -> int | None:
"""Major version of a user-pinned esp_hosted, or None without a pin."""
try:
full_config = fv.full_config.get()
except LookupError:
full_config = CORE.config
esp32_config = full_config.get(KEY_ESP32) or {}
for component in esp32_config.get(CONF_FRAMEWORK, {}).get(CONF_COMPONENTS, []):
if component.get(CONF_NAME) != ESP_HOSTED_COMPONENT:
continue
ref = component.get(CONF_REF, "").strip()
if not ref:
return 2
if match := _PINNED_MAJOR.match(ref):
return int(match.group(1))
return 3
return None
def uses_esp_hosted_3x() -> bool:
"""Whether the user pinned esp_hosted 3.x."""
return (major := user_esp_hosted_major()) is not None and major >= 3
def _final_validate(config: ConfigType) -> None:
# The esp_hosted releases compatible with older ESP-IDF versions crash at
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
# 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time.
if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0):
if (idf_ver := esp32.idf_version()) < cv.Version(5, 5, 0):
raise cv.Invalid(
f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. "
f"esp32_hosted requires ESP-IDF 5.5 or newer, got {idf_ver}. "
"Remove the framework version from your configuration to use the "
"recommended version, or pin a version at or above 5.3."
)
if (major := user_esp_hosted_major()) is None or major < 3:
return
if idf_ver < cv.Version(5, 5, 0):
raise cv.Invalid(
f"esp_hosted 3.x requires ESP-IDF 5.5 or newer, got {idf_ver}. "
f"Remove the {ESP_HOSTED_COMPONENT} pin from esp32.framework."
"components to stay on the 2.x line, or use ESP-IDF 5.5 or newer."
)
if not config[CONF_ACTIVE_HIGH]:
raise cv.Invalid(
"esp_hosted 3.x always parks the reset line high with a low pulse, so "
"'active_high: false' cannot be expressed on it. Remove the "
f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay "
"on the 2.x line."
)
if config[CONF_TYPE] == "sdio" and config[CONF_BUS_WIDTH] == 1:
raise cv.Invalid(
"esp_hosted 3.x cannot be built with a 1-bit SDIO bus "
"(espressif/esp-hosted-mcu#245). Remove the "
f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay "
"on the 2.x line, or use a 4-bit bus."
"recommended version, or pin a version at or above 5.5."
)
FINAL_VALIDATE_SCHEMA = _final_validate
def _configure_sdio_2x(config: ConfigType) -> None:
slot = config[CONF_SLOT]
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}",
True,
)
if config[CONF_BUS_WIDTH] == 1:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SDIO_1_BIT_BUS", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS", True)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_{slot}",
config[CONF_CLK_PIN],
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_{slot}",
config[CONF_CMD_PIN],
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_{slot}",
config[CONF_D0_PIN],
)
if config[CONF_BUS_WIDTH] == 4:
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_{slot}",
config[CONF_D1_PIN],
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_{slot}",
config[CONF_D2_PIN],
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_{slot}",
config[CONF_D3_PIN],
)
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ",
int(config[CONF_SDIO_FREQUENCY] // 1000),
)
def _configure_sdio_3x(config: ConfigType) -> None:
def _configure_sdio(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO", True)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SDIO_SLOT_{config[CONF_SLOT]}",
@@ -283,10 +197,11 @@ def _configure_sdio_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0", config[CONF_D0_PIN]
)
# D1 carries the card interrupt on a 1-bit bus too
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN]
)
if config[CONF_BUS_WIDTH] == 4:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2", config[CONF_D2_PIN]
)
@@ -299,55 +214,7 @@ def _configure_sdio_3x(config: ConfigType) -> None:
)
def _configure_spi_2x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True)
# SPI mode is set via per-variant choice options
variant = config[CONF_VARIANT]
mode = config[CONF_SPI_MODE]
suffix = "ESP32" if variant == "ESP32" else "ESP32XX"
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SPI_PRIV_MODE_{mode}_{suffix}",
True,
)
# Frequency is set via per-variant options
freq_mhz = int(config[CONF_FREQUENCY] // 1e6)
if variant == "ESP32":
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32", freq_mhz)
elif variant == "ESP32C6":
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32C6", freq_mhz)
else:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32XX", freq_mhz)
# Pin configuration (use HSPI variant as P4/H2 hosts don't have VSPI)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_MOSI", config[CONF_MOSI_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_MISO", config[CONF_MISO_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_CLK", config[CONF_CLK_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_CS", config[CONF_CS_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_GPIO_HANDSHAKE", config[CONF_HANDSHAKE_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_GPIO_DATA_READY", config[CONF_DATA_READY_PIN]
)
# Handshake and data_ready polarity
if config[CONF_HANDSHAKE_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HS_ACTIVE_HIGH", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HS_ACTIVE_LOW", True)
if config[CONF_DATA_READY_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_HIGH", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True)
def _configure_spi_3x(config: ConfigType) -> None:
def _configure_spi(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SPI", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_MODE", config[CONF_SPI_MODE]
@@ -392,59 +259,28 @@ def _configure_spi_3x(config: ConfigType) -> None:
)
def _configure_2x(config: ConfigType) -> None:
transport_prefix = "SDIO" if config[CONF_TYPE] == "sdio" else "SPI"
# Reset polarity
if config[CONF_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_{transport_prefix}_RESET_ACTIVE_HIGH", True
)
else:
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_{transport_prefix}_RESET_ACTIVE_LOW", True
)
# Reset GPIO
async def to_code(config: ConfigType) -> None:
add_define("USE_ESP32_HOSTED")
# The board choice defaults to an Espressif dev board, which locks every
# pin's Kconfig range to that board and silently drops other values
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_P4_DEV_BOARD_NONE", True)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_{transport_prefix}_GPIO_RESET_SLAVE", # NOLINT
config[CONF_RESET_PIN],
"CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN]
)
# Slave variant # NOLINT
# Co-processor variant. Wi-Fi Remote owns the choice and esp_hosted only
# offers the matching CP target, so both must name the same chip
esp32.add_idf_sdkconfig_option(
f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT
True,
)
if config[CONF_TYPE] == "sdio":
_configure_sdio_2x(config)
else:
_configure_spi_2x(config)
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS
)
# Place the transport mempool in PSRAM. Required on memory-tight host
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
# mempool allocation fails at boot with `sdio_mempool_create` assert.
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
def _configure_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN]
)
# Co-processor variant
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_CP_TARGET_{config[CONF_VARIANT]}",
True,
)
if config[CONF_TYPE] == "sdio":
_configure_sdio_3x(config)
_configure_sdio(config)
else:
_configure_spi_3x(config)
_configure_spi(config)
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA", True)
esp32.add_idf_sdkconfig_option(
@@ -455,16 +291,6 @@ def _configure_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_EH_HOST_PORT_DMA_PREFER_SPIRAM", True)
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DFLT_TASK_FROM_SPIRAM", True)
async def to_code(config: ConfigType) -> None:
add_define("USE_ESP32_HOSTED")
use_3x = uses_esp_hosted_3x()
if use_3x:
_configure_3x(config)
else:
_configure_2x(config)
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
# gated on the `espnow` component being present: the shim is tiny and the
@@ -478,14 +304,14 @@ async def to_code(config: ConfigType) -> None:
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
esp32.include_builtin_idf_component("esp_wifi")
# Library versions; this component set requires ESP-IDF 5.3 or newer,
# Library versions; this component set requires ESP-IDF 5.5 or newer,
# which is enforced at validation time.
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.5")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13")
esp32.add_idf_component(name="espressif/esp_hosted", ref="3.0.10")
esp32.add_extra_script(
"post",
"esp32_hosted.py",
@@ -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)
@@ -24,8 +24,8 @@ namespace esphome::esp32_hosted {
ESPHOME_LOG_TAG(TAG, "esp32_hosted.update");
// Older coprocessor firmware versions have a 1500-byte limit per RPC call
constexpr size_t CHUNK_SIZE = 1500;
// The host rejects writes above its per-RPC payload limit before they reach the coprocessor
constexpr size_t CHUNK_SIZE = EH_HOST_CP_OTA_CHUNK_MAX;
// OTA begin blocks while the coprocessor erases its partition, end while it validates the image
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
+21 -16
View File
@@ -46,6 +46,7 @@ from esphome.const import (
CONF_TYPE,
CONF_USE_ADDRESS,
CONF_VALUE,
CONF_WIFI,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
Platform,
@@ -562,6 +563,25 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate_wifi(config: ConfigType) -> None:
"""A wifi station competes with ethernet for the primary interface, so both must be in network: priority:."""
full_config = fv.full_config.get()
if (wifi_config := full_config.get(CONF_WIFI)) is None:
return
from esphome.components.wifi import wifi_stays_ap_only
if wifi_stays_ap_only(wifi_config, full_config):
return # an access point does not claim the primary interface
priority_ifaces = get_priority_interfaces_from_full_config(full_config)
if missing := [i for i in (DOMAIN, CONF_WIFI) if i not in priority_ifaces]:
raise cv.Invalid(
"When ethernet and a wifi station are used together, 'network: priority:' "
f"must list both interfaces; missing: {', '.join(missing)}"
)
def _final_validate_spi(config: ConfigType) -> None:
if not CORE.is_esp32:
return # SPI interface validation is ESP32-only
@@ -846,22 +866,7 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> None:
"""Final validation for Ethernet component."""
# Allow ethernet + wifi coexistence only when both are declared in network: priority:.
if "wifi" in fv.full_config.get():
priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get())
missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces]
if missing and priority_ifaces:
# A priority list exists but is incomplete: point at what to add.
raise cv.Invalid(
"When ethernet and wifi are used together, 'network: priority:' must "
f"list both interfaces; missing: {', '.join(missing)}"
)
if missing:
raise cv.Invalid(
"Component ethernet cannot be used together with component wifi "
"unless both are listed under 'network: priority:'"
)
_final_validate_wifi(config)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
+2 -5
View File
@@ -1,5 +1,6 @@
#include "gree.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
namespace esphome::gree {
@@ -24,11 +25,7 @@ void GreeClimate::set_model(Model model) {
}
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
if (enabled) {
this->mode_bits_ |= bit_mask;
} else {
this->mode_bits_ &= ~bit_mask;
}
assign_bits(this->mode_bits_, bit_mask, enabled);
this->transmit_state();
}
+2 -2
View File
@@ -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);
}
@@ -1,4 +1,5 @@
#include "hitachi_ac344.h"
#include "esphome/core/helpers.h"
namespace esphome::hitachi_ac344 {
@@ -16,15 +17,6 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
*dst |= ((data & mask) << offset);
}
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
uint8_t mask = 1 << position;
if (on) {
*data |= mask;
} else {
*data &= ~mask;
}
}
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
for (uint16_t i = 1; i < length; i += 2) {
// Code done this way to avoid a compiler warning bug.
@@ -124,7 +116,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC3
void HitachiClimate::set_swing_v_(bool on) {
set_swing_v_toggle_(on); // Set the button value.
set_bit(&remote_state_[HITACHI_AC344_SWINGV_BYTE], HITACHI_AC344_SWINGV_OFFSET, on);
assign_bits(this->remote_state_[HITACHI_AC344_SWINGV_BYTE], 1 << HITACHI_AC344_SWINGV_OFFSET, on);
}
bool HitachiClimate::get_swing_v_() {
@@ -1,4 +1,5 @@
#include "hitachi_ac424.h"
#include "esphome/core/helpers.h"
namespace esphome::hitachi_ac424 {
@@ -16,15 +17,6 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
*dst |= ((data & mask) << offset);
}
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
uint8_t mask = 1 << position;
if (on) {
*data |= mask;
} else {
*data &= ~mask;
}
}
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
for (uint16_t i = 1; i < length; i += 2) {
// Code done this way to avoid a compiler warning bug.
@@ -124,7 +116,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC4
void HitachiClimate::set_swing_v_(bool on) {
set_swing_v_toggle_(on); // Set the button value.
set_bit(&remote_state_[HITACHI_AC424_SWINGV_BYTE], HITACHI_AC424_SWINGV_OFFSET, on);
assign_bits(this->remote_state_[HITACHI_AC424_SWINGV_BYTE], 1 << HITACHI_AC424_SWINGV_OFFSET, on);
}
bool HitachiClimate::get_swing_v_() {
@@ -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
+3 -1
View File
@@ -169,7 +169,9 @@ void LEDCOutput::setup() {
#if !defined(CLANG_TIDY)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); }
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) || \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) && !defined(__PERIPH_CTRL_ALLOW_LEGACY_API))
// Also on the targets whose periph_module_reset() is an empty stub in ESP-IDF 5.x (P4, C5, C61, H4)
PERIPH_RCC_ATOMIC() {
ledc_ll_enable_reset_reg(true);
ledc_ll_enable_reset_reg(false);
+2
View File
@@ -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) {
+3
View File
@@ -1,6 +1,7 @@
#pragma once
#include "light_color_values.h"
#include "esphome/core/string_ref.h"
namespace esphome {
@@ -133,6 +134,8 @@ class LightCall {
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
/// Set the effect of the light by its name (const char * overload to resolve ambiguity).
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
/// Set the effect of the light by its name (StringRef overload to resolve ambiguity, zero-copy).
LightCall &set_effect(StringRef effect) { return this->set_effect(effect.c_str(), effect.size()); }
/// Set the effect of the light by its name and length (zero-copy from API).
LightCall &set_effect(const char *effect, size_t len);
/// Set the effect of the light by its internal index number (only for internal use).
+6
View File
@@ -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")
@@ -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,
+1 -2
View File
@@ -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));
+2 -2
View File
@@ -329,8 +329,8 @@ void MS8607Component::read_humidity_(float temperature_float) {
humidity &= ~(0b11); // strip status & unassigned bits from data
// map 16 bit humidity value into range [-6%, 118%]
float const humidity_partial = double(humidity) / (1 << 16);
float const humidity_percentage = std::lerp(-6.0, 118.0, humidity_partial);
float const humidity_partial = float(humidity) / (1 << 16);
float const humidity_percentage = std::lerp(-6.0f, 118.0f, humidity_partial);
float const compensated_humidity_percentage =
humidity_percentage + (20 - temperature_float) * MS8607_H_TEMP_COEFFICIENT;
ESP_LOGD(TAG, "Compensated for temperature, humidity=%.2f%%", compensated_humidity_percentage);
+13 -2
View File
@@ -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)}"',
")",
]
+16 -16
View File
@@ -33,22 +33,22 @@ uint16_t parse_u8_hb_60(OpenthermData &data) { return data.valueHB * 60; }
int16_t parse_s16(OpenthermData &data) { return data.get_s16(); }
float parse_f88(OpenthermData &data) { return data.get_f88(); }
void write_flag8_lb_0(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 0, value); }
void write_flag8_lb_1(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 1, value); }
void write_flag8_lb_2(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 2, value); }
void write_flag8_lb_3(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 3, value); }
void write_flag8_lb_4(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 4, value); }
void write_flag8_lb_5(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 5, value); }
void write_flag8_lb_6(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 6, value); }
void write_flag8_lb_7(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 7, value); }
void write_flag8_hb_0(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 0, value); }
void write_flag8_hb_1(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 1, value); }
void write_flag8_hb_2(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 2, value); }
void write_flag8_hb_3(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 3, value); }
void write_flag8_hb_4(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 4, value); }
void write_flag8_hb_5(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 5, value); }
void write_flag8_hb_6(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 6, value); }
void write_flag8_hb_7(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 7, value); }
void write_flag8_lb_0(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 0, value); }
void write_flag8_lb_1(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 1, value); }
void write_flag8_lb_2(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 2, value); }
void write_flag8_lb_3(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 3, value); }
void write_flag8_lb_4(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 4, value); }
void write_flag8_lb_5(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 5, value); }
void write_flag8_lb_6(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 6, value); }
void write_flag8_lb_7(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 7, value); }
void write_flag8_hb_0(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 0, value); }
void write_flag8_hb_1(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 1, value); }
void write_flag8_hb_2(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 2, value); }
void write_flag8_hb_3(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 3, value); }
void write_flag8_hb_4(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 4, value); }
void write_flag8_hb_5(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 5, value); }
void write_flag8_hb_6(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 6, value); }
void write_flag8_hb_7(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 7, value); }
void write_u8_lb(const uint8_t value, OpenthermData &data) { data.valueLB = value; }
void write_u8_hb(const uint8_t value, OpenthermData &data) { data.valueHB = value; }
void write_s8_lb(const int8_t value, OpenthermData &data) { data.valueLB = (uint8_t) value; }
-8
View File
@@ -20,14 +20,6 @@ namespace esphome::opentherm {
template<class T> constexpr T read_bit(T value, uint8_t bit) { return (value >> bit) & 0x01; }
template<class T> constexpr T set_bit(T value, uint8_t bit) { return value |= (1UL << bit); }
template<class T> constexpr T clear_bit(T value, uint8_t bit) { return value &= ~(1UL << bit); }
template<class T> constexpr T write_bit(T value, uint8_t bit, uint8_t bit_value) {
return bit_value ? set_bit(value, bit) : clear_bit(value, bit);
}
enum OperationMode {
IDLE = 0, // no operation
+1 -5
View File
@@ -90,11 +90,7 @@ void HOT PCD8544::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y % 8;
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
}
void PCD8544::dump_config() {
+5 -1
View File
@@ -115,7 +115,11 @@ def request_external_task_stack() -> None:
``to_code`` when the option is enabled. The sdkconfig option only permits external
stacks; it does not move any stack into PSRAM on its own, so it stays opt-in per task.
"""
add_idf_sdkconfig_option("CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True)
# Renamed in ESP-IDF 5.4; the old name is still mapped there, but each configure reports it
if idf_version() >= cv.Version(5, 4, 0):
add_idf_sdkconfig_option("CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM", True)
else:
add_idf_sdkconfig_option("CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True)
def validate_task_stack_in_psram(value: Any) -> bool:
@@ -1,5 +1,6 @@
#include "runtime_image.h"
#include "image_decoder.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstdint>
@@ -91,11 +92,7 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
auto on = is_color_on(color);
if (this->has_transparency() && color.w < 0x80)
on = false;
if (on) {
this->buffer_[pos] |= bitno;
} else {
this->buffer_[pos] &= ~bitno;
}
assign_bits(this->buffer_[pos], bitno, on);
break;
}
case image::IMAGE_TYPE_RGB111: {
+2 -2
View File
@@ -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));
+2 -2
View File
@@ -60,7 +60,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 +88,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);
+2 -2
View File
@@ -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));
@@ -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,
}
)
@@ -342,11 +342,7 @@ void HOT SSD1306::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y & 0x07;
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
}
void SSD1306::fill(Color color) {
// If clipping is active, fall back to base implementation
+2 -5
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::st7305 {
@@ -191,11 +192,7 @@ void HOT ST7305::draw_absolute_pixel_internal(int x, int y, Color color) {
// The init sequence enables INVON, so a set bit is white and a clear bit is black.
uint8_t &value = this->buffer_[index];
const uint8_t previous = value;
if (color.is_on()) {
value |= mask;
} else {
value &= ~mask;
}
assign_bits(value, mask, color.is_on());
if (value == previous)
return;
this->extend_dirty_(x, y, x, y);
@@ -123,11 +123,7 @@ void HOT ST7567::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y & 0x07;
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
}
void ST7567::fill(Color color) {
@@ -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
+90 -5
View File
@@ -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
+15 -15
View File
@@ -51,8 +51,8 @@ void TMP1075Sensor::dump_config() {
" polarity : %d\n"
" alert_mode : %d\n"
" shutdown : %d",
alert_limit_low_, alert_limit_high_, config_.fields.oneshot, config_.fields.rate, config_.fields.faults,
config_.fields.polarity, config_.fields.alert_mode, config_.fields.shutdown);
this->alert_limit_low_, this->alert_limit_high_, this->config_.oneshot, this->config_.rate,
this->config_.faults, this->config_.polarity, this->config_.alert_mode, this->config_.shutdown);
}
void TMP1075Sensor::set_fault_count(const int faults) {
@@ -64,7 +64,7 @@ void TMP1075Sensor::set_fault_count(const int faults) {
ESP_LOGE(TAG, "'%s' - fault_count too high: %d", LOG_STR_ARG(this->get_log_name()), faults);
return;
}
config_.fields.faults = faults - 1;
this->config_.faults = faults - 1;
}
void TMP1075Sensor::log_config_() {
@@ -75,28 +75,28 @@ void TMP1075Sensor::log_config_() {
" polarity : %d\n"
" alert_mode: %d\n"
" shutdown : %d",
config_.fields.oneshot, config_.fields.rate, config_.fields.faults, config_.fields.polarity,
config_.fields.alert_mode, config_.fields.shutdown);
this->config_.oneshot, this->config_.rate, this->config_.faults, this->config_.polarity,
this->config_.alert_mode, this->config_.shutdown);
}
void TMP1075Sensor::write_config() {
send_alert_limit_low_();
send_alert_limit_high_();
send_config_();
this->send_alert_limit_low_();
this->send_alert_limit_high_();
this->send_config_();
}
void TMP1075Sensor::send_config_() {
ESP_LOGV(TAG, "'%s' - sending configuration %02x", LOG_STR_ARG(this->get_log_name()), config_.regvalue);
log_config_();
if (!this->write_byte(REG_CFGR, config_.regvalue)) {
ESP_LOGV(TAG, "'%s' - sending configuration %02x", LOG_STR_ARG(this->get_log_name()), this->config_.to_byte());
this->log_config_();
if (!this->write_byte(REG_CFGR, this->config_.to_byte())) {
ESP_LOGW(TAG, "'%s' - unable to write configuration register", LOG_STR_ARG(this->get_log_name()));
return;
}
}
void TMP1075Sensor::send_alert_limit_low_() {
ESP_LOGV(TAG, "'%s' - sending alert limit low %.3f °C", LOG_STR_ARG(this->get_log_name()), alert_limit_low_);
const uint16_t regvalue = temp2regvalue(alert_limit_low_);
ESP_LOGV(TAG, "'%s' - sending alert limit low %.3f °C", LOG_STR_ARG(this->get_log_name()), this->alert_limit_low_);
const uint16_t regvalue = temp2regvalue(this->alert_limit_low_);
if (!this->write_byte_16(REG_LLIM, regvalue)) {
ESP_LOGW(TAG, "'%s' - unable to write low limit register", LOG_STR_ARG(this->get_log_name()));
return;
@@ -104,8 +104,8 @@ void TMP1075Sensor::send_alert_limit_low_() {
}
void TMP1075Sensor::send_alert_limit_high_() {
ESP_LOGV(TAG, "'%s' - sending alert limit high %.3f °C", LOG_STR_ARG(this->get_log_name()), alert_limit_high_);
const uint16_t regvalue = temp2regvalue(alert_limit_high_);
ESP_LOGV(TAG, "'%s' - sending alert limit high %.3f °C", LOG_STR_ARG(this->get_log_name()), this->alert_limit_high_);
const uint16_t regvalue = temp2regvalue(this->alert_limit_high_);
if (!this->write_byte_16(REG_HLIM, regvalue)) {
ESP_LOGW(TAG, "'%s' - unable to write high limit register", LOG_STR_ARG(this->get_log_name()));
return;
+31 -30
View File
@@ -7,37 +7,38 @@
namespace esphome::tmp1075 {
struct TMP1075Config {
union {
struct {
uint8_t oneshot : 1; // One-shot conversion mode. Writing 1, starts a single temperature
// conversion. Read returns 0.
bool oneshot{false}; // One-shot conversion mode. Writing 1, starts a single temperature
// conversion. Read returns 0.
uint8_t rate : 2; // Conversion rate setting when device is in continuous conversion mode.
// 00: 27.5 ms conversion rate
// 01: 55 ms conversion rate
// 10: 110 ms conversion rate
// 11: 220 ms conversion rate (35 ms TMP1075N)
uint8_t rate{0}; // Conversion rate setting when device is in continuous conversion mode.
// 00: 27.5 ms conversion rate
// 01: 55 ms conversion rate
// 10: 110 ms conversion rate
// 11: 220 ms conversion rate (35 ms TMP1075N)
uint8_t faults : 2; // Consecutive fault measurements to trigger the alert function.
// 00: 1 fault
// 01: 2 faults
// 10: 3 faults (4 faults TMP1075N)
// 11: 4 faults (6 faults TMP1075N)
uint8_t faults{0}; // Consecutive fault measurements to trigger the alert function.
// 00: 1 fault
// 01: 2 faults
// 10: 3 faults (4 faults TMP1075N)
// 11: 4 faults (6 faults TMP1075N)
uint8_t polarity : 1; // Polarity of the output pin.
// 0: Active low ALERT pin
// 1: Active high ALERT pin
bool polarity{false}; // Polarity of the output pin.
// 0: Active low ALERT pin
// 1: Active high ALERT pin
uint8_t alert_mode : 1; // Selects the function of the ALERT pin.
// 0: ALERT pin functions in comparator mode
// 1: ALERT pin functions in interrupt mode
bool alert_mode{false}; // Selects the function of the ALERT pin.
// 0: ALERT pin functions in comparator mode
// 1: ALERT pin functions in interrupt mode
uint8_t shutdown : 1; // Sets the device in shutdown mode to conserve power.
// 0: Device is in continuous conversion
// 1: Device is in shutdown mode
} fields;
uint8_t regvalue;
};
bool shutdown{false}; // Sets the device in shutdown mode to conserve power.
// 0: Device is in continuous conversion
// 1: Device is in shutdown mode
// Upper byte of the configuration register: OS R1 R0 F1 F0 POL TM SD
uint8_t to_byte() const {
return (this->oneshot << 7) | ((this->rate & 0x03) << 5) | ((this->faults & 0x03) << 3) | (this->polarity << 2) |
(this->alert_mode << 1) | this->shutdown;
}
};
enum EConversionRate {
@@ -63,10 +64,10 @@ class TMP1075Sensor final : public PollingComponent, public sensor::Sensor, publ
// the IC. The setup() function also does this.
void set_alert_limit_low(const float temp) { this->alert_limit_low_ = temp; }
void set_alert_limit_high(const float temp) { this->alert_limit_high_ = temp; }
void set_oneshot(const bool oneshot) { config_.fields.oneshot = oneshot; }
void set_conversion_rate(const enum EConversionRate rate) { config_.fields.rate = rate; }
void set_alert_polarity(const bool polarity) { config_.fields.polarity = polarity; }
void set_alert_function(const enum EAlertFunction function) { config_.fields.alert_mode = function; }
void set_oneshot(const bool oneshot) { this->config_.oneshot = oneshot; }
void set_conversion_rate(const enum EConversionRate rate) { this->config_.rate = rate; }
void set_alert_polarity(const bool polarity) { this->config_.polarity = polarity; }
void set_alert_function(const enum EAlertFunction function) { this->config_.alert_mode = function; }
void set_fault_count(int faults);
void write_config();
+138
View File
@@ -0,0 +1,138 @@
"""VNC (RFB) client that shows a remote screen on a display."""
from esphome import automation
import esphome.codegen as cg
from esphome.components import display, network, socket
from esphome.components.const import (
BYTE_ORDER_LITTLE,
CONF_HOST,
CONF_RECONNECT_INTERVAL,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_DISPLAY_ID,
CONF_ID,
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_PLATFORM,
CONF_PORT,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@MindFreeze"]
DEPENDENCIES = ["display", "network"]
AUTO_LOAD = ["socket"]
DOMAIN = "vnc"
MULTI_CONF = True
CONF_MAX_UPDATE_DELAY = "max_update_delay"
CONF_UPDATE_DELAY = "update_delay"
vnc_ns = cg.esphome_ns.namespace("vnc")
VncClient = vnc_ns.class_("VncClient", cg.Component)
# Drivers that cannot take another component's pixels yet.
UNSUPPORTED_DISPLAYS = dict.fromkeys(
("mipi_dsi", "mipi_rgb", "mipi_spi"), "it is not supported by vnc yet"
) | {
"waveshare_epaper": "it draws every colour except pure black as ink; use epaper_spi"
}
def _register_writer(config: ConfigType) -> ConfigType:
display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(VncClient),
cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display),
cv.Required(CONF_HOST): socket.ipv4_host,
cv.Optional(CONF_PORT, default=5900): cv.port,
cv.Optional(CONF_RECONNECT_INTERVAL, default="5s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1)),
),
cv.Optional(
CONF_UPDATE_DELAY, default="200ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_MAX_UPDATE_DELAY, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA),
_register_writer,
socket.consume_sockets(1, DOMAIN),
network.require_ipv4(DOMAIN),
)
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
disp_id = config[CONF_DISPLAY_ID]
disp_conf = fv.get_declaring_config(disp_id)
if (platform := disp_conf.get(CONF_PLATFORM)) in UNSUPPORTED_DISPLAYS:
raise cv.Invalid(
f"Display '{disp_id}' uses the {platform} platform: "
f"{UNSUPPORTED_DISPLAYS[platform]}",
path=[CONF_DISPLAY_ID],
)
if (
display.get_display_metadata(disp_id).has_writer
or disp_conf.get(CONF_AUTO_CLEAR_ENABLED) is True
):
raise cv.Invalid(
f"vnc draws the whole of display '{disp_id}'; remove lambda, pages, "
"auto_clear_enabled and show_test_card from it",
path=[CONF_DISPLAY_ID],
)
if len(writers := display.get_external_writers(disp_id)) > 1:
raise cv.Invalid(
f"Display '{disp_id}' is drawn by more than one component: {', '.join(writers)}",
path=[CONF_DISPLAY_ID],
)
lvgl_configs = full_config.get("lvgl") or []
if isinstance(lvgl_configs, dict):
lvgl_configs = [lvgl_configs]
for lvgl_config in lvgl_configs:
if any(d.id == disp_id.id for d in lvgl_config.get("displays", [])):
raise cv.Invalid(
f"Display '{disp_id}' is also used by LVGL", path=[CONF_DISPLAY_ID]
)
FINAL_VALIDATE_SCHEMA = _final_validate
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
)
async def to_code(config: ConfigType) -> None:
disp = await cg.get_variable(config[CONF_DISPLAY_ID])
# Ask the server for the display's own byte order, so block-copying drivers skip the per-pixel path
meta = display.get_display_metadata(config[CONF_DISPLAY_ID])
var = cg.new_Pvariable(config[CONF_ID], disp, meta.byte_order != BYTE_ORDER_LITTLE)
await cg.register_component(var, config)
socket.require_tcp_client_link()
cg.add(var.set_host(config[CONF_HOST]))
cg.add(var.set_port(config[CONF_PORT]))
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
cg.add(var.set_update_delay(config[CONF_UPDATE_DELAY]))
cg.add(var.set_max_update_delay(config[CONF_MAX_UPDATE_DELAY]))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
VNC_ID_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(VncClient)},
)
automation.register_apply_condition("vnc.is_connected", VNC_ID_SCHEMA, "is_connected()")
+702
View File
@@ -0,0 +1,702 @@
#include "vnc_client.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cinttypes>
#include <cstring>
namespace esphome::vnc {
ESPHOME_LOG_TAG(TAG, "vnc");
// Below the 50 ms blocking warning, and the same slice it8951 uses for its transfers.
static constexpr uint32_t LOOP_BUDGET_MS = 20;
static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 15000;
static constexpr uint32_t STABLE_SESSION_MS = 60000;
static constexpr uint32_t MAX_BACKOFF_MS = 60000;
static constexpr uint32_t CONFIG_RETRY_MS = 300000;
static constexpr uint32_t HEARTBEAT_MS = 60000;
static constexpr uint32_t MIN_REQUEST_SPACING_MS = 50;
// The longest the first refresh waits for a display with a loop to park once after boot.
static constexpr uint32_t DISPLAY_START_MS = 5000;
// Longer than any driver's own busy timeout: a panel that stays busy this long is stuck
static constexpr uint32_t DISPLAY_BUSY_TIMEOUT_MS = 120000;
static constexpr uint16_t MAX_CURSOR_SIDE = 1024;
static constexpr uint32_t MAX_TEXT_LEN = 63;
static constexpr uint8_t SECURITY_NONE = 1;
static constexpr uint8_t SECURITY_VNC_AUTH = 2;
static constexpr int32_t ENCODING_RAW = 0;
static constexpr int32_t ENCODING_RICH_CURSOR = -239;
static constexpr uint8_t MSG_FRAMEBUFFER_UPDATE = 0;
static constexpr uint8_t MSG_SET_COLOUR_MAP = 1;
static constexpr uint8_t MSG_BELL = 2;
static constexpr uint8_t MSG_SERVER_CUT_TEXT = 3;
static inline uint16_t read_be16(const uint8_t *p) { return encode_uint16(p[0], p[1]); }
static inline uint32_t read_be32(const uint8_t *p) { return encode_uint32(p[0], p[1], p[2], p[3]); }
static inline void write_be16(uint8_t *p, uint16_t v) {
p[0] = v >> 8;
p[1] = v;
}
static inline void write_be32(uint8_t *p, uint32_t v) {
p[0] = v >> 24;
p[1] = v >> 16;
p[2] = v >> 8;
p[3] = v;
}
static int parse_three_digits(const uint8_t *p) {
int value = 0;
for (int i = 0; i < 3; i++) {
if (p[i] < '0' || p[i] > '9') {
return -1;
}
value = value * 10 + (p[i] - '0');
}
return value;
}
void VncClient::setup() {
this->setup_ms_ = App.get_loop_component_start_time();
this->link_.begin(TAG);
}
void VncClient::dump_config() {
ESP_LOGCONFIG(TAG,
"VNC:\n"
" Server: %s:%u\n"
" Reconnect Interval: %" PRIu32 "ms\n"
" Update Delay: %" PRIu32 "ms\n"
" Max Update Delay: %" PRIu32 "ms",
this->link_.host(), this->link_.port(), this->link_.reconnect_interval(), this->update_delay_ms_,
this->max_update_delay_ms_);
}
void VncClient::on_shutdown() { this->link_.close(); }
void VncClient::loop() {
const uint32_t now = App.get_loop_component_start_time();
this->pass_start_ms_ = millis();
if (this->display_->is_failed()) {
this->link_.close(); // a connect still in flight would otherwise keep its socket for good
if (this->state_ != VncState::VNC_STATE_IDLE) {
this->session_end_(now, VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Display failed"));
}
this->status_set_error(LOG_STR("Display failed"));
this->disable_loop();
return;
}
// A display that parks its loop when idle is busy while that loop runs. Displays that never
// park (no loop, or one that always runs) update synchronously, so they are never busy.
if (this->display_->is_idle()) {
this->display_parks_ = true;
this->display_busy_ = false;
} else {
this->display_busy_ = this->display_parks_;
}
// A stuck panel, or another owner of the display's loop, would otherwise freeze the screen silently
if (!this->display_busy_) {
this->busy_since_ms_ = now;
if (this->busy_warned_) {
this->busy_warned_ = false;
// The connection path owns the warning while no session runs
if (this->state_ == VncState::VNC_STATE_RUNNING) {
this->status_clear_warning();
}
}
} else if (!this->busy_warned_ && now - this->busy_since_ms_ >= DISPLAY_BUSY_TIMEOUT_MS) {
this->busy_warned_ = true;
this->status_set_warning(LOG_STR("Display busy too long"));
}
if (!this->link_.connected()) {
if (this->state_ != VncState::VNC_STATE_IDLE) {
this->session_lost_(now);
}
this->connect_step_(now);
if (!this->link_.connected()) {
return;
}
this->start_handshake_(now);
}
if (this->state_ == VncState::VNC_STATE_HANDSHAKE && now - this->session_start_ms_ >= HANDSHAKE_TIMEOUT_MS) {
this->fail_(this->lost_failure_(), LOG_STR("Handshake timed out"));
return;
}
const bool blocked = this->in_update_ && this->display_busy_;
// A display still starting up (it8951) can go from its start-up straight into a refresh without parking.
const bool display_starting =
!this->display_parks_ && this->display_->has_overridden_loop() && now - this->setup_ms_ < DISPLAY_START_MS;
// Wait until the screen stops changing, so an animation becomes one refresh showing where it ended.
if (this->refresh_pending_ && !this->in_update_ && !this->display_busy_ && !display_starting &&
(now - this->last_change_ms_ >= this->update_delay_ms_ ||
now - this->first_change_ms_ >= this->max_update_delay_ms_)) {
// Synchronous displays flush inside update(), so this pass does nothing else.
this->refresh_pending_ = false;
this->display_->update();
this->display_busy_ = this->display_parks_ && !this->display_->is_idle();
} else if (!blocked && (this->rx_pending_ || this->link_.ready())) {
this->service_rx_(now);
}
if (this->state_ == VncState::VNC_STATE_RUNNING) {
// Mid-frame there is nothing to send, so a vanished server would never fail a write.
if (this->in_update_ && !blocked && !this->rx_pending_ && now - this->last_rx_ms_ >= HEARTBEAT_MS) {
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Server stopped sending"));
return;
}
this->pace_(now);
}
this->link_.flush_tx();
if (this->rx_pending_ && !(this->in_update_ && this->display_busy_)) {
this->high_freq_.start();
} else {
this->high_freq_.stop();
}
}
void VncClient::connect_step_(uint32_t now) {
const bool up = network::is_connected();
if (up && !this->network_was_up_) {
this->backoff_ms_ = 0;
this->hold_ms_ = 0;
}
this->network_was_up_ = up;
if (!up) {
return;
}
if (!this->link_.connecting() && (now - this->hold_start_ms_ < this->hold_ms_ || this->link_.in_backoff())) {
return;
}
this->link_.poll();
// DNS failures, refused connects and connect timeouts all leave no socket and start the link's backoff.
if (!this->link_.connected() && !this->link_.connecting() && this->link_.in_backoff()) {
this->session_end_(now, VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Cannot reach the server"));
}
}
void VncClient::reset_session_() {
this->rx_pos_ = 0;
this->rx_len_ = 0;
this->rx_pending_ = false;
this->in_update_ = false;
this->request_outstanding_ = false;
this->refresh_pending_ = false;
this->frame_drawn_ = false;
this->step_ = VncStep::VNC_STEP_VERSION;
this->reason_failure_ = VncFailure::VNC_FAILURE_TRANSIENT;
this->high_freq_.stop();
}
void VncClient::start_handshake_(uint32_t now) {
this->reset_session_();
this->state_ = VncState::VNC_STATE_HANDSHAKE;
this->session_start_ms_ = now;
this->last_rx_ms_ = now;
}
VncFailure VncClient::lost_failure_() const {
if (this->state_ == VncState::VNC_STATE_HANDSHAKE) {
if (this->step_ == VncStep::VNC_STEP_REASON_LENGTH || this->step_ == VncStep::VNC_STEP_REASON) {
return this->reason_failure_;
}
}
return VncFailure::VNC_FAILURE_TRANSIENT;
}
void VncClient::session_lost_(uint32_t now) {
this->session_end_(now, this->lost_failure_(), LOG_STR("Connection lost"));
}
VncParse VncClient::fail_(VncFailure failure, const LogString *reason) {
this->link_.close();
this->link_.note_attempt();
this->session_end_(App.get_loop_component_start_time(), failure, reason);
return VncParse::VNC_PARSE_STOP;
}
void VncClient::session_end_(uint32_t now, VncFailure failure, const LogString *reason) {
const bool was_running = this->state_ == VncState::VNC_STATE_RUNNING;
this->reset_session_();
this->state_ = VncState::VNC_STATE_IDLE;
this->hold_start_ms_ = now;
if (was_running) {
const uint32_t lasted = now - this->session_start_ms_;
ESP_LOGW(TAG, "%s after %" PRIu32 "s", LOG_STR_ARG(reason), lasted / 1000);
this->disconnect_callback_.call();
this->status_set_warning();
if (lasted >= STABLE_SESSION_MS) {
this->backoff_ms_ = 0;
this->hold_ms_ = 0;
return;
}
failure = VncFailure::VNC_FAILURE_TRANSIENT;
}
switch (failure) {
case VncFailure::VNC_FAILURE_CONFIG:
this->hold_ms_ = CONFIG_RETRY_MS;
ESP_LOGE(TAG, "%s; retrying in %" PRIu32 "s", LOG_STR_ARG(reason), this->hold_ms_ / 1000);
this->status_set_error(reason);
return;
default: {
const uint32_t base = this->link_.reconnect_interval();
this->backoff_ms_ =
this->backoff_ms_ == 0 ? base : std::min(this->backoff_ms_ * 2, std::max(MAX_BACKOFF_MS, base));
this->hold_ms_ = this->backoff_ms_;
if (!was_running) {
ESP_LOGW(TAG, "%s; retrying in %" PRIu32 "s", LOG_STR_ARG(reason), this->hold_ms_ / 1000);
}
this->status_set_warning();
return;
}
}
}
bool VncClient::budget_exhausted_() const { return millis() - this->pass_start_ms_ >= LOOP_BUDGET_MS; }
void VncClient::service_rx_(uint32_t now) {
for (;;) {
VncParse result = this->parse_();
if (result == VncParse::VNC_PARSE_STOP) {
return;
}
if (result == VncParse::VNC_PARSE_PAUSE) {
this->rx_pending_ = true;
return;
}
// Less than one item is left, so the buffer always has room after compacting.
if (this->rx_pos_ != 0) {
this->rx_len_ -= this->rx_pos_;
std::memmove(this->rx_, this->rx_ + this->rx_pos_, this->rx_len_);
this->rx_pos_ = 0;
}
if (this->budget_exhausted_()) {
this->rx_pending_ = true;
return;
}
ssize_t count = this->link_.read(this->rx_ + this->rx_len_, RX_BUFFER_SIZE - this->rx_len_);
if (count < 0) {
this->session_lost_(now);
return;
}
if (count == 0) {
this->rx_pending_ = false;
return;
}
this->rx_len_ += static_cast<uint16_t>(count);
this->rx_total_ += count;
this->last_rx_ms_ = now;
}
}
void VncClient::skip_(uint32_t count, VncStep next) {
this->skip_left_ = count;
this->after_skip_ = next;
this->step_ = VncStep::VNC_STEP_SKIP;
}
bool VncClient::send_client_init_() {
const uint8_t shared = 1;
return this->send_(&shared, 1);
}
VncParse VncClient::parse_() {
for (;;) {
const uint8_t *p = this->rx_ + this->rx_pos_;
const size_t avail = this->rx_len_ - this->rx_pos_;
switch (this->step_) {
case VncStep::VNC_STEP_VERSION: {
if (avail < 12) {
return VncParse::VNC_PARSE_MORE;
}
const int major = parse_three_digits(p + 4);
const int minor = parse_three_digits(p + 8);
if (std::memcmp(p, "RFB ", 4) != 0 || p[7] != '.' || p[11] != '\n' || major < 0 || minor < 0) {
return this->fail_(VncFailure::VNC_FAILURE_CONFIG, LOG_STR("Not a VNC server"));
}
if (major < 3) {
return this->fail_(VncFailure::VNC_FAILURE_CONFIG, LOG_STR("Unsupported RFB version"));
}
this->minor_ = (major > 3 || minor >= 8) ? 8 : (minor == 7 ? 7 : 3);
this->rx_pos_ += 12;
uint8_t reply[12] = {'R', 'F', 'B', ' ', '0', '0', '3', '.', '0', '0', '8', '\n'};
reply[10] = '0' + this->minor_;
if (!this->send_(reply, sizeof(reply))) {
return this->fail_send_();
}
this->step_ = this->minor_ == 3 ? VncStep::VNC_STEP_SECURITY_TYPE_33 : VncStep::VNC_STEP_SECURITY_COUNT;
break;
}
case VncStep::VNC_STEP_SECURITY_COUNT: {
if (avail < 1) {
return VncParse::VNC_PARSE_MORE;
}
this->sec_count_ = p[0];
this->rx_pos_ += 1;
if (this->sec_count_ == 0) {
// A refusal before authentication, such as a lockout caused by another client.
this->step_ = VncStep::VNC_STEP_REASON_LENGTH;
} else {
this->step_ = VncStep::VNC_STEP_SECURITY_LIST;
}
break;
}
case VncStep::VNC_STEP_SECURITY_LIST: {
if (avail < this->sec_count_) {
return VncParse::VNC_PARSE_MORE;
}
uint8_t chosen = 0;
bool wants_password = false;
for (uint8_t i = 0; i < this->sec_count_; i++) {
if (p[i] == SECURITY_NONE) {
chosen = p[i];
break;
}
wants_password |= p[i] == SECURITY_VNC_AUTH;
}
this->rx_pos_ += this->sec_count_;
if (chosen == 0) {
return this->fail_(VncFailure::VNC_FAILURE_CONFIG, wants_password ? LOG_STR("The server requires a password")
: LOG_STR("No supported security type"));
}
if (!this->send_(&chosen, 1)) {
return this->fail_send_();
}
if (this->minor_ == 8) {
this->step_ = VncStep::VNC_STEP_SECURITY_RESULT;
} else {
if (!this->send_client_init_()) {
return this->fail_send_();
}
this->step_ = VncStep::VNC_STEP_SERVER_INIT;
}
break;
}
case VncStep::VNC_STEP_SECURITY_TYPE_33: {
if (avail < 4) {
return VncParse::VNC_PARSE_MORE;
}
const uint32_t type = read_be32(p);
this->rx_pos_ += 4;
if (type == 0) {
this->step_ = VncStep::VNC_STEP_REASON_LENGTH;
} else if (type == SECURITY_NONE) {
if (!this->send_client_init_()) {
return this->fail_send_();
}
this->step_ = VncStep::VNC_STEP_SERVER_INIT;
} else {
return this->fail_(VncFailure::VNC_FAILURE_CONFIG, type == SECURITY_VNC_AUTH
? LOG_STR("The server requires a password")
: LOG_STR("No supported security type"));
}
break;
}
case VncStep::VNC_STEP_SECURITY_RESULT: {
if (avail < 4) {
return VncParse::VNC_PARSE_MORE;
}
const uint32_t result = read_be32(p);
this->rx_pos_ += 4;
if (result == 0) {
if (!this->send_client_init_()) {
return this->fail_send_();
}
this->step_ = VncStep::VNC_STEP_SERVER_INIT;
break;
}
this->reason_failure_ = VncFailure::VNC_FAILURE_CONFIG;
this->step_ = VncStep::VNC_STEP_REASON_LENGTH;
break;
}
case VncStep::VNC_STEP_REASON_LENGTH: {
if (avail < 4) {
return VncParse::VNC_PARSE_MORE;
}
this->text_len_ = static_cast<uint8_t>(std::min(read_be32(p), MAX_TEXT_LEN));
this->rx_pos_ += 4;
this->step_ = VncStep::VNC_STEP_REASON;
break;
}
case VncStep::VNC_STEP_REASON: {
if (avail < this->text_len_) {
return VncParse::VNC_PARSE_MORE;
}
ESP_LOGE(TAG, "Server: %.*s", static_cast<int>(this->text_len_), reinterpret_cast<const char *>(p));
return this->fail_(this->reason_failure_, LOG_STR("Connection refused"));
}
case VncStep::VNC_STEP_SERVER_INIT: {
if (avail < 24) {
return VncParse::VNC_PARSE_MORE;
}
this->fb_w_ = read_be16(p);
this->fb_h_ = read_be16(p + 2);
// Reused for the length of the name that follows.
this->skip_left_ = read_be32(p + 20);
this->rx_pos_ += 24;
if (this->fb_w_ == 0 || this->fb_h_ == 0) {
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("The server screen is empty"));
}
this->text_len_ = static_cast<uint8_t>(std::min(this->skip_left_, MAX_TEXT_LEN));
this->step_ = VncStep::VNC_STEP_SERVER_NAME;
break;
}
case VncStep::VNC_STEP_SERVER_NAME: {
if (avail < this->text_len_) {
return VncParse::VNC_PARSE_MORE;
}
ESP_LOGI(TAG, "Server '%.*s', %ux%u, RFB 3.%u", static_cast<int>(this->text_len_),
reinterpret_cast<const char *>(p), this->fb_w_, this->fb_h_, this->minor_);
this->rx_pos_ += this->text_len_;
this->skip_(this->skip_left_ - this->text_len_, VncStep::VNC_STEP_SESSION_START);
break;
}
case VncStep::VNC_STEP_SESSION_START:
if (!this->start_session_(App.get_loop_component_start_time())) {
return this->fail_send_();
}
this->step_ = VncStep::VNC_STEP_MESSAGE;
break;
case VncStep::VNC_STEP_MESSAGE: {
if (avail < 1) {
return VncParse::VNC_PARSE_MORE;
}
switch (p[0]) {
case MSG_FRAMEBUFFER_UPDATE:
if (avail < 4) {
return VncParse::VNC_PARSE_MORE;
}
this->rects_left_ = read_be16(p + 2);
this->rx_pos_ += 4;
this->in_update_ = true;
this->update_start_ms_ = millis();
this->update_start_bytes_ = this->rx_total_ - (this->rx_len_ - this->rx_pos_);
this->rect_done_();
break;
case MSG_SET_COLOUR_MAP:
if (avail < 6) {
return VncParse::VNC_PARSE_MORE;
}
this->rx_pos_ += 6;
this->skip_(6u * read_be16(p + 4), VncStep::VNC_STEP_MESSAGE);
break;
case MSG_BELL:
this->rx_pos_ += 1;
break;
case MSG_SERVER_CUT_TEXT:
if (avail < 8) {
return VncParse::VNC_PARSE_MORE;
}
this->rx_pos_ += 8;
this->skip_(read_be32(p + 4), VncStep::VNC_STEP_MESSAGE);
break;
default:
ESP_LOGW(TAG, "Unknown message type %u", p[0]);
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error"));
}
break;
}
case VncStep::VNC_STEP_RECT: {
if (this->display_busy_) {
return VncParse::VNC_PARSE_PAUSE;
}
if (avail < 12) {
return VncParse::VNC_PARSE_MORE;
}
this->rect_x_ = read_be16(p);
this->rect_y_ = read_be16(p + 2);
this->rect_w_ = read_be16(p + 4);
this->rect_h_ = read_be16(p + 6);
const auto encoding = static_cast<int32_t>(read_be32(p + 8));
this->rx_pos_ += 12;
this->rects_left_--;
if (encoding == ENCODING_RAW) {
if (static_cast<uint32_t>(this->rect_x_) + this->rect_w_ > this->fb_w_ ||
static_cast<uint32_t>(this->rect_y_) + this->rect_h_ > this->fb_h_) {
ESP_LOGW(TAG, "Rectangle %ux%u at %u,%u is outside the %ux%u screen", this->rect_w_, this->rect_h_,
this->rect_x_, this->rect_y_, this->fb_w_, this->fb_h_);
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error"));
}
if (this->rect_w_ == 0 || this->rect_h_ == 0) {
this->rect_done_();
} else {
this->pos_x_ = 0;
this->pos_y_ = 0;
this->step_ = VncStep::VNC_STEP_RAW;
}
} else if (encoding == ENCODING_RICH_CURSOR) {
if (this->rect_w_ > MAX_CURSOR_SIDE || this->rect_h_ > MAX_CURSOR_SIDE) {
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error"));
}
const uint32_t size = static_cast<uint32_t>(this->rect_w_) * this->rect_h_ * this->bpp_ +
((this->rect_w_ + 7u) / 8u) * this->rect_h_;
this->skip_(size, VncStep::VNC_STEP_RECT_DONE);
} else {
ESP_LOGW(TAG, "Unsupported encoding %" PRId32, encoding);
return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error"));
}
break;
}
case VncStep::VNC_STEP_RECT_DONE:
this->rect_done_();
break;
case VncStep::VNC_STEP_RAW: {
if (this->display_busy_) {
return VncParse::VNC_PARSE_PAUSE;
}
const size_t pixels = avail / this->bpp_;
if (pixels == 0) {
return VncParse::VNC_PARSE_MORE;
}
if (this->budget_exhausted_()) {
return VncParse::VNC_PARSE_PAUSE;
}
const auto count = static_cast<uint16_t>(std::min<size_t>(pixels, this->rect_w_ - this->pos_x_));
this->draw_row_(this->rect_x_ + this->pos_x_, this->rect_y_ + this->pos_y_, count, p);
this->rx_pos_ += count * this->bpp_;
this->pos_x_ += count;
if (this->pos_x_ == this->rect_w_) {
this->pos_x_ = 0;
if (++this->pos_y_ == this->rect_h_) {
this->rect_done_();
}
}
break;
}
case VncStep::VNC_STEP_SKIP: {
const auto count = static_cast<uint32_t>(std::min<size_t>(avail, this->skip_left_));
this->rx_pos_ += count;
this->skip_left_ -= count;
if (this->skip_left_ != 0) {
return VncParse::VNC_PARSE_MORE;
}
this->step_ = this->after_skip_;
break;
}
}
}
}
void VncClient::rect_done_() {
if (this->rects_left_ == 0) {
this->frame_done_();
} else {
this->step_ = VncStep::VNC_STEP_RECT;
}
}
void VncClient::frame_done_() {
this->in_update_ = false;
this->request_outstanding_ = false;
this->step_ = VncStep::VNC_STEP_MESSAGE;
if (this->frame_drawn_) {
const uint32_t now = App.get_loop_component_start_time();
this->frame_drawn_ = false;
if (!this->refresh_pending_) {
this->first_change_ms_ = now;
}
this->last_change_ms_ = now;
this->refresh_pending_ = true;
ESP_LOGD(TAG, "Update: %" PRIu32 " bytes in %" PRIu32 "ms",
this->rx_total_ - (this->rx_len_ - this->rx_pos_) - this->update_start_bytes_,
millis() - this->update_start_ms_);
}
}
void VncClient::draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t *pixels) {
if (y >= this->vis_h_ || x >= this->vis_w_) {
return;
}
const int visible = std::min<int>(count, this->vis_w_ - x);
this->display_->draw_pixels_at(x, y, visible, 1, pixels, this->bitness_, this->big_endian_);
this->frame_drawn_ = true;
}
bool VncClient::start_session_(uint32_t now) {
const bool rgb565 = this->display_->get_display_type() != display::DISPLAY_TYPE_BINARY;
this->bpp_ = rgb565 ? 2 : 1;
this->bitness_ = rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_332;
const int disp_w = this->display_->get_width();
const int disp_h = this->display_->get_height();
this->vis_w_ = std::min<int>(this->fb_w_, disp_w);
this->vis_h_ = std::min<int>(this->fb_h_, disp_h);
if (this->fb_w_ != disp_w || this->fb_h_ != disp_h) {
ESP_LOGW(TAG, "Server screen is %ux%u but the display is %dx%d; showing the top-left %ux%u", this->fb_w_,
this->fb_h_, disp_w, disp_h, this->vis_w_, this->vis_h_);
}
// SetPixelFormat: true colour, in the byte order the display takes
uint8_t pixel_format[20] = {};
pixel_format[4] = rgb565 ? 16 : 8;
pixel_format[5] = rgb565 ? 16 : 8;
pixel_format[6] = this->big_endian_;
pixel_format[7] = 1;
write_be16(pixel_format + 8, rgb565 ? 31 : 7);
write_be16(pixel_format + 10, rgb565 ? 63 : 7);
write_be16(pixel_format + 12, rgb565 ? 31 : 3);
pixel_format[14] = rgb565 ? 11 : 5;
pixel_format[15] = rgb565 ? 5 : 2;
if (!this->send_(pixel_format, sizeof(pixel_format))) {
return false;
}
// SetEncodings: TightVNC uses the first real encoding it supports for every rectangle.
uint8_t encodings[12] = {2, 0, 0, 2};
write_be32(encodings + 4, ENCODING_RAW);
write_be32(encodings + 8, static_cast<uint32_t>(ENCODING_RICH_CURSOR));
if (!this->send_(encodings, sizeof(encodings)) || !this->send_update_request_(false, now)) {
return false;
}
this->state_ = VncState::VNC_STATE_RUNNING;
this->session_start_ms_ = now;
if (!this->busy_warned_) {
this->status_clear_warning(); // a stuck display keeps its warning through a reconnect
}
this->status_clear_error();
this->connect_callback_.call();
return true;
}
void VncClient::pace_(uint32_t now) {
if (this->display_busy_ || this->in_update_ || now - this->last_request_ms_ < MIN_REQUEST_SPACING_MS) {
return;
}
// Repeating a parked request costs nothing on the server and detects a dead link.
if (!this->request_outstanding_ ||
(now - this->last_rx_ms_ >= HEARTBEAT_MS && now - this->last_request_ms_ >= HEARTBEAT_MS)) {
this->send_update_request_(true, now);
}
}
bool VncClient::send_(const uint8_t *data, size_t len) {
if (this->link_.tx_free() < len) {
this->link_.flush_tx();
if (this->link_.tx_free() < len) {
return false;
}
}
this->link_.queue(data, len);
return true;
}
bool VncClient::send_update_request_(bool incremental, uint32_t now) {
uint8_t msg[10] = {3, static_cast<uint8_t>(incremental ? 1 : 0)};
write_be16(msg + 6, this->vis_w_);
write_be16(msg + 8, this->vis_h_);
if (!this->send_(msg, sizeof(msg))) {
return false;
}
this->request_outstanding_ = true;
this->last_request_ms_ = now;
return true;
}
} // namespace esphome::vnc
+162
View File
@@ -0,0 +1,162 @@
#pragma once
#include "esphome/components/display/display.h"
#include "esphome/components/socket/tcp_client_link.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstddef>
#include <cstdint>
namespace esphome::vnc {
enum class VncState : uint8_t {
VNC_STATE_IDLE,
VNC_STATE_HANDSHAKE,
VNC_STATE_RUNNING,
};
enum class VncStep : uint8_t {
VNC_STEP_VERSION,
VNC_STEP_SECURITY_COUNT,
VNC_STEP_SECURITY_LIST,
VNC_STEP_SECURITY_TYPE_33,
VNC_STEP_SECURITY_RESULT,
VNC_STEP_REASON_LENGTH,
VNC_STEP_REASON,
VNC_STEP_SERVER_INIT,
VNC_STEP_SERVER_NAME,
VNC_STEP_SESSION_START,
VNC_STEP_MESSAGE,
VNC_STEP_RECT,
VNC_STEP_RECT_DONE,
VNC_STEP_RAW,
VNC_STEP_SKIP,
};
enum class VncParse : uint8_t {
VNC_PARSE_MORE, // the next step needs bytes that have not arrived
VNC_PARSE_PAUSE, // out of time, or the display is busy; resume without new data
VNC_PARSE_STOP, // the session ended
};
enum class VncFailure : uint8_t {
VNC_FAILURE_TRANSIENT,
VNC_FAILURE_CONFIG,
};
/// RFB (VNC) client that draws the remote framebuffer into a display.
class VncClient : public Component {
public:
explicit VncClient(display::Display *display, bool big_endian = false) : display_(display), big_endian_(big_endian) {}
void set_host(const char *host) { this->link_.set_host(host); }
void set_port(uint16_t port) { this->link_.set_port(port); }
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
void set_update_delay(uint32_t ms) { this->update_delay_ms_ = ms; }
void set_max_update_delay(uint32_t ms) { this->max_update_delay_ms_ = ms; }
void setup() override;
void loop() override;
void dump_config() override;
void on_shutdown() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
bool is_connected() const { return this->state_ == VncState::VNC_STATE_RUNNING; }
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callback_.add(std::forward<F>(callback));
}
protected:
static constexpr size_t RX_BUFFER_SIZE = 512;
void connect_step_(uint32_t now);
void start_handshake_(uint32_t now);
void service_rx_(uint32_t now);
VncParse parse_();
void pace_(uint32_t now);
bool start_session_(uint32_t now);
bool send_client_init_();
void frame_done_();
void rect_done_();
void draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t *pixels);
void skip_(uint32_t count, VncStep next);
bool budget_exhausted_() const;
bool send_(const uint8_t *data, size_t len);
bool send_update_request_(bool incremental, uint32_t now);
VncParse fail_(VncFailure failure, const LogString *reason);
VncParse fail_send_() { return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Send failed")); }
VncFailure lost_failure_() const;
void session_lost_(uint32_t now);
void session_end_(uint32_t now, VncFailure failure, const LogString *reason);
void reset_session_();
display::Display *display_;
socket::TcpClientLink link_;
LazyCallbackManager<void()> connect_callback_;
LazyCallbackManager<void()> disconnect_callback_;
HighFrequencyLoopRequester high_freq_;
uint32_t update_delay_ms_{200};
uint32_t max_update_delay_ms_{1000};
uint32_t backoff_ms_{0};
uint32_t hold_ms_{0};
uint32_t hold_start_ms_{0};
uint32_t setup_ms_{0};
uint32_t busy_since_ms_{0};
uint32_t session_start_ms_{0};
uint32_t update_start_ms_{0};
// The first and the latest frame drawn since the display last refreshed.
uint32_t first_change_ms_{0};
uint32_t last_change_ms_{0};
uint32_t rx_total_{0};
uint32_t update_start_bytes_{0};
uint32_t last_request_ms_{0};
uint32_t last_rx_ms_{0};
uint32_t pass_start_ms_{0};
uint32_t skip_left_{0};
uint16_t rx_pos_{0};
uint16_t rx_len_{0};
uint16_t fb_w_{0};
uint16_t fb_h_{0};
uint16_t vis_w_{0};
uint16_t vis_h_{0};
uint16_t rects_left_{0};
uint16_t rect_x_{0};
uint16_t rect_y_{0};
uint16_t rect_w_{0};
uint16_t rect_h_{0};
// Raw: column and row inside the rect.
uint16_t pos_x_{0};
uint16_t pos_y_{0};
uint8_t bpp_{2};
uint8_t minor_{8};
uint8_t sec_count_{0};
uint8_t text_len_{0}; // clamped to MAX_TEXT_LEN
display::ColorBitness bitness_{display::COLOR_BITNESS_565};
VncState state_{VncState::VNC_STATE_IDLE};
VncStep step_{VncStep::VNC_STEP_VERSION};
VncStep after_skip_{VncStep::VNC_STEP_MESSAGE};
VncFailure reason_failure_{VncFailure::VNC_FAILURE_TRANSIENT};
bool rx_pending_{false};
bool in_update_{false};
bool request_outstanding_{false};
bool refresh_pending_{false};
bool frame_drawn_{false};
bool display_parks_{false};
bool display_busy_{false};
bool busy_warned_{false};
bool network_was_up_{false};
bool big_endian_; // the pixel order the display takes
uint8_t rx_[RX_BUFFER_SIZE];
};
} // namespace esphome::vnc
+46 -14
View File
@@ -39,6 +39,7 @@ from esphome.const import (
CONF_ENABLE_BTM,
CONF_ENABLE_ON_BOOT,
CONF_ENABLE_RRM,
CONF_ETHERNET,
CONF_FAST_CONNECT,
CONF_GATEWAY,
CONF_HIDDEN,
@@ -223,10 +224,8 @@ WIFI_AP_MODE_WISP = "WISP"
def _validate_ap_mode(value: str) -> str:
value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value)
if value == WIFI_AP_MODE_WISP:
# esp_netif_napt_enable() exists from ESP-IDF 5.1
cv.require_framework_version(
esp_idf=cv.Version(5, 1, 0), extra_message="WISP mode routes through NAT"
)(value)
# WISP mode routes through NAT, which only ESP-IDF provides
cv.only_with_framework(Framework.ESP_IDF)(value)
return value
@@ -431,8 +430,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 +464,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 +477,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 +597,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
@@ -840,7 +872,7 @@ async def to_code(config):
if (dpp := config.get(CONF_DPP)) is not None:
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_DPP", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_WIFI_EXT_DPP", True)
if (key := dpp.get(CONF_PRIVATE_KEY)) is not None:
cg.add(var.set_dpp_key(key))
if (info := dpp.get(CONF_DEVICE_INFO)) is not None:
+20 -16
View File
@@ -783,12 +783,9 @@ void WiFiComponent::start() {
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
this->start_initial_connection_();
#endif
#ifdef USE_WIFI_AP
} else if (this->has_ap()) {
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
} else if (this->has_ap()) { // a bridge waits in loop() for the upstream's resolver
this->setup_ap_config_();
if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
ESP_LOGV(TAG, "Setting Output Power Option failed");
}
#ifdef USE_CAPTIVE_PORTAL
// Where the radio scans alongside the AP, the portal can list networks.
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
@@ -797,7 +794,7 @@ void WiFiComponent::start() {
}
#endif
this->start_ap_portal_();
#endif // USE_WIFI_AP
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
}
#ifdef USE_WIFI_DPP
// Without networks there is nothing to try first, so listen for a configurator right away
@@ -937,15 +934,7 @@ void WiFiComponent::loop() {
return;
}
#ifdef USE_WIFI_AP_WISP
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
if (!this->ap_setup_ && this->ap_dns_known_() &&
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
ESP_LOGI(TAG, "Starting AP");
this->ap_changed_ = now;
this->setup_ap_config_();
}
#elif defined(USE_WIFI_AP)
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
// Once the provisioning window has closed, don't bring up the fallback AP (or
// the captive portal on it) - the device must stay unprovisionable until it is
// power-cycled.
@@ -975,7 +964,7 @@ void WiFiComponent::loop() {
this->start_ap_portal_();
}
}
#endif // USE_WIFI_AP
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
#ifdef USE_IMPROV_BLE
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
@@ -1013,6 +1002,16 @@ void WiFiComponent::loop() {
}
}
#ifdef USE_WIFI_AP_WISP
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && this->ap_dns_known_() &&
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
ESP_LOGI(TAG, "Starting AP");
this->ap_changed_ = now;
this->setup_ap_config_();
}
#endif
#ifdef USE_WIFI_DPP
// Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again
// here, at most once per timeout
@@ -1098,6 +1097,11 @@ void WiFiComponent::setup_ap_config_() {
// Reset before the AP exists: afterwards only the platform's event path writes the counter
this->ap_clients_ = 0;
this->ap_setup_ = this->wifi_start_ap_(this->ap_);
// The station path applied it in start(); without a station the radio only exists from here
if (this->ap_setup_ && !this->has_sta() && !std::isnan(this->output_power_) &&
!this->wifi_apply_output_power_(this->output_power_)) {
ESP_LOGV(TAG, "Setting Output Power Option failed");
}
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
// A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
// the STA attempts until the next try, and a bridge would take clients it cannot serve.
@@ -127,6 +127,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi
#if USE_NETWORK_IPV6
} else if (event_base == IP_EVENT && event_id == IP_EVENT_GOT_IP6) {
memcpy(&event.data.ip_got_ip6, event_data, sizeof(ip_event_got_ip6_t));
#endif
#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS)
} else if (event_base == IP_EVENT && event_id == IP_EVENT_ETH_GOT_IP) { // NOLINT(bugprone-branch-clone)
// no data: the bridge rereads the resolvers an ethernet upstream just received
#endif
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) { // NOLINT(bugprone-branch-clone)
// no data
@@ -726,7 +730,7 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
uint32_t age = lease_cache_age_secs(lease);
uint32_t remaining = lease.lease_secs > age ? lease.lease_secs - age : 0;
ESP_LOGI(TAG, "Applying cached lease " IPSTR ", ~%us left, skipping DHCP", IP2STR(&ip4), remaining);
ESP_LOGI(TAG, "Applying cached lease " IPSTR ", ~%" PRIu32 "s left, skipping DHCP", IP2STR(&ip4), remaining);
// Still awake at T1: switch to real DHCP. lease_valid_for_apply_() guarantees age < T1.
uint32_t renew_in = lease_renew_at_secs(lease) - age;
@@ -842,7 +846,7 @@ void WiFiComponent::save_lease_settings_() {
this->lease_cache_pref_.save(&lease);
this->cached_lease_ = lease;
ESP_LOGD(TAG, "Lease cache: saved " IPSTR " (network %d, lease %us)", IP2STR(&info.ip), lease.ap_index,
ESP_LOGD(TAG, "Lease cache: saved " IPSTR " (network %d, lease %" PRIu32 "s)", IP2STR(&info.ip), lease.ap_index,
lease.lease_secs);
}
#endif
@@ -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;
+3
View File
@@ -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")
+1
View File
@@ -351,6 +351,7 @@
#define USE_API_USER_DEFINED_ACTIONS
#define USE_API_CUSTOM_SERVICES
#define USE_API_USER_DEFINED_ACTION_METADATA
#define USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
#define USE_API_USER_DEFINED_ACTION_RESPONSES
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#define API_MAX_SEND_QUEUE 8
+10
View File
@@ -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);
+5 -5
View File
@@ -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;
+3 -2
View File
@@ -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
View File
@@ -40,7 +40,7 @@ dependencies:
rules:
- if: "target in [esp32h2, esp32p4]"
espressif/esp_hosted:
version: 2.12.13
version: 3.0.10
rules:
- if: "target in [esp32h2, esp32p4]"
francescopace/espectre:
+11
View File
@@ -201,8 +201,19 @@ class RedirectText:
"""Whether output is handled line by line rather than passed through."""
return self._filter_pattern is not None or bool(self._line_callbacks)
def _drop_line(self, line: str) -> None:
"""Leave out a filtered line, keeping the color codes it carried.
cmake ends a warning with the reset code on a line of its own; dropped
with the blank lines, it would leave everything after it in the
warning's color.
"""
if codes := "".join(ANSI_ESCAPE.findall(line)):
self._write_color_replace(codes)
def _emit_line(self, line: str) -> None:
if self._is_filtered(line):
self._drop_line(line)
return
line_without_end = ANSI_ESCAPE.sub("", line).rstrip()
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.6.0
click==8.5.0
aioesphomeapi==46.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
+19 -15
View File
@@ -4,25 +4,29 @@
# codegen via add_idf_sdkconfig_option, which the stub tidy build skips).
# esp32_hosted (P4 has no native Wi-Fi; it drives a co-processor over SDIO/SPI).
# Mirrors a default SDIO 4-bit setup (slot 1, ESP32-C6 slave) so the esp_hosted
# Mirrors a default SDIO 4-bit setup (slot 1, ESP32-C6 co-processor) on the
# esp_hosted 3.x host line pinned in esphome/idf_component.yml, so the esp_hosted
# code paths compile under static analysis.
CONFIG_SLAVE_IDF_TARGET_ESP32C6=y
CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6=y
CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO=y
CONFIG_ESP_HOSTED_SDIO_SLOT_1=y
CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS=y
CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS=y
CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=40000
CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y
CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE=54
CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_1=18
CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1=19
CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_1=14
CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_1=15
CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_1=16
CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1=17
CONFIG_ESP_HOSTED_HOST_SDIO_BUS_WIDTH_4=y
CONFIG_ESP_HOSTED_HOST_SDIO_CLK_KHZ=40000
CONFIG_ESP_HOSTED_HOST_RESET_GPIO=54
CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CLK=18
CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CMD=19
CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0=14
CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1=15
CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2=16
CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D3=17
# ESP-NOW-over-hosted shim (esp_now_hosted.cpp) uses the peer-data feature.
CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA=y
CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA_MAX_CUSTOM_MSG_HANDLERS=8
# BLE runs over the hosted co-processor on P4 (no native BT controller), so
# esp32_ble_tracker must take the hosted bluedroid path instead of <esp_bt.h>.
CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID=y
# the esp32_ble components must take the hosted bluedroid path instead of <esp_bt.h>.
CONFIG_ESP_HOSTED_HOST_FEAT_BT=y
# tinyusb CDC (usb_cdc_acm), same as esp32s3
CONFIG_TINYUSB_CDC_ENABLED=y
@@ -112,7 +112,7 @@ def test_empty_metadata_is_unset() -> None:
"description": "",
"variables": {"b": {"type": "int", "description": "", "example": "ex"}},
}
strings = _action_strings(conf, has_metadata=True)
strings = _action_strings(conf, has_metadata=True, has_optional=False)
assert strings == ["a", None, "b", None, "ex"]
@@ -0,0 +1,158 @@
"""Tests for optional user-defined action variables (required/default)."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.api import (
_action_strings,
_has_optional_args,
validate_variable,
)
from esphome.config_validation import Invalid
from esphome.core import CORE
CONFIG = "tests/component_tests/api/test_action_optional_args.yaml"
CONFIG_ESP8266 = "tests/component_tests/api/test_action_optional_args_esp8266.yaml"
CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml"
def test_defaults_are_emitted_in_the_string_table(
generate_main: Callable[[str | Path], str],
) -> None:
"""Each argument gains a trailing default slot: "" marks optional without a default."""
main_cpp = generate_main(CONFIG)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = {"
'"play_buzzer", "song_str", "", "notes", "", "octave", "5", '
'"bpm", "100", "loud", "true"};' in main_cpp
)
# An all-required action still carries the default slots (as nullptr)
assert (
"static constexpr const char * api_action1_strings[] PROGMEM = {"
'"plain_action", "value", nullptr};' in main_cpp
)
assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" in {d.name for d in CORE.defines}
def test_esp8266_defaults_are_progmem_arrays(
generate_main: Callable[[str | Path], str],
) -> None:
"""On ESP8266 each default is its own PROGMEM array, referenced from the table."""
main_cpp = generate_main(CONFIG_ESP8266)
def array_for(text: str) -> str:
match = re.search(
rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp
)
assert match, text
return match.group(1)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = {"
f"{array_for('play_buzzer')}, {array_for('song_str')}, {array_for('')}, "
f"{array_for('notes')}, {array_for('')}, {array_for('octave')}, {array_for('5')}, "
f"{array_for('bpm')}, {array_for('100')}, {array_for('loud')}, "
f"{array_for('true')}}};" in main_cpp
)
def test_required_variables_emit_no_define(
generate_main: Callable[[str | Path], str],
) -> None:
generate_main(CONFIG_SHORTHAND)
assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" not in {
d.name for d in CORE.defines
}
def test_action_strings_orders_default_after_metadata() -> None:
conf = {
"action": "a",
"description": "d",
"variables": {
"b": {"type": "int", "example": "1", "default": 5},
"c": {"type": "bool", "default": False},
},
}
assert _action_strings(conf, has_metadata=True, has_optional=True) == [
"a",
"d",
"b",
None,
"1",
"5",
"c",
None,
None,
"false",
]
@pytest.mark.parametrize(
("var", "expected"),
[
({"type": "int", "required": False}, ""),
({"type": "string", "default": ""}, ""),
({"type": "float", "default": 5}, "5.0"),
],
)
def test_default_slot_marks_optional_and_keeps_the_type(
var: dict, expected: str
) -> None:
actions = [{"action": "a", "variables": {"b": validate_variable(var)}}]
assert _has_optional_args(actions)
assert _action_strings(actions[0], has_metadata=False, has_optional=True) == [
"a",
"b",
expected,
]
@pytest.mark.parametrize(
("var", "expected"),
[
({"type": "int", "default": "5"}, 5),
({"type": "int", "default": -(2**31)}, -(2**31)),
({"type": "int", "default": 2**31 - 1}, 2**31 - 1),
({"type": "bool", "default": "true"}, True),
({"type": "float", "default": "1.5"}, 1.5),
({"type": "float", "default": 3.4028235e38}, 3.4028235e38),
({"type": "string", "default": "0123"}, "0123"),
],
)
def test_default_is_validated_against_type(var: dict, expected: object) -> None:
assert validate_variable(var)["default"] == expected
@pytest.mark.parametrize(
"var",
[
{"type": "int[]", "required": False},
{"type": "string[]", "default": "x"},
{"type": "int", "default": 5, "required": True},
{"type": "int", "default": "not_a_number"},
# ExecuteServiceArgument.int_ is a signed 32-bit value
{"type": "int", "default": 2**31},
{"type": "int", "default": -(2**31) - 1},
# A boolean is an int in Python but "true" is not a number on the wire
{"type": "int", "default": True},
{"type": "float", "default": False},
# ExecuteServiceArgument.float_ is a 32-bit float
{"type": "float", "default": 1e40},
{"type": "float", "default": "nan"},
{"type": "float", "default": "inf"},
# A string default is what the user typed, and a C string in flash
{"type": "string", "default": 123},
{"type": "string", "default": "a\0b"},
{"type": "string", "default": "\0a"},
{"type": "float", "default": -1e40},
],
)
def test_variable_rejects_invalid_optionality(var: dict) -> None:
with pytest.raises(Invalid):
validate_variable(var)
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_optional_args_common.yaml
@@ -0,0 +1,26 @@
api:
actions:
- action: play_buzzer
variables:
song_str:
type: string
required: false
notes:
type: string
required: false
octave:
type: int
default: 5
bpm:
type: int
default: 100
loud:
type: bool
default: true
then:
- logger.log: Action Called
- action: plain_action
variables:
value: int
then:
- logger.log: Action Called
@@ -0,0 +1,14 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_optional_args_common.yaml
@@ -1,8 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
version: 5.0.6
+26 -17
View File
@@ -1279,24 +1279,33 @@ def test_platformio_arduino_enables_reproducible_build(
@pytest.mark.parametrize(
("config_file", "expected"),
[
("reproducible_build.yaml", True),
("reproducible_build_arduino.yaml", True),
("file_macro_idf_5_0.yaml", False),
],
"config_file", ["reproducible_build.yaml", "reproducible_build_arduino.yaml"]
)
def test_file_macro_is_basename_only(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
expected: bool,
) -> None:
"""__FILE__ becomes the basename on GCC 12 toolchains; IDF 5.0 (GCC 11) is skipped."""
"""__FILE__ becomes the basename; every supported IDF ships a GCC 12 toolchain."""
generate_main(component_config_path(config_file))
assert ("-D__FILE__=__FILE_NAME__" in CORE.build_flags) is expected
assert ("-Wno-builtin-macro-redefined" in CORE.build_flags) is expected
assert "-D__FILE__=__FILE_NAME__" in CORE.build_flags
assert "-Wno-builtin-macro-redefined" in CORE.build_flags
def test_esp_idf_5_0_is_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""ESP-IDF 5.0 is out: its Kconfig names and toolchain predate what the component writes."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
with pytest.raises(cv.Invalid, match=r"Only ESP-IDF 5\.1\+ is supported"):
CONFIG_SCHEMA(
{
"variant": "esp32",
"board": "esp32dev",
"framework": {"type": "esp-idf", "version": "5.0.6"},
}
)
def test_native_idf_enables_reproducible_build(
@@ -1417,7 +1426,7 @@ def test_flash_mode_unset_leaves_defaults(
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
"CONFIG_ESP_COEX_SW_COEXIST_ENABLE": False,
},
id="idf_ethernet_only",
),
@@ -1429,7 +1438,7 @@ def test_flash_mode_unset_leaves_defaults(
"CONFIG_BT_ENABLED": True,
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
"CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False,
"CONFIG_SW_COEXIST_ENABLE": True,
"CONFIG_ESP_COEX_SW_COEXIST_ENABLE": True,
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
@@ -1481,7 +1490,7 @@ def test_flash_mode_unset_leaves_defaults(
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
"CONFIG_ESP_COEX_SW_COEXIST_ENABLE": False,
},
id="arduino_ethernet_only_disables_wifi",
),
@@ -1492,7 +1501,7 @@ def test_flash_mode_unset_leaves_defaults(
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
"CONFIG_ESP_COEX_SW_COEXIST_ENABLE": False,
},
id="arduino_ethernet_dhcps_exclusion",
),
@@ -1704,7 +1713,7 @@ def test_network_ethernet_only_reconciles_end_to_end(
generate_main(component_config_path("network_ethernet_only.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False
assert sdkconfig.get("CONFIG_ESP_COEX_SW_COEXIST_ENABLE") is False
# The whole radio stack stays out of the build set as well.
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
assert {"esp_wifi", "wpa_supplicant", "esp_phy", "esp_coex", "bt"} <= excluded
@@ -1724,7 +1733,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
assert sdkconfig.get("CONFIG_ESP_COEX_SW_COEXIST_ENABLE") is True
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
# WiFi present alongside BT -> WiFi stack must stay enabled.
@@ -1742,7 +1751,7 @@ def test_network_wifi_ethernet_priority_keeps_wifi_enabled(
generate_main(component_config_path("network_wifi_ethernet_priority.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
assert "CONFIG_SW_COEXIST_ENABLE" not in sdkconfig
assert "CONFIG_ESP_COEX_SW_COEXIST_ENABLE" not in sdkconfig
# WiFi has no AP here, so SoftAP/DHCP server are still dropped.
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
+106 -17
View File
@@ -1,20 +1,75 @@
"""Tests for the esp32_hosted ESP-IDF version gate."""
"""Tests for the esp32_hosted validation."""
import logging
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_hosted import _final_validate
from esphome.const import PlatformFramework
from esphome.components.esp32 import (
KEY_BOARD,
KEY_IDF_VERSION,
KEY_VARIANT,
VARIANT_ESP32P4,
)
from esphome.components.esp32_hosted import (
CONF_ACTIVE_HIGH,
CONF_BUS_WIDTH,
CONF_CMD_PIN,
CONF_D0_PIN,
CONF_D1_PIN,
CONF_D2_PIN,
CONF_D3_PIN,
CONFIG_SCHEMA,
_final_validate,
_validate_active_high,
)
from esphome.const import CONF_CLK_PIN, CONF_RESET_PIN, CONF_VARIANT, PlatformFramework
from esphome.types import ConfigType
from ..types import SetCoreConfigCallable
SDIO_PINS: ConfigType = {
CONF_VARIANT: "ESP32C6",
CONF_RESET_PIN: 54,
CONF_CMD_PIN: 19,
CONF_CLK_PIN: 18,
CONF_D0_PIN: 14,
CONF_D1_PIN: 15,
CONF_D2_PIN: 16,
CONF_D3_PIN: 17,
}
@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"])
@pytest.fixture
def p4_core(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-p4-evboard",
KEY_IDF_VERSION: cv.Version.parse("5.5.6"),
KEY_VARIANT: VARIANT_ESP32P4,
},
)
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2", "5.3.2", "5.4.2"])
def test_final_validate_rejects_old_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF older than 5.5 is rejected with a clear error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.5 or newer"):
_final_validate({})
@pytest.mark.parametrize("idf", ["5.5.0", "5.5.6"])
def test_final_validate_accepts_supported_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF 5.3 and newer passes validation unchanged."""
"""ESP-IDF 5.5 and newer passes validation unchanged."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
@@ -22,14 +77,48 @@ def test_final_validate_accepts_supported_idf(
_final_validate({})
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
def test_final_validate_rejects_old_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF older than 5.3 is rejected with a clear error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"):
_final_validate({})
@pytest.mark.usefixtures("p4_core")
@pytest.mark.parametrize("bus_width", [1, 4])
def test_sdio_requires_d1_pin(bus_width: int) -> None:
"""D1 carries the card interrupt, so a 1-bit bus needs it too."""
config = {k: v for k, v in SDIO_PINS.items() if k != CONF_D1_PIN}
config[CONF_BUS_WIDTH] = bus_width
with pytest.raises(cv.Invalid, match="required key not provided"):
CONFIG_SCHEMA(config)
@pytest.mark.usefixtures("p4_core")
@pytest.mark.parametrize("pin", [CONF_D2_PIN, CONF_D3_PIN])
def test_sdio_4bit_requires_d2_d3(pin: str) -> None:
"""A 4-bit bus needs D2 and D3."""
config = {k: v for k, v in SDIO_PINS.items() if k != pin}
with pytest.raises(cv.Invalid, match=f"{pin} is required when bus_width is 4"):
CONFIG_SCHEMA(config)
@pytest.mark.usefixtures("p4_core")
def test_sdio_1bit_accepts_d0_d1_only() -> None:
"""A 1-bit bus validates with just D0 and D1."""
config = {k: v for k, v in SDIO_PINS.items() if k not in (CONF_D2_PIN, CONF_D3_PIN)}
config[CONF_BUS_WIDTH] = 1
CONFIG_SCHEMA(config)
def test_active_high_absent_is_silent(caplog: pytest.LogCaptureFixture) -> None:
"""A config without the deprecated key logs nothing."""
with caplog.at_level(logging.WARNING):
assert _validate_active_high({}) == {}
assert "active_high" not in caplog.text
def test_active_high_true_warns(caplog: pytest.LogCaptureFixture) -> None:
"""The old required value still validates, with a deprecation warning."""
with caplog.at_level(logging.WARNING):
_validate_active_high({CONF_ACTIVE_HIGH: True})
assert "'active_high' is deprecated" in caplog.text
def test_active_high_false_rejected() -> None:
"""esp_hosted drives the reset line with a low pulse only."""
with pytest.raises(cv.Invalid, match="active_high: false"):
_validate_active_high({CONF_ACTIVE_HIGH: False})
+35 -18
View File
@@ -11,9 +11,13 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate
from esphome.components.ethernet import (
CONF_CLOCK_SPEED,
CONFIG_SCHEMA,
_final_validate_wifi,
)
from esphome.components.network import _validate_priority_list
from esphome.const import CONF_PRIORITY, PlatformFramework
from esphome.const import CONF_AP, CONF_NETWORKS, CONF_PRIORITY, PlatformFramework
from esphome.core import CORE
import esphome.final_validate as fv
@@ -28,24 +32,37 @@ _CH390_CONFIG = {
}
def test_rejects_wifi_and_ethernet_without_priority() -> None:
"""Wi-Fi + ethernet without a network: priority: list must be rejected."""
fv.full_config.set({"wifi": {}, "ethernet": {}})
with pytest.raises(Invalid, match="cannot be used together with component wifi"):
_final_validate({})
_WIFI_STA_CONFIG = {CONF_NETWORKS: [{"ssid": "MySSID", "password": "password1"}]}
_WIFI_AP_CONFIG = {CONF_AP: {"ssid": "espap", "password": "mypassword"}}
def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None:
"""A priority list missing an interface is rejected and names what's missing."""
fv.full_config.set(
{
"wifi": {},
"ethernet": {},
"network": {CONF_PRIORITY: _validate_priority_list(["ethernet"])},
}
)
with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"):
_final_validate({})
@pytest.mark.parametrize(
("wifi", "priority", "extra", "match"),
[
(_WIFI_STA_CONFIG, None, {}, "missing: ethernet, wifi"),
(_WIFI_STA_CONFIG, ["ethernet"], {}, "missing: wifi"),
(_WIFI_STA_CONFIG, ["wifi", "ethernet"], {}, None),
(_WIFI_AP_CONFIG, None, {}, None),
# A station can still appear at run time: DPP or a provisioner saves credentials
({"dpp": {}}, None, {}, "missing: ethernet, wifi"),
({"dpp": {}, **_WIFI_AP_CONFIG}, None, {}, "missing: ethernet, wifi"),
(_WIFI_AP_CONFIG, None, {"captive_portal": {}}, "missing: ethernet, wifi"),
(_WIFI_AP_CONFIG, None, {"improv_serial": {}}, "missing: ethernet, wifi"),
],
)
def test_wifi_station_needs_a_full_priority_list_but_an_access_point_does_not(
wifi: dict, priority: list[str] | None, extra: dict, match: str | None
) -> None:
"""A station, configured or provisioned later, competes with ethernet; an access point does not."""
full = {"wifi": wifi, "ethernet": {}, **extra}
if priority:
full["network"] = {CONF_PRIORITY: _validate_priority_list(priority)}
fv.full_config.set(full)
if match is None:
_final_validate_wifi({})
else:
with pytest.raises(Invalid, match=match):
_final_validate_wifi({})
@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"])
@@ -132,7 +132,7 @@ def test_border_router_codegen(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
for option, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items():
assert sdkconfig[option] == value
assert sdkconfig["CONFIG_SW_COEXIST_ENABLE"] is True
assert sdkconfig["CONFIG_ESP_COEX_SW_COEXIST_ENABLE"] is True
assert sdkconfig["CONFIG_OPENTHREAD_SRP_CLIENT"] is False
assert sdkconfig["CONFIG_OPENTHREAD_DNS64_CLIENT"] is False
assert (
@@ -158,7 +158,7 @@ def test_border_router_rcp_codegen(
assert sdkconfig["CONFIG_OPENTHREAD_RADIO_NATIVE"] is False
assert sdkconfig["CONFIG_OPENTHREAD_RADIO_SPINEL_UART"] is True
assert sdkconfig["CONFIG_VFS_SUPPORT_DIR"] is True
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is not True
assert sdkconfig.get("CONFIG_ESP_COEX_SW_COEXIST_ENABLE") is not True
assert any(define.name == "USE_OPENTHREAD_RCP_UART" for define in CORE.defines)
assert "OpenThreadComponent(460800, 18, 17, nullptr)" in cpp_main
+30
View File
@@ -5,6 +5,9 @@ from typing import Any
import pytest
from esphome.components.esp32 import (
KEY_ESP32,
KEY_IDF_VERSION,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32C2,
@@ -21,6 +24,7 @@ from esphome.components.esp32 import (
)
import esphome.config_validation as cv
from esphome.const import CONF_ESPHOME, PlatformFramework
from esphome.core import CORE
from tests.component_tests.types import SetCoreConfigCallable
UNSUPPORTED_PSRAM_VARIANTS = [
@@ -255,3 +259,29 @@ def test_psram_final_validation(
else:
# This should not raise an exception
FINAL_VALIDATE_SCHEMA(validated_config)
@pytest.mark.parametrize(
("version", "key"),
[
(cv.Version(5, 3, 2), "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY"),
(cv.Version(5, 4, 0), "CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM"),
],
)
def test_external_task_stack_uses_the_kconfig_name_of_the_idf(
version: cv.Version, key: str, set_core_config: SetCoreConfigCallable
) -> None:
"""ESP-IDF 5.4 renamed the option; the old name is mapped there but reported on every configure."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_VARIANT: VARIANT_ESP32,
KEY_IDF_VERSION: version,
KEY_SDKCONFIG_OPTIONS: {},
},
)
from esphome.components.psram import request_external_task_stack
request_external_task_stack()
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == {key: True}
@@ -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]
Whitespace-only changes.
+406
View File
@@ -0,0 +1,406 @@
"""Code generation and validation tests for the vnc component."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.config import read_config
from esphome.core import CORE
TEST_CARD = "show_test_card()"
UPDATE_NEVER = "set_update_interval(4294967295UL)"
BASE = """
esphome:
name: vnc-test
esp32:
board: esp32dev
logger:
wifi:
ssid: MySSID
password: password1
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
"""
IT8951 = """
display:
- platform: it8951
id: epd
model: m5stack-m5paper
"""
VNC = """
vnc:
- host: 192.168.1.20
"""
EPAPER_SPI = """
- platform: epaper_spi
id: other
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
"""
S3_BASE = """
esphome:
name: vnc-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
wifi:
ssid: MySSID
password: password1
"""
RPI_DPI_RGB = """
display:
- platform: rpi_dpi_rgb
id: rgb
dimensions:
width: 800
height: 480
de_pin: 40
hsync_pin: 39
vsync_pin: 41
pclk_pin: 42
data_pins:
red: [45, 48, 47, 21, 14]
green: [5, 6, 7, 15, 16, 4]
blue: [8, 3, 46, 9, 1]
"""
OPENTHREAD_BASE = """
esphome:
name: vnc-test
esp32:
variant: esp32c6
framework:
type: esp-idf
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
spi:
clk_pin: GPIO6
mosi_pin: GPIO7
"""
C6_EPAPER = """
display:
- platform: epaper_spi
id: epd
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO4
busy_pin: GPIO3
reset_pin: GPIO2
"""
@pytest.fixture(autouse=True)
def _clear_toolchain() -> None:
# A host build earlier in the same session leaves the host toolchain selected.
CORE.toolchain = None
@pytest.fixture
def write_yaml(tmp_path: Path) -> Callable[..., Path]:
def writer(*parts: str, base: str = BASE) -> Path:
path = tmp_path / "vnc.yaml"
path.write_text(base + "".join(parts), encoding="utf-8")
return path
return writer
@pytest.fixture
def config_error(
write_yaml: Callable[..., Path], capsys: pytest.CaptureFixture[str]
) -> Callable[..., str]:
"""Validate a config that must fail and return the printed errors."""
def validate(*parts: str, base: str = BASE) -> str:
CORE.config_path = write_yaml(*parts, base=base)
assert read_config({}) is None
return capsys.readouterr().out
return validate
def test_it8951_owned_by_vnc(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(write_yaml(IT8951, VNC))
assert TEST_CARD not in main_cpp
assert f"epd->{UPDATE_NEVER}" in main_cpp
assert re.search(r"new\(\w+\) vnc::VncClient\(epd, \w+\);", main_cpp)
assert 'set_host("192.168.1.20");' in main_cpp
assert "set_port(5900);" in main_cpp
assert "set_reconnect_interval(5000);" in main_cpp
assert "set_update_delay(200);" in main_cpp
assert "set_max_update_delay(1000);" in main_cpp
def test_epaper_spi_owned_by_vnc(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml("display:", EPAPER_SPI, "\nvnc:\n - host: 192.168.1.20\n")
)
assert TEST_CARD not in main_cpp
assert f"other->{UPDATE_NEVER}" in main_cpp
assert re.search(r"new\(\w+\) vnc::VncClient\(other, \w+\);", main_cpp)
def test_display_with_generated_id(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml("display:\n - platform: it8951\n model: m5stack-m5paper\n", VNC)
)
assert TEST_CARD not in main_cpp
assert UPDATE_NEVER in main_cpp
assert "vnc::VncClient(" in main_cpp
def test_only_the_vnc_display_loses_test_card(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951, EPAPER_SPI, "\nvnc:\n - host: 192.168.1.20\n display_id: epd\n"
)
)
assert f"other->{TEST_CARD}" in main_cpp
assert f"epd->{TEST_CARD}" not in main_cpp
assert f"epd->{UPDATE_NEVER}" in main_cpp
def test_options(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951,
"""
vnc:
- host: vnc.local
port: 5901
reconnect_interval: 2s
update_delay: 0ms
max_update_delay: 3s
on_connect:
- logger.log: up
on_disconnect:
- logger.log: down
""",
)
)
assert 'set_host("vnc.local");' in main_cpp
assert "set_port(5901);" in main_cpp
assert "set_reconnect_interval(2000);" in main_cpp
assert "set_update_delay(0);" in main_cpp
assert "set_max_update_delay(3000);" in main_cpp
assert "add_on_connect_callback(" in main_cpp
assert "add_on_disconnect_callback(" in main_cpp
def test_two_clients_on_two_displays(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951,
EPAPER_SPI,
"""
vnc:
- host: 192.168.1.20
display_id: epd
- host: 192.168.1.21
display_id: other
""",
)
)
assert re.search(r"vnc::VncClient\(epd, \w+\);", main_cpp)
assert re.search(r"vnc::VncClient\(other, \w+\);", main_cpp)
assert TEST_CARD not in main_cpp
@pytest.mark.parametrize(
("display_options", "error"),
[
pytest.param(
" lambda: |-\n it.fill(Color::BLACK);\n",
"vnc draws the whole of display 'epd'",
id="lambda",
),
pytest.param(
" pages:\n - id: page1\n lambda: |-\n"
" it.fill(Color::BLACK);\n",
"vnc draws the whole of display 'epd'",
id="pages",
),
pytest.param(
" show_test_card: true\n",
"vnc draws the whole of display 'epd'",
id="show_test_card",
),
pytest.param(
" auto_clear_enabled: true\n",
"vnc draws the whole of display 'epd'",
id="auto_clear_enabled",
),
],
)
def test_display_with_own_writer_rejected(
display_options: str, error: str, config_error: Callable[..., str]
) -> None:
assert error in config_error(IT8951, display_options, VNC)
def test_lvgl_on_same_display_rejected(config_error: Callable[..., str]) -> None:
out = config_error(IT8951, VNC, "\nlvgl:\n displays: [epd]\n")
assert "Display 'epd' is also used by LVGL" in out
def test_lvgl_on_other_display_accepted(
write_yaml: Callable[..., Path],
) -> None:
CORE.config_path = write_yaml(
IT8951,
EPAPER_SPI,
"\nvnc:\n - host: 192.168.1.20\n display_id: epd\n",
"\nlvgl:\n displays: [other]\n",
)
assert read_config({}) is not None
def test_two_clients_on_one_display_rejected(
config_error: Callable[..., str],
) -> None:
out = config_error(
IT8951, "\nvnc:\n - host: 192.168.1.20\n - host: 192.168.1.21\n"
)
assert "Display 'epd' is drawn by more than one component: vnc, vnc" in out
def test_mipi_spi_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"""
display:
- platform: mipi_spi
id: epd
model: st7735
cs_pin: GPIO15
dc_pin: GPIO27
reset_pin: GPIO26
""",
VNC,
)
assert "Display 'epd' uses the mipi_spi platform" in out
def test_rotated_rpi_dpi_rgb_accepted(write_yaml: Callable[..., Path]) -> None:
CORE.config_path = write_yaml(RPI_DPI_RGB, " rotation: 90\n", VNC, base=S3_BASE)
assert read_config({}) is not None
def test_ipv4_kept_on_with_openthread(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
generate_main(write_yaml(C6_EPAPER, VNC, base=OPENTHREAD_BASE))
define = next(d for d in CORE.defines if d.name == "USE_NETWORK_IPV4")
assert str(define.value) == "true"
def test_ipv4_disabled_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"network:\n enable_ipv4: false\n", C6_EPAPER, VNC, base=OPENTHREAD_BASE
)
assert "IPv4 is required by vnc" in out
def test_waveshare_epaper_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"""
display:
- platform: waveshare_epaper
id: epd
model: 1.54in
cs_pin: GPIO15
dc_pin: GPIO27
busy_pin: GPIO26
reset_pin: GPIO25
""",
VNC,
)
assert "Display 'epd' uses the waveshare_epaper platform" in out
assert "use epaper_spi" in out
@pytest.mark.parametrize(
("vnc_options", "error"),
[
pytest.param(
" reconnect_interval: 500ms\n",
"value must be at least 1s",
id="reconnect",
),
pytest.param(" port: 0\n", "value must be at least 1", id="port"),
],
)
def test_option_rejected(
vnc_options: str, error: str, config_error: Callable[..., str]
) -> None:
out = config_error(IT8951, VNC, vnc_options)
assert error in out
@pytest.mark.parametrize("host", ["::1", "fe80::1", "2001:db8::10"])
def test_ipv6_host_rejected(host: str, config_error: Callable[..., str]) -> None:
out = config_error(IT8951, f'\nvnc:\n - host: "{host}"\n')
assert "IPv6 addresses are not supported" in out
+2 -1
View File
@@ -78,6 +78,7 @@ api:
type: string
description: Name to greet
example: World
default: World
then:
- logger.log:
format: Hello World %s!
@@ -176,7 +177,7 @@ api:
message: "{{ room }} is at {{ level }}"
variables:
room: !lambda 'return room.c_str();'
level: !lambda 'static char buf[12]; snprintf(buf, sizeof(buf), "%d", level); return buf;'
level: !lambda 'static char buf[12]; snprintf(buf, sizeof(buf), "%d", (int) level); return buf;'
- homeassistant.action:
action: notify.html5
data:
@@ -8,7 +8,6 @@ bluetooth_proxy:
connection_slots: 9
esp32_hosted:
active_high: true
variant: ESP32C6
reset_pin: GPIO54
cmd_pin: GPIO19
+8
View File
@@ -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
@@ -30,7 +30,6 @@ wifi:
esp32_hosted:
variant: ESP32C6
slot: 1
active_high: true
reset_pin: GPIO15
cmd_pin: GPIO13
clk_pin: GPIO12
@@ -1,7 +1,6 @@
esp32_hosted:
variant: ESP32C6
slot: 1
active_high: true
reset_pin: GPIO15
use_psram: true
cmd_pin: GPIO13
@@ -2,7 +2,6 @@
esp32_hosted:
variant: ESP32C6
slot: 1
active_high: true
reset_pin: GPIO15
cmd_pin: GPIO13
clk_pin: GPIO12
@@ -2,11 +2,11 @@ esp32_hosted:
variant: ESP32C6
slot: 1
bus_width: 1
active_high: true
reset_pin: GPIO15
cmd_pin: GPIO13
clk_pin: GPIO12
d0_pin: GPIO11
d1_pin: GPIO10
wifi:
ssid: MySSID
@@ -1,7 +1,6 @@
esp32_hosted:
variant: ESP32C6
slot: 1
active_high: true
reset_pin: GPIO15
cmd_pin: GPIO13
clk_pin: GPIO12
@@ -1,7 +1,6 @@
esp32_hosted:
type: spi
variant: ESP32C6
active_high: true
reset_pin: GPIO15
handshake_pin: GPIO54
data_ready_pin: GPIO14
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more