Compare commits

..
Author SHA1 Message Date
J. Nick Koston d92fdfa24b Take the byte order in the constructor, keep ESP8266 per pixel, guard software rotation in mipi_spi and qspi_dbi 2026-09-17 19:19:23 -05:00
J. Nick Koston 2da55e1f65 Benchmark the opaque RGB565 draw on a display with a bulk path 2026-09-17 19:02:40 -05:00
J. Nick Koston 30e8b8379c Fall back per pixel when a bulk blit does not match the panel, add image draw tests 2026-09-17 18:56:41 -05:00
J. Nick Koston 1203e6009a WIP: route opaque RGB565 and RGB images through draw_pixels_at 2026-09-17 18:13:22 -05:00
J. Nick Koston a35fcc12c3 [display] Share the packed pixel read through ColorUtil
ColorUtil::read_packed<BITNESS>() lives next to to_color() so drivers
with their own blit loop (it8951 carries a copy of the same switch)
can pick it up; the comments say where the folding happens.
2026-09-17 18:01:44 -05:00
J. Nick Koston 2b894cd255 [display] Resolve the blit bitness once so channel scaling stops dividing
The per pixel fallback of draw_pixels_at() scaled every channel through
esp_scale() with a divisor derived from the runtime bitness, three real
divisions per pixel, and switched on the bitness per pixel as well. The
loop is now instantiated per bitness, so the divisor is a constant the
compiler turns into a multiply and shift with identical results, and
the 888 case scales by nothing. Same pixels drawn.
2026-09-17 17:43:00 -05:00
J. Nick Koston eab2adc9f3 [display] Test that both draw_pixels_at walks draw the same pixels
A recording display runs the row and column walks over a source with
nonzero offsets and padding for 332, 565 and 888 in both byte orders
and requires the same pixel set, plus a check that the orders differ.
2026-09-17 08:44:14 -05:00
J. Nick Koston 76b6251c6c [display] Write the draw_pixels_at pixel step once
One always-inline lambda serves both loops, comments follow the image
component's wording, and the column base is stepped like the row base.
2026-09-17 08:44:14 -05:00
J. Nick Koston cdfcb71f58 [display] Keep to_color() inlined in both draw_pixels_at loops
With two call sites GCC at -Os stopped inlining to_color(), which cost
a call and spills per pixel; flatten keeps it inlined, and the row loop
now measures faster than before the change.
2026-09-17 08:44:14 -05:00
J. Nick Koston e3a62194e5 [display] Walk columns in draw_pixels_at when the display swaps the axes
The per pixel fallback always walked rows, so on a display that swaps
x and y before writing every frame buffer write landed a row apart. It
now asks pixel_axes_swapped() once and walks columns in that case, the
same rule Image::draw() uses; the column walk is only built with PSRAM.
The source pixel decode moves into a helper shared by both loops.
2026-09-17 08:44:14 -05:00
J. Nick Koston bf5e74120f [image] Check the rotated benchmark frame against the plain one
The benchmark now draws once at each rotation and requires the rotated
frame to be the transpose of the plain one before timing, so a wrong
traversal fails the run instead of passing silently.
2026-09-17 08:43:02 -05:00
J. Nick Koston d67a78d53a [image] Keep the benchmark override working without a to_code priority 2026-09-17 08:43:02 -05:00
J. Nick Koston 24edcbc1e1 [image] Build the column walk in the host benchmark
USE_PSRAM gates it, so the rotated benchmark was running the row walk.
2026-09-17 08:43:02 -05:00
J. Nick Koston 3a460d950e [image] Only build the column walk when PSRAM is present
Without PSRAM the frame buffer is in uncached RAM where a strided write
costs nothing, so the second copy of the loops bought only flash.
Adds the transparent binary and grayscale paths to the host benchmark.
2026-09-17 08:43:02 -05:00
J. Nick Koston 1c2325da30 [epaper_spi] Keep the size accessors as they were 2026-09-17 08:43:01 -05:00
J. Nick Koston e41d3511ed [image][display] One loop body per format with the order as a template argument
for_each_pixel() holds the loop order once; the per format bodies are
always-inline lambdas, which keeps the inner loop identical to dev
(GCC at -Os otherwise leaves them out of line and calls per pixel).
Members read in the loop are copied to locals so the opaque virtual
draw_pixel_at() does not force a reload per pixel. The hub75 override
goes, its rotation is forced to 0 at codegen. The host benchmark's
frame is square so the rotated case stays in bounds.
2026-09-17 08:43:01 -05:00
J. Nick Koston eaec226add [image][display] Let the display say whether pixel writes swap the axes
Rotation alone is the wrong signal: mipi_spi with hardware rotation,
epaper_spi and it8951 with a swap_xy transform, and hub75 write the
frame buffer differently from what the rotation implies. The display
now answers through pixel_axes_swapped() and Image::draw() picks the
loop order from that. The four per-format loops collapse into one body
with the format resolved at compile time.
2026-09-17 08:43:01 -05:00
J. Nick Koston 3dea35814c [image] Follow the display rotation when choosing the loop order
A rotated display swaps x and y before the frame buffer write, so the
row walk strides the frame buffer there and a strided write costs more
than a strided read. The loop order is now a compile time template
picked from the display rotation, so both orders keep the plain loop
shape. Adds a rotated case to the host benchmark.
2026-09-17 08:42:31 -05:00
J. Nick Koston 4d130624b4 [image] Trim the draw benchmark
Own the frame buffer so nothing leaks per trial, size the pixel data
once for the widest format, and drop the manifest override the build
does not need.
2026-09-17 08:42:31 -05:00
J. Nick Koston de93f2e9ff [image] Draw rows in the outer loop so pixel reads walk memory sequentially
Image::draw() iterated columns in the outer loop over a row-major
pixel buffer, so every pixel read and every frame buffer write jumped
by a full row. Walking rows keeps both accesses sequential regardless
of image shape or display rotation. The output is unchanged.

Adds a host benchmark for the four pixel formats plus the alpha path.
2026-09-17 08:42:31 -05:00
82 changed files with 1053 additions and 1575 deletions

No files matched your search

-116
View File
@@ -1,116 +0,0 @@
---
name: code-review
description: Review guidance for ESPHome pull requests. Use this when reviewing a pull request that changes ESPHome Python, C++, or component code, to check it against the project's coding conventions, embedded-systems memory rules, testing requirements, and breaking-change policy.
---
# Reviewing ESPHome pull requests
ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers
(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and
flash are scarce, and code runs unattended for months.
`AGENTS.md` in the repository root is the full contributor guide and the
authority when it disagrees with this summary. The developer documentation at
https://developers.esphome.io explains the component lifecycle and the reasoning
behind these rules. This skill lists the concrete things worth flagging in a
review; read `AGENTS.md` for the detail behind any item.
Only raise findings that the diff actually introduces or changes. Do not ask for
drive-by cleanup of pre-existing code the PR did not touch.
## Memory and embedded constraints (highest value)
Heap allocation after `setup()` is treated as a reliability bug, not a
performance nit, because it fragments a small shared heap. Flag:
- New heap allocation on a hot path or after setup that could be avoided.
- `std::vector` where the size is known at compile time (use `std::array`, or
`StaticVector<T, N>` when a `push_back` API is needed) or fixed at runtime
init (use `FixedVector<T>`).
- Listener / child-entity registration lists stored as `std::vector`; these have
a compile-time-known count and should use `cg.slot_counter()` plus
`StaticVector`.
- `std::vector<uint8_t>` for a byte buffer that never grows: prefer
`std::unique_ptr<uint8_t[]>` or `std::array`.
- `std::map` / `std::set` / `std::unordered_map` for small datasets (1-16
elements): a `std::vector` of a small struct with linear search is lighter.
- `std::deque` anywhere: it allocates 512-byte blocks and should be avoided.
- `std::string` storing a value set once from config: prefer `StringRef` (the
literal already lives in flash).
- `std::string` / `std::to_string` / string-returning helpers on hot paths where
a buffer or view API exists.
## C++ conventions
- Include what you use: a file referencing a symbol must include the header that
declares it, even if it currently arrives transitively. New or changed uses of
a symbol need the matching include.
- Prefix all member access with `this->`.
- Naming: `lower_snake_case` for functions/methods/variables, `UpperCamelCase`
for classes/structs/enums, `UPPER_SNAKE_CASE` for namespace-scope constants,
trailing underscore on protected/private fields.
- `enum class` values must be prefixed with the enum name in `UPPER_SNAKE_CASE`
(e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Bare names like `SUCCESS`,
`FAIL`, or `OK` collide with SDK macros on some platforms and break the build.
- Prefer `const`/`enum` over `#define`; `#define` is only for conditional
compilation and code-generation sizes.
- Never call `millis()` in a `loop()` body; use
`App.get_loop_component_start_time()`. A rate-limit gate below ~16 ms (the loop
period) does nothing.
- Pick the timing primitive by cadence: gated `loop()` under 250 ms,
`set_interval` at 500 ms and above.
- Do not override a base method to return the value it already returns (e.g.
`get_setup_priority()` returning `setup_priority::DATA`).
- Wrap string literals passed as printf `%s` args in `LOG_STR_LITERAL()`.
- Required, invariant dependencies should be constructor parameters, not setters.
- Callback registration methods must be templated (`template<typename F>`), not
typed as `std::function`, so lightweight forwarders avoid a heap allocation.
- Two-space indent, `using` over `typedef`, wrap at 120 columns.
## Python conventions
- Type-annotate every new function signature (params and return), new dataclass
fields, and new module-level variables. Import `ConfigType` from
`esphome.types`.
- Use the walrus operator to avoid a double lookup, e.g.
`if (blah := config.get(CONF_BLAH)) is not None:`.
- Reuse existing validators from `config_validation.py` (`cv.rename_key`,
`cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling.
- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in
the component's own `.py`, or in `esphome/components/const/__init__.py` when
shared. The same constant defined in three or more component files fails CI.
- State that must persist during code generation goes in `CORE.data` namespaced
under the component `DOMAIN` (a `@dataclass`), not module-level mutable globals.
- Prefer callback-based triggers via `build_callback_automation()`; only use a
`Trigger<Ts...>` subclass when the forwarder needs mutable state.
## Testing and coverage
- New and changed lines and branches need test coverage, including defensive
early-returns, error paths, and no-op guards. A mocked-out function is not
covered; exercise the real call path too.
- Component YAML tests live in `tests/components/<component>/`. Never define
buses (uart, i2c, spi, modbus) directly in a test file: pull them from
`tests/test_build_components/common/` through dict-style `packages:` so CI can
group builds. List-style packages or top-level merge keys block grouping.
- Config-only checks use the `validate.*.yaml` prefix; compiled checks use
`test.*.yaml`.
## Breaking changes and public API
- Base classes under `esphome/core/` and documented config options are public
API. Undocumented `public` members of a component are internal.
- A breaking change needs justification, a migration path in the PR description,
and a deprecation window where feasible (`ESPDEPRECATED` in C++,
`cv.rename_key(..., removed_in=...)` in Python). Changing a codegen-injected
lambda signature is not a breaking change.
## Process and PR hygiene
- PR titles start with a `[tag]` prefix: the component name (e.g. `[uart] ...`)
or `[core]` for shared code.
- Prose in docs, comments, and commit messages should be plain English. Keep
inline comments short and only where the code is not self-explanatory; do not
restate what the code says.
- Verify the PR fills out `.github/PULL_REQUEST_TEMPLATE.md` and adds
`CODEOWNERS` entries for a new component.
+2 -2
View File
@@ -42,7 +42,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -67,7 +67,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Determine tag and whether to push
id: tag
@@ -159,7 +159,7 @@ jobs:
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to the GitHub container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+2 -2
View File
@@ -321,7 +321,7 @@ jobs:
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -350,7 +350,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Report empty upload to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
run_command: empty-upload
force: true
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
+2 -2
View File
@@ -123,7 +123,7 @@ jobs:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to docker hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -202,7 +202,7 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
+1 -1
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.8
rev: v0.16.7
hooks:
# Run the linter.
- id: ruff
+2 -2
View File
@@ -5,8 +5,8 @@
namespace esphome::animation {
Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count,
image::ImageType type, image::Transparency transparent)
: Image(data_start, width, height, type, transparent),
image::ImageType type, image::Transparency transparent, bool big_endian)
: Image(data_start, width, height, type, transparent, big_endian),
animation_data_start_(data_start),
current_frame_(0),
animation_frame_count_(animation_frame_count),
+1 -1
View File
@@ -8,7 +8,7 @@ namespace esphome::animation {
class Animation final : public image::Image {
public:
Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type,
image::Transparency transparent);
image::Transparency transparent, bool big_endian = false);
uint32_t get_animation_frame_count() const;
int get_current_frame() const;
+2 -1
View File
@@ -1,6 +1,6 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
from esphome.components.const import CONF_BYTE_ORDER, CONF_LOOP
from esphome.components.file import image as file_image
from esphome.components.file.image import image_schema, write_image
from esphome.components.image import Image_, validate_settings
@@ -114,6 +114,7 @@ async def setup_animation(config: ConfigType) -> None:
frame_count,
image_type,
trans_value,
config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN",
)
if loop_config := config.get(CONF_LOOP):
start = loop_config[CONF_START_FRAME]
+2 -24
View File
@@ -29,29 +29,6 @@ static const char *const TAG = "api";
// APIServer
APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#ifdef USE_API_NOISE
static constexpr uint32_t NOISE_PSK_PREF_HASH = 88491486UL;
#endif
#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED)
bool load_saved_noise_psk(noise::psk_t &out) {
SavedNoisePsk saved;
#ifdef USE_PREFERENCE_KEY_LOOKUP
const bool loaded =
global_preferences->load_from_key(NOISE_PSK_PREF_HASH, reinterpret_cast<uint8_t *>(&saved), sizeof(saved));
#else
// Slot backends need the reservation walk; it only lands on the record when the reservations before
// it match a normal boot, otherwise the type checked checksum fails the load
const bool loaded = global_preferences->make_preference<SavedNoisePsk>(NOISE_PSK_PREF_HASH, true).load(&saved);
#endif
// The all-zeros record means no key
if (!loaded || noise::NoiseContext::is_all_zeros(saved.psk))
return false;
out = saved.psk;
return true;
}
#endif
APIServer::APIServer() { global_api_server = this; }
void APIServer::socket_failed_(const LogString *msg) {
@@ -66,7 +43,8 @@ void APIServer::setup() {
#ifdef USE_API_NOISE
// Always reserve the slot: flash preferences are positional on esp8266, so
// a yaml key build must keep the layout of a runtime key build
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(NOISE_PSK_PREF_HASH, true);
uint32_t hash = 88491486UL;
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true);
#ifndef USE_API_NOISE_PSK_FROM_YAML
// A cleared record loads fine but holds no key
if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) {
-5
View File
@@ -43,11 +43,6 @@ struct SavedNoisePsk {
noise::psk_t psk;
} PACKED; // NOLINT
#endif
#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED)
/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when
/// there is no key
bool load_saved_noise_psk(noise::psk_t &out);
#endif
class APIServer final : public Component,
public Controller
+7 -7
View File
@@ -181,11 +181,11 @@ async def at581x_settings_to_code(
await cg.register_parented(var, config[CONF_ID])
# Radar configuration
if (frontend_reset := config.get(CONF_HW_FRONTEND_RESET)) is not None:
if frontend_reset := config.get(CONF_HW_FRONTEND_RESET):
template_ = await cg.templatable(frontend_reset, args, cg.int8)
cg.add(var.set_hw_frontend_reset(template_))
if (freq := config.get(CONF_FREQUENCY)) is not None:
if freq := config.get(CONF_FREQUENCY):
if not cg.is_template(freq):
freq = int(freq / 1000000)
template_ = await cg.templatable(freq, args, cg.int_)
@@ -195,19 +195,19 @@ async def at581x_settings_to_code(
template_ = await cg.templatable(sens_dist, args, cg.int_)
cg.add(var.set_sensing_distance(template_))
if (selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME)) is not None:
if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME):
template_ = await cg.templatable(selfcheck, args, cg.int_)
cg.add(var.set_poweron_selfcheck_time(template_))
if (protect := config.get(CONF_PROTECT_TIME)) is not None:
if protect := config.get(CONF_PROTECT_TIME):
template_ = await cg.templatable(protect, args, cg.int_)
cg.add(var.set_protect_time(template_))
if (trig_base := config.get(CONF_TRIGGER_BASE)) is not None:
if trig_base := config.get(CONF_TRIGGER_BASE):
template_ = await cg.templatable(trig_base, args, cg.int_)
cg.add(var.set_trigger_base(template_))
if (trig_keep := config.get(CONF_TRIGGER_KEEP)) is not None:
if trig_keep := config.get(CONF_TRIGGER_KEEP):
template_ = await cg.templatable(trig_keep, args, cg.int_)
cg.add(var.set_trigger_keep(template_))
@@ -215,7 +215,7 @@ async def at581x_settings_to_code(
template_ = await cg.templatable(stage_gain, args, cg.int_)
cg.add(var.set_stage_gain(template_))
if (power := config.get(CONF_POWER_CONSUMPTION)) is not None:
if power := config.get(CONF_POWER_CONSUMPTION):
if not cg.is_template(power):
power = int(power * 1000000)
template_ = await cg.templatable(power, args, cg.int_)
+53 -27
View File
@@ -5,6 +5,7 @@
#include "display_color_utils.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::display {
@@ -52,40 +53,65 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_
this->line(x1, y1, x2, y2, color);
}
void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels
uint32_t color_value;
// flatten keeps to_color() inlined in the pixel loop; in PSRAM builds GCC at
// -Os stops inlining it once there are two call sites, which costs a call and
// spills per pixel. Without PSRAM there is one call site and it changes
// nothing, and draw_pixel_at() stays a virtual call either way.
template<ColorBitness BITNESS>
static void __attribute__((flatten))
draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
bool big_endian, int x_offset, int y_offset, int x_pad) {
const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels
// BITNESS is a constant here, so to_color()'s three per pixel divisions
// become a multiply and shift and its bitness switch folds.
const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE {
display->draw_pixel_at(
x + x_start, y + y_start,
ColorUtil::to_color(ColorUtil::read_packed<BITNESS>(ptr, source_idx, big_endian), order, BITNESS));
};
// The source and frame buffers are row-major, so walking rows keeps the read
// and the write sequential. When the display swaps the axes before writing,
// walking columns keeps the write sequential instead; with the frame buffer
// in PSRAM a strided write costs more than a strided read.
#ifdef USE_PSRAM
if (display->pixel_axes_swapped()) {
size_t column_idx = y_offset * line_stride + x_offset;
for (int x = 0; x != w; x++, column_idx++) {
size_t source_idx = column_idx;
for (int y = 0; y != h; y++, source_idx += line_stride) {
draw_source_pixel(x, y, source_idx);
}
}
return;
}
#endif
// Row walk: builds without PSRAM, and PSRAM builds whose axes are not swapped.
for (int y = 0; y != h; y++) {
size_t source_idx = (y_offset + y) * line_stride + x_offset;
size_t source_idx_mod;
for (int x = 0; x != w; x++, source_idx++) {
switch (bitness) {
default:
color_value = ptr[source_idx];
break;
case COLOR_BITNESS_565:
source_idx_mod = source_idx * 2;
if (big_endian) {
color_value = (ptr[source_idx_mod] << 8) + ptr[source_idx_mod + 1];
} else {
color_value = ptr[source_idx_mod] + (ptr[source_idx_mod + 1] << 8);
}
break;
case COLOR_BITNESS_888:
source_idx_mod = source_idx * 3;
if (big_endian) {
color_value = (ptr[source_idx_mod + 0] << 16) + (ptr[source_idx_mod + 1] << 8) + ptr[source_idx_mod + 2];
} else {
color_value = ptr[source_idx_mod + 0] + (ptr[source_idx_mod + 1] << 8) + (ptr[source_idx_mod + 2] << 16);
}
break;
}
this->draw_pixel_at(x + x_start, y + y_start, ColorUtil::to_color(color_value, order, bitness));
draw_source_pixel(x, y, source_idx);
}
}
}
void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
switch (bitness) {
case COLOR_BITNESS_565:
draw_packed_pixels<COLOR_BITNESS_565>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
case COLOR_BITNESS_888:
draw_packed_pixels<COLOR_BITNESS_888>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
default:
draw_packed_pixels<COLOR_BITNESS_332>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
}
}
void HOT Display::horizontal_line(int x, int y, int width, Color color) {
// Future: Could be made more efficient by manipulating buffer directly in certain rotations.
for (int i = x; i < x + width; i++)
+16
View File
@@ -137,6 +137,11 @@ enum DisplayRotation {
DISPLAY_ROTATION_270_DEGREES = 270,
};
/// Whether a rotation swaps the x and y axes.
constexpr bool rotation_swaps_axes(DisplayRotation rotation) {
return rotation == DISPLAY_ROTATION_90_DEGREES || rotation == DISPLAY_ROTATION_270_DEGREES;
}
const int EDGES_TRIGON = 3;
const int EDGES_TRIANGLE = 3;
const int EDGES_TETRAGON = 4;
@@ -355,6 +360,11 @@ class Display : public PollingComponent {
* \param x_pad How many pixels are in each line after the end of the pixels to be copied.
*
* The length of each source buffer line (stride) will be x_offset + w + x_pad.
*
* The destination is in drawn coordinates, so an override that copies the block straight into
* its buffer must hand anything it cannot copy as is (another pixel format or byte order, a
* software rotation, an active clipping rectangle) to this base implementation, which goes
* through draw_pixel_at(). Callers keep the block on screen.
*/
virtual void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad);
@@ -710,6 +720,12 @@ class Display : public PollingComponent {
DisplayRotation get_rotation() const { return this->rotation_; }
/// True when draw_pixel_at() swaps x and y before writing, so pixels that are
/// neighbours along x land a row apart in the frame buffer. Not the same as
/// get_width()/get_height() swapping: a display that rotates in hardware
/// reports swapped dimensions but writes unswapped pixels.
virtual bool pixel_axes_swapped() const { return rotation_swaps_axes(this->rotation_); }
/** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays,
* returns the type the display is currently configured to.
*/
@@ -1,5 +1,6 @@
#pragma once
#include "esphome/core/color.h"
#include "esphome/core/helpers.h"
namespace esphome::display {
enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDER_GRB = 2 };
@@ -8,6 +9,27 @@ inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 25
class ColorUtil {
public:
/// Reads one packed pixel of the given bitness from a source buffer into the
/// value to_color() takes. BITNESS is a template argument so the byte layout
/// folds; pass it on to to_color() as a constant too, and its channel scaling
/// divides by a constant, which the compiler turns into a multiply and shift.
template<ColorBitness BITNESS>
static ESPHOME_ALWAYS_INLINE uint32_t read_packed(const uint8_t *ptr, size_t index, bool big_endian) {
if constexpr (BITNESS == COLOR_BITNESS_565) {
const size_t idx = index * 2;
if (big_endian)
return (ptr[idx] << 8) + ptr[idx + 1];
return ptr[idx] + (ptr[idx + 1] << 8);
} else if constexpr (BITNESS == COLOR_BITNESS_888) {
const size_t idx = index * 3;
if (big_endian)
return (ptr[idx + 0] << 16) + (ptr[idx + 1] << 8) + ptr[idx + 2];
return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16);
} else {
return ptr[index];
}
}
static Color to_color(uint32_t colorcode, ColorOrder color_order,
ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) {
uint8_t first_color, second_color, third_color;
@@ -113,6 +113,7 @@ class EPaperBase : public Display,
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; }
void draw_pixel_at(int x, int y, Color color) override;
protected:
+1 -6
View File
@@ -262,7 +262,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_lcd", # LCD controller drivers - only needed by display component
"esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API
"esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; bt pulls it back for BLE builds
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds
"espcoredump", # Core dump support - ESPHome has its own debug component
"fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage
"ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back
@@ -2792,11 +2792,6 @@ async def to_code(config):
config.get(CONF_ENGINEERING_SAMPLE, False),
)
# ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not
# understand %zu or %lld and crashes on any %s that follows one.
if variant == VARIANT_ESP32C2:
add_idf_sdkconfig_option("CONFIG_LIBC_NEWLIB_NANO_FORMAT", False)
# Set minimum chip revision for ESP32 variant
# Setting this to 3.0 or higher reduces flash size by excluding workaround code,
# and for PSRAM users saves significant IRAM by keeping C library functions in ROM.
@@ -290,8 +290,6 @@ async def to_code(config: ConfigType) -> None:
# symbols are simply unused and never register a callback at runtime.
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
add_define("USE_ESP_NOW_HOSTED")
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
esp32.include_builtin_idf_component("esp_wifi")
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
+11 -7
View File
@@ -316,16 +316,20 @@ async def to_code(config: ConfigType) -> None:
# One key per device: an api encryption block supplies it (static or
# runtime) and offers; the ota block only adds the requirement
api_conf = CORE.config.get(CONF_API) or {}
if key := static_encryption_key(config) or static_encryption_key(api_conf):
# Build time key: the ota keeps its own pointer so safe mode, which
# has no api server, still has it
encryption_conf = config.get(CONF_ENCRYPTION)
own_key = None
if encryption_conf is not None and static_encryption_key(api_conf) is None:
own_key = encryption_conf[CONF_KEY]
if own_key is not None:
cg.add_define("USE_OTA_ENCRYPTION")
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key)))
elif CONF_ENCRYPTION in api_conf:
# Runtime key: found in the api server, or in preferences in safe mode
cg.add_define("USE_OTA_ENCRYPTION")
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
if CONF_ENCRYPTION in config:
cg.add_define("USE_OTA_ENCRYPTION_FROM_API")
if static_encryption_key(api_conf) is None:
# The key arrives at runtime, so the offer has to look for it
cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED")
if encryption_conf is not None:
cg.add_define("USE_OTA_ENCRYPTION_REQUIRED")
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
+5 -17
View File
@@ -1,5 +1,5 @@
#include "ota_esphome.h"
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
#ifdef USE_OTA_ENCRYPTION_FROM_API
#include "esphome/components/api/api_server.h"
#endif
#ifdef USE_OTA
@@ -32,13 +32,11 @@ static const char *const TAG = "esphome.ota";
#ifdef USE_OTA_ENCRYPTION
const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
// The api server holds the live key; safe mode never constructs it, and then
// noise_ctx_ holds the saved key setup() found, if any
if (api::global_api_server != nullptr)
return api::global_api_server->get_noise_ctx();
#endif
#ifdef USE_OTA_ENCRYPTION_FROM_API
return api::global_api_server->get_noise_ctx();
#else
return this->noise_ctx_;
#endif
}
#endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
@@ -60,16 +58,6 @@ extern "C" void esphome_wake_ota_component_any_context() {
}
void ESPHomeOTAComponent::setup() {
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
// Safe mode never constructs the api server, so read the key it saved
noise::psk_t psk;
if (api::global_api_server == nullptr && api::load_saved_noise_psk(psk)) {
this->saved_psk_ = RAMAllocator<noise::psk_t>().make_unique(psk);
if (this->saved_psk_ != nullptr) {
this->noise_ctx_.set_psk(this->saved_psk_->data());
}
}
#endif
this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->server_ == nullptr) {
this->server_failed_(LOG_STR("creation"));
+3 -6
View File
@@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
}
#endif // USE_OTA_PASSWORD
#ifdef USE_OTA_ENCRYPTION
#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API)
/// psk points at 32 bytes that live in flash for the life of the program
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
#endif
@@ -86,8 +86,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
bool writing{false}; // a produced handshake frame is still being flushed
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
};
// The api server's live context when it exists, otherwise our own (a build
// time key, or the saved key loaded in safe mode)
// The api server's live context when the api has encryption, else our own
const noise::NoiseContext &noise_context_() const;
bool noise_start_session_(uint8_t server_feature_flags);
bool handle_noise_handshake_();
@@ -149,10 +148,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
RAMUniquePtr<uint8_t[]> auth_buf_;
#endif // USE_OTA_PASSWORD
#ifdef USE_OTA_ENCRYPTION
#ifndef USE_OTA_ENCRYPTION_FROM_API
noise::NoiseContext noise_ctx_;
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
// Backs noise_ctx_ in safe mode, where no api server holds the saved key
RAMUniquePtr<noise::psk_t> saved_psk_;
#endif
RAMUniquePtr<NoiseSession> noise_;
#endif // USE_OTA_ENCRYPTION
+7 -1
View File
@@ -254,7 +254,13 @@ async def new_image(config: ConfigType) -> MockObj:
"""
prog_arr, width, height, image_type, trans_value, _ = await write_image(config)
var = cg.new_Pvariable(
config[CONF_ID], prog_arr, width, height, image_type, trans_value
config[CONF_ID],
prog_arr,
width,
height,
image_type,
trans_value,
config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN",
)
add_metadata(
config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY]
+122 -64
View File
@@ -1,85 +1,135 @@
#include "image.h"
#include <algorithm>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
namespace esphome::image {
// Walks the clipped image range with rows (ROWS_OUTER) or columns as the outer
// loop. The coordinate selection folds at compile time, and the pixel body must
// be ESPHOME_ALWAYS_INLINE: at -Os GCC otherwise leaves it out of line and calls
// it once per pixel.
template<bool ROWS_OUTER, typename F>
static void for_each_pixel(int x_start, int x_end, int y_start, int y_end, F &&f) {
const int outer_start = ROWS_OUTER ? y_start : x_start;
const int outer_end = ROWS_OUTER ? y_end : x_end;
const int inner_start = ROWS_OUTER ? x_start : y_start;
const int inner_end = ROWS_OUTER ? x_end : y_end;
for (int outer = outer_start; outer < outer_end; outer++) {
for (int inner = inner_start; inner < inner_end; inner++) {
f(ROWS_OUTER ? inner : outer, ROWS_OUTER ? outer : inner);
}
}
}
void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
int img_x0 = 0;
int img_y0 = 0;
int w = width_;
int h = height_;
int x_start = 0;
int y_start = 0;
int x_end = width_;
int y_end = height_;
auto clipping = display->get_clipping();
if (clipping.is_set()) {
if (clipping.x > x)
img_x0 += clipping.x - x;
x_start += clipping.x - x;
if (clipping.y > y)
img_y0 += clipping.y - y;
if (w > clipping.x2() - x)
w = clipping.x2() - x;
if (h > clipping.y2() - y)
h = clipping.y2() - y;
y_start += clipping.y - y;
if (x_end > clipping.x2() - x)
x_end = clipping.x2() - x;
if (y_end > clipping.y2() - y)
y_end = clipping.y2() - y;
}
switch (type_) {
case IMAGE_TYPE_BINARY: {
for (int img_x = img_x0; img_x < w; img_x++) {
for (int img_y = img_y0; img_y < h; img_y++) {
if (this->get_binary_pixel_(img_x, img_y)) {
display->draw_pixel_at(x + img_x, y + img_y, color_on);
} else if (!this->transparency_) {
display->draw_pixel_at(x + img_x, y + img_y, color_off);
}
#ifndef USE_ESP8266
// Opaque RGB565 and RGB pixels are already in the layout draw_pixels_at()
// takes, so hand the window to the display: drivers with a bulk path blit
// it, the others walk it per pixel as before. Not on the ESP8266, where the
// pixels live in flash and only progmem_read_byte() may read them.
if (this->transparency_ == TRANSPARENCY_OPAQUE &&
(this->type_ == IMAGE_TYPE_RGB565 || this->type_ == IMAGE_TYPE_RGB)) {
// Clamp to the display so the block never reaches off screen
x_start = std::max(x_start, -x);
y_start = std::max(y_start, -y);
x_end = std::min(x_end, display->get_width() - x);
y_end = std::min(y_end, display->get_height() - y);
if (x_end <= x_start || y_end <= y_start)
return;
const bool rgb565 = this->type_ == IMAGE_TYPE_RGB565;
display->draw_pixels_at(x + x_start, y + y_start, x_end - x_start, y_end - y_start, this->data_start_,
display::COLOR_ORDER_RGB, rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_888,
this->big_endian_, x_start, y_start, this->width_ - x_end);
return;
}
#endif
// Pixel data and frame buffers are row-major, so walking rows keeps the
// image read and the frame buffer write sequential. When the display swaps
// the axes before writing, walking columns keeps the write sequential
// instead; a strided write costs more than a strided read.
#ifdef USE_PSRAM
if (display->pixel_axes_swapped()) {
this->draw_<false>(x, y, display, color_on, color_off, x_start, x_end, y_start, y_end);
return;
}
#endif
// Without PSRAM the frame buffer is in uncached RAM, where the strided write
// is free, so one copy of the loops is enough.
this->draw_<true>(x, y, display, color_on, color_off, x_start, x_end, y_start, y_end);
}
template<bool ROWS_OUTER>
void Image::draw_(int x, int y, display::Display *display, Color color_on, Color color_off, int x_start, int x_end,
int y_start, int y_end) {
// Local copies: draw_pixel_at() is an opaque virtual call, so members read
// in the loop would otherwise be reloaded every pixel.
const Transparency transparency = this->transparency_;
switch (this->type_) {
case IMAGE_TYPE_BINARY:
for_each_pixel<ROWS_OUTER>(x_start, x_end, y_start, y_end, [&](int img_x, int img_y) ESPHOME_ALWAYS_INLINE {
if (this->get_binary_pixel_(img_x, img_y)) {
display->draw_pixel_at(x + img_x, y + img_y, color_on);
} else if (transparency == TRANSPARENCY_OPAQUE) {
display->draw_pixel_at(x + img_x, y + img_y, color_off);
}
}
});
break;
case IMAGE_TYPE_GRAYSCALE: {
const int width = this->width_;
const uint8_t *const data = this->data_start_;
for_each_pixel<ROWS_OUTER>(x_start, x_end, y_start, y_end, [&](int img_x, int img_y) ESPHOME_ALWAYS_INLINE {
const uint8_t gray = progmem_read_byte(data + img_x + img_y * width);
Color color = Color(gray, gray, gray, 0xFF);
if (transparency == TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
return; // transparent pixel, skip drawing
}
} else if (transparency == TRANSPARENCY_ALPHA_CHANNEL) {
// gray is the alpha: blend from color_off to color_on, drawn opaque
color = Color(Color::blend_channel(color_off.r, color_on.r, gray),
Color::blend_channel(color_off.g, color_on.g, gray),
Color::blend_channel(color_off.b, color_on.b, gray), 0xFF);
}
display->draw_pixel_at(x + img_x, y + img_y, color);
});
break;
}
case IMAGE_TYPE_GRAYSCALE:
for (int img_x = img_x0; img_x < w; img_x++) {
for (int img_y = img_y0; img_y < h; img_y++) {
const uint32_t pos = (img_x + img_y * this->width_);
const uint8_t gray = progmem_read_byte(this->data_start_ + pos);
Color color = Color(gray, gray, gray, 0xFF);
switch (this->transparency_) {
case TRANSPARENCY_CHROMA_KEY:
if (gray == 1) {
continue; // skip drawing
}
break;
case TRANSPARENCY_ALPHA_CHANNEL:
// gray is the alpha: blend from color_off to color_on, drawn opaque
color = Color(Color::blend_channel(color_off.r, color_on.r, gray),
Color::blend_channel(color_off.g, color_on.g, gray),
Color::blend_channel(color_off.b, color_on.b, gray), 0xFF);
break;
default:
break;
}
case IMAGE_TYPE_RGB565:
for_each_pixel<ROWS_OUTER>(x_start, x_end, y_start, y_end, [&](int img_x, int img_y) ESPHOME_ALWAYS_INLINE {
auto color = this->get_rgb565_pixel_(img_x, img_y);
if (color.w >= 0x80) {
display->draw_pixel_at(x + img_x, y + img_y, color);
}
}
break;
case IMAGE_TYPE_RGB565:
for (int img_x = img_x0; img_x < w; img_x++) {
for (int img_y = img_y0; img_y < h; img_y++) {
auto color = this->get_rgb565_pixel_(img_x, img_y);
if (color.w >= 0x80) {
display->draw_pixel_at(x + img_x, y + img_y, color);
}
}
}
});
break;
case IMAGE_TYPE_RGB:
for (int img_x = img_x0; img_x < w; img_x++) {
for (int img_y = img_y0; img_y < h; img_y++) {
auto color = this->get_rgb_pixel_(img_x, img_y);
if (color.w >= 0x80) {
display->draw_pixel_at(x + img_x, y + img_y, color);
}
for_each_pixel<ROWS_OUTER>(x_start, x_end, y_start, y_end, [&](int img_x, int img_y) ESPHOME_ALWAYS_INLINE {
auto color = this->get_rgb_pixel_(img_x, img_y);
if (color.w >= 0x80) {
display->draw_pixel_at(x + img_x, y + img_y, color);
}
}
});
break;
}
}
@@ -153,7 +203,7 @@ bool Image::get_binary_pixel_(int x, int y) const {
return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u));
}
Color Image::get_rgb_pixel_(int x, int y) const {
const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8;
const uint32_t pos = (x + y * this->width_) * static_cast<size_t>(this->bpp_) / 8;
Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1),
progmem_read_byte(this->data_start_ + pos + 0), 0xFF);
@@ -173,8 +223,10 @@ Color Image::get_rgb_pixel_(int x, int y) const {
return color;
}
Color Image::get_rgb565_pixel_(int x, int y) const {
const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8;
uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos));
const uint8_t *pos = this->data_start_ + (x + y * this->width_) * static_cast<size_t>(this->bpp_) / 8;
// The high byte sits at pos when big endian, at pos + 1 otherwise
const uint8_t high = this->big_endian_ ? 0 : 1;
const uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + high), progmem_read_byte(pos + 1 - high));
auto r = (rgb565 & 0xF800) >> 11;
auto g = (rgb565 & 0x07E0) >> 5;
auto b = rgb565 & 0x001F;
@@ -210,8 +262,14 @@ Color Image::get_grayscale_pixel_(int x, int y) const {
int Image::get_width() const { return this->width_; }
int Image::get_height() const { return this->height_; }
ImageType Image::get_type() const { return this->type_; }
Image::Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency)
: width_(width), height_(height), type_(type), data_start_(data_start), transparency_(transparency) {
Image::Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency,
bool big_endian)
: width_(width),
height_(height),
type_(type),
data_start_(data_start),
transparency_(transparency),
big_endian_(big_endian && type == IMAGE_TYPE_RGB565) {
switch (this->type_) {
case IMAGE_TYPE_BINARY:
this->bpp_ = 1;
+9 -2
View File
@@ -23,7 +23,9 @@ enum Transparency {
class Image : public display::BaseImage {
public:
Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency);
/// big_endian is the RGB565 byte order; codegen writes little endian unless configured otherwise.
Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency,
bool big_endian = false);
Color get_pixel(int x, int y, Color color_on = display::COLOR_ON, Color color_off = display::COLOR_OFF) const;
int get_width() const override;
int get_height() const override;
@@ -43,6 +45,10 @@ class Image : public display::BaseImage {
lv_image_dsc_t *get_lv_image_dsc();
#endif
protected:
/// Per-pixel draw of the clipped range, rows (true) or columns (false) outer.
template<bool ROWS_OUTER>
void draw_(int x, int y, display::Display *display, Color color_on, Color color_off, int x_start, int x_end,
int y_start, int y_end);
bool get_binary_pixel_(int x, int y) const;
Color get_rgb_pixel_(int x, int y) const;
Color get_rgb565_pixel_(int x, int y) const;
@@ -53,7 +59,8 @@ class Image : public display::BaseImage {
ImageType type_;
const uint8_t *data_start_;
Transparency transparency_;
size_t bpp_{};
bool big_endian_;
uint8_t bpp_{};
#ifdef USE_LVGL
lv_img_dsc_t dsc_{};
#endif
+1
View File
@@ -204,6 +204,7 @@ class IT8951Display : public Display,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; }
int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; }
bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; }
protected:
int get_height_internal() override { return this->height_; }
@@ -118,9 +118,6 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
}
#endif
#endif
#ifdef USE_OTA_SIGNED_VERIFICATION
txt_count++; // ota_signed
#endif
#ifdef ESPHOME_PROJECT_NAME
txt_count += 2; // project_name and project_version
#endif
@@ -189,13 +186,6 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
#endif
#endif
#ifdef USE_OTA_SIGNED_VERIFICATION
// Signals that an unsigned OTA image is rejected; serial flash is unaffected.
MDNS_STATIC_CONST_CHAR(TXT_OTA_SIGNED, "ota_signed");
MDNS_STATIC_CONST_CHAR(VALUE_TRUE, "1");
txt_records.push_back({MDNS_STR(TXT_OTA_SIGNED), MDNS_STR(VALUE_TRUE)});
#endif
#ifdef ESPHOME_PROJECT_NAME
MDNS_STATIC_CONST_CHAR(TXT_PROJECT_NAME, "project_name");
MDNS_STATIC_CONST_CHAR(TXT_PROJECT_VERSION, "project_version");
+4 -3
View File
@@ -206,9 +206,10 @@ void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
if (w <= 0 || h <= 0)
return;
// if color mapping is required, pass the buck.
// note that endianness is not considered here - it is assumed to match!
if (bitness != this->color_depth_) {
// The buffer holds little endian pixels in the panel's own orientation; anything else goes through
// the base class, which converts and rotates one pixel at a time.
if (bitness != this->color_depth_ || (bitness == display::COLOR_BITNESS_565 && big_endian) ||
this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES) {
display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset,
x_pad);
return;
+8 -5
View File
@@ -192,6 +192,10 @@ void MipiRgb::update() {
} else {
this->stop_poller();
}
this->flush_dirty_();
}
void MipiRgb::flush_dirty_() {
if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_)
return;
ESP_LOGV(TAG, "x_low %d, y_low %d, x_high %d, y_high %d", this->x_low_, this->y_low_, this->x_high_, this->y_high_);
@@ -210,12 +214,11 @@ void MipiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
if (w <= 0 || h <= 0 || this->is_failed())
return;
// if color mapping is required, pass the buck.
// note that endianness is not considered here - it is assumed to match!
if (bitness != display::COLOR_BITNESS_565) {
// The panel takes big endian 565 in its own orientation; anything else goes through the base
// class, which converts and rotates one pixel at a time into the buffer, then gets flushed.
if (bitness != display::COLOR_BITNESS_565 || !big_endian || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES) {
Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
this->write_to_display_(x_start, y_start, w, h, reinterpret_cast<const uint8_t *>(this->buffer_), x_start, y_start,
this->width_ - w - x_start);
this->flush_dirty_();
} else {
this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad);
}
+2
View File
@@ -70,6 +70,8 @@ class MipiRgb : public display::Display {
void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset,
int x_pad);
bool check_buffer_();
/// Send the region touched since the last flush to the panel.
void flush_dirty_();
void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset);
void setup_enables_();
void common_setup_();
+10 -16
View File
@@ -119,19 +119,8 @@ class MipiSpi : public display::Display,
}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width() override {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return HEIGHT;
return WIDTH;
}
int get_height() override {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return WIDTH;
return HEIGHT;
}
int get_width() override { return display::rotation_swaps_axes(this->rotation_) ? HEIGHT : WIDTH; }
int get_height() override { return display::rotation_swaps_axes(this->rotation_) ? WIDTH : HEIGHT; }
void set_init_sequence(const std::vector<uint8_t> &sequence) { this->init_sequence_ = sequence; }
@@ -199,9 +188,11 @@ class MipiSpi : public display::Display,
return;
if (w <= 0 || h <= 0)
return;
if (get_pixel_mode(bitness) != BUFFERPIXEL || big_endian != IS_BIG_ENDIAN) {
// note that the usual logging macros are banned in header files, so use their replacement
esph_log_e(TAG, "Unsupported color depth or bit order");
if (get_pixel_mode(bitness) != BUFFERPIXEL || big_endian != IS_BIG_ENDIAN ||
(!HAS_HARDWARE_ROTATION && this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES)) {
// Not the buffer's own format, or rotated in software: go per pixel through draw_pixel_at()
display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset,
x_pad);
return;
}
this->write_to_display_(x_start, y_start, w, h, reinterpret_cast<const BUFFERTYPE *>(ptr), x_offset, y_offset,
@@ -602,6 +593,9 @@ class MipiSpiBuffer
#endif
}
// With hardware rotation the buffer is written in drawn coordinates.
bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); }
// Draw a pixel at the given coordinates.
void draw_pixel_at(int x, int y, Color color) override {
if (this->is_point_clipped(x, y))
+6 -12
View File
@@ -5,12 +5,11 @@ from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ENCRYPTION, CONF_KEY
from esphome.core import CORE, ID
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "noise"
noise_ns = cg.esphome_ns.namespace("noise")
@@ -71,16 +70,11 @@ def static_encryption_key(conf: ConfigType) -> str | None:
def new_psk_progmem(parent_id: ID, key: str) -> MockObj:
"""Emit the decoded key as a PROGMEM array; the component keeps a pointer
so the key never occupies RAM. Components sharing one key (api and ota)
share the array."""
decoded = decode_encryption_key(key)
arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {})
if (array := arrays.get(decoded)) is None:
array = arrays[decoded] = cg.progmem_array(
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
list(decoded),
)
return array
so the key never occupies RAM."""
return cg.progmem_array(
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
list(decode_encryption_key(key)),
)
def encryption_schema(config: ConfigType | None) -> ConfigType:
+2 -1
View File
@@ -139,7 +139,8 @@ void QspiDbi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8
if (w <= 0 || h <= 0)
return;
if (bitness != display::COLOR_BITNESS_565 || order != this->color_mode_ ||
big_endian != (this->bit_order_ == spi::BIT_ORDER_MSB_FIRST)) {
big_endian != (this->bit_order_ == spi::BIT_ORDER_MSB_FIRST) ||
this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES) {
Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
return;
} else if (this->draw_from_origin_) {
@@ -51,13 +51,18 @@ void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uin
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
if (w <= 0 || h <= 0)
return;
// if color mapping is required, pass the buck.
// note that endianness is not considered here - it is assumed to match!
if (bitness != display::COLOR_BITNESS_565) {
// The panel takes big endian 565 in its own orientation; anything else goes through the base
// class, which converts and rotates one pixel at a time.
if (bitness != display::COLOR_BITNESS_565 || !big_endian || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES) {
display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset,
x_pad);
return;
}
this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad);
}
void RpiDpiRgb::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset,
int y_offset, int x_pad) {
x_start += this->offset_x_;
y_start += this->offset_y_;
esp_err_t err = ESP_OK;
@@ -122,8 +127,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) {
}
auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color));
this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true,
0, 0, 0);
this->write_to_display_(x, y, 1, 1, (const uint8_t *) &pixel, 0, 0, 0);
this->feed_wdt_per_pixel_();
}
@@ -62,6 +62,8 @@ class RpiDpiRgb final : public display::Display {
void dump_config() override;
protected:
void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset,
int x_pad);
int get_width_internal() override { return this->width_; }
int get_height_internal() override { return this->height_; }
void reset_display_() const;
@@ -41,12 +41,11 @@ inline bool is_color_on(const Color &color) {
RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Transparency transparency,
image::Image *placeholder, bool is_big_endian, int fixed_width, int fixed_height)
: Image(nullptr, 0, 0, type, transparency),
: Image(nullptr, 0, 0, type, transparency, is_big_endian),
format_(format),
fixed_width_(fixed_width),
fixed_height_(fixed_height),
placeholder_(placeholder),
is_big_endian_(is_big_endian) {}
placeholder_(placeholder) {}
RuntimeImage::~RuntimeImage() { this->release(); }
@@ -121,7 +120,7 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
Color mapped_color = color;
this->map_chroma_key(mapped_color);
uint16_t rgb565 = display::ColorUtil::color_to_565(mapped_color);
if (this->is_big_endian_) {
if (this->big_endian_) {
this->buffer_[pos + 0] = static_cast<uint8_t>((rgb565 >> 8) & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>(rgb565 & 0xFF);
} else {
@@ -227,11 +227,6 @@ class RuntimeImage : public image::Image {
// Configuration
bool progressive_display_{false};
/**
* Whether the image is stored in big-endian format.
* This is used to determine how to store 16 bit colors in the buffer.
*/
bool is_big_endian_{false};
/** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */
bool external_buffer_{false};
};
+1 -1
View File
@@ -211,7 +211,7 @@ bool Snapshot::take_snapshot(const char *filename) {
exact = !name.empty();
if (name_changed) {
ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename,
name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str());
name.empty() ? "a name made from the time" : name.c_str());
}
}
if (name.empty()) {
+1 -5
View File
@@ -185,11 +185,7 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
return 0;
}
#else
// inet_aton() reports failure separately from the address, so 255.255.255.255 needs no special case
if (inet_aton(ip_address, &server->sin_addr) == 0) {
errno = EINVAL;
return 0;
}
server->sin_addr.s_addr = inet_addr(ip_address);
#endif
server->sin_port = htons(port);
return sizeof(sockaddr_in);
+9 -5
View File
@@ -54,13 +54,18 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
if (w <= 0 || h <= 0)
return;
// if color mapping is required, pass the buck.
// note that endianness is not considered here - it is assumed to match!
if (bitness != display::COLOR_BITNESS_565) {
// The panel takes big endian 565 in its own orientation; anything else goes through the base
// class, which converts and rotates one pixel at a time.
if (bitness != display::COLOR_BITNESS_565 || !big_endian || this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES) {
display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset,
x_pad);
return;
}
this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad);
}
void ST7701S::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset,
int x_pad) {
x_start += this->offset_x_;
y_start += this->offset_y_;
esp_err_t err;
@@ -105,8 +110,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) {
}
auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color));
this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true,
0, 0, 0);
this->write_to_display_(x, y, 1, 1, (const uint8_t *) &pixel, 0, 0, 0);
this->feed_wdt_per_pixel_();
}
+2
View File
@@ -77,6 +77,8 @@ class ST7701S final : public display::Display,
// this will be horribly slow.
protected:
void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset,
int x_pad);
void write_command_(uint8_t value);
void write_data_(uint8_t value);
void write_sequence_(uint8_t cmd, size_t len, const uint8_t *bytes);
+1 -11
View File
@@ -4,7 +4,7 @@ from esphome.components.logger import LOG_LEVELS, is_log_level, request_log_list
from esphome.components.time import RealTimeClock
from esphome.components.udp import CONF_UDP_ID
import esphome.config_validation as cv
from esphome.const import CONF_FORMAT, CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID
from esphome.const import CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID
from esphome.cpp_types import Component, Parented
from esphome.types import ConfigType
@@ -14,12 +14,6 @@ DEPENDENCIES = ["udp", "logger", "time"]
syslog_ns = cg.esphome_ns.namespace("syslog")
Syslog = syslog_ns.class_("Syslog", Component, Parented.template(udp.UDPComponent))
SyslogFormat = syslog_ns.enum("SyslogFormat")
SYSLOG_FORMATS = {
"RFC3164": SyslogFormat.SYSLOG_FORMAT_RFC3164,
"RFC5424": SyslogFormat.SYSLOG_FORMAT_RFC5424,
}
CONF_STRIP = "strip"
CONF_FACILITY = "facility"
@@ -31,9 +25,6 @@ CONFIG_SCHEMA = udp.UDP_SCHEMA.extend(
cv.Optional(CONF_LEVEL, default="DEBUG"): is_log_level,
cv.Optional(CONF_STRIP, default=True): cv.boolean,
cv.Optional(CONF_FACILITY, default=16): cv.int_range(0, 23),
cv.Optional(CONF_FORMAT, default="RFC3164"): cv.enum(
SYSLOG_FORMATS, upper=True
),
}
)
@@ -50,4 +41,3 @@ async def to_code(config: ConfigType) -> None:
await cg.register_parented(var, parent)
cg.add(var.set_strip(config[CONF_STRIP]))
cg.add(var.set_facility(config[CONF_FACILITY]))
cg.add(var.set_format(config[CONF_FORMAT]))
+14 -24
View File
@@ -49,37 +49,27 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t
// Build syslog packet on stack (508 bytes chosen as practical limit for syslog over UDP)
char packet[508];
size_t offset = 0;
size_t remaining = sizeof(packet);
// Write PRI - abort if this fails as packet would be malformed
offset = buf_append_printf(packet, sizeof(packet), 0, "<%d>", pri);
if (offset == 0) {
return; // PRI always produces at least "<0>" (3 chars), so 0 means error
}
auto now = this->time_->now();
if (this->format_ == SYSLOG_FORMAT_RFC5424) {
offset = buf_append_str(packet, sizeof(packet), offset, "1 ");
remaining -= offset;
char timestamp[32];
size_t timestamp_len = now.is_valid() ? now.strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S%z") : 0;
if (timestamp_len == 24) {
// ESPTime formats the numeric offset as +HHMM. RFC 3339 requires +HH:MM.
timestamp[25] = '\0';
timestamp[24] = timestamp[23];
timestamp[23] = timestamp[22];
timestamp[22] = ':';
offset = buf_append_printf(packet, sizeof(packet), offset, "%s", timestamp);
} else {
offset = buf_append_str(packet, sizeof(packet), offset, "-");
}
offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s - - - %.*s", App.get_name().c_str(), tag,
(int) len, message);
} else {
// RFC 3164 has no NILVALUE. If the clock is invalid, omit TIMESTAMP so a relay can add it.
if (now.is_valid()) {
offset += now.strftime(packet + offset, sizeof(packet) - offset, "%b %e %H:%M:%S ");
}
offset = buf_append_printf(packet, sizeof(packet), offset, "%s %s: %.*s", App.get_name().c_str(), tag, (int) len,
message);
// Write timestamp directly into packet (RFC 5424: use "-" if time not valid or strftime fails)
auto now = this->time_->now();
size_t ts_written = now.is_valid() ? now.strftime(packet + offset, remaining, "%b %e %H:%M:%S") : 0;
if (ts_written > 0) {
offset += ts_written;
} else if (remaining > 0) {
packet[offset++] = '-';
}
// Write hostname, tag, and message
offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len,
message);
// Clamp to exclude null terminator position if buffer was filled
if (offset >= sizeof(packet)) {
offset = sizeof(packet) - 1;
@@ -7,11 +7,6 @@
#ifdef USE_NETWORK
namespace esphome::syslog {
enum SyslogFormat : uint8_t {
SYSLOG_FORMAT_RFC3164,
SYSLOG_FORMAT_RFC5424,
};
class Syslog final : public Component, public Parented<udp::UDPComponent> {
public:
Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {}
@@ -19,7 +14,6 @@ class Syslog final : public Component, public Parented<udp::UDPComponent> {
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len);
void set_strip(bool strip) { this->strip_ = strip; }
void set_facility(int facility) { this->facility_ = facility; }
void set_format(SyslogFormat format) { this->format_ = format; }
protected:
int log_level_;
@@ -27,7 +21,6 @@ class Syslog final : public Component, public Parented<udp::UDPComponent> {
time::RealTimeClock *time_;
bool strip_{true};
int facility_{16};
SyslogFormat format_{SYSLOG_FORMAT_RFC3164};
};
} // namespace esphome::syslog
#endif
+4 -4
View File
@@ -200,10 +200,10 @@ void DeferredUpdateEventSource::process_deferred_queue_() {
deferred_queue_.erase(deferred_queue_.begin());
this->consecutive_send_failures_ = 0; // Reset failure count on successful send
} else {
// NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_().
// The close mechanisms are platform-specific (this path calls close() directly; the IDF path is time-based and
// closes through HTTPD to preserve session ownership), but both drop a client after roughly 20 seconds without
// send progress. Keep that stall policy in sync when changing either side.
// NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_()
// The implementations differ due to platform-specific APIs (DISCARDED vs HTTPD_SOCK_ERR_TIMEOUT, close() vs
// fd_.store(0)), but the failure counting and timeout logic should be kept in sync. If you change this logic,
// also update the ESP-IDF implementation.
this->consecutive_send_failures_++;
if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) {
// Too many failures, connection is likely dead
@@ -6,7 +6,6 @@
#include <cctype>
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -729,7 +728,7 @@ bool AsyncEventSource::loop() {
for (size_t i = 0; i < this->sessions_.size();) {
auto *ses = this->sessions_[i];
// If the session has a dead socket (marked by destroy callback)
if (ses->safe_to_delete_()) {
if (ses->fd_.load() == 0) {
// destroy() already logged the close with the fd; don't double-log here.
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
// Remove by swapping with last element (O(1) removal, order doesn't matter for sessions)
@@ -752,7 +751,7 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() {
}
for (auto *rsp : incoming) {
// Already disconnected? Drop it; skip on_connect_/session start on a dead session.
if (rsp->safe_to_delete_()) {
if (rsp->fd_.load() == 0) {
delete rsp; // NOLINT(cppcoreguidelines-owning-memory)
continue;
}
@@ -866,16 +865,10 @@ void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_g
}
void AsyncEventSourceResponse::process_deferred_queue_() {
if (this->close_requested_) {
return;
}
while (!deferred_queue_.empty()) {
DeferredEvent &de = deferred_queue_.front();
auto message = de.message_generator_(web_server_, de.source_);
if (this->try_send_nodefer(message.c_str(), message.size(), "state")) {
if (this->close_requested_ || deferred_queue_.empty()) {
return;
}
// O(n) but memory efficiency is more important than speed here which is why std::vector was chosen
deferred_queue_.erase(deferred_queue_.begin());
} else {
@@ -884,64 +877,8 @@ void AsyncEventSourceResponse::process_deferred_queue_() {
}
}
void AsyncEventSourceResponse::request_close_() {
if (!this->close_requested_) {
this->close_requested_ = true;
this->deferred_queue_.clear();
this->event_buffer_.clear();
this->event_bytes_sent_ = 0;
this->next_close_attempt_ms_ = App.get_loop_component_start_time();
}
this->process_close_();
}
void AsyncEventSourceResponse::process_close_() {
if (!this->close_requested_ || this->close_work_queued_.load(std::memory_order_acquire)) {
return;
}
const int fd = this->fd_.load();
if (fd == 0) {
return;
}
const uint32_t now = App.get_loop_component_start_time();
if (static_cast<int32_t>(now - this->next_close_attempt_ms_) < 0) {
return;
}
// Queue an identity-checked shutdown on the HTTPD task. The public
// httpd_sess_trigger_close() queues only a reusable fd/session slot and can
// therefore close a new client if the original peer disconnects meanwhile.
this->close_work_queued_.store(true, std::memory_order_release);
const esp_err_t err = httpd_queue_work(this->hd_, &AsyncEventSourceResponse::close_session_work, this);
this->next_close_attempt_ms_ = now + (err == ESP_OK ? CLOSE_CONFIRM_INTERVAL_MS : CLOSE_RETRY_INTERVAL_MS);
if (err == ESP_OK) {
return;
}
this->close_work_queued_.store(false, std::memory_order_release);
if (!this->close_retry_warning_logged_) {
ESP_LOGW(TAG, "Failed to queue EventSource close (%s); retrying", esp_err_to_name(err));
this->close_retry_warning_logged_ = true;
}
}
void AsyncEventSourceResponse::close_session_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
const int fd = response->fd_.load();
if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) {
// The HTTPD task remains the session owner. Shutting the socket down makes
// its next select/recv path delete the session and invoke destroy().
shutdown(fd, SHUT_RDWR);
}
// Release self only after the HTTPD-task callback has finished every access.
response->close_work_queued_.store(false, std::memory_order_release);
}
void AsyncEventSourceResponse::process_buffer_() {
if (this->close_requested_ || event_buffer_.empty()) {
if (event_buffer_.empty()) {
return;
}
if (event_bytes_sent_ == event_buffer_.size()) {
@@ -955,33 +892,32 @@ void AsyncEventSourceResponse::process_buffer_() {
httpd_socket_send(this->hd_, this->fd_.load(), event_buffer_.c_str() + event_bytes_sent_, remaining, 0);
if (bytes_sent == HTTPD_SOCK_ERR_TIMEOUT) {
// EAGAIN/EWOULDBLOCK - socket buffer full, try again later
// NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_().
// The IDF path is intentionally time-based and closes through HTTPD to preserve session ownership.
const uint32_t now = App.get_loop_component_start_time();
if (this->send_failure_started_ms_ == 0) {
this->send_failure_started_ms_ = now != 0 ? now : 1; // Reserve zero for no stall.
}
if (static_cast<int32_t>(now - (this->send_failure_started_ms_ + SEND_STALL_TIMEOUT_MS)) >= 0) {
ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu32 " ms without send progress",
now - this->send_failure_started_ms_);
this->request_close_();
// NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_()
// The implementations differ due to platform-specific APIs (HTTPD_SOCK_ERR_TIMEOUT vs DISCARDED, fd_.store(0) vs
// close()), but the failure counting and timeout logic should be kept in sync. If you change this logic, also
// update the Arduino implementation.
this->consecutive_send_failures_++;
if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) {
// Too many failures, connection is likely dead
ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu16 " failed sends",
this->consecutive_send_failures_);
this->fd_.store(0); // Mark for cleanup
this->deferred_queue_.clear();
}
return;
}
if (bytes_sent == HTTPD_SOCK_ERR_FAIL) {
// Low-level asynchronous sends do not make HTTPD close the session automatically.
this->request_close_();
// Real socket error - connection will be closed by httpd and destroy callback will be called
return;
}
if (bytes_sent <= 0) {
// Unexpected error or zero bytes sent
ESP_LOGW(TAG, "Unexpected send result: %d", bytes_sent);
this->request_close_();
return;
}
// Successful send - reset stall tracking
this->send_failure_started_ms_ = 0;
// Successful send - reset failure counter
this->consecutive_send_failures_ = 0;
event_bytes_sent_ += bytes_sent;
// Log partial sends for debugging
@@ -997,26 +933,20 @@ void AsyncEventSourceResponse::process_buffer_() {
}
void AsyncEventSourceResponse::loop() {
if (this->close_requested_) {
this->process_close_();
return;
}
process_buffer_();
process_deferred_queue_();
if (this->close_requested_)
return;
// One step per loop; refusals retry next pass
this->entities_iterator_.try_advance(1);
}
bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
uint32_t reconnect) {
if (this->fd_.load() == 0 || this->close_requested_) {
if (this->fd_.load() == 0) {
return false;
}
process_buffer_();
if (this->close_requested_ || !event_buffer_.empty()) {
if (!event_buffer_.empty()) {
// there is still pending event data to send first
return false;
}
@@ -1168,10 +1098,6 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e
process_buffer_();
process_deferred_queue_();
if (this->close_requested_) {
return;
}
if (!event_buffer_.empty() || !deferred_queue_.empty()) {
// outgoing event buffer or deferred queue still not empty which means downstream tcp send buffer full, no point
// trying to send first
@@ -301,14 +301,6 @@ class AsyncEventSourceResponse {
void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator);
void process_deferred_queue_();
void process_buffer_();
void request_close_();
void process_close_();
static void close_session_work(void *arg);
// Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object.
bool safe_to_delete_() const {
return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire);
}
static void destroy(void *p);
AsyncEventSource *server_;
@@ -319,16 +311,8 @@ class AsyncEventSourceResponse {
esphome::web_server::ListEntitiesIterator entities_iterator_;
std::string event_buffer_;
size_t event_bytes_sent_;
uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress.
uint32_t next_close_attempt_ms_{0};
// Main-loop only; the HTTPD task never reads or writes this flag.
bool close_requested_{false};
bool close_retry_warning_logged_{false};
// Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done.
std::atomic<bool> close_work_queued_{false};
static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000;
static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250;
static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000;
uint16_t consecutive_send_failures_{0};
static constexpr uint16_t MAX_CONSECUTIVE_SEND_FAILURES = 2500; // ~20 seconds at 125Hz loop rate
};
using AsyncEventSourceClient = AsyncEventSourceResponse;
@@ -10,7 +10,6 @@ from esphome.components.esp32 import (
add_idf_sdkconfig_option,
add_partition,
include_builtin_idf_component,
require_mbedtls_tls_extras,
require_vfs_select,
)
import esphome.config_validation as cv
@@ -290,12 +289,6 @@ async def esp32_to_code(config: ConfigType) -> "MockObj":
ref="2.0.4",
)
# Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly.
# Keep the esp32 component from trimming them out of mbedTLS.
require_mbedtls_tls_extras(
("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC")
)
if CONF_WIFI in CORE.config:
# zigbee_esp32.cpp uses esp_coexist.h when WiFi is present
include_builtin_idf_component("esp_coex")
+1
View File
@@ -283,6 +283,7 @@
#define USE_RUNTIME_STATS
#define USE_OTA
#define USE_OTA_ENCRYPTION
#define USE_OTA_ENCRYPTION_FROM_API
#define USE_OTA_ENCRYPTION_PROVISIONED
#define USE_OTA_ENCRYPTION_REQUIRED
#define USE_OTA_PASSWORD
+14 -85
View File
@@ -74,64 +74,6 @@ FILTER_IDF_LINES: list[str] = [
r"Stopping at filesystem boundary",
]
# Windows code page identifier for UTF-8, as used by ``chcp 65001``.
UTF8_CODEPAGE = 65001
def _get_kernel32():
"""Return the Windows kernel32 module, or None on any other platform."""
if sys.platform != "win32":
return None
import ctypes
return ctypes.windll.kernel32
class _Utf8Console:
"""Keep an attached Windows console on UTF-8 for the length of the build.
The build tree runs in UTF-8 mode, so esp_idf_size draws its table with
Unicode box characters. ``idf.py size`` reaches it through ``cmake -P``,
and CMake re-decodes the child's output with the console code page before
printing it, which turns the table into mojibake on any code page but
UTF-8. Every process in the build shares this console, so switching it
here covers CMake too. The old code pages go back on exit so the user's
terminal is left as it was.
A console that is already on UTF-8 is left alone. The code page belongs to
the console, not to this process, so a build that overlaps another one
must not save UTF-8 as the page to go back to.
"""
def __init__(self, kernel32) -> None:
self._kernel32 = kernel32
self._codepages: tuple[int, int] | None = None
def __enter__(self) -> None:
kernel32 = self._kernel32
if kernel32 is None:
return
old_in = kernel32.GetConsoleCP()
old_out = kernel32.GetConsoleOutputCP()
# Both calls return 0 when no console is attached.
if not old_in or not old_out:
return
if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE:
return
# Record the old pages first so a switch that fails part way through
# still gets put back on exit.
self._codepages = (old_in, old_out)
kernel32.SetConsoleCP(UTF8_CODEPAGE)
kernel32.SetConsoleOutputCP(UTF8_CODEPAGE)
def __exit__(self, *exc_info: object) -> None:
if self._codepages is None:
return
old_in, old_out = self._codepages
self._codepages = None
self._kernel32.SetConsoleCP(old_in)
self._kernel32.SetConsoleOutputCP(old_out)
def main() -> int:
# ---- sys.path fix-up ---------------------------------------------------
@@ -327,29 +269,8 @@ def main() -> int:
is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:])
filter_lines = None if is_verbose else FILTER_IDF_LINES or None
class _FilteredStreams:
"""Route ``sys.stdout`` and ``sys.stderr`` through the filtering shims.
On exit each shim releases a last line that never got its
terminator. The shims made here are drained rather than whatever
``sys.stdout`` holds by then, which the script is free to replace.
"""
def __init__(self, filter_lines: list[str] | None) -> None:
self._stdout = _FilteringTTYStream(sys.stdout, filter_lines)
self._stderr = _FilteringTTYStream(sys.stderr, filter_lines)
def __enter__(self) -> None:
sys.stdout = self._stdout # type: ignore[assignment]
sys.stderr = self._stderr # type: ignore[assignment]
def __exit__(self, *exc_info: object) -> None:
# Drain stderr from a finally so a surprise from the first one
# cannot strand the second.
try:
self._stdout.drain()
finally:
self._stderr.drain()
stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment]
stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment]
# Shift argv so the target script sees its own path as argv[0] and
# its own arguments starting at argv[1]. runpy.run_path does not
@@ -367,11 +288,19 @@ def main() -> int:
# If idf.py calls sys.exit(), SystemExit propagates out of run_path
# and carries the exit code back to our caller. For normal returns,
# fall through and exit with 0. Either way the context managers drain
# the streams and put the console back on the way out, and they report
# instead of raising so cleanup cannot bury the real exit code.
with _FilteredStreams(filter_lines), _Utf8Console(_get_kernel32()):
# fall through and exit with 0. Either way the streams get a chance to
# release a last line that never got its terminator. Drain the shims we
# made rather than sys.stdout, which the script is free to replace, and
# report instead of raising so cleanup cannot bury the real exit code.
try:
runpy.run_path(script_path, run_name="__main__")
finally:
# Drain stderr from a finally so a surprise from the first one cannot
# strand the second.
try:
stdout_shim.drain()
finally:
stderr_shim.drain()
return 0
+1 -1
View File
@@ -272,7 +272,7 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger):
num = conf[CONF_NUMBER]
if num in strapping_pin_list and not conf.get(CONF_IGNORE_STRAPPING_WARNING):
logger.warning(
"GPIO%s is a strapping pin and should only be used for I/O with care.\n"
"GPIO%s is a strapping PIN and should only be used for I/O with care.\n"
"Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n"
"See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins",
num,
-5
View File
@@ -56,7 +56,6 @@ TXT_RECORD_PROJECT_VERSION = b"project_version"
TXT_RECORD_NETWORK = b"network"
TXT_RECORD_FRIENDLY_NAME = b"friendly_name"
TXT_RECORD_VERSION = b"version"
TXT_RECORD_OTA_SIGNED = b"ota_signed"
@dataclass
@@ -77,8 +76,6 @@ class DiscoveredImport:
project_name: str
project_version: str
network: str
# Defaults False so entries persisted before this field still load.
ota_signed: bool = False
class DashboardBrowser(AsyncServiceBrowser):
@@ -172,7 +169,6 @@ class DashboardImportDiscovery:
project_name = info.properties[TXT_RECORD_PROJECT_NAME].decode()
project_version = info.properties[TXT_RECORD_PROJECT_VERSION].decode()
network = info.properties.get(TXT_RECORD_NETWORK, b"wifi").decode()
ota_signed = info.properties.get(TXT_RECORD_OTA_SIGNED) == b"1"
friendly_name = info.properties.get(TXT_RECORD_FRIENDLY_NAME)
if friendly_name is not None:
friendly_name = friendly_name.decode()
@@ -184,7 +180,6 @@ class DashboardImportDiscovery:
project_name=project_name,
project_version=project_version,
network=network,
ota_signed=ota_signed,
)
is_new = name not in self.import_state
self.import_state[name] = discovered
+3 -3
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.4.0
click==8.3.3
aioesphomeapi==46.5.0
aioesphomeapi==46.4.1
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.3
puremagic==2.2.0
@@ -27,9 +27,9 @@ bleak==3.0.2
smpclient==7.2.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.11.11 # native esp-idf toolchain global cache dir
platformdirs==4.11.8 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native esp8266 arduino toolchain build driver
filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
filelock==3.32.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.2
+1 -1
View File
@@ -1,6 +1,6 @@
pylint==4.0.8
flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.16.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.16.7 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
prek==0.5.3 # .github/workflows/ci.yml reads this pin
yamlrocks==0.6.1 # used by script/sync_dependency_versions.py
+6 -140
View File
@@ -3,7 +3,6 @@
import argparse
import codecs
import collections
from collections.abc import Iterator
import fnmatch
import functools
import os.path
@@ -1139,56 +1138,7 @@ def lint_no_std_bind(fname, match):
)
LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(")
# Comments, raw/plain string literals and single char literals are consumed whole so ; ( ) ? :
# inside them are never seen. A char literal is exactly one (escaped) char so a digit separator
# like 1'000'000 cannot open one.
CPP_COMMENT_RE = r"//[^\n]*|/\*.*?\*/"
CPP_SKIP_RE = (
CPP_COMMENT_RE
+ r'|R"(?P<raw_delim>[^(\s]*)\(.*?\)(?P=raw_delim)"|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\\n]|\\.)\''
)
LOG_CALL_TOKEN_RE = re.compile(CPP_SKIP_RE + r"|[()]", re.DOTALL)
# The last alternative matches a ? or : followed (after spaces or comments) by an opening quote,
# i.e. a string literal used as a ternary branch.
LOG_TERNARY_LITERAL_RE = re.compile(
CPP_SKIP_RE + r"|[?:](?:\s|" + CPP_COMMENT_RE + r')*(?=")', re.DOTALL
)
# A bare NOLINT; a clang-tidy NOLINT(check-name) is aimed at a different tool.
NOLINT_RE = re.compile(r"\bNOLINT\b(?!\()")
def _line_col(content: str, pos: int) -> tuple[int, int]:
"""1-based line and column of an offset in content."""
return content.count("\n", 0, pos) + 1, pos - content.rfind("\n", 0, pos)
def _iter_log_calls(content: str) -> Iterator[tuple[int, str | None]]:
"""Yield (start, text) for every ESP_LOG*(...) call, text running to the matching close paren.
text is None when no matching paren exists so callers can report the call instead of skipping it."""
for head in LOG_CALL_START_RE.finditer(content):
depth = 1
for tok in LOG_CALL_TOKEN_RE.finditer(content, head.end()):
if tok.group(0) == "(":
depth += 1
elif tok.group(0) == ")":
depth -= 1
if depth == 0:
yield head.start(), content[head.start() : tok.end()]
break
else:
yield head.start(), None
def _unbalanced_log_call_error(content: str, pos: int) -> tuple[int, int, str]:
lineno, col = _line_col(content, pos)
return (
lineno,
col,
"ESP_LOG call has no matching closing parenthesis, so it cannot be checked.",
)
LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL)
LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])')
LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)')
@@ -1196,16 +1146,16 @@ LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)')
@lint_content_check(include=cpp_include)
def lint_log_multiline_continuation(fname, content):
errs = []
for log_start, log_text in _iter_log_calls(content):
if log_text is None:
errs.append(_unbalanced_log_call_error(content, log_start))
continue
for log_match in LOG_MULTILINE_RE.finditer(content):
log_text = log_match.group(0)
for bad_match in LOG_BAD_CONTINUATION_RE.finditer(log_text):
# %s may expand to a whitespace prefix at runtime, skip those
if LOG_PERCENT_S_CONTINUATION_RE.match(log_text, bad_match.start()):
continue
# Calculate line number from position in full content
lineno, col = _line_col(content, log_start + bad_match.start())
abs_pos = log_match.start() + bad_match.start()
lineno = content.count("\n", 0, abs_pos) + 1
col = abs_pos - content.rfind("\n", 0, abs_pos)
errs.append(
(
lineno,
@@ -1223,90 +1173,6 @@ def lint_log_multiline_continuation(fname, content):
return errs
def _find_ternary_literals(text: str) -> Iterator[tuple[int, str]]:
"""Yield (offset, literal) for every string literal used as a ternary branch."""
branch = False
for m in LOG_TERNARY_LITERAL_RE.finditer(text):
tok = m.group(0)
# An empty literal is merged with every other string's terminator, so it costs no RAM,
# while a PSTR("") would add its own flash array; leave it alone.
if branch and tok[0] == '"' and tok != '""':
yield m.start(), tok
branch = tok[0] in "?:"
# LOG_STR_LITERAL is a no op everywhere except ESP8266, so code that never builds there is skipped
# to avoid churn: platform specific sources and components for ESP32, LibreTiny, RP2 and Zephyr only.
# A component belongs here only if it has no tests/components/<name>/test.esp8266-ard.yaml.
LOG_LITERAL_LINT_EXCLUDE = [
"*_esp32.cpp",
"*_esp32_*.cpp",
"*_esp_idf.cpp",
"*_rmt.cpp",
"*_zephyr.cpp",
"*_bk72xx.cpp",
"*_libretiny.cpp",
"*_pico_w.cpp",
"*_host.cpp",
"esphome/components/esp32*/*",
"esphome/components/bk72xx*/*",
"esphome/components/ln882h*/*",
"esphome/components/ln882x*/*",
"esphome/components/rp2*/*",
"esphome/components/zephyr*/*",
"esphome/components/host/*",
"esphome/components/libretiny*/*",
"esphome/components/bluetooth_proxy/*",
"esphome/components/bluetooth_connection/*",
"esphome/components/ble_client/*",
"esphome/components/bedjet/*",
"esphome/components/anova/*",
"esphome/components/xiaomi_ble/*",
"esphome/components/bthome_mithermometer/*",
"esphome/components/usb_host/*",
"esphome/components/zigbee/*",
"esphome/components/lvgl/*",
# Test fixtures and host only unit tests - not production embedded code
"tests/integration/fixtures/*",
"tests/components/*",
]
@lint_content_check(include=cpp_include, exclude=LOG_LITERAL_LINT_EXCLUDE)
def lint_log_no_bare_literal_ternary(
fname: Path, content: str
) -> list[tuple[int, int, str]]:
errs = []
for log_start, log_text in _iter_log_calls(content):
if log_text is None:
continue # reported by lint_log_multiline_continuation, which sees every file
# A NOLINT anywhere on the lines the call spans silences every branch in it
first_line = content.rfind("\n", 0, log_start) + 1
last_line = content.find("\n", log_start + len(log_text))
if NOLINT_RE.search(
content[first_line : last_line if last_line != -1 else None]
):
continue
for offset, literal in _find_ternary_literals(log_text):
lineno, col = _line_col(content, log_start + offset)
errs.append(
(
lineno,
col,
(
"String literal used as a ternary branch in a log call. On ESP8266 the "
"log macro moves the format string to flash, but bare literal arguments "
"stay in RAM. Wrap each branch passed straight to the log call in "
f"{highlight('LOG_STR_LITERAL(...)')}:\n"
f" Before: {highlight(literal)}\n"
f" After: {highlight(f'LOG_STR_LITERAL({literal})')}\n"
f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)"
),
)
)
return errs
@lint_content_find_check(
"ESP_LOG",
include=["*.h", "*.tcc"],
@@ -0,0 +1,17 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The column walk in Image::draw() is only built with PSRAM; define it so
# the rotated benchmark exercises that loop order on the host.
manifest.enable_codegen()
original_to_code = manifest.to_code
async def to_code(config):
await original_to_code(config)
cg.add_define("USE_PSRAM")
if hasattr(original_to_code, "priority"):
to_code.priority = original_to_code.priority
manifest.to_code = to_code
@@ -0,0 +1,159 @@
#include <benchmark/benchmark.h>
#include <cstdint>
#include <cstring>
#include <memory>
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/image/image.h"
namespace esphome::image::benchmarks {
static constexpr int kWidth = 128;
static constexpr int kHeight = 96;
// Square frame so a rotated draw of the image stays inside it.
static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight;
// One byte per pixel frame buffer with no hardware behind it, so the
// measurement is Image::draw() plus the DisplayBuffer pixel path every
// buffered display shares. Everything fits the host cache, so this counts
// the per pixel work; the memory locality win only shows on hardware.
class BenchDisplay : public display::DisplayBuffer {
public:
BenchDisplay() : frame_(std::make_unique<uint8_t[]>(kStride * kStride)) {}
void update() override {}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return kStride; }
int get_height_internal() override { return kStride; }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; }
std::unique_ptr<uint8_t[]> frame_;
public:
uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; }
};
// Models a driver with a bulk path: rows of little endian RGB565 are copied
// straight into a 16 bit frame, as the TFT drivers do for their own format.
class BulkBenchDisplay : public BenchDisplay {
public:
BulkBenchDisplay() : frame565_(std::make_unique<uint16_t[]>(kStride * kStride)) {}
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override {
if (bitness != display::COLOR_BITNESS_565 || big_endian) {
BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
return;
}
const size_t stride = x_offset + w + x_pad;
for (int y = 0; y < h; y++) {
memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2,
w * 2);
}
}
protected:
std::unique_ptr<uint16_t[]> frame565_;
};
// Draws the image at rotation 0 and 90 and checks the second frame is the
// transpose of the first, which is what a wrong traversal order would break.
static bool frames_match(const Image &image) {
BenchDisplay plain;
BenchDisplay rotated;
rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES);
const_cast<Image &>(image).draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF);
const_cast<Image &>(image).draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF);
for (int y = 0; y < kHeight; y++) {
for (int x = 0; x < kWidth; x++) {
// DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees
if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x))
return false;
}
}
return true;
}
// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of
// pixels. Sized for the widest format, the image reads only what it needs.
static std::unique_ptr<uint8_t[]> make_pixels() {
constexpr size_t bytes = kWidth * kHeight * 4;
auto data = std::make_unique<uint8_t[]>(bytes);
uint32_t seed = 11897;
for (size_t i = 0; i < bytes; i++) {
seed = seed * 1103515245u + 12345u;
data[i] = static_cast<uint8_t>(seed >> 16);
}
return data;
}
static void draw_image(benchmark::State &state, ImageType type, Transparency transparency,
display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) {
auto data = make_pixels();
Image image(data.get(), kWidth, kHeight, type, transparency);
BenchDisplay display;
display.set_rotation(rotation);
if (!frames_match(image)) {
state.SkipWithError("rotated draw is not the transpose of the plain draw");
return;
}
for (auto _ : state) {
image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_Binary);
static void ImageDraw_Grayscale(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE);
}
BENCHMARK(ImageDraw_Grayscale);
static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_RGB565);
static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_RGB);
// The same opaque RGB565 image on a display with a bulk path.
static void ImageDraw_RGB565_Bulk(benchmark::State &state) {
auto data = make_pixels();
Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE);
BulkBenchDisplay display;
for (auto _ : state) {
image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
BENCHMARK(ImageDraw_RGB565_Bulk);
static void ImageDraw_RGB_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB_Rotated);
static void ImageDraw_Binary_Transparent(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_Binary_Transparent);
static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_Grayscale_ChromaKey);
static void ImageDraw_Grayscale_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_Grayscale_Alpha);
static void ImageDraw_RGB_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_RGB_Alpha);
} // namespace esphome::image::benchmarks
@@ -1,11 +0,0 @@
esphome:
name: test
esp32:
board: esp32-c6-devkitc-1
framework:
type: esp-idf
zigbee:
model: zigbee_test
power_source: MAINS_SINGLE_PHASE
+4 -25
View File
@@ -1457,7 +1457,7 @@ def test_esp32_s31_gpio_validation(
pin = {CONF_NUMBER: 36, CONF_MODE: input_mode}
with caplog.at_level("WARNING"):
validate_supports(pin)
assert "GPIO36 is a strapping pin" in caplog.text
assert "GPIO36 is a strapping PIN" in caplog.text
_TLS_SERVER_OPTIONS = (
@@ -1488,7 +1488,7 @@ def test_mbedtls_tls_trim_sdkconfig(
assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras}
_CCM_ECDSA_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
@@ -1500,19 +1500,7 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None)
for name in MBEDTLS_TLS_EXTRA_OPTIONS:
assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False)
def test_mbedtls_tls_zigbee_keeps_only_what_it_uses(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The Zigbee config keeps CCM and deterministic ECDSA; the rest is trimmed."""
generate_main(component_config_path("tls_zigbee_c6.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (True, False)
for name in MBEDTLS_TLS_EXTRA_OPTIONS:
assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False)
assert sdkconfig.get(name) is (None if name in _OPENTHREAD_EXTRAS else False)
def test_mbedtls_tls_user_sdkconfig_wins(
@@ -1541,16 +1529,7 @@ def test_mbedtls_tls_openthread_requires_server_and_extras(
"""The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required."""
generate_main(component_config_path("mbedtls_tls_openthread.yaml"))
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS
def test_mbedtls_tls_zigbee_requires_extras(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The Zigbee hooks mark the CCM/deterministic ECDSA as required."""
generate_main(component_config_path("tls_zigbee_c6.yaml"))
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
+16 -10
View File
@@ -476,36 +476,43 @@ def test_static_encryption_key() -> None:
("yaml_name", "defines_present", "defines_absent"),
[
# An api key alone compiles the transport in without requiring it;
# the ota keeps its own pointer to the key so safe mode, which never
# constructs the api server, can still use it
# the device uses the api server's key, not a copy
(
"api_key_offer",
{"USE_OTA_ENCRYPTION"},
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# A password still guards plaintext uploads on an offering device
(
"api_key_offer_password",
{"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"},
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"},
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# The ota encryption block is what makes the device refuse plaintext
(
"encryption_required",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_REQUIRED",
"USE_OTA_ENCRYPTION_FROM_API",
},
{"USE_OTA_ENCRYPTION_PROVISIONED"},
),
# Without api encryption the ota key is the device's own
(
"own_key",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
{"USE_OTA_ENCRYPTION_PROVISIONED"},
{"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"},
),
# A key provisioned at runtime lives in the api server; the device
# offers with it once provisioned and never requires it
(
"runtime_api_key",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_PROVISIONED"},
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
{"USE_OTA_ENCRYPTION_REQUIRED"},
),
# No api encryption at all keeps the noise glue out of the build
@@ -515,6 +522,7 @@ def test_static_encryption_key() -> None:
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_REQUIRED",
"USE_OTA_ENCRYPTION_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
),
@@ -533,10 +541,8 @@ def test_encryption_offer_codegen(
assert defines_present <= defines
assert not (defines_absent & defines)
encrypted = "USE_OTA_ENCRYPTION" in defines_present
own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present
own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
# The api shares the ota's array instead of emitting the same key twice
assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0)
assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present)
# The noise transport source compiles only when the define is set
assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"])
+12
View File
@@ -0,0 +1,12 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The column walk in Display::draw_pixels_at() is only built with PSRAM;
# define it so the host test can run both walk orders.
async def to_code_testing(config: ConfigType) -> None:
cg.add_define("USE_PSRAM")
manifest.to_code = to_code_testing
@@ -0,0 +1,82 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <tuple>
#include <vector>
#include "esphome/components/display/display.h"
namespace esphome::display::testing {
// Records every pixel write; pixel_axes_swapped() picks the walk order.
class RecordingDisplay : public Display {
public:
void set_axes_swapped(bool swapped) { this->axes_swapped_ = swapped; }
bool pixel_axes_swapped() const override { return this->axes_swapped_; }
void draw_pixel_at(int x, int y, Color color) override { this->pixels_.emplace_back(x, y, color.raw_32); }
void update() override {}
DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return 64; }
int get_height_internal() override { return 64; }
std::vector<std::tuple<int, int, uint32_t>> sorted_pixels() const {
auto sorted = this->pixels_;
std::sort(sorted.begin(), sorted.end());
return sorted;
}
size_t count() const { return this->pixels_.size(); }
const std::vector<std::tuple<int, int, uint32_t>> &pixels() const { return this->pixels_; }
protected:
bool axes_swapped_{false};
std::vector<std::tuple<int, int, uint32_t>> pixels_;
};
// A 7x5 region with a 2 pixel left offset, a 3 line top offset and 4 pixels of
// right padding, so the source index arithmetic of both walks is exercised.
static constexpr int W = 7, H = 5, X_OFFSET = 2, Y_OFFSET = 3, X_PAD = 4;
static constexpr int LINE_STRIDE = X_OFFSET + W + X_PAD;
static constexpr int LINES = Y_OFFSET + H;
static std::vector<uint8_t> make_source(int bytes_per_pixel) {
std::vector<uint8_t> source(LINE_STRIDE * LINES * bytes_per_pixel);
uint8_t value = 1;
for (auto &byte : source)
byte = value++;
return source;
}
static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool big_endian) {
const auto source = make_source(bytes_per_pixel);
RecordingDisplay rows;
RecordingDisplay columns;
columns.set_axes_swapped(true);
rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD);
columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD);
EXPECT_EQ(rows.count(), static_cast<size_t>(W * H));
EXPECT_EQ(columns.count(), static_cast<size_t>(W * H));
EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels());
}
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); }
// The column walk really is a different order, otherwise the tests above prove nothing.
TEST(DrawPixelsAt, ColumnWalkChangesOrder) {
const auto source = make_source(1);
RecordingDisplay rows;
RecordingDisplay columns;
columns.set_axes_swapped(true);
rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD);
columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET,
X_PAD);
ASSERT_GT(rows.count(), 1u);
ASSERT_GT(columns.count(), 1u);
EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first
EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first
}
} // namespace esphome::display::testing
@@ -1,12 +0,0 @@
# No wifi, espnow or BLE: nothing else re-includes esp_wifi for the ESP-NOW shim.
esp32_hosted:
variant: ESP32C6
slot: 1
active_high: true
reset_pin: GPIO15
cmd_pin: GPIO13
clk_pin: GPIO12
d0_pin: GPIO11
d1_pin: GPIO10
d2_pin: GPIO9
d3_pin: GPIO8
+166
View File
@@ -0,0 +1,166 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/components/display/display.h"
#include "esphome/components/image/image.h"
namespace esphome::image::testing {
class TestDisplay : public display::Display {
public:
void update() override {}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return 64; }
int get_height_internal() override { return 48; }
};
struct Blit {
int x, y, w, h;
const uint8_t *ptr;
display::ColorBitness bitness;
bool big_endian;
int x_offset, y_offset, x_pad;
};
// Records the bulk calls and counts the per pixel calls Image::draw() makes.
class RecordingDisplay : public TestDisplay {
public:
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override {
this->blits.push_back({x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad});
}
void draw_pixel_at(int x, int y, Color color) override { this->pixels++; }
std::vector<Blit> blits;
int pixels{0};
};
// Lets the bulk call fall through to the base class and keeps the pixels it produces.
class FallbackDisplay : public TestDisplay {
public:
void draw_pixel_at(int x, int y, Color color) override { this->pixels.push_back({x, y, color.raw_32}); }
struct Pixel {
int x, y;
uint32_t raw;
};
std::vector<Pixel> pixels;
};
static constexpr int W = 8, H = 6;
static std::vector<uint8_t> make_data(int bytes_per_pixel) {
std::vector<uint8_t> data(W * H * bytes_per_pixel);
uint8_t value = 1;
for (auto &byte : data)
byte = value++;
return data;
}
// Draws an opaque RGB565 image at (x, y) and returns the single bulk call it made.
static Blit draw_one(int x, int y, const std::vector<uint8_t> &data, RecordingDisplay &display,
bool big_endian = false) {
Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian);
image.draw(x, y, &display, Color::WHITE, Color::BLACK);
EXPECT_EQ(display.blits.size(), 1u);
EXPECT_EQ(display.pixels, 0);
return display.blits.at(0);
}
static void expect_window(const Blit &blit, int x, int y, int w, int h, int x_offset, int y_offset, int x_pad) {
EXPECT_EQ(blit.x, x);
EXPECT_EQ(blit.y, y);
EXPECT_EQ(blit.w, w);
EXPECT_EQ(blit.h, h);
EXPECT_EQ(blit.x_offset, x_offset);
EXPECT_EQ(blit.y_offset, y_offset);
EXPECT_EQ(blit.x_pad, x_pad);
}
TEST(ImageDraw, OpaqueRgb565IsOneBulkCall) {
const auto data = make_data(2);
RecordingDisplay display;
const Blit blit = draw_one(10, 20, data, display);
expect_window(blit, 10, 20, W, H, 0, 0, 0);
EXPECT_EQ(blit.ptr, data.data());
EXPECT_EQ(blit.bitness, display::COLOR_BITNESS_565);
EXPECT_FALSE(blit.big_endian);
}
TEST(ImageDraw, BigEndianRgb565IsPassedThrough) {
const auto data = make_data(2);
RecordingDisplay display;
EXPECT_TRUE(draw_one(0, 0, data, display, true).big_endian);
}
TEST(ImageDraw, OpaqueRgbIsOneBulkCall) {
const auto data = make_data(3);
Image image(data.data(), W, H, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, true); // byte order only applies to 565
RecordingDisplay display;
image.draw(0, 0, &display, Color::WHITE, Color::BLACK);
ASSERT_EQ(display.blits.size(), 1u);
EXPECT_EQ(display.blits[0].bitness, display::COLOR_BITNESS_888);
EXPECT_FALSE(display.blits[0].big_endian);
}
TEST(ImageDraw, ClippingShrinksTheWindow) {
const auto data = make_data(2);
RecordingDisplay display;
display.start_clipping(12, 22, 15, 24); // right and bottom are exclusive
expect_window(draw_one(10, 20, data, display), 12, 22, 3, 2, 2, 2, W - 5);
}
TEST(ImageDraw, PartlyOffTheTopLeftIsClamped) {
const auto data = make_data(2);
RecordingDisplay display;
expect_window(draw_one(-3, -2, data, display), 0, 0, W - 3, H - 2, 3, 2, 0);
}
TEST(ImageDraw, PartlyOffTheBottomRightIsClamped) {
const auto data = make_data(2);
RecordingDisplay display;
expect_window(draw_one(60, 44, data, display), 60, 44, 4, 4, 0, 0, W - 4);
}
TEST(ImageDraw, FullyOffScreenDrawsNothing) {
const auto data = make_data(2);
Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE);
RecordingDisplay display;
image.draw(64, 0, &display, Color::WHITE, Color::BLACK);
image.draw(0, -H, &display, Color::WHITE, Color::BLACK);
EXPECT_TRUE(display.blits.empty());
EXPECT_EQ(display.pixels, 0);
}
TEST(ImageDraw, TransparentAndOtherFormatsStayPerPixel) {
const auto rgb565 = make_data(2);
const auto gray = make_data(1);
Image keyed(rgb565.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY);
Image grayscale(gray.data(), W, H, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE);
RecordingDisplay display;
keyed.draw(0, 0, &display, Color::WHITE, Color::BLACK);
grayscale.draw(0, 0, &display, Color::WHITE, Color::BLACK);
EXPECT_TRUE(display.blits.empty());
EXPECT_GT(display.pixels, 0);
}
// The base class fallback must read the pixels in the byte order the image declares.
static void expect_fallback_decodes(bool big_endian) {
const auto data = make_data(2);
Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian);
FallbackDisplay display;
image.draw(0, 0, &display, Color::WHITE, Color::BLACK);
ASSERT_EQ(display.pixels.size(), static_cast<size_t>(W * H));
for (const auto &pixel : display.pixels) {
const uint32_t rgb565 =
display::ColorUtil::read_packed<display::COLOR_BITNESS_565>(data.data(), pixel.x + pixel.y * W, big_endian);
const Color expected = display::ColorUtil::to_color(rgb565, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565);
EXPECT_EQ(pixel.raw, expected.raw_32) << "at " << pixel.x << "," << pixel.y;
}
}
TEST(ImageDraw, FallbackDecodesLittleEndian) { expect_fallback_decodes(false); }
TEST(ImageDraw, FallbackDecodesBigEndian) { expect_fallback_decodes(true); }
} // namespace esphome::image::testing
@@ -1,15 +0,0 @@
# Signed OTA plus the native api: the only combination that emits ota_signed.
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
wifi:
ssid: MySSID
password: password1
api:
mdns:
-16
View File
@@ -1,16 +0,0 @@
# This file's presence makes pytest treat this directory as a package named "socket",
# shadowing the stdlib module -- no test_*.py (Python-level) tests can live here.
# required for cpp unit testing.
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
real_to_code = manifest.to_code
async def to_code_testing(config):
await real_to_code(config)
cg.add_define("USE_NETWORK_IPV6", True)
manifest.to_code = to_code_testing
+12
View File
@@ -0,0 +1,12 @@
"""Configuration file for socket component tests."""
import pytest
from esphome.core import CORE
@pytest.fixture(autouse=True)
def reset_core():
"""Reset CORE after each test."""
yield
CORE.reset()
@@ -1,146 +0,0 @@
#include <gtest/gtest.h>
#include <cerrno>
#include <cstring>
#include "esphome/components/socket/socket.h"
#ifdef USE_HOST
namespace esphome::socket::testing {
// =========================================================================
// Independent of USE_NETWORK_IPV6
// =========================================================================
TEST(SetSockaddr, IPv4Basic) {
struct sockaddr_storage addr {};
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "192.168.1.1", 6053);
ASSERT_EQ(len, sizeof(sockaddr_in));
auto *in = reinterpret_cast<sockaddr_in *>(&addr);
EXPECT_EQ(in->sin_family, AF_INET);
EXPECT_EQ(ntohs(in->sin_port), 6053);
EXPECT_EQ(in->sin_addr.s_addr, htonl(0xC0A80101));
}
TEST(SetSockaddr, IPv4Broadcast) {
// 255.255.255.255 is INADDR_NONE's bit pattern but also a valid address.
struct sockaddr_storage addr {};
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "255.255.255.255", 12345);
ASSERT_EQ(len, sizeof(sockaddr_in));
auto *in = reinterpret_cast<sockaddr_in *>(&addr);
EXPECT_EQ(in->sin_addr.s_addr, ESPHOME_INADDR_NONE);
}
TEST(SetSockaddr, IPv4MalformedYieldsZeroAndEINVAL) {
struct sockaddr_storage addr {};
errno = 0;
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "not-an-ip", 1);
EXPECT_EQ(len, 0u);
EXPECT_EQ(errno, EINVAL);
}
TEST(SetSockaddr, IPv4BufferTooSmallYieldsZeroAndEINVAL) {
struct sockaddr_storage addr {};
errno = 0;
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(sockaddr_in) - 1, "192.168.1.1", 1);
EXPECT_EQ(len, 0u);
EXPECT_EQ(errno, EINVAL);
}
TEST(SetSockaddr, StringOverloadMatchesCStringOverload) {
struct sockaddr_storage addr_c {};
struct sockaddr_storage addr_s {};
socklen_t len_c = set_sockaddr((struct sockaddr *) &addr_c, sizeof(addr_c), "10.0.0.5", 80);
socklen_t len_s = set_sockaddr((struct sockaddr *) &addr_s, sizeof(addr_s), std::string("10.0.0.5"), 80);
ASSERT_EQ(len_c, len_s);
EXPECT_EQ(std::memcmp(&addr_c, &addr_s, len_c), 0);
}
TEST(FormatSockaddrTo, IPv4RoundTrip) {
struct sockaddr_storage addr {};
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "203.0.113.7", 1);
ASSERT_GT(len, 0u);
char buf[SOCKADDR_STR_LEN];
size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf);
EXPECT_GT(written, 0u);
EXPECT_STREQ(buf, "203.0.113.7");
}
TEST(FormatSockaddrTo, UnknownFamilyYieldsEmptyString) {
struct sockaddr addr {};
addr.sa_family = AF_UNSPEC;
char buf[SOCKADDR_STR_LEN] = {'x', '\0'};
size_t written = format_sockaddr_to(&addr, sizeof(addr), buf);
EXPECT_EQ(written, 0u);
EXPECT_STREQ(buf, "");
}
// =========================================================================
// IPv6 - this directory's __init__.py forces USE_NETWORK_IPV6 on
// =========================================================================
#if USE_NETWORK_IPV6
TEST(SetSockaddr, IPv6Basic) {
struct sockaddr_storage addr {};
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "::1", 443);
ASSERT_EQ(len, sizeof(sockaddr_in6));
auto *in6 = reinterpret_cast<sockaddr_in6 *>(&addr);
EXPECT_EQ(in6->sin6_family, AF_INET6);
EXPECT_EQ(ntohs(in6->sin6_port), 443);
EXPECT_TRUE(IN6_IS_ADDR_LOOPBACK(&in6->sin6_addr));
}
TEST(SetSockaddr, IPv6MalformedYieldsZeroAndEINVAL) {
struct sockaddr_storage addr {};
errno = 0;
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "gg::not-ipv6", 1);
EXPECT_EQ(len, 0u);
EXPECT_EQ(errno, EINVAL);
}
TEST(SetSockaddrAny, FillsIPv6AnyAddressWithPort) {
struct sockaddr_storage addr {};
socklen_t len = set_sockaddr_any((struct sockaddr *) &addr, sizeof(addr), 9999);
ASSERT_EQ(len, sizeof(sockaddr_in6));
auto *in6 = reinterpret_cast<sockaddr_in6 *>(&addr);
EXPECT_EQ(in6->sin6_family, AF_INET6);
EXPECT_EQ(ntohs(in6->sin6_port), 9999);
EXPECT_TRUE(IN6_IS_ADDR_UNSPECIFIED(&in6->sin6_addr));
}
TEST(FormatSockaddrTo, IPv6RoundTrip) {
struct sockaddr_storage addr {};
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "2001:db8::1", 1);
ASSERT_GT(len, 0u);
char buf[SOCKADDR_STR_LEN];
size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf);
EXPECT_GT(written, 0u);
EXPECT_STREQ(buf, "2001:db8::1");
}
TEST(FormatSockaddrTo, V4MappedFormatsAsPlainIPv4) {
struct sockaddr_storage addr {};
socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "::ffff:198.51.100.9", 1);
ASSERT_GT(len, 0u);
char buf[SOCKADDR_STR_LEN];
size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf);
EXPECT_GT(written, 0u);
EXPECT_STREQ(buf, "198.51.100.9");
}
TEST(SocketIp, CreatesIPv6Socket) {
auto sock = socket_ip(SOCK_DGRAM, IPPROTO_UDP);
ASSERT_NE(sock, nullptr);
struct sockaddr_storage addr {};
socklen_t len = sizeof(addr);
ASSERT_EQ(sock->getsockname((struct sockaddr *) &addr, &len), 0);
EXPECT_EQ(addr.ss_family, AF_INET6);
}
#endif // USE_NETWORK_IPV6
} // namespace esphome::socket::testing
#endif // USE_HOST
@@ -1,8 +0,0 @@
<<: !include common.yaml
syslog:
port: 514
strip: true
level: info
facility: 16
format: RFC5424
@@ -1,13 +0,0 @@
esphome:
name: host-ota-test
host:
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
port: __OTA_PORT__
encryption:
safe_mode:
logger:
level: DEBUG
@@ -1,11 +0,0 @@
esphome:
name: host-ota-test
host:
api:
encryption:
ota:
- platform: esphome
port: __OTA_PORT__
safe_mode:
logger:
level: DEBUG
-1
View File
@@ -41,4 +41,3 @@ syslog:
level: DEBUG
strip: true
facility: 16
format: SYSLOG_FORMAT_PLACEHOLDER
+6 -29
View File
@@ -15,12 +15,6 @@ import os
from pathlib import Path
import struct
_ENTRY = struct.Struct("<IB") # key, data length
# Must match esphome::safe_mode::RTC_KEY in safe_mode.h
_SAFE_MODE_RTC_KEY = 233825507
# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC
_ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE
def host_prefs_path(device_name: str) -> Path:
"""Return the on-disk prefs file path for a host-platform device.
@@ -48,33 +42,16 @@ def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path:
for key, data in entries.items():
if len(data) > 255:
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
payload += _ENTRY.pack(key, len(data)) + data
payload += struct.pack("<IB", key, len(data)) + data
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
return path
def read_host_prefs(device_name: str) -> dict[int, bytes]:
"""Read the preference entries of a host-platform device; empty when
the file does not exist."""
path = host_prefs_path(device_name)
if not path.exists():
return {}
payload = path.read_bytes()
entries: dict[int, bytes] = {}
pos = 0
while pos < len(payload):
key, length = _ENTRY.unpack_from(payload, pos)
pos += _ENTRY.size
entries[key] = payload[pos : pos + length]
pos += length
return entries
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
"""Write a single preference entry, replacing the file's contents.
def force_safe_mode(device_name: str) -> None:
"""Make the next boot of a host-platform device enter safe mode; other
saved preferences are kept."""
entries = read_host_prefs(device_name)
entries[_SAFE_MODE_RTC_KEY] = struct.pack("<I", _ENTER_SAFE_MODE_MAGIC)
write_host_prefs(device_name, entries)
Returns the path that was written.
"""
return write_host_prefs(device_name, {key: data})
+152 -153
View File
@@ -1,156 +1,155 @@
{
"tests/integration/test_action_concurrent_reentry.py": 33.88,
"tests/integration/test_addressable_light_transition.py": 25.37,
"tests/integration/test_alarm_control_panel_state_transitions.py": 29.76,
"tests/integration/test_api_action_metadata.py": 25.54,
"tests/integration/test_api_action_responses.py": 21.15,
"tests/integration/test_api_action_timeout.py": 37.89,
"tests/integration/test_api_conditional_memory.py": 13.21,
"tests/integration/test_api_custom_services.py": 18.72,
"tests/integration/test_api_get_time_response_timezone.py": 19.55,
"tests/integration/test_api_homeassistant.py": 32.09,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 17.67,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 14.04,
"tests/integration/test_api_list_entities_backpressure.py": 14.96,
"tests/integration/test_api_message_size_batching.py": 22.77,
"tests/integration/test_api_reboot_timeout.py": 20.76,
"tests/integration/test_api_string_lambda.py": 35.07,
"tests/integration/test_api_vv_logging.py": 17.17,
"tests/integration/test_api_zero_psk_provisioning.py": 27.91,
"tests/integration/test_areas_and_devices.py": 23.45,
"tests/integration/test_automation_wait_actions.py": 21.21,
"tests/integration/test_automations.py": 30.73,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 25.92,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 24.97,
"tests/integration/test_binary_sensor_invalidate_state.py": 17.7,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 15.43,
"tests/integration/test_build_info.py": 23.12,
"tests/integration/test_camera_mock.py": 21.23,
"tests/integration/test_climate_control_action.py": 26.55,
"tests/integration/test_climate_custom_modes.py": 24.85,
"tests/integration/test_continuation_actions.py": 14.94,
"tests/integration/test_cover_control_action.py": 25.35,
"tests/integration/test_crc8_helper.py": 19.94,
"tests/integration/test_device_id_in_state.py": 57.04,
"tests/integration/test_duplicate_entities.py": 17.91,
"tests/integration/test_entity_icon.py": 23.81,
"tests/integration/test_fan_turn_on_action.py": 14.88,
"tests/integration/test_fnv1_hash_object_id.py": 21.14,
"tests/integration/test_fnv1a_hash.py": 16.96,
"tests/integration/test_gpio_expander_cache.py": 16.1,
"tests/integration/test_host_logger_thread_safety.py": 17.96,
"tests/integration/test_host_mode_basic.py": 8.17,
"tests/integration/test_host_mode_batch_delay.py": 18.78,
"tests/integration/test_host_mode_climate_basic_state.py": 28.88,
"tests/integration/test_host_mode_climate_control.py": 20.11,
"tests/integration/test_host_mode_empty_string_options.py": 21.1,
"tests/integration/test_host_mode_entity_fields.py": 22.3,
"tests/integration/test_host_mode_fan_preset.py": 18.12,
"tests/integration/test_host_mode_many_entities.py": 47.88,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 30.94,
"tests/integration/test_host_mode_noise_encryption.py": 30.68,
"tests/integration/test_host_mode_reconnect.py": 4.8,
"tests/integration/test_host_mode_sensor.py": 17.36,
"tests/integration/test_host_ota.py": 94.88,
"tests/integration/test_host_preferences.py": 20.49,
"tests/integration/test_host_preferences_suspend_resume.py": 24.17,
"tests/integration/test_improv_serial_uart.py": 22.53,
"tests/integration/test_large_message_batching.py": 17.54,
"tests/integration/test_legacy_area.py": 23.03,
"tests/integration/test_legacy_climate_compat.py": 19.3,
"tests/integration/test_legacy_fan_compat.py": 14.69,
"tests/integration/test_light_automations.py": 21.44,
"tests/integration/test_light_binary_effect_off_phase.py": 29.84,
"tests/integration/test_light_calls.py": 26.72,
"tests/integration/test_light_constant_brightness.py": 29.08,
"tests/integration/test_light_control_action.py": 22.44,
"tests/integration/test_light_dim_relative_action.py": 16.57,
"tests/integration/test_light_effect_zero_brightness.py": 32.2,
"tests/integration/test_light_initial_state.py": 18.42,
"tests/integration/test_light_toggle_action.py": 16.23,
"tests/integration/test_lock_automations.py": 28.17,
"tests/integration/test_logger_buffered_recursion_guard.py": 23.52,
"tests/integration/test_loop_disable_enable.py": 19.4,
"tests/integration/test_loop_interval_decoupling.py": 27.32,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.47,
"tests/integration/test_lvgl_headless_render.py": 58.56,
"tests/integration/test_micros_to_millis.py": 21.91,
"tests/integration/test_multi_click_trigger.py": 25.74,
"tests/integration/test_multi_device_preferences.py": 24.1,
"tests/integration/test_noise_encryption_key_protection.py": 22.54,
"tests/integration/test_object_id_api_verification.py": 14.59,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.88,
"tests/integration/test_object_id_no_friendly_name.py": 40.23,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 48.98,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 44.62,
"tests/integration/test_online_image_bmp.py": 8.46,
"tests/integration/test_oversized_payloads.py": 41.29,
"tests/integration/test_preference_key_stability.py": 33.97,
"tests/integration/test_runtime_stats.py": 23.77,
"tests/integration/test_safe_mode_loop_runs.py": 7.96,
"tests/integration/test_scheduler_blocking_warning.py": 45.2,
"tests/integration/test_scheduler_bulk_cleanup.py": 16.2,
"tests/integration/test_scheduler_defer_cancel.py": 19.87,
"tests/integration/test_scheduler_defer_cancel_regular.py": 15.93,
"tests/integration/test_scheduler_defer_fifo_simple.py": 14.12,
"tests/integration/test_scheduler_defer_stress.py": 24.55,
"tests/integration/test_scheduler_heap_stress.py": 19.84,
"tests/integration/test_scheduler_internal_id_no_collision.py": 18.42,
"tests/integration/test_scheduler_interval_reschedule.py": 22.37,
"tests/integration/test_scheduler_interval_zero_coerced.py": 3.69,
"tests/integration/test_scheduler_null_name.py": 22.08,
"tests/integration/test_scheduler_numeric_id_test.py": 18.45,
"tests/integration/test_scheduler_pool.py": 16.04,
"tests/integration/test_scheduler_rapid_cancellation.py": 4.32,
"tests/integration/test_scheduler_recursive_timeout.py": 24.06,
"tests/integration/test_scheduler_removed_item_race.py": 24.2,
"tests/integration/test_scheduler_self_keyed.py": 19.48,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 22.9,
"tests/integration/test_scheduler_string_test.py": 18.13,
"tests/integration/test_script_array_params.py": 8.06,
"tests/integration/test_script_delay_params.py": 13.13,
"tests/integration/test_script_queued.py": 17.67,
"tests/integration/test_script_queued_idle_loop.py": 16.33,
"tests/integration/test_script_wait_on_boot.py": 14.01,
"tests/integration/test_sdl_headless_screenshot.py": 12.91,
"tests/integration/test_select_stringref_trigger.py": 17.66,
"tests/integration/test_sensor_filters_delta.py": 25.94,
"tests/integration/test_sensor_filters_ring_buffer.py": 17.25,
"tests/integration/test_sensor_filters_sliding_window.py": 44.9,
"tests/integration/test_sensor_filters_value_list.py": 16.0,
"tests/integration/test_sensor_raw_state.py": 42.47,
"tests/integration/test_sensor_timeout_filter.py": 23.58,
"tests/integration/test_set_internal_at_boot.py": 21.55,
"tests/integration/test_snapshot_display.py": 7.58,
"tests/integration/test_socket_wake_gate_tcp.py": 15.76,
"tests/integration/test_status_flags.py": 37.76,
"tests/integration/test_strftime_to.py": 19.69,
"tests/integration/test_syslog.py": 21.44,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 16.98,
"tests/integration/test_action_concurrent_reentry.py": 34.72,
"tests/integration/test_addressable_light_transition.py": 33.71,
"tests/integration/test_alarm_control_panel_state_transitions.py": 38.96,
"tests/integration/test_api_action_metadata.py": 33.36,
"tests/integration/test_api_action_responses.py": 26.22,
"tests/integration/test_api_action_timeout.py": 25.41,
"tests/integration/test_api_conditional_memory.py": 20.74,
"tests/integration/test_api_custom_services.py": 23.21,
"tests/integration/test_api_get_time_response_timezone.py": 25.04,
"tests/integration/test_api_homeassistant.py": 24.18,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 23.47,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 22.86,
"tests/integration/test_api_list_entities_backpressure.py": 18.8,
"tests/integration/test_api_message_size_batching.py": 28.8,
"tests/integration/test_api_reboot_timeout.py": 9.47,
"tests/integration/test_api_string_lambda.py": 16.88,
"tests/integration/test_api_vv_logging.py": 17.99,
"tests/integration/test_api_zero_psk_provisioning.py": 47.07,
"tests/integration/test_areas_and_devices.py": 20.77,
"tests/integration/test_automation_wait_actions.py": 20.07,
"tests/integration/test_automations.py": 27.41,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 20.5,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 26.54,
"tests/integration/test_binary_sensor_invalidate_state.py": 16.09,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.7,
"tests/integration/test_build_info.py": 18.07,
"tests/integration/test_camera_mock.py": 20.44,
"tests/integration/test_climate_control_action.py": 27.97,
"tests/integration/test_climate_custom_modes.py": 26.77,
"tests/integration/test_continuation_actions.py": 12.09,
"tests/integration/test_cover_control_action.py": 19.77,
"tests/integration/test_crc8_helper.py": 12.64,
"tests/integration/test_device_id_in_state.py": 63.19,
"tests/integration/test_duplicate_entities.py": 29.26,
"tests/integration/test_entity_icon.py": 34.95,
"tests/integration/test_fan_turn_on_action.py": 25.98,
"tests/integration/test_fnv1_hash_object_id.py": 4.85,
"tests/integration/test_fnv1a_hash.py": 5.14,
"tests/integration/test_gpio_expander_cache.py": 21.0,
"tests/integration/test_host_logger_thread_safety.py": 17.51,
"tests/integration/test_host_mode_basic.py": 21.2,
"tests/integration/test_host_mode_batch_delay.py": 26.68,
"tests/integration/test_host_mode_climate_basic_state.py": 18.04,
"tests/integration/test_host_mode_climate_control.py": 29.64,
"tests/integration/test_host_mode_empty_string_options.py": 28.8,
"tests/integration/test_host_mode_entity_fields.py": 28.68,
"tests/integration/test_host_mode_fan_preset.py": 16.98,
"tests/integration/test_host_mode_many_entities.py": 39.8,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 33.12,
"tests/integration/test_host_mode_noise_encryption.py": 52.53,
"tests/integration/test_host_mode_reconnect.py": 14.48,
"tests/integration/test_host_mode_sensor.py": 17.38,
"tests/integration/test_host_ota.py": 94.96,
"tests/integration/test_host_preferences.py": 27.11,
"tests/integration/test_host_preferences_suspend_resume.py": 21.48,
"tests/integration/test_improv_serial_uart.py": 19.43,
"tests/integration/test_large_message_batching.py": 25.67,
"tests/integration/test_legacy_area.py": 15.59,
"tests/integration/test_legacy_climate_compat.py": 18.59,
"tests/integration/test_legacy_fan_compat.py": 18.34,
"tests/integration/test_light_automations.py": 16.74,
"tests/integration/test_light_binary_effect_off_phase.py": 57.7,
"tests/integration/test_light_calls.py": 25.88,
"tests/integration/test_light_constant_brightness.py": 22.32,
"tests/integration/test_light_control_action.py": 18.15,
"tests/integration/test_light_dim_relative_action.py": 30.35,
"tests/integration/test_light_effect_zero_brightness.py": 18.38,
"tests/integration/test_light_initial_state.py": 23.93,
"tests/integration/test_light_toggle_action.py": 26.16,
"tests/integration/test_lock_automations.py": 34.13,
"tests/integration/test_logger_buffered_recursion_guard.py": 25.77,
"tests/integration/test_loop_disable_enable.py": 14.71,
"tests/integration/test_loop_interval_decoupling.py": 26.16,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 28.47,
"tests/integration/test_lvgl_headless_render.py": 96.36,
"tests/integration/test_micros_to_millis.py": 28.76,
"tests/integration/test_multi_click_trigger.py": 19.8,
"tests/integration/test_multi_device_preferences.py": 38.85,
"tests/integration/test_noise_encryption_key_protection.py": 25.81,
"tests/integration/test_object_id_api_verification.py": 28.46,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.14,
"tests/integration/test_object_id_no_friendly_name.py": 18.82,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 30.16,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.67,
"tests/integration/test_online_image_bmp.py": 7.41,
"tests/integration/test_oversized_payloads.py": 59.52,
"tests/integration/test_preference_key_stability.py": 27.24,
"tests/integration/test_runtime_stats.py": 20.53,
"tests/integration/test_safe_mode_loop_runs.py": 10.17,
"tests/integration/test_scheduler_blocking_warning.py": 51.45,
"tests/integration/test_scheduler_bulk_cleanup.py": 22.59,
"tests/integration/test_scheduler_defer_cancel.py": 17.64,
"tests/integration/test_scheduler_defer_cancel_regular.py": 21.61,
"tests/integration/test_scheduler_defer_fifo_simple.py": 24.73,
"tests/integration/test_scheduler_defer_stress.py": 23.91,
"tests/integration/test_scheduler_heap_stress.py": 25.77,
"tests/integration/test_scheduler_internal_id_no_collision.py": 19.83,
"tests/integration/test_scheduler_interval_reschedule.py": 23.4,
"tests/integration/test_scheduler_interval_zero_coerced.py": 5.11,
"tests/integration/test_scheduler_null_name.py": 17.36,
"tests/integration/test_scheduler_numeric_id_test.py": 20.81,
"tests/integration/test_scheduler_pool.py": 17.42,
"tests/integration/test_scheduler_rapid_cancellation.py": 25.28,
"tests/integration/test_scheduler_recursive_timeout.py": 16.48,
"tests/integration/test_scheduler_removed_item_race.py": 16.14,
"tests/integration/test_scheduler_self_keyed.py": 26.19,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 23.26,
"tests/integration/test_scheduler_string_test.py": 27.09,
"tests/integration/test_script_array_params.py": 3.6,
"tests/integration/test_script_delay_params.py": 24.74,
"tests/integration/test_script_queued.py": 17.21,
"tests/integration/test_script_queued_idle_loop.py": 3.4,
"tests/integration/test_script_wait_on_boot.py": 23.7,
"tests/integration/test_sdl_headless_screenshot.py": 19.53,
"tests/integration/test_select_stringref_trigger.py": 18.93,
"tests/integration/test_sensor_filters_delta.py": 20.85,
"tests/integration/test_sensor_filters_ring_buffer.py": 16.82,
"tests/integration/test_sensor_filters_sliding_window.py": 54.78,
"tests/integration/test_sensor_filters_value_list.py": 19.46,
"tests/integration/test_sensor_timeout_filter.py": 18.39,
"tests/integration/test_set_internal_at_boot.py": 21.69,
"tests/integration/test_snapshot_display.py": 12.64,
"tests/integration/test_socket_wake_gate_tcp.py": 13.08,
"tests/integration/test_status_flags.py": 29.54,
"tests/integration/test_strftime_to.py": 25.62,
"tests/integration/test_syslog.py": 16.25,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 27.18,
"tests/integration/test_template_climate_basic.py": 20.51,
"tests/integration/test_template_climate_custom_modes.py": 16.94,
"tests/integration/test_template_climate_nonoptimistic.py": 19.28,
"tests/integration/test_template_climate_on_control_ordering.py": 15.47,
"tests/integration/test_template_climate_publish_all_fields.py": 18.9,
"tests/integration/test_template_climate_sensor_push.py": 14.96,
"tests/integration/test_template_climate_set_actions.py": 16.04,
"tests/integration/test_template_climate_two_point_temperature.py": 23.72,
"tests/integration/test_template_text_save.py": 15.49,
"tests/integration/test_text_command.py": 14.21,
"tests/integration/test_text_sensor_raw_state.py": 16.32,
"tests/integration/test_uart_mock_ld2410.py": 44.46,
"tests/integration/test_uart_mock_ld2412.py": 85.51,
"tests/integration/test_uart_mock_ld2420.py": 33.99,
"tests/integration/test_uart_mock_ld2450.py": 25.3,
"tests/integration/test_uart_mock_modbus.py": 522.13,
"tests/integration/test_udp.py": 13.7,
"tests/integration/test_use_address_runtime.py": 33.8,
"tests/integration/test_valve_control_action.py": 13.0,
"tests/integration/test_varint_five_byte_device_id.py": 25.11,
"tests/integration/test_wait_until_mid_loop_timing.py": 22.73,
"tests/integration/test_wait_until_on_boot.py": 10.45,
"tests/integration/test_wait_until_ordering.py": 3.64,
"tests/integration/test_wait_until_reentrant_restart.py": 18.73,
"tests/integration/test_wake_loop_forces_phase_b.py": 22.85,
"tests/integration/test_water_heater_template.py": 31.06
"tests/integration/test_template_climate_custom_modes.py": 27.73,
"tests/integration/test_template_climate_nonoptimistic.py": 26.35,
"tests/integration/test_template_climate_on_control_ordering.py": 26.55,
"tests/integration/test_template_climate_publish_all_fields.py": 17.59,
"tests/integration/test_template_climate_sensor_push.py": 22.04,
"tests/integration/test_template_climate_set_actions.py": 16.82,
"tests/integration/test_template_climate_two_point_temperature.py": 25.13,
"tests/integration/test_template_text_save.py": 25.36,
"tests/integration/test_text_command.py": 18.79,
"tests/integration/test_text_sensor_raw_state.py": 17.07,
"tests/integration/test_uart_mock_ld2410.py": 59.58,
"tests/integration/test_uart_mock_ld2412.py": 59.4,
"tests/integration/test_uart_mock_ld2420.py": 45.27,
"tests/integration/test_uart_mock_ld2450.py": 27.96,
"tests/integration/test_uart_mock_modbus.py": 562.45,
"tests/integration/test_udp.py": 7.48,
"tests/integration/test_use_address_runtime.py": 17.27,
"tests/integration/test_valve_control_action.py": 18.33,
"tests/integration/test_varint_five_byte_device_id.py": 17.59,
"tests/integration/test_wait_until_mid_loop_timing.py": 16.93,
"tests/integration/test_wait_until_on_boot.py": 19.96,
"tests/integration/test_wait_until_ordering.py": 16.19,
"tests/integration/test_wait_until_reentrant_restart.py": 23.67,
"tests/integration/test_wake_loop_forces_phase_b.py": 17.58,
"tests/integration/test_water_heater_template.py": 21.96
}
+5 -75
View File
@@ -29,7 +29,6 @@ from .const import (
PROVISIONING_PSK,
ZERO_PSK,
)
from .host_prefs import force_safe_mode
from .types import APIClientConnectedFactory, CompileFunction, ConfigWriter
DEVICE_NAME = "host-ota-test"
@@ -167,15 +166,6 @@ class _Device:
assert self.proc.returncode is None, "process died on rejected OTA"
async def _provision_key(
dev: _Device, api_client_connected: APIClientConnectedFactory
) -> None:
"""Provision PROVISIONING_PSK over the api and wait for it to activate."""
async with api_client_connected(port=dev.api_port, noise_psk=ZERO_PSK) as client:
assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True
await asyncio.sleep(KEY_ACTIVATION_DELAY)
@pytest.mark.asyncio
async def test_host_ota_self_update(
yaml_config: str,
@@ -237,31 +227,6 @@ async def test_host_ota_encrypted(
await dev.ota(None, API_KEY, "encrypted OTA reported failure")
@pytest.mark.asyncio
async def test_host_ota_encrypted_safe_mode(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Safe mode never constructs the api server, so an encrypted OTA with the
api key has to run on the ota component's own copy of that key."""
pytest.importorskip("aioesphomeapi.noise")
dev = _Device(
*await _build(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
)
)
# The api port never opens in safe mode, so wait for the log line instead
force_safe_mode(DEVICE_NAME)
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
dev.proc = proc
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
# The safe mode boot clears the counter, so the re-exec boots normally
await dev.ota(None, API_KEY, "encrypted OTA in safe mode reported failure")
@pytest.mark.asyncio
async def test_host_ota_api_key_offer_with_password(
yaml_config: str,
@@ -340,7 +305,11 @@ async def test_host_ota_provisioned_api_key(
None, None, "plaintext upload to an unprovisioned device must succeed"
)
await _provision_key(dev, api_client_connected)
async with api_client_connected(
port=dev.api_port, noise_psk=ZERO_PSK
) as client:
assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True
await asyncio.sleep(KEY_ACTIVATION_DELAY)
key = PROVISIONING_PSK.decode()
await dev.ota(
@@ -350,45 +319,6 @@ async def test_host_ota_provisioned_api_key(
await dev.ota(None, None, "plaintext must stay accepted on an offering device")
@pytest.mark.asyncio
async def test_host_ota_provisioned_api_key_safe_mode(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Safe mode never constructs the api server, so the OTA has to load the
provisioned key from preferences itself to keep encrypting there."""
pytest.importorskip("aioesphomeapi.noise")
dev = _Device(
*await _build(
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
)
)
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines):
dev.proc = proc
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
await _provision_key(dev, api_client_connected)
# The saved key is already on disk; a host reboot outside an OTA just
# exits, so safe mode takes a second start
force_safe_mode(DEVICE_NAME)
key = PROVISIONING_PSK.decode()
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
dev.proc = proc
await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT)
await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT)
await dev.ota(
None,
key,
"encrypted upload with the provisioned key must succeed in safe mode",
)
# The re-exec boots normally and the api reads the same record
async with api_client_connected(port=dev.api_port, noise_psk=key):
pass
@pytest.mark.asyncio
async def test_host_ota_rejects_garbage(
yaml_config: str,
+10 -2
View File
@@ -25,13 +25,19 @@ from __future__ import annotations
import asyncio
import re
import struct
import pytest
from .conftest import run_binary
from .host_prefs import clear_host_prefs, force_safe_mode
from .host_prefs import clear_host_prefs, write_host_pref
from .types import CompileFunction, ConfigWriter
# Must match esphome::safe_mode::RTC_KEY in safe_mode.h
SAFE_MODE_RTC_KEY = 233825507
# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC
ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE
DEVICE_NAME = "safe-mode-loop-runs"
THREAD_LOG_MARKER = "looping component ran in safe mode"
@@ -50,7 +56,9 @@ async def test_safe_mode_loop_runs(
# Compile finished successfully; pre-populate prefs so the *next* run
# enters safe mode immediately.
force_safe_mode(DEVICE_NAME)
write_host_pref(
DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack("<I", ENTER_SAFE_MODE_MAGIC)
)
try:
loop = asyncio.get_running_loop()
+9 -28
View File
@@ -31,25 +31,14 @@ class ParsedSyslogMessage(TypedDict):
# RFC 3164 syslog message pattern:
# <PRI>TIMESTAMP HOSTNAME TAG: MESSAGE
# Example: <134>Dec 20 14:30:45 syslog-test app: [D][app:029]: Running...
RFC3164_PATTERN = re.compile(
SYSLOG_PATTERN = re.compile(
r"<(\d+)>" # PRI (priority = facility * 8 + severity)
r"(?:(\S+ +\d+ \d+:\d+:\d+) )?" # Optional BSD TIMESTAMP
r"(\S+)" # HOSTNAME
r"(\S+ +\d+ \d+:\d+:\d+|-)" # TIMESTAMP (BSD-style "%b %e %H:%M:%S", e.g. "Dec 20 14:30:45", or NILVALUE "-")
r" (\S+)" # HOSTNAME
r" (\S+):" # TAG
r" (.*)" # MESSAGE
)
# RFC 5424 syslog message pattern:
# <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
RFC5424_PATTERN = re.compile(
r"<(\d+)>1 "
r"(\S+) "
r"(\S+) "
r"(\S+) "
r"- - - "
r"(.*)"
)
@dataclass
class SyslogReceiver:
@@ -134,10 +123,9 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]:
sock.close()
def parse_syslog_message(msg: str, format_: str) -> ParsedSyslogMessage | None:
def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None:
"""Parse a syslog message and return its components."""
pattern = RFC3164_PATTERN if format_ == "RFC3164" else RFC5424_PATTERN
match = pattern.fullmatch(msg)
match = SYSLOG_PATTERN.match(msg)
if not match:
return None
pri, timestamp, hostname, tag, message = match.groups()
@@ -149,7 +137,7 @@ def parse_syslog_message(msg: str, format_: str) -> ParsedSyslogMessage | None:
pri=pri_val,
facility=facility,
severity=severity,
timestamp=timestamp or "",
timestamp=timestamp,
hostname=hostname,
tag=tag,
message=message,
@@ -157,18 +145,15 @@ def parse_syslog_message(msg: str, format_: str) -> ParsedSyslogMessage | None:
@pytest.mark.asyncio
@pytest.mark.parametrize("format_", ["RFC3164", "RFC5424"])
async def test_syslog(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
format_: str,
) -> None:
"""Test syslog component sends properly formatted messages."""
async with syslog_udp_listener() as (udp_port, receiver):
# Replace the placeholder port in the config
config = yaml_config.replace("SYSLOG_PORT_PLACEHOLDER", str(udp_port))
config = config.replace("SYSLOG_FORMAT_PLACEHOLDER", format_)
async with run_compiled(config), api_client_connected() as client:
# Verify device is running
@@ -191,7 +176,7 @@ async def test_syslog(
# Parse and validate all messages
parsed_messages: list[ParsedSyslogMessage] = []
for msg in receiver.messages:
parsed = parse_syslog_message(msg, format_)
parsed = parse_syslog_message(msg)
if parsed:
parsed_messages.append(parsed)
@@ -219,16 +204,12 @@ async def test_syslog(
f"Unexpected hostname: {parsed['hostname']}"
)
if format_ == "RFC3164" and parsed["timestamp"]:
# Validate timestamp format (BSD or NILVALUE)
if parsed["timestamp"] != "-":
assert re.match(
r"[A-Z][a-z]{2} +\d+ \d{2}:\d{2}:\d{2}",
parsed["timestamp"],
), f"Invalid timestamp format: {parsed['timestamp']}"
elif format_ == "RFC5424" and parsed["timestamp"] != "-":
assert re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}",
parsed["timestamp"],
), f"Invalid timestamp format: {parsed['timestamp']}"
# Verify we see different severity levels in the logs
severities_seen = {p["severity"] for p in parsed_messages}
-126
View File
@@ -4,16 +4,12 @@ The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() ca
empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These
tests pin the comment/string/raw-string masker, the accepted control-statement shapes, and the
NOLINT escape hatch at both placements a contributor would try.
Also covers the ESP_LOG call scanner (_iter_log_calls) and the bare-literal-ternary lint.
"""
import importlib.util
from pathlib import Path
import sys
import pytest
SCRIPT_DIR = (Path(__file__).parent / ".." / ".." / "script").resolve()
sys.path.insert(0, str(SCRIPT_DIR))
_spec = importlib.util.spec_from_file_location("ci_custom", SCRIPT_DIR / "ci-custom.py")
@@ -149,125 +145,3 @@ def test_nolint_at_end_of_log_line_suppresses() -> None:
def test_nolint_on_control_line_suppresses() -> None:
assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n")
# --- ESP_LOG call scanner and bare-literal-ternary lint ---
def _calls(content: str) -> list[str | None]:
return [text for _, text in ci_custom._iter_log_calls(content)]
def _ternary_errors(content: str) -> list[tuple[int, int]]:
errs = ci_custom.lint_log_no_bare_literal_ternary(Path("x.cpp"), content)
return [(line, col) for line, col, _ in errs]
@pytest.mark.parametrize(
"content",
[
'ESP_LOGD(TAG, "a ) b ( c; d")',
'ESP_LOGD(TAG, "quote \\" inside")',
"ESP_LOGD(TAG, \"%s\", format_hex_pretty(x, '-', false).c_str())",
"ESP_LOGD(TAG, \"%c%c\", '(', ')')",
"ESP_LOGD(TAG, \"%d\", 1'000'000)",
'ESP_LOGD(TAG, // it\'s a comment with ) and (\n "x")',
'ESP_LOGD(TAG, /* :) */ "x")',
'ESP_LOGD(TAG, "%s", R"(say "hi" :) )")',
'ESP_LOGD(TAG, "%s", R"x(a)"b)x")',
],
)
def test_iter_log_calls_spans_whole_call(content: str) -> None:
calls = _calls(content + ";\nint other = (1);")
assert calls == [content]
def test_iter_log_calls_reports_unbalanced_call_once() -> None:
content = 'ESP_LOGD(TAG, "x";\nvoid f();'
assert _calls(content) == [None]
errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content)
assert len(errs) == 1
assert errs[0][:2] == (1, 1)
assert "no matching closing parenthesis" in errs[0][2]
assert _ternary_errors(content) == []
@pytest.mark.parametrize(
("content", "expected"),
[
# A ; inside the format string no longer cuts the call short
('ESP_LOGD(TAG, "a; b\\nc %s", x);', [(1, 20)]),
# A \n%s continuation is exempt since %s may expand to leading whitespace
('ESP_LOGD(TAG, "a\\n%s", x);', []),
('ESP_LOGD(TAG, "a\\n b");', []),
],
)
def test_multiline_continuation_detection(
content: str, expected: list[tuple[int, int]]
) -> None:
errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content)
assert [(line, col) for line, col, _ in errs] == expected
def test_exclusion_list_only_names_components_without_esp8266_tests() -> None:
root = Path(__file__).parent / ".." / ".."
for pattern in ci_custom.LOG_LITERAL_LINT_EXCLUDE:
if not pattern.startswith("esphome/components/"):
continue
prefix = pattern.removeprefix("esphome/components/").split("/")[0]
comps = list((root / "esphome" / "components").glob(prefix))
assert comps, f"{pattern!r} matches no component"
for comp in comps:
test = root / "tests" / "components" / comp.name / "test.esp8266-ard.yaml"
assert not test.exists(), (
f"{comp.name} builds for ESP8266, drop {pattern!r}"
)
def test_unbalanced_calls_are_reported_by_a_check_that_sees_every_file() -> None:
# lint_log_no_bare_literal_ternary skips unbalanced calls and relies on this
checks = {c["func"].__name__: c for c in ci_custom.LINT_CONTENT_CHECKS}
continuation = checks["lint_log_multiline_continuation"]
ternary = checks["lint_log_no_bare_literal_ternary"]
assert continuation["exclude"] == []
assert continuation["include"] == ternary["include"]
@pytest.mark.parametrize(
("content", "expected"),
[
('ESP_LOGD(TAG, "%s", x ? "on" : "off");', [(1, 25), (1, 32)]),
(
'ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));',
[],
),
('ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : "off");', [(1, 49)]),
('ESP_LOGD(TAG, "%s", x ? "on" : "");', [(1, 25)]),
(
'ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no");',
[(2, 14), (3, 14)],
),
("ESP_LOGD(TAG, \"%c\", x ? '1' : '0');", []),
('ESP_LOGD(TAG, "a ? b : c %s", x ? "on" : "off");', [(1, 35), (1, 42)]),
('ESP_LOGD(TAG, "x:" "y %s", p);', []),
('ESP_LOGD(TAG, "%s", x ? "on" : "off"); // NOLINT', []),
('ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no"); // NOLINT', []),
('ESP_LOGD(TAG, "%s", x ? /* c */ "on" : "off");', [(1, 33), (1, 40)]),
(
'ESP_LOGD(TAG, "%s",\n x ? "on" // NOLINT(some-clang-check)\n : "off");',
[(2, 14), (3, 14)],
),
],
)
def test_ternary_literal_detection(
content: str, expected: list[tuple[int, int]]
) -> None:
assert _ternary_errors(content) == expected
def test_ternary_error_message_names_the_literal() -> None:
errs = ci_custom.lint_log_no_bare_literal_ternary(
Path("x.cpp"), 'ESP_LOGD(TAG, "%s", x ? "enabled" : LOG_STR_LITERAL("off"));'
)
assert len(errs) == 1
assert 'LOG_STR_LITERAL("enabled")' in errs[0][2]
-104
View File
@@ -209,107 +209,3 @@ def test_runner_streams_output_before_the_build_finishes(
# Join before leaving the block, so the reader is done rather than
# racing ``Popen`` closing the pipe under it.
reader.join(1.0)
class _FakeKernel32:
"""Stand-in for the Windows kernel32 console code page calls."""
def __init__(self, input_cp: int, output_cp: int) -> None:
self.input_cp = input_cp
self.output_cp = output_cp
self.calls: list[tuple[str, int]] = []
def GetConsoleCP(self) -> int: # noqa: N802
return self.input_cp
def GetConsoleOutputCP(self) -> int: # noqa: N802
return self.output_cp
def SetConsoleCP(self, codepage: int) -> int: # noqa: N802
self.calls.append(("SetConsoleCP", codepage))
self.input_cp = codepage
return 1
def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802
self.calls.append(("SetConsoleOutputCP", codepage))
self.output_cp = codepage
return 1
def test_main_runs_the_build_with_a_utf8_console(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""An attached console is switched to UTF-8 and then put back."""
kernel32 = _FakeKernel32(850, 850)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == [
("SetConsoleCP", runner.UTF8_CODEPAGE),
("SetConsoleOutputCP", runner.UTF8_CODEPAGE),
("SetConsoleCP", 850),
("SetConsoleOutputCP", 850),
]
def test_main_restores_the_console_when_the_build_dies(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A failing build must not leave the user's console on UTF-8."""
kernel32 = _FakeKernel32(437, 437)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py")
with pytest.raises(SystemExit):
runner.main()
assert (kernel32.input_cp, kernel32.output_cp) == (437, 437)
def test_main_restores_the_console_when_the_switch_fails_part_way(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A failed output page switch must not strand the changed input page."""
kernel32 = _FakeKernel32(850, 850)
def _refuse(codepage: int) -> int:
kernel32.calls.append(("SetConsoleOutputCP", codepage))
return 0
kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign]
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.input_cp == 850
assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)]
def test_main_leaves_the_console_alone_when_there_is_none(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""Without a console the code page calls return 0 and nothing is set."""
kernel32 = _FakeKernel32(0, 0)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == []
def test_main_leaves_a_console_already_on_utf8_alone(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""An overlapping build must not save UTF-8 as the page to restore."""
kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == []
@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows")
def test_get_kernel32_is_none_off_windows() -> None:
assert runner._get_kernel32() is None
-18
View File
@@ -28,7 +28,6 @@ def _make_service_info(
network: str | None = "wifi",
friendly_name: str | None = "Living Room",
version: str | None = "2025.1.0",
ota_signed: str | None = None,
) -> MagicMock:
"""Build a fake ``AsyncServiceInfo`` with the TXT records we care about.
@@ -49,8 +48,6 @@ def _make_service_info(
properties[b"friendly_name"] = friendly_name.encode()
if version is not None:
properties[b"version"] = version.encode()
if ota_signed is not None:
properties[b"ota_signed"] = ota_signed.encode()
info.properties = properties
info.load_from_cache.return_value = True
return info
@@ -80,7 +77,6 @@ def test_added_service_populates_import_state_and_fires_callback() -> None:
assert entry.project_version == "1.0.0"
assert entry.network == "wifi"
assert entry.friendly_name == "Living Room"
assert entry.ota_signed is False
on_update.assert_called_once_with(name, entry)
@@ -210,20 +206,6 @@ def test_network_defaults_to_wifi_when_txt_absent() -> None:
assert discovery.import_state[name].network == "wifi"
def test_ota_signed_txt_is_parsed() -> None:
"""``ota_signed=1`` marks a device that only accepts signed OTA images.
Lets a consumer check whether it holds a key the device trusts before
an update is attempted, instead of failing at install time.
"""
discovery = DashboardImportDiscovery()
info = _make_service_info(ota_signed="1")
name = f"signed.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
assert discovery.import_state[name].ota_signed is True
def test_friendly_name_optional() -> None:
"""``friendly_name`` may be ``None`` if the device doesn't broadcast it.