Compare commits

...
Author SHA1 Message Date
J. Nick Koston e0385fae4a Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/core/config.py
2026-10-21 18:49:23 -10:00
J. Nick Koston 966193cb09 [core] Emit generated lambda code after the user includes (#21307) 2026-10-21 18:28:04 -10:00
e1077748be [api][cover] Expose toggle support in native API (#16506)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 18:11:09 -10:00
cc89a011e2 Bump clang-tidy + clang-format from 22.1.8 to 23.1.3 (#21296)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 17:45:36 -10:00
Craig Andrews c76e9e1d62 [epaper_spi] Support Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680) (#21218) 2026-10-21 17:09:44 -10:00
esphome[bot] 7a3aac407f Bump aioesphomeapi from 46.18.0 to 46.19.0 (#21345) 2026-10-21 17:03:34 -10:00
J. Nick Koston b0a1054e8d [ld2410] Publish queried number values directly (#21346) 2026-10-21 16:56:51 -10:00
J. Nick Koston 163a0d388b Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	tests/unit_tests/test_config_helpers.py
2026-10-21 16:39:59 -10:00
a8a8acb918 [sensor] Add Relative Humidity Correction Filter (#18287)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-10-21 16:33:11 -10:00
J. Nick Koston aa8e032081 Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-10-14 10:39:22 -10:00
J. Nick Koston efbee480dc Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/components/esp32/__init__.py
#	esphome/core/defines.h
2026-10-09 16:08:43 -10:00
J. Nick Koston 1e72cdb8a0 Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/writer.py
2026-10-09 13:09:48 -10:00
J. Nick Koston f117264eda Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-10-07 10:29:23 -10:00
J. Nick Koston 6ea7651a5c Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-10-06 21:09:14 -10:00
J. Nick Koston d253f6af4e Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/core/config.py
2026-10-02 09:20:38 -05:00
J. Nick Koston bbfdf02903 Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/core/config.py
2026-10-02 08:15:46 -05:00
J. Nick Koston 20ff795229 Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-10-01 15:23:04 -05:00
J. Nick Koston c9fbc2f046 Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/components/esp8266/__init__.py
#	esphome/writer.py
2026-09-28 19:52:32 +02:00
J. Nick Koston 7c420ec6ae [core] Use a component key in the keep_float_scanf test 2026-09-26 01:14:48 +01:00
J. Nick Koston feb139fd20 [core] Pass the config through to the external scan and scan once 2026-09-26 01:14:11 +01:00
J. Nick Koston 5af8b8a972 Merge remote-tracking branch 'origin/dev' into sscanf_stub
# Conflicts:
#	esphome/writer.py
#	tests/component_tests/esp32/test_esp32.py
2026-09-26 00:12:06 +01:00
J. Nick Koston 39fd5b6a87 Merge remote-tracking branch 'upstream/dev' into HEAD 2026-09-17 08:48:11 -05:00
J. Nick Koston 1167d95312 Merge remote-tracking branch 'origin/dev' into sscanf_stub 2026-09-16 09:22:35 -05:00
J. Nick Koston 9828037ff1 [core] Let the scanf buffer argument contain a call with commas 2026-09-11 14:18:53 -05:00
J. Nick Koston c3546f8320 [core] Find the scanf format literal with one regex instead of a call walker 2026-09-11 14:03:01 -05:00
J. Nick Koston 9d400ab787 Revert "[core] Treat a non-literal scanf format as a float scan"
This reverts commit 4f0fec3e50.
2026-09-11 14:00:05 -05:00
J. Nick Koston 4f0fec3e50 [core] Treat a non-literal scanf format as a float scan 2026-09-11 13:57:20 -05:00
J. Nick Koston f73dc0e7ce [core] Move the scanf scan into code_scan.py on the loader's component list and trim the tests 2026-09-11 13:40:28 -05:00
J. Nick Koston cd214cc98b [core] Cover an external component without a float scanf 2026-09-11 13:28:37 -05:00
J. Nick Koston 3d71e9050f [core] Cover the esphome core domain in the external scan test 2026-09-11 13:22:23 -05:00
J. Nick Koston 55bf8c4e0c [core] Treat everything in the esphome package as in-tree for the float scanf scan 2026-09-11 13:21:54 -05:00
J. Nick Koston 7e089621d1 [core] Scan external component sources for float scanf 2026-09-11 12:59:13 -05:00
J. Nick Koston 1d1758c508 [core] Share the include extension set and cover the scanf call walker 2026-09-11 12:19:49 -05:00
J. Nick Koston 2606c88f3c [core] Match newlib for %%, support %p, and find float scans past a semicolon 2026-09-11 11:27:59 -05:00
J. Nick Koston 5c0607fc6e [esp32] Keep the sscanf wrap independent of enable_full_printf and say the override aborts 2026-09-11 10:33:23 -05:00
J. Nick Koston 2b3921b565 [core] Test include sorting and add_includes 2026-09-11 10:13:12 -05:00
J. Nick Koston 6020b76587 [core] Compare include basenames as posix in the test 2026-09-11 10:12:14 -05:00
J. Nick Koston 6f1d7d78f6 [core] Reuse SOURCE_FILE_EXTENSIONS for the include scan 2026-09-11 10:07:23 -05:00
J. Nick Koston aef04162d9 [esp32] Share include iteration and the float scanf resolver, register the sscanf job with the other wraps 2026-09-11 10:06:23 -05:00
J. Nick Koston e92530a0d5 [esp32] Scan includes for float scanf and name the format in the abort 2026-09-11 09:56:41 -05:00
J. Nick Koston 3c8c511e12 [esp32] Trim the sscanf wrap comments and docstrings 2026-09-11 09:53:38 -05:00
J. Nick Koston c6062a73c3 [esp32] Name the newlib wrap predicate for what it decides and test width with hh 2026-09-11 09:21:09 -05:00
J. Nick Koston bd193c0aff [core] Use fixed-width types in the sscanf scanner tests for clang-tidy 2026-09-11 08:45:48 -05:00
J. Nick Koston 79754065cd [esp32] Guard the sscanf wrap explicitly on Arduino and warn on a forced float lambda 2026-09-11 08:24:32 -05:00
J. Nick Koston 0e800b2bd8 [core] Mark the va_list parameter of the sscanf scanner for clang-tidy 2026-09-11 08:12:06 -05:00
J. Nick Koston 2c7b2e6cb1 [core] Use fixed-width types in the sscanf scanner for clang-tidy 2026-09-11 08:05:59 -05:00
J. Nick Koston ac4abd7f1f [core] Pass the sscanf destination pointer instead of a va_list reference 2026-09-11 08:01:06 -05:00
J. Nick Koston da70e10832 [core] Move the sscanf scanner into a header and add unit tests 2026-09-11 07:50:20 -05:00
J. Nick Koston 8aa1424ae4 [esp32] Share the wrap stub helpers and trim the sscanf scanner 2026-09-11 07:44:14 -05:00
J. Nick Koston 7d8f4b60e1 [esp32] Keep the libc sscanf when a lambda scans a float 2026-09-11 07:33:23 -05:00
J. Nick Koston d1943fe835 [esp32] Wrap sscanf to drop newlib's scanf engine from Bluetooth builds 2026-09-11 07:28:40 -05:00
64 changed files with 2217 additions and 235 deletions
+8
View File
@@ -12,6 +12,7 @@ Checks: >-
-bugprone-multi-level-implicit-pointer-conversion,
-bugprone-narrowing-conversions,
-bugprone-tagged-union-member-count,
-bugprone-signed-bitwise,
-bugprone-signed-char-misuse,
-bugprone-switch-missing-default-case,
-cert-dcl50-cpp,
@@ -39,6 +40,7 @@ Checks: >-
-cppcoreguidelines-avoid-const-or-ref-data-members,
-cppcoreguidelines-avoid-do-while,
-cppcoreguidelines-avoid-magic-numbers,
-cppcoreguidelines-explicit-constructor,
-cppcoreguidelines-init-variables,
-cppcoreguidelines-macro-to-enum,
-cppcoreguidelines-macro-usage,
@@ -84,6 +86,7 @@ Checks: >-
-llvm-use-ranges,
-llvmlibc-*,
-misc-const-correctness,
-misc-explicit-constructor,
-misc-include-cleaner,
-misc-multiple-inheritance,
-misc-no-recursion,
@@ -113,6 +116,7 @@ Checks: >-
-objc-*,
-performance-enum-size,
-portability-avoid-pragma-once,
-portability-no-assembler,
-portability-template-virtual-member-function,
-readability-ambiguous-smartptr-reset-call,
-readability-avoid-nested-conditional-operator,
@@ -129,9 +133,13 @@ Checks: >-
-readability-named-parameter,
-readability-redundant-casting,
-readability-redundant-inline-specifier,
-readability-redundant-lambda-parameter-list,
-readability-redundant-member-init,
-readability-redundant-nested-if,
-readability-redundant-parentheses,
-readability-redundant-typename,
-readability-trailing-comma,
-readability-trivial-switch,
-readability-uppercase-literal-suffix,
-readability-use-anyofallof,
-readability-use-std-min-max,
+1 -1
View File
@@ -38,7 +38,7 @@ repos:
- id: yamllint
exclude: ^(\.clang-format|\.clang-tidy)$
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v22.1.8
rev: v23.1.3
hooks:
- id: clang-format
types_or: [c, c++]
+126
View File
@@ -0,0 +1,126 @@
"""Build-time scans of the user's C++ sources: lambdas, ``includes:`` files and
external components.
The only scan today finds scanf calls with float conversions, which decides
whether float scanf support (or, on ESP-IDF Bluetooth builds, the libc sscanf)
must stay linked. Other source heuristics belong here too.
"""
import logging
from pathlib import Path
import re
from esphome.config_helpers import iter_include_files
from esphome.const import (
CONF_ESPHOME,
CONF_INCLUDES,
CONF_INCLUDES_C,
SOURCE_FILE_EXTENSIONS,
)
from esphome.core import Lambda
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# The string literal handed to a scanf family call as its format: an optional
# first argument (sscanf/fscanf; a literal or a call with its own commas is
# fine) then the literal.
_SCANF_FORMAT_RE = re.compile(
r'scanf\s*\((?:(?:"(?:[^"\\]|\\.)*"|\([^()]*\)|[^,;"()])*,)?\s*"((?:[^"\\]|\\.)*)"'
)
# Standard scanf float conversions %f %F %e %E %g %G %a %A with optional
# suppression, width and length; also the invalid %.2f users write by analogy
# with printf.
_SCANF_FLOAT_SPEC_RE = re.compile(r"%[*\d.]*[hlL]*[feEgGaAF]")
def source_uses_scanf_float(src: str) -> bool:
"""Heuristic: does C++ source call a scanf family function with a float conversion?"""
if "scanf" not in src:
return False
src = Lambda.comment_remover(src)
return any(
_SCANF_FLOAT_SPEC_RE.search(match.group(1))
for match in _SCANF_FORMAT_RE.finditer(src)
)
def lambdas_use_scanf_float(config: ConfigType) -> bool:
"""Check if any lambda in the config uses scanf with a float format specifier."""
stack: list = [config]
while stack:
obj = stack.pop()
if isinstance(obj, Lambda):
if source_uses_scanf_float(obj.value):
return True
elif isinstance(obj, dict):
stack.extend(obj.values())
elif isinstance(obj, list):
stack.extend(obj)
return False
def includes_use_scanf_float(config: ConfigType) -> bool:
"""Check if an ``includes:`` or ``includes_c:`` file uses scanf with a float format specifier."""
esphome_config = config.get(CONF_ESPHOME, {})
includes = esphome_config.get(CONF_INCLUDES, []) + esphome_config.get(
CONF_INCLUDES_C, []
)
for path, _ in iter_include_files(includes):
if path.suffix not in SOURCE_FILE_EXTENSIONS:
continue
if source_uses_scanf_float(path.read_text(encoding="utf-8", errors="replace")):
return True
return False
def external_components_use_scanf_float(config: ConfigType) -> bool:
"""Whether a component from outside the esphome package scans a float."""
from esphome.config import iter_components
from esphome.loader import CORE_COMPONENTS_PATH
package_root = CORE_COMPONENTS_PATH.parent
for _, component in iter_components(config):
package_dir = Path(component.module.__file__).resolve().parent
if package_dir.is_relative_to(package_root):
continue
for resource in component.resources:
src = (package_dir / resource.resource).read_text(
encoding="utf-8", errors="replace"
)
if source_uses_scanf_float(src):
return True
return False
def user_code_uses_scanf_float(config: ConfigType) -> bool:
"""Whether a lambda, an ``includes:`` file or an external component scans a float."""
return (
lambdas_use_scanf_float(config)
or includes_use_scanf_float(config)
or external_components_use_scanf_float(config)
)
def keep_float_scanf(
option: bool | None, config: ConfigType, flash_note: str, override_note: str
) -> bool:
"""Resolve a tri-state float-scanf option: unset means "only if user code scans a float"."""
if option:
return True
found = user_code_uses_scanf_float(config)
if option is None:
if found:
_LOGGER.warning(
"Lambda, include or external component uses scanf with a float format "
"specifier; keeping float scanf support (%s)",
flash_note,
)
return found
if found:
_LOGGER.warning(
"Float scanf support is disabled but a lambda, include or external "
"component uses scanf with a float format specifier; %s",
override_note,
)
return False
+2
View File
@@ -608,6 +608,7 @@ message ListEntitiesCoverResponse {
bool supports_stop = 12;
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
bool supports_stop_tilt = 14;
bool supports_toggle = 15;
}
// Deprecated in API version 1.1
@@ -670,6 +671,7 @@ message CoverCommandRequest {
bool stop = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
bool stop_tilt = 10;
bool toggle = 11;
}
// ==================== FAN ====================
@@ -555,6 +555,7 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c
msg.supports_tilt = traits.get_supports_tilt();
msg.supports_stop = traits.get_supports_stop();
msg.supports_stop_tilt = traits.get_supports_stop_tilt();
msg.supports_toggle = traits.get_supports_toggle();
return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, conn, remaining_size);
}
void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
@@ -567,6 +568,8 @@ void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
call.set_command_stop();
if (msg.stop_tilt)
call.set_command_stop_tilt();
if (msg.toggle)
call.set_command_toggle();
call.perform();
}
#endif
+5
View File
@@ -484,6 +484,7 @@ uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__rest
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id);
#endif
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.supports_stop_tilt);
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.supports_toggle);
return pos;
}
uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
@@ -506,6 +507,7 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.device_id);
#endif
size += ProtoSize::calc_bool(1, msg.supports_stop_tilt);
size += ProtoSize::calc_bool(1, msg.supports_toggle);
return size;
}
uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
@@ -565,6 +567,9 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *
case proto_tag(10, WIRE_TYPE_VARINT):
msg.stop_tilt = value.as_bool();
break;
case proto_tag(11, WIRE_TYPE_VARINT):
msg.toggle = value.as_bool();
break;
default:
break;
}
+4 -2
View File
@@ -911,7 +911,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage {
class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 13;
static constexpr uint8_t ESTIMATED_SIZE = 59;
static constexpr uint8_t ESTIMATED_SIZE = 61;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); }
#endif
@@ -921,6 +921,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
ProgmemStringRef device_class{};
bool supports_stop{false};
bool supports_stop_tilt{false};
bool supports_toggle{false};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -958,7 +959,7 @@ class CoverStateResponse final : public StateResponseProtoMessage {
class CoverCommandRequest final : public CommandProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 30;
static constexpr uint8_t ESTIMATED_SIZE = 27;
static constexpr uint8_t ESTIMATED_SIZE = 29;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("cover_command_request"); }
#endif
@@ -968,6 +969,7 @@ class CoverCommandRequest final : public CommandProtoMessage {
float tilt{0.0f};
bool stop{false};
bool stop_tilt{false};
bool toggle{false};
void decode(const uint8_t *buffer, size_t length) {
ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);
}
+2
View File
@@ -1246,6 +1246,7 @@ const char *ListEntitiesCoverResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("supports_stop_tilt"), this->supports_stop_tilt);
dump_field(out, ESPHOME_PSTR("supports_toggle"), this->supports_toggle);
return out.c_str();
}
const char *CoverStateResponse::dump_to(DumpBuffer &out) const {
@@ -1271,6 +1272,7 @@ const char *CoverCommandRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("stop_tilt"), this->stop_tilt);
dump_field(out, ESPHOME_PSTR("toggle"), this->toggle);
return out.c_str();
}
#endif
+1 -1
View File
@@ -301,7 +301,7 @@ constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128
constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
/// Generated encode body: writes the fields at pos, returns the cursor past them.
using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
using ProtoEncodeFn = uint8_t *(*)(const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and
/// returns it advanced, so outlined calls at -Os chain through the return register instead of a
+4 -5
View File
@@ -111,10 +111,9 @@ static const uint8_t BL0906_USR_WRPROT = 0x9E;
// Reset Register
static const uint8_t BL0906_SOFT_RESET = 0x9F;
const uint8_t BL0906_INIT[2][6] = {
// Reset to default
{BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52},
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
const uint8_t BL0906_INIT[2][6] = {// Reset to default
{BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52},
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
} // namespace esphome::bl0906
@@ -0,0 +1,12 @@
#include "epaper_goodisplay_3c.h"
namespace esphome::epaper_spi {
void EPaperGoodDisplay3C::power_on() {}
void EPaperGoodDisplay3C::refresh_screen(bool partial) {
EPaperWeAct3C::refresh_screen(partial);
this->next_delay_ = REFRESH_START_MS;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,28 @@
#pragma once
#include "epaper_weact_3c.h"
namespace esphome::epaper_spi {
/**
* Good Display 3-color e-paper displays (SSD1680 controller), e.g. GDEY029Z95.
*
* Driven as EPaperWeAct3C, except that there is no separate power-on: the full refresh (0xF7) powers the panel
* up itself. Busy then only goes high a little after the refresh starts, so the next state waits before
* checking it.
*/
class EPaperGoodDisplay3C : public EPaperWeAct3C {
public:
using EPaperWeAct3C::EPaperWeAct3C;
protected:
// Time from master activation (0x20) until busy can be relied on
static constexpr uint16_t REFRESH_START_MS = 50;
// With EPaperWeAct3C's power-on (0x22 0xF8) first, this panel's refresh ends almost at once and the image
// does not change.
void power_on() override;
void refresh_screen(bool partial) override;
};
} // namespace esphome::epaper_spi
@@ -4,11 +4,17 @@ Supported models:
- weact-2.13in-3c: 122x250 pixels (2.13" display)
- weact-2.9in-3c: 128x296 pixels (2.9" display)
- weact-4.2in-3c: 400x300 pixels (4.2" display)
- goodisplay-gdey029z95-2.9: 128x296 pixels (Good Display GDEY029Z95 2.9" display)
These displays use SSD1680 or SSD1683 controller and require a specific initialization
sequence. The DRV_OUT_CTL command is calculated from the display height.
The Good Display panel is driven as the WeAct ones, except that it must not get the
separate power-on before the refresh, so it has its own driver class, EPaperGoodDisplay3C.
"""
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_RESET_PIN
from . import EpaperModel
@@ -17,8 +23,10 @@ class WeActBWR(EpaperModel):
source_files = ("epaper_weact_3c.cpp",)
def __init__(self, name, **defaults):
super().__init__(name, "EPaperWeAct3C", **defaults)
def __init__(
self, name: str, class_name: str = "EPaperWeAct3C", **defaults: object
) -> None:
super().__init__(name, class_name, **defaults)
def get_init_sequence(self, config):
"""Generate initialization sequence for WeAct BWR displays.
@@ -75,3 +83,27 @@ weact_4p2in3c = WeActBWR(
data_rate="10MHz",
minimum_update_interval="10s",
)
class GoodDisplayBWR(WeActBWR):
"""Good Display Black/White/Red displays using SSD1680 controller."""
source_files = ("epaper_weact_3c.cpp", "epaper_goodisplay_3c.cpp")
def __init__(self, name: str, **defaults: object) -> None:
super().__init__(name, "EPaperGoodDisplay3C", **defaults)
def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required:
# Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy
if name in (CONF_RESET_PIN, CONF_BUSY_PIN):
return cv.Required(name)
return super().option(name, fallback)
# Model: Good Display GDEY029Z95 2.9" BWR - 128x296 pixels, SSD1680 controller
GoodDisplayBWR(
"goodisplay-gdey029z95-2.9",
width=128,
height=296,
minimum_update_interval="10s",
)
+52 -10
View File
@@ -10,11 +10,13 @@ import subprocess
from typing import Any
from esphome import yaml_util
from esphome.code_scan import keep_float_scanf
import esphome.codegen as cg
from esphome.components.const import (
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION,
CONF_IGNORE_NOT_FOUND,
)
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_ADVANCED,
@@ -204,6 +206,10 @@ PSRAM_XIP_VARIANTS = {
# neither, so the engine is already in the image and the wrap saves nothing.
ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6}
# Variants whose ROM exports sscanf with the newlib format in use (C6 normal
# format, C2 nano format by IDF default), so the sscanf wrap saves nothing.
ROM_SSCANF_VARIANTS = {VARIANT_ESP32C2, VARIANT_ESP32C6}
# NVS encryption (HMAC peripheral scheme) is only available on variants that
# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original
# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral
@@ -2052,6 +2058,7 @@ CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram"
CONF_HEAP_IN_IRAM = "heap_in_iram"
CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size"
CONF_USE_FULL_CERTIFICATE_BUNDLE = "use_full_certificate_bundle"
CONF_ENABLE_FULL_SCANF = "enable_full_scanf"
CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs"
CONF_DISABLE_OCD_AWARE = "disable_ocd_aware"
CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary"
@@ -2347,6 +2354,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, default=[]
): cv.ensure_list(cv.string_strict),
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
cv.Optional(CONF_ENABLE_FULL_SCANF): cv.boolean,
cv.Optional(CONF_DISABLE_DEBUG_STUBS, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_OCD_AWARE, default=True): cv.boolean,
cv.Optional(
@@ -2649,6 +2657,32 @@ async def _set_libc_picolibc_newlib_compat() -> None:
)
def _add_wrap_stub(symbol: str, define: str) -> None:
"""Emit a linker wrap for ``symbol`` served by a stub compiled under ``define``.
--undefined pulls the stub from libsrc.a, scanned before the IDF library that
references the symbol.
"""
cg.add_define(define)
cg.add_build_flag(f"-Wl,--wrap={symbol}")
cg.add_build_flag(f"-Wl,--undefined=__wrap_{symbol}")
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_sscanf_stub(enable_full_scanf: bool | None) -> None:
"""Wrap sscanf when bluedroid is the only reason newlib's scanf engine links.
FINAL priority so every request_bluetooth() call has happened.
"""
if get_esp32_variant() in ROM_SSCANF_VARIANTS or not _network_sdkconfig().bluetooth:
return
if keep_float_scanf(
enable_full_scanf, CORE.config, "~13KB flash", "that call will abort the device"
):
return
_add_wrap_stub("sscanf", "USE_ESP32_SSCANF_STUB")
@coroutine_with_priority(CoroPriority.FINAL)
async def _apply_nvs_cache_in_psram(explicit: bool) -> None:
"""Keep the NVS cache in PSRAM unless NVS encryption is on, however it was enabled."""
@@ -3090,9 +3124,8 @@ async def to_code(config):
# saves nothing while costing ~170 B of shim. IDF 5.x defaults to
# newlib on every variant; IDF 6.0+ switches to picolibc on every
# variant.
if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or idf_version() >= cv.Version(
6, 0, 0
):
uses_newlib = idf_version() < cv.Version(6, 0, 0)
if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or not uses_newlib:
cg.add_define("USE_FULL_PRINTF")
else:
for symbol in ("vprintf", "printf", "fprintf", "vfprintf"):
@@ -3100,14 +3133,14 @@ async def to_code(config):
# esp_http_client calls vasprintf, which on the ESP32-C6 is the only
# reference to newlib's full printf engine (~20 KB: _svfprintf_r,
# _dtoa_r and their helpers); every other caller resolves to the
# ROM. See vasprintf_stubs.cpp. The --undefined flag is needed
# because libsrc.a is scanned before the IDF libraries that
# reference the symbol, so the stub would otherwise never be pulled
# from the archive.
# ROM. See vasprintf_stubs.cpp.
if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS:
cg.add_define("USE_ESP32_VASPRINTF_STUB")
cg.add_build_flag("-Wl,--wrap=vasprintf")
cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf")
_add_wrap_stub("vasprintf", "USE_ESP32_VASPRINTF_STUB")
if uses_newlib:
# bluedroid's sscanf calls; see sscanf_stubs.cpp
CORE.add_job(
_add_sscanf_stub, conf[CONF_ADVANCED].get(CONF_ENABLE_FULL_SCANF)
)
else:
cg.add_build_flag("-DUSE_ARDUINO")
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO")
@@ -4097,3 +4130,12 @@ def process_stacktrace(config, line, backtrace_state):
_decode_pc(config, addr.group())
return backtrace_state
# Each stub only compiles under its define, so keep the sources out of other builds
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{
"sscanf_stubs.cpp": "USE_ESP32_SSCANF_STUB",
"vasprintf_stubs.cpp": "USE_ESP32_VASPRINTF_STUB",
}
)
+43
View File
@@ -0,0 +1,43 @@
/*
* Linker wrap for sscanf on ESP-IDF with newlib.
*
* bluedroid's two "%02x" parses are the only sscanf callers in an ESPHome
* image and link newlib's whole scanf engine (~13 KB with _strtod_l). Routing
* them through core/sscanf_no_float.h drops it; floats would pull _strtod_l
* back, so those abort naming enable_full_scanf. See _add_sscanf_stub in
* esp32/__init__.py for when this is emitted.
*/
#include "esphome/core/defines.h"
#if defined(USE_ESP_IDF) && defined(USE_ESP32_SSCANF_STUB)
#include <cstdarg>
#include <cstdio>
#include "esp_system.h"
#include "esphome/core/sscanf_no_float.h"
namespace esphome::esp32 {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
int __wrap_sscanf(const char *str, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int result = esphome::vsscanf_no_float(str, fmt, ap);
va_end(ap);
if (result == esphome::SSCANF_UNSUPPORTED) {
char msg[160];
snprintf(msg, sizeof(msg),
"sscanf: unsupported conversion in \"%s\"; set enable_full_scanf: true in esp32 framework", fmt);
esp_system_abort(msg);
}
return result;
}
} // extern "C"
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_ESP_IDF && USE_ESP32_SSCANF_STUB
+8 -43
View File
@@ -6,6 +6,7 @@ import subprocess
from typing import Any
from esphome.build_helpers.native import native_backend
from esphome.code_scan import keep_float_scanf
import esphome.codegen as cg
from esphome.components import network
import esphome.config_validation as cv
@@ -26,13 +27,7 @@ from esphome.const import (
ThreadModel,
Toolchain,
)
from esphome.core import (
CORE,
CoroPriority,
EsphomeError,
Lambda,
coroutine_with_priority,
)
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import IS_MACOS, copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script
@@ -67,15 +62,6 @@ from .const import (
from .gpio import PinInitialState, add_pin_initial_states_array
CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float"
# Heuristically matches scanf/sscanf calls with float format specifiers.
# Standard scanf float conversions: %f %F %e %E %g %G %a %A
# With optional modifiers: %*f (suppression), %8f (width), %lf %Lf (length)
# Also matches non-standard patterns like %.2f as a heuristic — these are
# invalid in scanf but users may write them by analogy with printf.
# Uses [^;]*? to stay within a single statement, preventing false positives
# from e.g. sscanf(buf, "%d", &x); printf("%f", val);
_SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]")
CODEOWNERS = ["@esphome/core"]
DOMAIN = "esp8266"
_LOGGER = logging.getLogger(__name__)
@@ -83,26 +69,6 @@ AUTO_LOAD = ["preferences"]
IS_TARGET_PLATFORM = True
def lambdas_use_scanf_float(config: ConfigType) -> bool:
"""Check if any lambda in the config uses scanf with a float format specifier.
Comments are stripped before matching to avoid false positives from
commented-out code. The cost of a false positive is only ~8KB flash.
"""
stack: list = [config]
while stack:
obj = stack.pop()
if isinstance(obj, Lambda):
src = obj.comment_remover(obj.value)
if _SCANF_FLOAT_RE.search(src):
return True
elif isinstance(obj, dict):
stack.extend(obj.values())
elif isinstance(obj, list):
stack.extend(obj)
return False
def set_core_data(config: ConfigType) -> ConfigType:
CORE.data[KEY_ESP8266] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266
@@ -425,13 +391,12 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_ESP8266_CRASH_HANDLER")
cg.add_define("USE_CRASH_HANDLER")
enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT)
if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config):
enable_scanf_float = True
_LOGGER.warning(
"Lambda uses scanf with a float format specifier; "
"enabling scanf float support (~8KB flash)"
)
enable_scanf_float = keep_float_scanf(
config.get(CONF_ENABLE_SCANF_FLOAT),
CORE.config,
"~8KB flash",
"that call will not parse",
)
# The native generator reads the same decision (KEY_SCANF_FLOAT)
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
+9 -16
View File
@@ -407,12 +407,10 @@ void LD2410Component::handle_periodic_data_() {
}
#ifdef USE_NUMBER
std::function<void()> set_number_value(number::Number *n, float value) {
void set_number_value(number::Number *n, float value) {
if (n != nullptr && (!n->has_state() || n->state != value)) {
n->state = value;
return [n, value]() { n->publish_state(value); };
n->publish_state(value);
}
return []() {};
}
#endif
@@ -560,29 +558,24 @@ bool LD2410Component::handle_ack_data_() {
Moving distance range: 13th byte
Still distance range: 14th byte
*/
std::vector<std::function<void()>> updates;
updates.push_back(set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12]));
updates.push_back(set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13]));
set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12]);
set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13]);
/*
Moving Sensitivities: 15~23th bytes
*/
for (std::vector<number::Number *>::size_type i = 0; i != this->gate_move_threshold_numbers_.size(); i++) {
updates.push_back(set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i]));
for (size_t i = 0; i < TOTAL_GATES; i++) {
set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i]);
}
/*
Still Sensitivities: 24~32th bytes
*/
for (std::vector<number::Number *>::size_type i = 0; i != this->gate_still_threshold_numbers_.size(); i++) {
updates.push_back(set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i]));
for (size_t i = 0; i < TOTAL_GATES; i++) {
set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i]);
}
/*
None Duration: 33~34th bytes
*/
updates.push_back(
set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32])));
for (auto &update : updates) {
update();
}
set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32]));
#endif
break;
}
+2 -2
View File
@@ -244,9 +244,9 @@ class MipiSpi : public display::Display,
char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)];
// Don't spam the log after setup
if (this->init_sequence_len_ == 0) {
esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_v(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
} else {
esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
esph_log_d(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
}
if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
this->enable();
+21
View File
@@ -39,13 +39,16 @@ from esphome.const import (
CONF_REFERENCE_TEMPERATURE,
CONF_SEND_EVERY,
CONF_SEND_FIRST_AT,
CONF_SENSOR_ID,
CONF_STATE_CLASS,
CONF_TEMPERATURE,
CONF_TEMPERATURE_OFFSET,
CONF_TIMEOUT,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USE_FAHRENHEIT,
CONF_VALUE,
CONF_WEB_SERVER,
CONF_WINDOW_SIZE,
@@ -310,6 +313,7 @@ OrFilter = sensor_ns.class_("OrFilter", Filter)
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter)
ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter)
RHCorrectionFilter = sensor_ns.class_("RHCorrectionFilter", Filter)
CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter)
SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter)
ClampFilter = sensor_ns.class_("ClampFilter", Filter)
@@ -1281,6 +1285,23 @@ async def calibrate_ntc_temperature_filter_to_code(config, filter_id):
)
@FILTER_REGISTRY.register(
"rh_correction",
RHCorrectionFilter,
cv.Schema(
{
cv.Required(CONF_TEMPERATURE_OFFSET): cv.templatable(cv.float_),
cv.Required(CONF_SENSOR_ID): cv.use_id(Sensor),
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
}
),
)
async def rh_correction_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
sensor = await cg.get_variable(config[CONF_SENSOR_ID])
offset = await cg.templatable(config[CONF_TEMPERATURE_OFFSET], [], cg.float_)
return cg.new_Pvariable(filter_id, offset, sensor, config[CONF_USE_FAHRENHEIT])
def _mean(xs):
return sum(xs) / len(xs)
+42
View File
@@ -493,6 +493,48 @@ optional<float> ToNTCTemperatureFilter::new_value(float value) {
return temp;
}
// RHCorrectionFilter
static constexpr float MAGNUS_B = 17.67f, MAGNUS_C = 243.5f;
static bool rh_correction_temp_in_band(float t) { return t >= -40.0f && t <= 50.0f; }
optional<float> RHCorrectionFilter::new_value(float value) {
if (!this->temperature_sensor_->has_state()) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): no temperature yet, passing humidity through uncorrected", this);
this->warned_ = true;
}
return value;
}
float offset = this->temperature_offset_.value();
float tc = this->temperature_sensor_->get_state();
if (this->use_fahrenheit_) {
tc = fahrenheit_to_celsius(tc);
offset /= 1.8f;
}
const float tnc = tc - offset;
// A NaN temperature or offset is a faulted input: keep the last corrected value
if (std::isnan(tnc)) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): temperature or offset is NaN, dropping humidity updates", this);
this->warned_ = true;
}
return {};
}
if (!rh_correction_temp_in_band(tc) || !rh_correction_temp_in_band(tnc)) {
if (!this->warned_) {
ESP_LOGW(TAG, "RHCorrectionFilter(%p): Temperature out of range -40..50 °C. Tc=%f Tnc=%f", this, tc, tnc);
this->warned_ = true;
}
return value;
}
this->warned_ = false;
const float rh =
clamp(value * std::exp(MAGNUS_B * MAGNUS_C * (tnc - tc) / ((tnc + MAGNUS_C) * (tc + MAGNUS_C))), 0.0f, 100.0f);
ESP_LOGVV(TAG, "RHCorrectionFilter(%p)::new_value(%f) -> %f", this, value, rh);
return rh;
}
// StreamingFilter (base class)
StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at)
: window_size_(window_size), send_first_at_(send_first_at) {}
+20
View File
@@ -647,6 +647,26 @@ class ToNTCTemperatureFilter : public Filter {
double c_;
};
/** RH Temperature correction filter
*
* Allows for Relative Humidity Correction when a temperature offset is applied to the temperature sensor.
* Uses the Magnus formula, rh = rh_nc * exp(b*(t_nc/(c+t_nc) - t/(c+t))) with b = 17.67, c = 243.5 °C;
* see https://en.wikipedia.org/wiki/Dew_point
*/
class RHCorrectionFilter : public Filter {
public:
RHCorrectionFilter(TemplatableFn<float> offset, const Sensor *temperature_sensor, bool use_fahrenheit)
: temperature_offset_(offset), temperature_sensor_(temperature_sensor), use_fahrenheit_(use_fahrenheit) {}
optional<float> new_value(float value) override;
protected:
TemplatableFn<float> temperature_offset_;
const Sensor *temperature_sensor_;
bool use_fahrenheit_;
bool warned_{false};
};
/** Base class for streaming filters (batch windows where window_size == send_every).
*
* When window_size equals send_every, we don't need a sliding window.
+13 -13
View File
@@ -1043,7 +1043,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa
if (is_on) {
parse_cstr_param_(
request, ESPHOME_F("effect"), call,
static_cast<light::LightCall &(light::LightCall::*) (const char *, size_t)>(&decltype(call)::set_effect));
static_cast<light::LightCall &(light::LightCall::*)(const char *, size_t)>(&decltype(call)::set_effect));
}
DEFER_ACTION(call, call.perform());
@@ -1404,7 +1404,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("value"), call,
static_cast<text::TextCall &(text::TextCall::*) (const char *, size_t)>(&decltype(call)::set_value));
static_cast<text::TextCall &(text::TextCall::*)(const char *, size_t)>(&decltype(call)::set_value));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1464,7 +1464,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("option"), call,
static_cast<select::SelectCall &(select::SelectCall::*) (const char *, size_t)>(&decltype(call)::set_option));
static_cast<select::SelectCall &(select::SelectCall::*)(const char *, size_t)>(&decltype(call)::set_option));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1522,26 +1522,26 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url
// Parse string mode parameters
parse_cstr_param_(
request, ESPHOME_F("mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(&decltype(call)::set_mode));
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(&decltype(call)::set_mode));
parse_cstr_param_(request, ESPHOME_F("fan_mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_fan_mode));
parse_cstr_param_(request, ESPHOME_F("swing_mode"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_swing_mode));
parse_cstr_param_(request, ESPHOME_F("preset"), call,
static_cast<climate::ClimateCall &(climate::ClimateCall::*) (const char *, size_t)>(
static_cast<climate::ClimateCall &(climate::ClimateCall::*)(const char *, size_t)>(
&decltype(call)::set_preset));
// Parse temperature parameters
// static_cast needed to disambiguate overloaded setters (float vs optional<float>)
using ClimateCall = decltype(call);
parse_num_param_(request, ESPHOME_F("target_temperature_high"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_high));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature_high));
parse_num_param_(request, ESPHOME_F("target_temperature_low"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_low));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature_low));
parse_num_param_(request, ESPHOME_F("target_temperature"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature));
static_cast<ClimateCall &(ClimateCall::*)(float)>(&ClimateCall::set_target_temperature));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1818,8 +1818,8 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques
auto call = obj->make_call();
parse_cstr_param_(
request, ESPHOME_F("code"), call,
static_cast<alarm_control_panel::AlarmControlPanelCall &(
alarm_control_panel::AlarmControlPanelCall::*) (const char *, size_t)>(&decltype(call)::set_code));
static_cast<alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*)(
const char *, size_t)>(&decltype(call)::set_code));
static constexpr ActionEntry<alarm_control_panel::AlarmControlPanelCall> ACTIONS[] PROGMEM = {
{"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm},
@@ -1893,7 +1893,7 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons
// Parse mode parameter
parse_cstr_param_(
request, ESPHOME_F("mode"), base_call,
static_cast<water_heater::WaterHeaterCall &(water_heater::WaterHeaterCall::*) (const char *, size_t)>(
static_cast<water_heater::WaterHeaterCall &(water_heater::WaterHeaterCall::*)(const char *, size_t)>(
&water_heater::WaterHeaterCall::set_mode));
// Parse temperature parameters
@@ -5,7 +5,7 @@ namespace esphome::xiaomi_hhccpot002 {
ESPHOME_LOG_TAG(TAG, "xiaomi_hhccpot002");
void XiaomiHHCCPOT002 ::dump_config() {
void XiaomiHHCCPOT002::dump_config() {
ESP_LOGCONFIG(TAG, "Xiaomi HHCCPOT002");
LOG_SENSOR(" ", "Moisture", this->moisture_);
LOG_SENSOR(" ", "Conductivity", this->conductivity_);
+19 -1
View File
@@ -1,4 +1,6 @@
from collections.abc import Callable, Collection
from collections.abc import Callable, Collection, Iterator
import logging
from pathlib import Path
from esphome.const import (
CONF_LEVEL,
@@ -9,8 +11,11 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE
from esphome.helpers import walk_files
from esphome.util import OrderedDict
_LOGGER = logging.getLogger(__name__)
# Pre-build lookup map from (platform, framework) tuples to PlatformFramework enum
_PLATFORM_FRAMEWORK_LOOKUP = {
(pf.value[0].value, pf.value[1].value): pf for pf in PlatformFramework
@@ -191,3 +196,16 @@ def get_logger_level() -> str:
logger_config = CORE.config[CONF_LOGGER]
return logger_config.get(CONF_LEVEL, "DEBUG")
def iter_include_files(includes: list[str]) -> Iterator[tuple[Path, Path]]:
"""Yield ``(path, basename)`` per local ``includes:`` file; directory members keep the directory prefix."""
for include in includes:
if include.startswith("<") and include.endswith(">"):
continue
path = CORE.relative_config_path(include)
if path.is_dir():
for file in walk_files(path):
yield file, file.relative_to(path.parent)
else:
yield path, Path(path.name)
+18 -11
View File
@@ -658,6 +658,8 @@ class EsphomeCore:
self.main_statements: list[Statement] = []
# A list of statements to insert in the global block (includes and global variables)
self.global_statements: list[Statement] = []
# Generated code emitted after the user includes, so it can use what they declare
self.global_statements_after_includes: list[Statement] = []
# A map of platformio libraries to add to the project (shortname: (name, version, repository))
self.platformio_libraries: dict[str, Library] = {}
# A set of build flags to set in the platformio project
@@ -728,6 +730,7 @@ class EsphomeCore:
self.variables = {}
self.main_statements = []
self.global_statements = []
self.global_statements_after_includes = []
self.platformio_libraries = {}
self.build_flags = set()
self.cmake_args = {}
@@ -1045,8 +1048,8 @@ class EsphomeCore:
except RuntimeError as e:
raise EsphomeError(str(e)) from e
def add(self, expression, prepend=False) -> "Statement":
"""Add an expression or statement to the main setup() block."""
@staticmethod
def _as_statement(expression) -> "Statement":
from esphome.cpp_generator import Expression, Statement, statement
if isinstance(expression, Expression):
@@ -1055,7 +1058,11 @@ class EsphomeCore:
raise ValueError(
f"Add '{expression}' must be expression or statement, not {type(expression)}"
)
return expression
def add(self, expression, prepend=False) -> "Statement":
"""Add an expression or statement to the main setup() block."""
expression = self._as_statement(expression)
if prepend:
self.main_statements.insert(0, expression)
else:
@@ -1064,14 +1071,7 @@ class EsphomeCore:
return expression
def add_global(self, expression, prepend=False) -> "Statement":
from esphome.cpp_generator import Expression, Statement, statement
if isinstance(expression, Expression):
expression = statement(expression)
if not isinstance(expression, Statement):
raise ValueError(
f"Add '{expression}' must be expression or statement, not {type(expression)}"
)
expression = self._as_statement(expression)
if prepend:
self.global_statements.insert(0, expression)
else:
@@ -1079,6 +1079,13 @@ class EsphomeCore:
_LOGGER.debug("Adding global: %s", expression)
return expression
def add_global_after_includes(self, expression) -> "Statement":
"""Add a global after the user includes, for generated code that may use them."""
expression = self._as_statement(expression)
self.global_statements_after_includes.append(expression)
_LOGGER.debug("Adding global after includes: %s", expression)
return expression
def add_library(self, library: Library):
if not isinstance(library, Library):
raise TypeError(
@@ -1258,7 +1265,7 @@ class EsphomeCore:
from esphome.cpp_generator import statement
global_code = []
for exp in self.global_statements:
for exp in (*self.global_statements, *self.global_statements_after_includes):
text = str(statement(exp))
text = text.rstrip()
global_code.append(text)
+7 -14
View File
@@ -10,6 +10,7 @@ import esphome.codegen as cg
from esphome.config_helpers import (
filter_source_files_from_defines,
filter_source_files_from_platform,
iter_include_files,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -48,6 +49,7 @@ from esphome.const import (
PLATFORM_ESP32,
PLATFORM_HOST,
PLATFORM_RP2,
SOURCE_FILE_EXTENSIONS,
PlatformFramework,
__version__ as ESPHOME_VERSION,
)
@@ -64,7 +66,6 @@ from esphome.helpers import (
fnv1a_32bit_hash,
get_str_env,
get_usable_cpu_count,
walk_files,
)
from esphome.types import ConfigType
@@ -132,7 +133,7 @@ SHARED_DEVICE_RELATION_DEFINES: dict[str, str | None] = {
}
Area = cg.esphome_ns.class_("Area")
VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"}
VALID_INCLUDE_EXTS = SOURCE_FILE_EXTENSIONS
def validate_hostname(config):
@@ -590,18 +591,10 @@ async def add_arduino_global_workaround():
@coroutine_with_priority(CoroPriority.FINAL)
async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
# Add includes at the very end, so that the included files can access global variables
for include in includes:
path = CORE.relative_config_path(include)
if path.is_dir():
# Directory, copy tree
for p in walk_files(path):
basename = p.relative_to(path.parent)
include_file(p, basename, is_c_header)
else:
# Copy file
basename = Path(path.name)
include_file(path, basename, is_c_header)
# After the id declarations, so the headers can use id(x); the generated function bodies
# and flash tables come later still, so their lambdas can use the headers.
for path, basename in iter_include_files(includes):
include_file(path, basename, is_c_header)
def _add_library_str(lib: str) -> None:
+1
View File
@@ -434,6 +434,7 @@
#ifdef USE_ESP32
#define USE_ESP32_CRASH_HANDLER
#define USE_ESP32_VASPRINTF_STUB
#define USE_ESP32_SSCANF_STUB
#define USE_MQTT_IDF_ENQUEUE
#define USE_MQTT_TLS
#define USE_MQTT_CERT_BUNDLE
+4 -2
View File
@@ -59,13 +59,15 @@ template<typename T> class PlainAtomic {
constexpr PlainAtomic(T value) : value_(value) {}
T load(std::memory_order order = std::memory_order_seq_cst) const {
T value = value_;
if (order != std::memory_order_relaxed)
if (order != std::memory_order_relaxed) {
ESPHOME_LFQ_COMPILER_BARRIER(); // acquire: later reads may not hoist above this load
}
return value;
}
void store(T value, std::memory_order order = std::memory_order_seq_cst) {
if (order != std::memory_order_relaxed)
if (order != std::memory_order_relaxed) {
ESPHOME_LFQ_COMPILER_BARRIER(); // release: earlier writes may not sink below this store
}
value_ = value;
}
T fetch_add(T amount, std::memory_order /*order*/ = std::memory_order_seq_cst) {
+277
View File
@@ -0,0 +1,277 @@
#pragma once
// sscanf without floating-point conversions, backing the linker wrap in
// components/esp32/sscanf_stubs.cpp. Integer, character, string and scanset
// conversions behave like libc; anything else returns SSCANF_UNSUPPORTED.
// Header only so the host unit tests can cover it.
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include "esphome/core/helpers.h"
namespace esphome {
/// Returned by vsscanf_no_float() for a conversion it does not implement.
static constexpr int SSCANF_UNSUPPORTED = -2;
namespace sscanf_no_float_internal {
enum class SscanfLength : uint8_t {
SSCANF_LENGTH_NONE,
SSCANF_LENGTH_HH,
SSCANF_LENGTH_H,
SSCANF_LENGTH_L,
SSCANF_LENGTH_LL,
SSCANF_LENGTH_Z,
SSCANF_LENGTH_T,
SSCANF_LENGTH_PTR, // %p
};
inline bool is_space(char c) { return c == ' ' || (c >= '\t' && c <= '\r'); }
// The unsigned bit pattern is what the signed conversions want as well
inline void store_int(void *dest, SscanfLength length, uint64_t value) {
switch (length) {
case SscanfLength::SSCANF_LENGTH_HH:
*static_cast<uint8_t *>(dest) = static_cast<uint8_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_H:
*static_cast<uint16_t *>(dest) = static_cast<uint16_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_L:
// %l is defined in terms of long, whatever its width on the target
*static_cast<unsigned long *>(dest) = static_cast<unsigned long>(value); // NOLINT(google-runtime-int)
break;
case SscanfLength::SSCANF_LENGTH_LL:
*static_cast<uint64_t *>(dest) = value;
break;
case SscanfLength::SSCANF_LENGTH_Z:
*static_cast<size_t *>(dest) = static_cast<size_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_T:
*static_cast<ptrdiff_t *>(dest) = static_cast<ptrdiff_t>(value);
break;
case SscanfLength::SSCANF_LENGTH_PTR:
*static_cast<uintptr_t *>(dest) = static_cast<uintptr_t>(value);
break;
default:
*static_cast<unsigned *>(dest) = static_cast<unsigned>(value);
break;
}
}
struct Scanset {
uint8_t bits[32]{};
bool negate{false};
bool contains(char c) const {
auto u = static_cast<uint8_t>(c);
return ((this->bits[u >> 3] >> (u & 7)) & 1u) != this->negate;
}
};
// Parses "[set]" from just after the '[' and returns the position after ']'
inline const char *parse_scanset(const char *f, Scanset &set) {
if (*f == '^') {
set.negate = true;
f++;
}
// A ']' right after '[' or '[^' is a member, not the terminator.
bool first = true;
for (; *f != '\0' && (first || *f != ']'); f++) {
first = false;
auto lo = static_cast<uint8_t>(*f);
auto hi = lo;
if (f[1] == '-' && f[2] != ']' && f[2] != '\0') {
hi = static_cast<uint8_t>(f[2]);
f += 2;
}
for (unsigned c = lo; c <= hi; c++)
set.bits[c >> 3] |= static_cast<uint8_t>(1u << (c & 7));
}
return *f == ']' ? f + 1 : f;
}
// Radix of an integer conversion: 0 detects from the prefix (%i), 0xFF is unsupported
inline unsigned int_base(char conv) {
switch (conv) {
case 'd':
case 'u':
return 10;
case 'i':
return 0;
case 'o':
return 8;
case 'x':
case 'X':
case 'p':
return 16;
default:
return 0xFF;
}
}
} // namespace sscanf_no_float_internal
/// sscanf semantics for the integer, character, string and scanset
/// conversions; returns SSCANF_UNSUPPORTED on any other conversion.
inline int vsscanf_no_float(const char *str, const char *fmt, va_list ap) { // NOLINT(readability-non-const-parameter)
using namespace sscanf_no_float_internal;
const char *in = str;
int assigned = 0;
bool input_failure = false;
for (const char *f = fmt; *f != '\0'; f++) {
if (is_space(*f)) {
while (is_space(*in))
in++;
continue;
}
bool literal = *f != '%';
if (!literal && f[1] == '%') {
// "%%" matches one '%' with no whitespace skip, like newlib
f++;
literal = true;
}
if (literal) {
if (*in != *f) {
input_failure = *in == '\0';
break;
}
in++;
continue;
}
f++;
bool suppress = false;
if (*f == '*') {
suppress = true;
f++;
}
size_t width = 0;
while (*f >= '0' && *f <= '9') {
width = width * 10 + static_cast<size_t>(*f - '0');
f++;
}
if (width == 0)
width = SIZE_MAX;
auto length = SscanfLength::SSCANF_LENGTH_NONE;
switch (*f) {
case 'h':
f++;
if (*f == 'h') {
length = SscanfLength::SSCANF_LENGTH_HH;
f++;
} else {
length = SscanfLength::SSCANF_LENGTH_H;
}
break;
case 'l':
f++;
if (*f == 'l') {
length = SscanfLength::SSCANF_LENGTH_LL;
f++;
} else {
length = SscanfLength::SSCANF_LENGTH_L;
}
break;
case 'j':
length = SscanfLength::SSCANF_LENGTH_LL;
f++;
break;
case 'z':
length = SscanfLength::SSCANF_LENGTH_Z;
f++;
break;
case 't':
length = SscanfLength::SSCANF_LENGTH_T;
f++;
break;
default:
break;
}
const char conv = *f;
if (conv == '\0')
break;
if (conv == 'n') {
if (!suppress)
store_int(va_arg(ap, void *), length, static_cast<uint64_t>(in - str));
continue;
}
const bool string_conv = conv == 'c' || conv == 's' || conv == '[';
if (string_conv && length != SscanfLength::SSCANF_LENGTH_NONE)
return SSCANF_UNSUPPORTED; // wide characters
if (conv == 'p')
length = SscanfLength::SSCANF_LENGTH_PTR;
unsigned base = string_conv ? 0 : int_base(conv);
if (base == 0xFF)
return SSCANF_UNSUPPORTED;
if (conv != 'c' && conv != '[') {
while (is_space(*in))
in++;
}
if (*in == '\0') {
input_failure = true;
break;
}
if (!string_conv) {
const char *p = in;
size_t n = 0;
bool negative = false;
if (*p == '+' || *p == '-') {
negative = *p == '-';
p++;
n++;
}
if ((base == 0 || base == 16) && p[0] == '0' && (p[1] == 'x' || p[1] == 'X') && n + 2 < width &&
parse_hex_char(p[2]) != INVALID_HEX_CHAR) {
p += 2;
n += 2;
base = 16;
}
if (base == 0)
base = *p == '0' ? 8 : 10;
uint64_t value = 0;
bool any_digit = false;
for (uint8_t digit; n < width && (digit = parse_hex_char(*p)) < base; p++, n++) {
value = value * base + digit;
any_digit = true;
}
if (!any_digit)
break;
in = p;
if (!suppress) {
store_int(va_arg(ap, void *), length, negative ? 0 - value : value);
assigned++;
}
continue;
}
char *dest = suppress ? nullptr : va_arg(ap, char *);
Scanset set;
if (conv == '[')
f = parse_scanset(f + 1, set) - 1; // the loop's f++ steps past the ']'
// %c reads width characters (default 1); a short read still assigns, like newlib
const size_t limit = conv == 'c' && width == SIZE_MAX ? 1 : width;
size_t n = 0;
while (n < limit && in[n] != '\0' && (conv == 'c' || (conv == '[' ? set.contains(in[n]) : !is_space(in[n])))) {
if (dest != nullptr)
dest[n] = in[n];
n++;
}
if (conv != 'c') {
if (n == 0)
break;
if (dest != nullptr)
dest[n] = '\0';
}
in += n;
if (!suppress)
assigned++;
}
if (input_failure && assigned == 0)
return EOF;
return assigned;
}
} // namespace esphome
+20 -10
View File
@@ -536,8 +536,10 @@ def shared_progmem_array(
used |= {str(i) for i in CORE.variables}
used |= set(RESERVED_IDS) | CORE.loaded_integrations
id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_)
# Global, so any scope can use it; anything a lambda references is already declared.
CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs, constexpr))
# After the user includes, so a lambda in the table can use what their headers declare.
CORE.add_global_after_includes(
ProgmemAssignmentExpression(type_, id_, rhs, constexpr)
)
array = MockObj(id_, ".")
CORE.register_variable(id_, array)
if share:
@@ -789,15 +791,16 @@ def static_function(
"""Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an
expression naming it, for use as a template argument or a function pointer.
Every id the body names must already be declared, which holds when the statements were
rendered through ``get_variable`` or ``process_lambda``.
The declaration goes with the other globals, so a template argument can name the function
at once; the body goes after the user includes, where every id and everything those
headers declare is in scope.
"""
params = ParameterListExpression(*parameters)
add_global(
RawStatement(
f"static {safe_exp(return_type)} {name}({params}) {{\n"
f"{indent(chr(10).join(body))}\n}}"
)
signature = (
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
add_global(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}")
)
return RawExpression(name)
@@ -816,6 +819,13 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False):
CORE.add_global(expression, prepend)
def add_global_after_includes(expression: SafeExpType | Statement):
"""Add a global after the user's ``includes:`` headers, for generated code whose
lambdas may use what those headers declare.
"""
CORE.add_global_after_includes(expression)
def add_library(name: str, version: str | None, repository: str | None = None):
"""Add a library to the codegen library storage.
+2
View File
@@ -219,6 +219,8 @@ ESPHOME_H_EXCLUDE = {
Path(ENTITY_TYPES_H_TARGET),
# main.cpp includes it after defining esphome_controllers()
Path("esphome/core/controller_dispatch.h"),
# only the esp32 sscanf wrap includes it
Path("esphome/core/sscanf_no_float.h"),
# build machinery, not user API
Path("esphome/core/pch_prefix.h"),
}
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.6.0
click==8.5.0
aioesphomeapi==46.18.0
aioesphomeapi==46.19.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
+2 -2
View File
@@ -1,4 +1,4 @@
# Useful stuff when working in a development environment
clang-format==22.1.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==22.1.8
clang-format==23.1.3 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
clang-tidy==23.1.3
yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
+8 -14
View File
@@ -319,21 +319,15 @@ def run_tidy(
disabled_checks = []
if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")):
# MMIO peripheral access on these bare-metal platforms is all
# fixed-address.
disabled_checks.append("-clang-analyzer-core.FixedAddressDereference")
# fixed-address. clang-tidy 23 moved the checker under optin.
disabled_checks += [
"-clang-analyzer-core.FixedAddressDereference",
"-clang-analyzer-optin.core.FixedAddressDereference",
]
if args.environment.startswith("host"):
# glibc's assert() expands to a call the check mistakes for a
# constant condition.
disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"]
if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")):
# bugprone-pointer-arithmetic-on-polymorphic-object (and its
# cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher
# recursion on lvgl_esphome.h under the RP2 defines and on the
# all-include file under the host defines.
disabled_checks += [
"-bugprone-pointer-arithmetic-on-polymorphic-object",
"-cert-ctr56-cpp",
]
if os.path.normpath(path) in ANALYZER_SKIPPED_FILES:
disabled_checks.append("-clang-analyzer-*")
if disabled_checks:
@@ -523,7 +517,7 @@ def main():
failed_files = []
timings = []
try:
executable = get_binary("clang-tidy", 22)
executable = get_binary("clang-tidy", 23)
task_queue = queue.Queue(args.jobs)
lock = threading.Lock()
for _ in range(args.jobs):
@@ -571,13 +565,13 @@ def main():
try:
try:
subprocess.call(
["clang-apply-replacements-22", tmpdir], close_fds=False
["clang-apply-replacements-23", tmpdir], close_fds=False
)
except FileNotFoundError:
subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False)
except FileNotFoundError:
print(
"Error please install clang-apply-replacements-22 or clang-apply-replacements.\n",
"Error please install clang-apply-replacements-23 or clang-apply-replacements.\n",
file=sys.stderr,
)
except:
@@ -621,6 +621,69 @@ def test_uc8279_width_must_be_a_multiple_of_8(
)
GDEY029Z95 = "goodisplay-gdey029z95-2.9"
def _gdey029z95_config(**overrides: Any) -> ConfigType:
config: ConfigType = {
"id": "test_display",
"model": GDEY029Z95,
CONF_DC_PIN: 21,
CONF_RESET_PIN: 22,
CONF_BUSY_PIN: 23,
}
config.update(overrides)
return config
def test_gdey029z95_uses_the_good_display_driver() -> None:
"""The GDEY029Z95 uses EPaperGoodDisplay3C with the WeAct init sequence."""
model = MODELS[GDEY029Z95.upper()]
assert model.class_name == "EPaperGoodDisplay3C"
assert model.get_init_sequence({}) == (
(0x01, 0x27, 0x01, 0x00),
(0x11, 0x03),
(0x3C, 0x05),
(0x18, 0x80),
(0x21, 0x00, 0x80),
)
@pytest.mark.parametrize("pin", [CONF_RESET_PIN, CONF_BUSY_PIN])
def test_gdey029z95_requires_reset_and_busy_pins(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
pin: str,
) -> None:
"""Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = _gdey029z95_config()
del config[pin]
with pytest.raises(cv.Invalid, match=pin):
run_schema_validation(config)
def test_gdey029z95_has_no_partial_update(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
run_schema_validation(_gdey029z95_config())
with pytest.raises(cv.Invalid, match="does not support partial update"):
run_schema_validation(_gdey029z95_config(full_update_every=2))
def test_uc8279_x4pro_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
esp32_ble_tracker:
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
enable_full_printf: true
esp32_ble_tracker:
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
enable_full_scanf: true
esp32_ble_tracker:
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
esp32_ble_tracker:
sensor:
- platform: template
name: Parsed
lambda: |-
float value = 0;
sscanf("1.5", "%f", &value);
return value;
+34 -11
View File
@@ -2260,28 +2260,51 @@ def test_mbedtls_tls_zigbee_requires_extras(
assert mbedtls.tls_extras_required == _CCM_EXTRAS
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
@pytest.mark.parametrize(
("config_file", "expected"),
("config_file", "symbol", "expected"),
[
pytest.param("vasprintf_stub_c6.yaml", True, id="c6"),
pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"),
pytest.param("exclusion_reincludes.yaml", False, id="esp32"),
# vasprintf: only where the ROM lacks vasprintf but has vsnprintf
pytest.param("vasprintf_stub_c6.yaml", "vasprintf", True, id="vasprintf_c6"),
pytest.param(
"vasprintf_stub_c6_full_printf.yaml",
"vasprintf",
False,
id="vasprintf_c6_full_printf",
),
pytest.param(
"exclusion_reincludes.yaml", "vasprintf", False, id="vasprintf_esp32"
),
# sscanf: only for Bluetooth builds on variants whose ROM lacks sscanf,
# and not when a lambda scans a float
pytest.param(
"exclusion_reincludes_wifi_ble.yaml", "sscanf", True, id="sscanf_esp32_ble"
),
pytest.param("sscanf_stub_ble_c6.yaml", "sscanf", False, id="sscanf_c6_rom"),
pytest.param(
"sscanf_stub_ble_full_scanf.yaml", "sscanf", False, id="sscanf_full_scanf"
),
pytest.param(
"sscanf_stub_ble_lambda.yaml", "sscanf", False, id="sscanf_lambda_float"
),
pytest.param(
"sscanf_stub_ble_full_printf.yaml", "sscanf", True, id="sscanf_full_printf"
),
pytest.param("exclusion_reincludes.yaml", "sscanf", False, id="sscanf_no_ble"),
],
)
def test_vasprintf_stub_only_on_rom_vsnprintf_variants(
def test_wrap_stub_emission(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
symbol: str,
expected: bool,
) -> None:
"""The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf."""
"""A linker wrap stub is emitted as a define plus --wrap/--undefined flag pair."""
generate_main(component_config_path(config_file))
assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected
flags = {f"-Wl,--wrap={symbol}", f"-Wl,--undefined=__wrap_{symbol}"}
assert (CORE.build_flags >= flags) is expected
defines = {define.name for define in CORE.defines}
assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected
assert (f"USE_ESP32_{symbol.upper()}_STUB" in defines) is expected
@pytest.mark.parametrize(
@@ -0,0 +1,24 @@
esphome:
name: test
esp32:
board: esp32dev
sensor:
- platform: template
id: temperature
filters:
- offset: -7.3
- platform: template
id: constant_offset
filters:
- rh_correction:
sensor_id: temperature
temperature_offset: -7.3
- platform: template
id: templated_offset
filters:
- rh_correction:
sensor_id: temperature
temperature_offset: !lambda return -7.3;
use_fahrenheit: true
@@ -0,0 +1,60 @@
"""The rh_correction filter: schema and the constructor call it generates."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.sensor import validate_filters
import esphome.config_validation as cv
@pytest.mark.parametrize(
"filters",
[
pytest.param([{"rh_correction": {"sensor_id": "t"}}], id="no_offset"),
pytest.param([{"rh_correction": {"temperature_offset": -7.3}}], id="no_sensor"),
pytest.param(
[
{
"rh_correction": {
"sensor_id": "t",
"temperature_offset": -7.3,
"use_fahrenheit": "maybe",
}
}
],
id="bad_fahrenheit",
),
],
)
def test_rh_correction_rejects_incomplete_config(filters: list[dict]) -> None:
with pytest.raises(cv.Invalid):
validate_filters(filters)
def test_rh_correction_defaults_to_celsius() -> None:
config = validate_filters(
[{"rh_correction": {"sensor_id": "t", "temperature_offset": -7.3}}]
)
assert config[0]["rh_correction"]["use_fahrenheit"] is False
def test_rh_correction_codegen(
generate_main: Callable[[str | Path], str],
) -> None:
main_cpp = generate_main("tests/component_tests/sensor/rh_correction.yaml")
# A constant offset is wrapped in a stateless lambda so the filter stores a function pointer
assert re.search(
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{\s*return -7\.3f;\s*\}, "
r"temperature, false\);\s*constant_offset->add_filter\(",
main_cpp,
)
# A templated offset keeps the user's lambda body and passes use_fahrenheit through
assert re.search(
r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{[^}]*return -7\.3;[^}]*\}, "
r"temperature, true\);\s*templated_offset->add_filter\(",
main_cpp,
)
@@ -0,0 +1,250 @@
#include <gtest/gtest.h>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include "esphome/core/sscanf_no_float.h"
namespace esphome::core::testing {
static int sscanf_no_float(const char *str, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int result = vsscanf_no_float(str, fmt, ap);
va_end(ap);
return result;
}
// --- The two bluedroid call sites the wrap exists for ---
TEST(SscanfNoFloat, BluedroidHexByte) {
unsigned int value = 0;
EXPECT_EQ(sscanf_no_float("ab", "%02x", &value), 1);
EXPECT_EQ(value, 0xabu);
// Width stops after two digits even when more follow
EXPECT_EQ(sscanf_no_float("ab12", "%02x", &value), 1);
EXPECT_EQ(value, 0xabu);
}
TEST(SscanfNoFloat, BluedroidAddress) {
uint32_t a[6] = {0};
EXPECT_EQ(sscanf_no_float("AA:bb:0C:dd:ee:FF", "%02x:%02x:%02x:%02x:%02x:%02x", a, a + 1, a + 2, a + 3, a + 4, a + 5),
6);
EXPECT_EQ(a[0], 0xaau);
EXPECT_EQ(a[1], 0xbbu);
EXPECT_EQ(a[2], 0x0cu);
EXPECT_EQ(a[3], 0xddu);
EXPECT_EQ(a[4], 0xeeu);
EXPECT_EQ(a[5], 0xffu);
// Short input assigns what matched, like libc
EXPECT_EQ(sscanf_no_float("AA:bb:0C", "%02x:%02x:%02x:%02x:%02x:%02x", a, a + 1, a + 2, a + 3, a + 4, a + 5), 3);
}
TEST(SscanfNoFloat, WidthWithByteLength) {
uint8_t mac[2] = {0};
EXPECT_EQ(sscanf_no_float("AA:bb", "%02hhx:%02hhx", mac, mac + 1), 2);
EXPECT_EQ(mac[0], 0xaa);
EXPECT_EQ(mac[1], 0xbb);
// The width still bounds the digits when a length modifier is present
EXPECT_EQ(sscanf_no_float("abcd", "%02hhx", mac), 1);
EXPECT_EQ(mac[0], 0xab);
}
// --- Formats bluedroid's hands-free client uses: %hu with %n ---
TEST(SscanfNoFloat, ShortWithPosition) {
uint16_t h = 0, m = 0, s = 0;
int n1 = 0, n2 = 0, n3 = 0;
EXPECT_EQ(sscanf_no_float("6:30:15", "%hu%n:%hu%n:%hu%n", &h, &n1, &m, &n2, &s, &n3), 3);
EXPECT_EQ(h, 6);
EXPECT_EQ(m, 30);
EXPECT_EQ(s, 15);
EXPECT_EQ(n1, 1);
EXPECT_EQ(n2, 4);
EXPECT_EQ(n3, 7);
EXPECT_EQ(sscanf_no_float("M3.2.0", "M%hu%n.%hu%n.%hu%n", &h, &n1, &m, &n2, &s, &n3), 3);
EXPECT_EQ(sscanf_no_float("X3.2.0", "M%hu", &h), 0);
}
// --- Integer conversions ---
TEST(SscanfNoFloat, SignedDecimalSkipsLeadingSpace) {
int value = 0;
EXPECT_EQ(sscanf_no_float(" -42 rest", "%d", &value), 1);
EXPECT_EQ(value, -42);
EXPECT_EQ(sscanf_no_float("+7", "%u", &value), 1);
EXPECT_EQ(value, 7);
}
TEST(SscanfNoFloat, AutoBase) {
int value = 0;
EXPECT_EQ(sscanf_no_float("0x1F", "%i", &value), 1);
EXPECT_EQ(value, 31);
EXPECT_EQ(sscanf_no_float("017", "%i", &value), 1);
EXPECT_EQ(value, 15);
EXPECT_EQ(sscanf_no_float("0", "%i", &value), 1);
EXPECT_EQ(value, 0);
EXPECT_EQ(sscanf_no_float("-0x10", "%i", &value), 1);
EXPECT_EQ(value, -16);
}
TEST(SscanfNoFloat, OctalAndHexPrefix) {
unsigned int value = 0;
EXPECT_EQ(sscanf_no_float("17", "%o", &value), 1);
EXPECT_EQ(value, 15u);
EXPECT_EQ(sscanf_no_float("+0x10", "%x", &value), 1);
EXPECT_EQ(value, 16u);
// "0x" with no digit after it: only the 0 is consumed
EXPECT_EQ(sscanf_no_float("0xg", "%x", &value), 1);
EXPECT_EQ(value, 0u);
EXPECT_EQ(sscanf_no_float("zz", "%x", &value), 0);
}
TEST(SscanfNoFloat, WidthSplitsDigits) {
int a = 0;
unsigned int b = 0;
EXPECT_EQ(sscanf_no_float("12345", "%3d%d", &a, &b), 2);
EXPECT_EQ(a, 123);
EXPECT_EQ(b, 45u);
EXPECT_EQ(sscanf_no_float("ff", "%1x", &b), 1);
EXPECT_EQ(b, 15u);
}
TEST(SscanfNoFloat, LengthModifiers) {
unsigned char hh = 0;
int16_t h = 0;
long l = 0; // NOLINT(google-runtime-int) %ld is defined in terms of long
uint64_t ll = 0;
size_t z = 0;
ptrdiff_t t = 0;
EXPECT_EQ(sscanf_no_float("255", "%hhu", &hh), 1);
EXPECT_EQ(hh, 255);
EXPECT_EQ(sscanf_no_float("-2", "%hd", &h), 1);
EXPECT_EQ(h, -2);
EXPECT_EQ(sscanf_no_float("-1", "%ld", &l), 1);
EXPECT_EQ(l, -1L);
EXPECT_EQ(sscanf_no_float("18446744073709551615", "%llu", &ll), 1);
EXPECT_EQ(ll, UINT64_MAX);
EXPECT_EQ(sscanf_no_float("123", "%ju", &ll), 1);
EXPECT_EQ(ll, 123u);
EXPECT_EQ(sscanf_no_float("9", "%zu", &z), 1);
EXPECT_EQ(z, 9u);
EXPECT_EQ(sscanf_no_float("-9", "%td", &t), 1);
EXPECT_EQ(t, -9);
}
TEST(SscanfNoFloat, Pointer) {
void *p = nullptr;
EXPECT_EQ(sscanf_no_float("0x1234", "%p", &p), 1);
EXPECT_EQ(reinterpret_cast<uintptr_t>(p), 0x1234u);
EXPECT_EQ(sscanf_no_float("abcd rest", "%p", &p), 1);
EXPECT_EQ(reinterpret_cast<uintptr_t>(p), 0xabcdu);
}
TEST(SscanfNoFloat, Suppression) {
int value = 0;
EXPECT_EQ(sscanf_no_float("1 2 3", "%*d %d %*d", &value), 1);
EXPECT_EQ(value, 2);
}
// --- Character, string and scanset conversions ---
TEST(SscanfNoFloat, CharDoesNotSkipSpace) {
char c[3] = {0};
EXPECT_EQ(sscanf_no_float("xyz", "%c%c", c, c + 1), 2);
EXPECT_EQ(c[0], 'x');
EXPECT_EQ(c[1], 'y');
EXPECT_EQ(sscanf_no_float(" x", "%c", c), 1);
EXPECT_EQ(c[0], ' ');
}
TEST(SscanfNoFloat, CharWidthShortRead) {
char c[4] = {0};
EXPECT_EQ(sscanf_no_float("abc", "%2c", c), 1);
EXPECT_EQ(c[0], 'a');
EXPECT_EQ(c[1], 'b');
EXPECT_EQ(c[2], '\0');
// Fewer characters than the width still count as one assignment, like newlib
EXPECT_EQ(sscanf_no_float("ab", "%3c", c), 1);
EXPECT_EQ(sscanf_no_float("", "%c", c), EOF);
}
TEST(SscanfNoFloat, StringStopsAtSpaceAndWidth) {
char buf[16] = {0};
EXPECT_EQ(sscanf_no_float("hello world", "%5s", buf), 1);
EXPECT_STREQ(buf, "hello");
EXPECT_EQ(sscanf_no_float(" x", "%1s", buf), 1);
EXPECT_STREQ(buf, "x");
EXPECT_EQ(sscanf_no_float(" ", "%s", buf), EOF);
}
TEST(SscanfNoFloat, Scansets) {
char buf[16] = {0};
char rest[16] = {0};
EXPECT_EQ(sscanf_no_float("key=val", "%[^=]=%s", buf, rest), 2);
EXPECT_STREQ(buf, "key");
EXPECT_STREQ(rest, "val");
// ']' first is a member; '-' last is literal
EXPECT_EQ(sscanf_no_float("]]ab", "%[]]", buf), 1);
EXPECT_STREQ(buf, "]]");
EXPECT_EQ(sscanf_no_float("a-z]x", "%[a-]", buf), 1);
EXPECT_STREQ(buf, "a-");
EXPECT_EQ(sscanf_no_float("abc", "%[^]]", buf), 1);
EXPECT_STREQ(buf, "abc");
// No member matched is a matching failure
EXPECT_EQ(sscanf_no_float("123", "%[a-z]", buf), 0);
}
// --- Literals, whitespace and return value ---
TEST(SscanfNoFloat, LiteralsAndPercent) {
int value = 0;
EXPECT_EQ(sscanf_no_float("50%", "%d%%", &value), 1);
EXPECT_EQ(value, 50);
// Like newlib, %% does not skip whitespace; an explicit space in the format does
EXPECT_EQ(sscanf_no_float("50 % 7", "%d%%%d", &value, &value), 1);
EXPECT_EQ(sscanf_no_float("50 % 7", "%d %%%d", &value, &value), 2);
EXPECT_EQ(value, 7);
EXPECT_EQ(sscanf_no_float("1,2", "%d,%d", &value, &value), 2);
EXPECT_EQ(sscanf_no_float("1 ,2", "%d,%d", &value, &value), 1);
EXPECT_EQ(sscanf_no_float("1, 2", "%d,%d", &value, &value), 2);
EXPECT_EQ(sscanf_no_float("12\n34", "%d%d", &value, &value), 2);
}
TEST(SscanfNoFloat, PositionOnly) {
int n = 0;
EXPECT_EQ(sscanf_no_float("ab", "ab%n", &n), 0);
EXPECT_EQ(n, 2);
EXPECT_EQ(sscanf_no_float("", "%n", &n), 0);
EXPECT_EQ(n, 0);
}
TEST(SscanfNoFloat, EofVersusMatchingFailure) {
int value = 0;
EXPECT_EQ(sscanf_no_float("", "%d", &value), EOF);
EXPECT_EQ(sscanf_no_float(" ", "%d", &value), EOF);
EXPECT_EQ(sscanf_no_float("a", "ab"), EOF);
EXPECT_EQ(sscanf_no_float("abc", "%d", &value), 0);
EXPECT_EQ(sscanf_no_float("ac", "ab"), 0);
EXPECT_EQ(sscanf_no_float("12abc", "%d%d", &value, &value), 1);
EXPECT_EQ(sscanf_no_float("12", "%d%d", &value, &value), 1);
}
// --- Conversions the scanner refuses rather than misparses ---
TEST(SscanfNoFloat, UnsupportedConversions) {
float f = 0;
double d = 0;
wchar_t w[4] = {0};
EXPECT_EQ(sscanf_no_float("1.5", "%f", &f), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("1.5", "%lf", &d), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("1e3", "%g", &d), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("0x1p3", "%a", &d), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("abc", "%ls", w), SSCANF_UNSUPPORTED);
EXPECT_EQ(sscanf_no_float("abc", "%lc", w), SSCANF_UNSUPPORTED);
// Earlier conversions do not hide a later unsupported one
int value = 0;
EXPECT_EQ(sscanf_no_float("1 2.5", "%d %f", &value, &f), SSCANF_UNSUPPORTED);
}
} // namespace esphome::core::testing
@@ -0,0 +1,164 @@
#include <gtest/gtest.h>
#include <utility>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_goodisplay_3c.h"
namespace esphome::epaper_spi::testing {
namespace {
class TestableGoodDisplay3C : public EPaperGoodDisplay3C {
public:
TestableGoodDisplay3C(uint16_t width, uint16_t height) : EPaperGoodDisplay3C("test", width, height, nullptr, 0) {}
void install(spi::SPIDelegate *delegate) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
}
void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); }
/// Both planes of one push; returns how many calls it took.
int run_push() {
int calls = 1;
while (!this->transfer_data())
calls++;
return calls;
}
/// A whole update with the frame already drawn, run through loop() as the main loop would.
void run_update() {
this->set_auto_clear(false);
this->state_ = EPaperState::UPDATE;
const uint32_t deadline = millis() + 2000;
while (this->state_ != EPaperState::IDLE) {
ASSERT_LT(millis(), deadline) << "update did not finish";
this->loop();
}
}
using EPaperGoodDisplay3C::REFRESH_START_MS;
RecordingPin dc;
};
/// Also notes when each command byte was sent.
class TimedCommandDelegate : public RecordingDelegate {
public:
using RecordingDelegate::RecordingDelegate;
void write_array(const uint8_t *ptr, size_t length) override {
if (!this->dc_->level) {
for (size_t i = 0; i != length; i++)
this->sent.emplace_back(ptr[i], millis());
}
RecordingDelegate::write_array(ptr, length);
}
/// When the n-th (from 0) copy of a command was sent.
uint32_t time_of(uint8_t command, int n = 0) const {
for (const auto &[byte, time] : this->sent) {
if (byte == command && n-- == 0)
return time;
}
ADD_FAILURE() << "command " << int(command) << " not sent";
return 0;
}
std::vector<std::pair<uint8_t, uint32_t>> sent;
};
using Bytes = std::vector<uint8_t>;
const Color RED(255, 0, 0);
/// One row of 16 pixels: white, black, red, then white. The red plane goes first as 1=red, then the black/white
/// plane as 1=white with red pixels sent as 0.
TEST(EPaperGoodDisplay3CTransfer, PixelsReachThePanel) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(Color::WHITE);
display.draw(1, 0, Color::BLACK);
display.draw(2, 0, RED);
EXPECT_EQ(display.run_push(), 1);
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24}));
EXPECT_EQ(bus.data[0x44], (Bytes{0x00, 0x01}));
EXPECT_EQ(bus.data[0x45], (Bytes{0x00, 0x00, 0x00, 0x00}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x9F, 0xFF}));
EXPECT_EQ(bus.data[0x26], (Bytes{0x20, 0x00}));
}
/// A push that yields to the main loop after every chunk sends the same commands and bytes as one that runs to
/// completion in one call, and a second push starts again from the window setup.
TEST(EPaperGoodDisplay3CTransfer, ResumedPushMatches) {
// 128x20 pixels is 320 bytes per plane, so each plane takes three chunks of up to MAX_TRANSFER_SIZE.
constexpr int width = 128;
constexpr int height = 20;
const Color colors[] = {Color::WHITE, Color::BLACK, RED};
TestableGoodDisplay3C reference(width, height);
RecordingDelegate reference_bus(&reference.dc);
reference.install(&reference_bus);
TestableGoodDisplay3C display(width, height);
// Every data write takes longer than MAX_TRANSFER_TIME, so the push yields after each chunk.
RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1);
display.install(&bus);
for (int y = 0; y != height; y++) {
for (int x = 0; x != width; x++) {
reference.draw(x, y, colors[(x * 7 + y * 3) % 3]);
display.draw(x, y, colors[(x * 7 + y * 3) % 3]);
}
}
EXPECT_EQ(reference.run_push(), 1);
EXPECT_GT(display.run_push(), 2);
EXPECT_EQ(bus.commands, reference_bus.commands);
EXPECT_EQ(bus.data[0x24].size(), 320u);
EXPECT_EQ(bus.data[0x24], reference_bus.data[0x24]);
EXPECT_EQ(bus.data[0x26], reference_bus.data[0x26]);
bus.clear();
display.run_push();
EXPECT_EQ(bus.commands, reference_bus.commands);
}
/// A whole update sends no power-on (0x22 0xF8) before the refresh (0x22 0xF7): with it, this panel does not
/// refresh. Power off (0x83) and deep sleep follow.
TEST(EPaperGoodDisplay3CTransfer, UpdateHasNoSeparatePowerOn) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24, 0x4E, 0x4F, 0x22, 0x20, 0x22, 0x20, 0x10}));
EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00}));
EXPECT_EQ(bus.data[0x22], (Bytes{0xF7, 0x83}));
EXPECT_EQ(bus.data[0x10], (Bytes{0x01}));
}
/// Busy only goes high a little after master activation (0x20), so power off is not sent until REFRESH_START_MS
/// after the refresh starts.
TEST(EPaperGoodDisplay3CTransfer, WaitsForBusyAfterTheRefreshStarts) {
TestableGoodDisplay3C display(16, 1);
TimedCommandDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
// The first 0x20 starts the refresh; the second 0x22 selects power off.
EXPECT_GE(bus.time_of(0x22, 1) - bus.time_of(0x20, 0), TestableGoodDisplay3C::REFRESH_START_MS);
}
} // namespace
} // namespace esphome::epaper_spi::testing
@@ -209,6 +209,27 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
model: goodisplay-gdey029z95-2.9
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# WeAct 4.2" 3-color e-paper (400x300, SSD1683)
- platform: epaper_spi
spi_id: spi_bus
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# filter.cpp only compiles with the define the real to_code emits for configured filters
cg.add_define("USE_SENSOR_FILTER")
manifest.to_code = to_code_testing
+18
View File
@@ -254,3 +254,21 @@ sensor:
lambda: return 42.0;
update_interval: 1s
state_class: "measurement_angle"
# Testing rh_correction filter
- platform: copy
source_id: source_sensor
name: "RH Correction Filter Fahrenheit"
filters:
- rh_correction:
sensor_id: source_sensor
temperature_offset: -10.0
use_fahrenheit: true
- platform: copy
source_id: source_sensor
name: "RH Correction Filter Template"
filters:
- rh_correction:
sensor_id: source_sensor
temperature_offset: !lambda return -10.0;
+66
View File
@@ -0,0 +1,66 @@
#include <gtest/gtest.h>
#include <cmath>
#include "esphome/components/sensor/filter.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::sensor::testing {
// 40 % read 7.3 °C warm at a true 25 °C, in single precision Magnus arithmetic
static constexpr float CORRECTED = 61.13f;
struct RhFixture {
Sensor temperature;
RHCorrectionFilter filter;
RhFixture(float (*offset)(), bool use_fahrenheit = false) : filter(offset, &this->temperature, use_fahrenheit) {}
};
TEST(RHCorrectionFilter, CorrectsForTheOffset) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
}
TEST(RHCorrectionFilter, PositiveOffsetLowersTheHumidity) {
RhFixture f([]() -> float { return 7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_LT(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
}
TEST(RHCorrectionFilter, FahrenheitReadingsGiveTheSameResult) {
// 77 °F is 25 °C and -13.14 °F is -7.3 °C of offset
RhFixture f([]() -> float { return -13.14f; }, true);
f.temperature.publish_state(77.0f);
EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f);
}
TEST(RHCorrectionFilter, ClampsToOneHundredPercent) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(25.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(90.0f).value_or(NAN), 100.0f);
}
TEST(RHCorrectionFilter, PassesThroughWithoutAUsableTemperature) {
RhFixture f([]() -> float { return -7.3f; });
// No temperature reading yet
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
// Out of the -40..50 °C band
f.temperature.publish_state(60.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
// The band also covers the reconstructed enclosure temperature
f.temperature.publish_state(45.0f);
EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f);
}
TEST(RHCorrectionFilter, DropsTheUpdateOnNaN) {
RhFixture f([]() -> float { return -7.3f; });
f.temperature.publish_state(NAN);
EXPECT_FALSE(f.filter.new_value(40.0f).has_value());
RhFixture nan_offset([]() -> float { return NAN; });
nan_offset.temperature.publish_state(25.0f);
EXPECT_FALSE(nan_offset.filter.new_value(40.0f).has_value());
}
} // namespace esphome::sensor::testing
@@ -409,6 +409,10 @@ cover:
id: template_cover_actions
position: 0.0
tilt: 0.0
toggle_action:
- cover.template.publish:
id: template_cover_actions
current_operation: IDLE
stop_action:
- cover.template.publish:
id: template_cover_actions
+4
View File
@@ -236,6 +236,10 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
external_components_path = str(FIXTURES_DIR / "external_components")
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
# Replace the user includes path placeholder if present
if "INCLUDES_PATH" in content:
content = content.replace("INCLUDES_PATH", str(FIXTURES_DIR / "includes"))
if shared_name is not None:
# _compile verifies the marked test compiles this content unmodified
request.node._shared_yaml_content = content
@@ -0,0 +1,26 @@
esphome:
name: api-cover-toggle-test
host:
api:
logger:
level: DEBUG
cover:
- platform: template
name: "Toggle Cover"
id: toggle_cover
toggle_action:
- cover.template.publish:
id: toggle_cover
position: 0.5
stop_action:
- cover.template.publish:
id: toggle_cover
position: 0.25
- platform: template
name: "Plain Cover"
id: plain_cover
stop_action:
- cover.template.publish:
id: plain_cover
position: 0.25
@@ -0,0 +1,6 @@
#pragma once
static constexpr int HEADER_CONSTANT = 42;
// Names the global by its id, so the header must come after the id declarations.
inline int header_reads_global() { return header_value->value() * 2; }
@@ -0,0 +1,27 @@
esphome:
name: includes-lambda-order
includes:
- INCLUDES_PATH/lambda_order.h
on_boot:
# An apply action value is rendered at file scope.
- globals.set:
id: header_value
value: !lambda return HEADER_CONSTANT;
host:
api:
logger:
globals:
- id: header_value
type: int
initial_value: "0"
sensor:
- platform: template
name: Header Value
update_interval: 100ms
lambda: return header_reads_global();
filters:
# A lambda in a flash table.
- filter_out: !lambda return HEADER_CONSTANT - 1;
@@ -0,0 +1,102 @@
esphome:
name: test-rh-correction
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
sensor:
- platform: template
name: "Temperature C"
id: temperature_c
accuracy_decimals: 1
- platform: template
name: "Temperature F"
id: temperature_f
accuracy_decimals: 1
- platform: template
name: "Humidity Source"
id: humidity_source
accuracy_decimals: 1
- platform: copy
source_id: humidity_source
name: "RH Celsius"
id: rh_celsius
accuracy_decimals: 2
filters:
- rh_correction:
sensor_id: temperature_c
temperature_offset: -7.3
- platform: copy
source_id: humidity_source
name: "RH Fahrenheit"
id: rh_fahrenheit
accuracy_decimals: 2
filters:
- rh_correction:
sensor_id: temperature_f
temperature_offset: -13.14
use_fahrenheit: true
script:
- id: test_rh_correction
then:
# Both temperature sensors read 25 °C after a -7.3 °C (-13.14 °F) offset
- sensor.template.publish:
id: temperature_c
state: 25.0
- sensor.template.publish:
id: temperature_f
state: 77.0
- sensor.template.publish:
id: humidity_source
state: 40.0
- delay: 20ms
# Clamped to 100 %
- sensor.template.publish:
id: humidity_source
state: 90.0
- delay: 20ms
# Out of band: passes through unchanged
- sensor.template.publish:
id: temperature_c
state: 60.0
- sensor.template.publish:
id: temperature_f
state: 140.0
- sensor.template.publish:
id: humidity_source
state: 40.0
- delay: 20ms
# NaN temperature: the update is dropped
- sensor.template.publish:
id: temperature_c
state: !lambda "return NAN;"
- sensor.template.publish:
id: temperature_f
state: !lambda "return NAN;"
- sensor.template.publish:
id: humidity_source
state: 50.0
- delay: 20ms
- sensor.template.publish:
id: temperature_c
state: 25.0
- sensor.template.publish:
id: temperature_f
state: 77.0
- sensor.template.publish:
id: humidity_source
state: 40.0
button:
- platform: template
name: "Test RH Correction"
on_press:
- script.execute: test_rh_correction
+12 -5
View File
@@ -1,7 +1,7 @@
"""Minimal plaintext native-api client over a raw socket.
Reads only when told to, so tests control when the TCP pipe backs up toward
the device; payloads are skipped and only message types are counted.
the device; message types are counted and each frame is kept for inspection.
"""
from __future__ import annotations
@@ -59,15 +59,17 @@ class FrameParser:
def __init__(self) -> None:
self._buf = bytearray()
self.frames: list[tuple[int, bytes]] = []
def feed(self, data: bytes) -> list[int]:
self._buf.extend(data)
types: list[int] = []
while (msg_type := self._try_parse()) is not None:
types.append(msg_type)
while (frame := self._try_parse()) is not None:
self.frames.append(frame)
types.append(frame[0])
return types
def _try_parse(self) -> int | None:
def _try_parse(self) -> tuple[int, bytes] | None:
buf = self._buf
if not buf:
return None
@@ -80,8 +82,9 @@ class FrameParser:
msg_type, pos = type_decoded
if len(buf) - pos < size:
return None
payload = bytes(buf[pos : pos + size])
del buf[: pos + size]
return msg_type
return msg_type, payload
class RawApiClient:
@@ -145,5 +148,9 @@ class RawApiClient:
await asyncio.wait_for(_read_loop(), timeout)
def frames(self, msg_type: int) -> list[bytes]:
"""Payloads of every frame of msg_type received so far."""
return [payload for kind, payload in self._parser.frames if kind == msg_type]
def close(self) -> None:
self._sock.close()
+129
View File
@@ -0,0 +1,129 @@
"""The native API advertises cover toggle support and runs the toggle action on request."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import struct
from aioesphomeapi import CoverInfo, CoverState, EntityState, api_pb2
import pytest
from .log_utils import LineWaiter
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, decode_varint, encode_varint
from .state_utils import InitialStateHelper, StateWaiter, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
COVER_COMMAND = MESSAGE_TYPE_OF[api_pb2.CoverCommandRequest]
LIST_COVER = MESSAGE_TYPE_OF[api_pb2.ListEntitiesCoverResponse]
LIST_DONE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
# ListEntitiesCoverResponse fields; the installed client library does not know supports_toggle yet
FIELD_ENTITY_KEY, FIELD_SUPPORTS_TOGGLE = 2, 15
# CoverCommandRequest fields
FIELD_KEY, FIELD_STOP, FIELD_TOGGLE = 1, 8, 11
def tag(field: int, wire_type: int) -> bytes:
return encode_varint((field << 3) | wire_type)
def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]:
"""Split a protobuf payload into field number -> raw values."""
fields: dict[int, list[int | bytes]] = {}
buf = bytearray(payload)
pos = 0
while pos < len(buf):
key, pos = decode_varint(buf, pos)
field, wire_type = key >> 3, key & 0x07
if wire_type == WIRE_VARINT:
value, pos = decode_varint(buf, pos)
elif wire_type == WIRE_LENGTH:
length, pos = decode_varint(buf, pos)
value = bytes(buf[pos : pos + length])
pos += length
elif wire_type == WIRE_FIXED32:
value = struct.unpack_from("<I", buf, pos)[0]
pos += 4
else:
raise AssertionError(f"unexpected wire type {wire_type} for field {field}")
fields.setdefault(field, []).append(value)
return fields
@pytest.mark.asyncio
async def test_api_cover_toggle(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
unused_tcp_port: int,
) -> None:
lines = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=lines.callback),
api_client_connected() as client,
RawApiClient(unused_tcp_port) as raw,
):
entities, _ = await client.list_entities_services()
toggle_cover = require_entity(entities, "toggle_cover", CoverInfo, exact=True)
plain_cover = require_entity(entities, "plain_cover", CoverInfo, exact=True)
positions: dict[int, list[float]] = {toggle_cover.key: [], plain_cover.key: []}
waiter = StateWaiter()
def on_state(state: EntityState) -> None:
if isinstance(state, CoverState) and state.key in positions:
positions[state.key].append(state.position)
waiter.on_state(state)
def position_is(key: int, value: float) -> Callable[[EntityState], bool]:
return lambda s: (
isinstance(s, CoverState)
and s.key == key
and s.position == pytest.approx(value, abs=0.01)
)
initial = InitialStateHelper(entities)
client.subscribe_states(initial.on_state_wrapper(on_state))
await initial.wait_for_initial_states()
# Capability discovery: only the cover with a toggle_action advertises toggle
await raw.connect()
await raw.send_message(api_pb2.ListEntitiesRequest())
await raw.read_until_frame(LIST_DONE)
supports_toggle = {}
for payload in raw.frames(LIST_COVER):
fields = decode_fields(payload)
key = fields[FIELD_ENTITY_KEY][0]
supports_toggle[key] = bool(fields.get(FIELD_SUPPORTS_TOGGLE, [0])[0])
assert supports_toggle == {toggle_cover.key: True, plain_cover.key: False}
def key_field(key: int) -> bytes:
return tag(FIELD_KEY, WIRE_FIXED32) + struct.pack("<I", key)
toggle = tag(FIELD_TOGGLE, WIRE_VARINT) + b"\x01"
stop = tag(FIELD_STOP, WIRE_VARINT) + b"\x01"
# A toggle request runs the toggle action, which publishes 0.5
toggled = waiter.expect(
position_is(toggle_cover.key, 0.5), label="toggle action"
)
await raw.send_raw(COVER_COMMAND, key_field(toggle_cover.key) + toggle)
await toggled
# A cover without toggle_action drops the request with a warning; its state does not change
await raw.send_raw(COVER_COMMAND, key_field(plain_cover.key) + toggle)
await lines.wait_for("Plain Cover", "toggle unsupported", timeout=2.0)
await asyncio.sleep(0.5)
assert all(p == pytest.approx(1.0) for p in positions[plain_cover.key])
# Stop and toggle in one request: stop wins and the toggle action does not run
before = len(positions[toggle_cover.key])
stopped = waiter.expect(
position_is(toggle_cover.key, 0.25), label="stop action"
)
await raw.send_raw(COVER_COMMAND, key_field(toggle_cover.key) + stop + toggle)
await stopped
await asyncio.sleep(0.5)
after = positions[toggle_cover.key][before:]
assert all(p == pytest.approx(0.25, abs=0.01) for p in after), after
@@ -0,0 +1,31 @@
"""Lambdas rendered at file scope can use the user's includes, which can name ids."""
from __future__ import annotations
from aioesphomeapi import SensorInfo, SensorState
import pytest
from .state_utils import require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_includes_lambda_order(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The apply action and the sensor filter use the header, the header reads the global."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
sensor = require_entity(entities, "header_value", SensorInfo)
state = await wait_for_state(
client,
lambda s: (
isinstance(s, SensorState)
and s.key == sensor.key
and not s.missing_state
),
)
# globals.set stored HEADER_CONSTANT and the header read it back doubled.
assert state.state == 84.0
@@ -0,0 +1,55 @@
"""Test the sensor rh_correction filter numerically."""
from __future__ import annotations
from aioesphomeapi import ButtonInfo
import pytest
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# 40 % at 25 °C, measured 7.3 °C warmer, in single precision Magnus arithmetic
CORRECTED = pytest.approx(61.13, abs=0.05)
# Corrected, clamped, passed through out of band, corrected again; the NaN step is dropped
EXPECTED = [CORRECTED, 100.0, 40.0, CORRECTED]
@pytest.mark.asyncio
async def test_sensor_filters_rh_correction(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
collector = SensorStateCollector(sensor_names=["rh_celsius", "rh_fahrenheit"])
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
await initial_state_helper.wait_for_initial_states()
done = collector.add_waiter(
lambda: all(
len(values) == len(EXPECTED)
for values in collector.sensor_states.values()
)
)
button = require_entity(entities, "test_rh_correction", ButtonInfo, exact=True)
client.button_command(button.key)
await wait_or_fail(
done,
2.0,
"Timed out waiting for the corrected humidity values",
values=collector.sensor_states,
)
assert collector.sensor_states["rh_celsius"] == EXPECTED
# The same readings in °F give the same corrected humidity
assert collector.sensor_states["rh_fahrenheit"] == EXPECTED
+2 -58
View File
@@ -8,64 +8,8 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.components import esp8266
from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float
from esphome.core import EsphomeError, Lambda
from esphome.types import ConfigType
@pytest.mark.parametrize(
("src", "expected"),
[
# Basic float formats
('sscanf(buf, "%f", &v)', True),
('sscanf(buf, "%F", &v)', True),
('sscanf(buf, "%e", &v)', True),
('sscanf(buf, "%E", &v)', True),
('sscanf(buf, "%g", &v)', True),
('sscanf(buf, "%G", &v)', True),
('sscanf(buf, "%a", &v)', True),
('sscanf(buf, "%A", &v)', True),
# With modifiers
('sscanf(buf, "%lf", &v)', True),
('sscanf(buf, "%Lf", &v)', True),
('sscanf(buf, "%8lf", &v)', True),
('sscanf(buf, "%*f")', True),
('sscanf(buf, "%.2f", &v)', True),
# Mixed formats
('sscanf(buf, "%d,%f", &a, &b)', True),
# fscanf and std::sscanf
('fscanf(fp, "%f", &v)', True),
('std::sscanf(buf, "%f", &v)', True),
# Multi-line
('sscanf(buf,\n"%f", &v)', True),
# No float format
('sscanf(buf, "%d", &v)', False),
('sscanf(buf, "%s", s)', False),
# printf not scanf
('printf("%f", val)', False),
# %f in a different statement after scanf
('sscanf(buf, "%d", &x); printf("%f", val);', False),
# scanf %f in comment only
('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False),
('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False),
],
)
def test_lambdas_use_scanf_float(src: str, expected: bool) -> None:
"""Test scanf float detection in lambda source."""
config: ConfigType = {"test": [Lambda(src)]}
assert lambdas_use_scanf_float(config) is expected
def test_lambdas_use_scanf_float_no_lambdas() -> None:
"""Test with config containing no lambdas."""
config: ConfigType = {"key": "value", "list": [1, 2]}
assert lambdas_use_scanf_float(config) is False
def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True
from esphome.components.esp8266 import check_rosetta
from esphome.core import EsphomeError
@pytest.fixture
+35 -1
View File
@@ -26,7 +26,7 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
from esphome.core import CORE, ID, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
from esphome.core.config import (
CONF_RELATION,
CONF_SUSPEND_LOOP,
@@ -1114,6 +1114,40 @@ async def test_add_includes_with_single_file(
assert any('#include "my_header.h"' in inc for inc in includes_added)
@pytest.mark.asyncio
async def test_add_includes_sit_between_declarations_and_generated_code(
tmp_path: Path, mock_copy_file_if_changed: Mock
) -> None:
"""A header can name an id, and generated functions and tables can use the header."""
CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = tmp_path / "build"
CORE.build_path.mkdir(parents=True, exist_ok=True)
header = tmp_path / "my_header.h"
header.write_text("static constexpr int MY_CONSTANT = 42;")
CORE.config = {}
value = cg.new_Pvariable(ID("my_value", is_declaration=True, type=cg.int32))
cg.static_function(
"esphome__my_action__fn", cg.void, [], [f"*{value} = MY_CONSTANT;"]
)
cg.shared_progmem_array("my_table", cg.int32, [cg.RawExpression("MY_CONSTANT")])
await config.add_includes([str(header)])
# A job that runs after the includes still lands before the generated code.
cg.add_global(cg.RawStatement("static constexpr int FINAL_JOB = 1;"))
section = CORE.cpp_global_section
expected_order = [
"*const my_value",
"static void esphome__my_action__fn();",
'#include "my_header.h"',
"static constexpr int FINAL_JOB = 1;",
"static void esphome__my_action__fn() {",
"my_table[] PROGMEM",
]
positions = [section.index(text) for text in expected_order]
assert positions == sorted(positions), section
@pytest.mark.asyncio
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
async def test_add_includes_with_directory_unix(
+24 -5
View File
@@ -785,8 +785,9 @@ class MockCodegen(NamedTuple):
new_pvariable: MagicMock
register_parented: AsyncMock
add_global: MagicMock
add_global_after_includes: MagicMock
add: MagicMock
calls: MagicMock # new_pvariable and add_global attached, to check their order
calls: MagicMock # the global emitters and new_pvariable attached, to check their order
@pytest.fixture
@@ -848,6 +849,9 @@ def mock_cg() -> Generator[MockCodegen]:
"esphome.codegen.register_parented", new_callable=AsyncMock
) as register_parented,
patch("esphome.cpp_generator.add_global") as add_global,
patch(
"esphome.cpp_generator.add_global_after_includes"
) as add_global_after_includes,
patch("esphome.codegen.add") as add,
):
get_variable.return_value = PARENT_OBJ
@@ -855,8 +859,15 @@ def mock_cg() -> Generator[MockCodegen]:
calls = MagicMock()
calls.attach_mock(new_pvariable, "new_pvariable")
calls.attach_mock(add_global, "add_global")
calls.attach_mock(add_global_after_includes, "add_global_after_includes")
yield MockCodegen(
get_variable, new_pvariable, register_parented, add_global, add, calls
get_variable,
new_pvariable,
register_parented,
add_global,
add_global_after_includes,
add,
calls,
)
@@ -984,7 +995,7 @@ async def _run_apply_condition(
def _apply_definition(mock_cg: MockCodegen) -> str:
return str(mock_cg.add_global.call_args.args[0])
return str(mock_cg.add_global_after_includes.call_args.args[0])
@pytest.mark.asyncio
@@ -998,8 +1009,16 @@ async def test_register_apply_action_entry(
action_id, template_arg = mock_cg.new_pvariable.call_args.args
assert action_id == ID("obj_1")
assert str(template_arg) == "<esphome__obj_1__fn, int32_t>"
# The definition must precede the storage line that names the function.
assert [c[0] for c in mock_cg.calls.mock_calls] == ["add_global", "new_pvariable"]
# The declaration must precede the storage line that names the function; the body
# goes after the user includes.
assert [c[0] for c in mock_cg.calls.mock_calls] == [
"add_global",
"add_global_after_includes",
"new_pvariable",
]
assert str(mock_cg.add_global.call_args.args[0]) == (
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x);"
)
assert _apply_definition(mock_cg).startswith(
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x) {"
)
+162
View File
@@ -0,0 +1,162 @@
"""Unit tests for esphome.code_scan."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.code_scan import (
external_components_use_scanf_float,
includes_use_scanf_float,
keep_float_scanf,
lambdas_use_scanf_float,
user_code_uses_scanf_float,
)
from esphome.const import CONF_ESPHOME, CONF_INCLUDES, CONF_INCLUDES_C
from esphome.core import CORE, Lambda
from esphome.loader import CORE_COMPONENTS_PATH, FileResource
from esphome.types import ConfigType
@pytest.mark.parametrize(
("src", "expected"),
[
# Basic float formats
('sscanf(buf, "%f", &v)', True),
('sscanf(buf, "%F", &v)', True),
('sscanf(buf, "%e", &v)', True),
('sscanf(buf, "%E", &v)', True),
('sscanf(buf, "%g", &v)', True),
('sscanf(buf, "%G", &v)', True),
('sscanf(buf, "%a", &v)', True),
('sscanf(buf, "%A", &v)', True),
# With modifiers
('sscanf(buf, "%lf", &v)', True),
('sscanf(buf, "%Lf", &v)', True),
('sscanf(buf, "%8lf", &v)', True),
('sscanf(buf, "%*f")', True),
('sscanf(buf, "%.2f", &v)', True),
# Mixed formats, including a ';' inside the format string
('sscanf(buf, "%d,%f", &a, &b)', True),
('sscanf(buf, "%d;%f", &a, &b)', True),
('sscanf(buf, "a;b", &a); sscanf(buf, "%d", &b); printf("%f", v)', False),
# Escaped quotes, nested calls, a ')' inside the format, an unterminated call
('sscanf(buf, "\\"%f\\"", &v)', True),
('sscanf(f(x), "%f", &v)', True),
('sscanf(s.substr(0, 2).c_str(), "%f", &v)', True),
('sscanf(strtok(s, ","), "%f", &v)', True),
('sscanf(strtok(s, ","), "%d", &v)', False),
('sscanf(buf, "%d)", &a); g("%f")', False),
('sscanf(buf, "%d", &a; g("%f")', False),
('sscanf(buf, "%f"', True),
# fscanf and std::sscanf
('fscanf(fp, "%f", &v)', True),
('std::sscanf(buf, "%f", &v)', True),
# Multi-line
('sscanf(buf,\n"%f", &v)', True),
# No float format
('sscanf(buf, "%d", &v)', False),
('sscanf(buf, "%s", s)', False),
# printf not scanf
('printf("%f", val)', False),
# %f in a different statement after scanf
('sscanf(buf, "%d", &x); printf("%f", val);', False),
# scanf %f in comment only
('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False),
('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False),
],
)
def test_lambdas_use_scanf_float(src: str, expected: bool) -> None:
"""Test scanf float detection in lambda source."""
config: ConfigType = {"test": [Lambda(src)]}
assert lambdas_use_scanf_float(config) is expected
def test_lambdas_use_scanf_float_no_lambdas() -> None:
"""Test with config containing no lambdas."""
config: ConfigType = {"key": "value", "list": [1, 2]}
assert lambdas_use_scanf_float(config) is False
def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True
def test_includes_use_scanf_float(setup_core: Path) -> None:
"""A float scanf in an includes: or includes_c: file is found; comments and non-source files are ignored."""
(setup_core / "parse.h").write_text(
'static void f(const char *b) { float v; sscanf(b, "%f", &v); }'
)
(setup_core / "lib").mkdir()
(setup_core / "lib" / "int.h").write_text(
'static int g(const char *b) { int v; sscanf(b, "%d", &v); return v; }'
)
(setup_core / "lib" / "notes.txt").write_text('sscanf(b, "%f", &v)')
(setup_core / "commented.h").write_text(
'// sscanf(b, "%f", &v)\n/* sscanf(b, "%g", &v) */'
)
assert (
includes_use_scanf_float({CONF_ESPHOME: {CONF_INCLUDES: ["parse.h"]}}) is True
)
assert (
includes_use_scanf_float({CONF_ESPHOME: {CONF_INCLUDES_C: ["parse.h"]}}) is True
)
assert includes_use_scanf_float({CONF_ESPHOME: {CONF_INCLUDES: ["lib"]}}) is False
assert (
includes_use_scanf_float(
{CONF_ESPHOME: {CONF_INCLUDES: ["commented.h", "<cstdio>"]}}
)
is False
)
assert includes_use_scanf_float({}) is False
def test_keep_float_scanf() -> None:
"""Explicit values win; unset follows whether user code scans a float."""
float_config: ConfigType = {
CONF_ESPHOME: {"on_boot": [Lambda('sscanf(b, "%f", &v)')]}
}
int_config: ConfigType = {
CONF_ESPHOME: {"on_boot": [Lambda('sscanf(b, "%d", &v)')]}
}
assert keep_float_scanf(True, int_config, "x", "y") is True
assert keep_float_scanf(False, float_config, "x", "y") is False
assert keep_float_scanf(False, int_config, "x", "y") is False
assert keep_float_scanf(None, float_config, "x", "y") is True
assert keep_float_scanf(None, int_config, "x", "y") is False
def test_external_components_use_scanf_float(setup_core: Path) -> None:
"""Sources of components outside the esphome package are scanned; in-tree ones are skipped."""
def manifest(package_dir: Path, *sources: str) -> MagicMock:
m = MagicMock()
m.is_platform_component = False
m.module.__file__ = str(package_dir / "__init__.py")
m.resources = [FileResource("pkg", name) for name in sources]
return m
(setup_core / "ext_float").mkdir()
(setup_core / "ext_float" / "parse.cpp").write_text(
'void f(const char *b) { float v; sscanf(b, "%f", &v); }'
)
(setup_core / "ext_int").mkdir()
(setup_core / "ext_int" / "parse.cpp").write_text(
'int g(const char *b) { int v; sscanf(b, "%d", &v); return v; }'
)
lookup = {
"ext_float": manifest(setup_core / "ext_float", "parse.cpp"),
"ext_int": manifest(setup_core / "ext_int", "parse.cpp"),
"sensor": manifest(CORE_COMPONENTS_PATH / "sensor", "sensor.cpp"),
"esphome": manifest(CORE_COMPONENTS_PATH.parent / "core", "config.cpp"),
}
with patch("esphome.config.get_component", side_effect=lookup.get):
CORE.config = {"esphome": {}, "sensor": {}, "ext_int": {}}
assert external_components_use_scanf_float(CORE.config) is False
CORE.config = {"sensor": {}, "ext_float": {}}
assert external_components_use_scanf_float(CORE.config) is True
assert user_code_uses_scanf_float(CORE.config) is True
+14
View File
@@ -1,6 +1,7 @@
"""Unit tests for esphome.config_helpers module."""
from collections.abc import Callable
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -12,6 +13,7 @@ from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
get_logger_level,
iter_include_files,
)
from esphome.const import (
CONF_LEVEL,
@@ -209,3 +211,15 @@ def test_filter_source_files_from_platform_unknown_pair() -> None:
{"a.cpp": {PlatformFramework.ESP32_ARDUINO}}
)
assert func() == []
def test_iter_include_files(setup_core: Path) -> None:
"""Files yield their name, directory members keep the directory prefix, system headers are skipped."""
(setup_core / "one.h").write_text("")
(setup_core / "lib").mkdir()
(setup_core / "lib" / "two.h").write_text("")
files = {
basename.as_posix()
for _, basename in iter_include_files(["one.h", "lib", "<cstdio>"])
}
assert files == {"one.h", "lib/two.h"}
+25 -4
View File
@@ -866,7 +866,8 @@ class TestSharedProgmemArray:
c = cg.shared_progmem_array("table", ct.uint8, [4])
assert a is b
assert str(a) != str(c)
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
late = CORE.global_statements_after_includes
assert sum("PROGMEM" in str(st) for st in late) == 2
assert not any("PROGMEM" in str(st) for st in CORE.main_statements)
def test_share_false_always_emits_a_new_array(self) -> None:
@@ -874,7 +875,8 @@ class TestSharedProgmemArray:
a = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
b = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
assert str(a) != str(b)
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
late = CORE.global_statements_after_includes
assert sum("PROGMEM" in str(st) for st in late) == 2
def test_is_static_pointer_only_for_placement_new(self) -> None:
CORE.config = {}
@@ -910,7 +912,9 @@ class TestSharedProgmemArray:
CORE.config = {}
cg.shared_progmem_array("table", ct.uint8, [1], constexpr=False)
(statement,) = (
str(st) for st in CORE.global_statements if "PROGMEM" in str(st)
str(st)
for st in CORE.global_statements_after_includes
if "PROGMEM" in str(st)
)
assert statement.startswith(
"ESPHOME_FLASH_CONSTINIT static uint8_t const table[] PROGMEM = {1}"
@@ -948,7 +952,7 @@ class TestProgmemString:
assert a is b
assert str(a) == "progmem_str"
assert str(c) == "progmem_str_2"
assert [str(st) for st in CORE.global_statements] == [
assert [str(st) for st in CORE.global_statements_after_includes] == [
'static constexpr char progmem_str[] PROGMEM = "level";',
'static constexpr char progmem_str_2[] PROGMEM = "say \\042hi\\042";',
]
@@ -957,6 +961,7 @@ class TestProgmemString:
self._set_platform(PLATFORM_ESP32)
assert str(cg.shared_progmem_string('say "hi"')) == '"say \\042hi\\042"'
assert not CORE.global_statements
assert not CORE.global_statements_after_includes
def test_extern_progmem_array_is_an_externally_linked_global() -> None:
@@ -967,6 +972,22 @@ def test_extern_progmem_array_is_an_externally_linked_global() -> None:
]
def test_static_function_declares_early_and_defines_after_includes() -> None:
fn = cg.static_function("esphome__act__fn", ct.void, [(ct.int32, "x")], ["return;"])
assert str(fn) == "esphome__act__fn"
assert [str(st) for st in CORE.global_statements] == [
"static void esphome__act__fn(int32_t x);"
]
assert [str(st) for st in CORE.global_statements_after_includes] == [
"static void esphome__act__fn(int32_t x) {\n return;\n}"
]
def test_add_global_after_includes_rejects_non_statements() -> None:
with pytest.raises(ValueError, match="must be expression or statement"):
CORE.add_global_after_includes("not a statement")
class TestEnumOptions:
def test_class_enum_with_prefix(self) -> None:
mode = cg.MockObj("esphome::foo", "::").enum("Mode", is_class=True)