mirror of
https://github.com/esphome/esphome.git
synced 2026-10-28 00:42:11 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d92fdfa24b | ||
|
|
2da55e1f65 | ||
|
|
30e8b8379c | ||
|
|
1203e6009a | ||
|
|
a35fcc12c3 | ||
|
|
2b894cd255 | ||
|
|
eab2adc9f3 | ||
|
|
76b6251c6c | ||
|
|
cdfcb71f58 | ||
|
|
e3a62194e5 | ||
|
|
bf5e74120f | ||
|
|
d67a78d53a | ||
|
|
24edcbc1e1 | ||
|
|
3a460d950e | ||
|
|
1c2325da30 | ||
|
|
e41d3511ed | ||
|
|
eaec226add | ||
|
|
3dea35814c | ||
|
|
4d130624b4 | ||
|
|
de93f2e9ff |
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_)
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]))
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -41,4 +41,3 @@ syslog:
|
||||
level: DEBUG
|
||||
strip: true
|
||||
facility: 16
|
||||
format: SYSLOG_FORMAT_PLACEHOLDER
|
||||
@@ -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})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user