Compare commits

..
Author SHA1 Message Date
J. Nick Koston 7c992d1ad3 [at581x] Apply configured false and zero values in the settings action 2026-09-22 23:59:43 +01:00
luar123 79532f2a85 [zigbee] Require mbedTLS extras for Zigbee crypto (#19457) 2026-09-22 22:48:50 +01:00
dependabot[bot] 2b805b897b Bump platformdirs from 4.11.10 to 4.11.11 (#19465) 2026-09-22 22:08:24 +01:00
dependabot[bot] 44cf2b594a Bump filelock from 4.0.0 to 4.0.1 (#19464) 2026-09-22 22:08:00 +01:00
esphome[bot] f4aaa595f8 Bump aioesphomeapi from 46.4.1 to 46.5.0 (#19463) 2026-09-22 17:59:19 +00:00
dependabot[bot] a78e955691 Bump esphome/workflows/.github/workflows/stale.yml from a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 to cc3e76de337dc59bc1cba8da58d963cd23b873f1 (#19444)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 22:42:45 +01:00
dependabot[bot] 632f63b34b Bump github/codeql-action/init from 4.38.0 to 4.38.1 (#19445)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 22:42:33 +01:00
dependabot[bot] cd50f949ae Bump github/codeql-action/analyze from 4.38.0 to 4.38.1 (#19447)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 22:42:25 +01:00
dependabot[bot] 352121d60f Bump esphome/workflows/.github/workflows/lock.yml from 2026.8.1 to 2026.9.0 (#19448)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 22:42:02 +01:00
dependabot[bot] 3b4a6f21f9 Bump filelock from 3.32.6 to 4.0.0 (#19442)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 22:41:52 +01:00
dependabot[bot]andesphome[bot] cd012b57ed Bump ruff from 0.16.7 to 0.16.8 (#19441)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 22:41:36 +01:00
dependabot[bot] 8f3f25b832 Bump codecov/codecov-action from 7.1.0 to 7.1.1 (#19446)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 22:41:22 +01:00
dependabot[bot] 81dd57b402 Bump platformdirs from 4.11.8 to 4.11.10 (#19443)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 22:41:00 +01:00
Iago VeigaandClyde Stubbs 5764a72f1b [syslog] Add configurable RFC 3164 and RFC 5424 formats (#18993)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-09-22 07:26:06 +10:00
esphome[bot] 7a27d5dde1 [ci] Refresh integration test durations (#19435) 2026-09-21 11:45:23 +01:00
Jonathan SwobodaandClaude Fable 5.1 a00d241cad [esp32] Disable newlib nano printf formatting on ESP32-C2 (#19418)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 18:08:56 -04:00
Jesse Hills c10c06eeb5 [ci] Add code-review agent skill for GitHub Copilot (#19370) 2026-09-20 00:44:33 +12:00
rwrozelleandClaude Sonnet 5 f94661561d [socket] Fix set_sockaddr() accepting malformed IPv4 addresses (#19394)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:10:11 -05:00
Xiretza a53efcf646 [core] fix capitalisation of "pin" (#19405) 2026-09-18 18:30:35 -04:00
Keith BurzinskiandClaude Opus 5 623aec2984 [mdns] Advertise ota_signed when signed OTA verification is compiled in (#19407)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:18:44 -05:00
dependabot[bot] aba49c3d4f Bump docker/build-push-action from 7.3.0 to 7.4.0 in /.github/actions/build-image (#19403)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:32:42 -05:00
dependabot[bot] 28189ea026 Bump docker/setup-buildx-action from 4.3.0 to 4.4.0 in the docker-actions group (#19404)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:32:33 -05:00
dependabot[bot] f6897f2257 Bump codecov/codecov-action from 7.0.0 to 7.1.0 (#19382)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-17 14:57:00 -05:00
9309cf96b9 [web_server_idf] Close stalled EventSource sessions via HTTPD (#17800)
Co-authored-by: Jeroen Jansen <jeroen@Jeroens-MacBook-Air.local>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-09-17 10:38:56 -05:00
J. Nick Koston a63dc0d9c9 [esphome] Keep the OTA encryption key without the api server so safe mode uploads work (#19349) 2026-09-17 10:20:41 -05:00
J. Nick Koston f433f99e90 [esp32_hosted] Fix ESP32-P4 build without wifi, espnow or BLE (#19374) 2026-09-17 14:57:59 +00:00
J. Nick Koston 1dbbf3f45a [espidf] Switch the Windows console to UTF-8 while idf.py runs (#19352) 2026-09-17 09:40:22 -05:00
J. Nick Koston 91ab229086 [core] Lint bare string literal ternaries in ESP_LOG arguments (#18908) 2026-09-17 09:17:08 -05:00
63 changed files with 1504 additions and 577 deletions

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
---
name: code-review
description: Review guidance for ESPHome pull requests. Use this when reviewing a pull request that changes ESPHome Python, C++, or component code, to check it against the project's coding conventions, embedded-systems memory rules, testing requirements, and breaking-change policy.
---
# Reviewing ESPHome pull requests
ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers
(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and
flash are scarce, and code runs unattended for months.
`AGENTS.md` in the repository root is the full contributor guide and the
authority when it disagrees with this summary. The developer documentation at
https://developers.esphome.io explains the component lifecycle and the reasoning
behind these rules. This skill lists the concrete things worth flagging in a
review; read `AGENTS.md` for the detail behind any item.
Only raise findings that the diff actually introduces or changes. Do not ask for
drive-by cleanup of pre-existing code the PR did not touch.
## Memory and embedded constraints (highest value)
Heap allocation after `setup()` is treated as a reliability bug, not a
performance nit, because it fragments a small shared heap. Flag:
- New heap allocation on a hot path or after setup that could be avoided.
- `std::vector` where the size is known at compile time (use `std::array`, or
`StaticVector<T, N>` when a `push_back` API is needed) or fixed at runtime
init (use `FixedVector<T>`).
- Listener / child-entity registration lists stored as `std::vector`; these have
a compile-time-known count and should use `cg.slot_counter()` plus
`StaticVector`.
- `std::vector<uint8_t>` for a byte buffer that never grows: prefer
`std::unique_ptr<uint8_t[]>` or `std::array`.
- `std::map` / `std::set` / `std::unordered_map` for small datasets (1-16
elements): a `std::vector` of a small struct with linear search is lighter.
- `std::deque` anywhere: it allocates 512-byte blocks and should be avoided.
- `std::string` storing a value set once from config: prefer `StringRef` (the
literal already lives in flash).
- `std::string` / `std::to_string` / string-returning helpers on hot paths where
a buffer or view API exists.
## C++ conventions
- Include what you use: a file referencing a symbol must include the header that
declares it, even if it currently arrives transitively. New or changed uses of
a symbol need the matching include.
- Prefix all member access with `this->`.
- Naming: `lower_snake_case` for functions/methods/variables, `UpperCamelCase`
for classes/structs/enums, `UPPER_SNAKE_CASE` for namespace-scope constants,
trailing underscore on protected/private fields.
- `enum class` values must be prefixed with the enum name in `UPPER_SNAKE_CASE`
(e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Bare names like `SUCCESS`,
`FAIL`, or `OK` collide with SDK macros on some platforms and break the build.
- Prefer `const`/`enum` over `#define`; `#define` is only for conditional
compilation and code-generation sizes.
- Never call `millis()` in a `loop()` body; use
`App.get_loop_component_start_time()`. A rate-limit gate below ~16 ms (the loop
period) does nothing.
- Pick the timing primitive by cadence: gated `loop()` under 250 ms,
`set_interval` at 500 ms and above.
- Do not override a base method to return the value it already returns (e.g.
`get_setup_priority()` returning `setup_priority::DATA`).
- Wrap string literals passed as printf `%s` args in `LOG_STR_LITERAL()`.
- Required, invariant dependencies should be constructor parameters, not setters.
- Callback registration methods must be templated (`template<typename F>`), not
typed as `std::function`, so lightweight forwarders avoid a heap allocation.
- Two-space indent, `using` over `typedef`, wrap at 120 columns.
## Python conventions
- Type-annotate every new function signature (params and return), new dataclass
fields, and new module-level variables. Import `ConfigType` from
`esphome.types`.
- Use the walrus operator to avoid a double lookup, e.g.
`if (blah := config.get(CONF_BLAH)) is not None:`.
- Reuse existing validators from `config_validation.py` (`cv.rename_key`,
`cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling.
- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in
the component's own `.py`, or in `esphome/components/const/__init__.py` when
shared. The same constant defined in three or more component files fails CI.
- State that must persist during code generation goes in `CORE.data` namespaced
under the component `DOMAIN` (a `@dataclass`), not module-level mutable globals.
- Prefer callback-based triggers via `build_callback_automation()`; only use a
`Trigger<Ts...>` subclass when the forwarder needs mutable state.
## Testing and coverage
- New and changed lines and branches need test coverage, including defensive
early-returns, error paths, and no-op guards. A mocked-out function is not
covered; exercise the real call path too.
- Component YAML tests live in `tests/components/<component>/`. Never define
buses (uart, i2c, spi, modbus) directly in a test file: pull them from
`tests/test_build_components/common/` through dict-style `packages:` so CI can
group builds. List-style packages or top-level merge keys block grouping.
- Config-only checks use the `validate.*.yaml` prefix; compiled checks use
`test.*.yaml`.
## Breaking changes and public API
- Base classes under `esphome/core/` and documented config options are public
API. Undocumented `public` members of a component are internal.
- A breaking change needs justification, a migration path in the PR description,
and a deprecation window where feasible (`ESPDEPRECATED` in C++,
`cv.rename_key(..., removed_in=...)` in Python). Changing a codegen-injected
lambda signature is not a breaking change.
## Process and PR hygiene
- PR titles start with a `[tag]` prefix: the component name (e.g. `[uart] ...`)
or `[core]` for shared code.
- Prose in docs, comments, and commit messages should be plain English. Keep
inline comments short and only where the code is not self-explanatory; do not
restate what the code says.
- Verify the PR fills out `.github/PULL_REQUEST_TEMPLATE.md` and adds
`CODEOWNERS` entries for a new component.
+2 -2
View File
@@ -42,7 +42,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Log in to the GitHub container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+2 -2
View File
@@ -321,7 +321,7 @@ jobs:
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
- 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
run_command: empty-upload
force: true
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0
+2 -2
View File
@@ -123,7 +123,7 @@ jobs:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
+1 -1
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.7
rev: v0.16.8
hooks:
# Run the linter.
- id: ruff
+24 -2
View File
@@ -29,6 +29,29 @@ 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) {
@@ -43,8 +66,7 @@ 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
uint32_t hash = 88491486UL;
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true);
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(NOISE_PSK_PREF_HASH, true);
#ifndef USE_API_NOISE_PSK_FROM_YAML
// A cleared record loads fine but holds no key
if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) {
+5
View File
@@ -43,6 +43,11 @@ struct SavedNoisePsk {
noise::psk_t psk;
} PACKED; // NOLINT
#endif
#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED)
/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when
/// there is no key
bool load_saved_noise_psk(noise::psk_t &out);
#endif
class APIServer final : public Component,
public Controller
+7 -7
View File
@@ -181,11 +181,11 @@ async def at581x_settings_to_code(
await cg.register_parented(var, config[CONF_ID])
# Radar configuration
if frontend_reset := config.get(CONF_HW_FRONTEND_RESET):
if (frontend_reset := config.get(CONF_HW_FRONTEND_RESET)) is not None:
template_ = await cg.templatable(frontend_reset, args, cg.int8)
cg.add(var.set_hw_frontend_reset(template_))
if freq := config.get(CONF_FREQUENCY):
if (freq := config.get(CONF_FREQUENCY)) is not None:
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):
if (selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME)) is not None:
template_ = await cg.templatable(selfcheck, args, cg.int_)
cg.add(var.set_poweron_selfcheck_time(template_))
if protect := config.get(CONF_PROTECT_TIME):
if (protect := config.get(CONF_PROTECT_TIME)) is not None:
template_ = await cg.templatable(protect, args, cg.int_)
cg.add(var.set_protect_time(template_))
if trig_base := config.get(CONF_TRIGGER_BASE):
if (trig_base := config.get(CONF_TRIGGER_BASE)) is not None:
template_ = await cg.templatable(trig_base, args, cg.int_)
cg.add(var.set_trigger_base(template_))
if trig_keep := config.get(CONF_TRIGGER_KEEP):
if (trig_keep := config.get(CONF_TRIGGER_KEEP)) is not None:
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):
if (power := config.get(CONF_POWER_CONSUMPTION)) is not None:
if not cg.is_template(power):
power = int(power * 1000000)
template_ = await cg.templatable(power, args, cg.int_)
-11
View File
@@ -137,11 +137,6 @@ 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;
@@ -715,12 +710,6 @@ 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.
*/
@@ -113,7 +113,6 @@ 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:
+6 -1
View File
@@ -262,7 +262,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_lcd", # LCD controller drivers - only needed by display component
"esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API
"esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds
"esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; 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,6 +2792,11 @@ 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,6 +290,8 @@ 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
+7 -11
View File
@@ -316,20 +316,16 @@ 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 {}
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:
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
cg.add_define("USE_OTA_ENCRYPTION")
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key)))
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], 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_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_PROVISIONED")
if CONF_ENCRYPTION in config:
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.
+17 -5
View File
@@ -1,5 +1,5 @@
#include "ota_esphome.h"
#ifdef USE_OTA_ENCRYPTION_FROM_API
#ifdef USE_OTA_ENCRYPTION_PROVISIONED
#include "esphome/components/api/api_server.h"
#endif
#ifdef USE_OTA
@@ -32,11 +32,13 @@ static const char *const TAG = "esphome.ota";
#ifdef USE_OTA_ENCRYPTION
const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
#ifdef USE_OTA_ENCRYPTION_FROM_API
return api::global_api_server->get_noise_ctx();
#else
return this->noise_ctx_;
#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
return this->noise_ctx_;
}
#endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
@@ -58,6 +60,16 @@ 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"));
+6 -3
View File
@@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
}
#endif // USE_OTA_PASSWORD
#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API)
#ifdef USE_OTA_ENCRYPTION
/// 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,7 +86,8 @@ 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 the api has encryption, else our own
// The api server's live context when it exists, otherwise our own (a build
// time key, or the saved key loaded in safe mode)
const noise::NoiseContext &noise_context_() const;
bool noise_start_session_(uint8_t server_feature_flags);
bool handle_noise_handshake_();
@@ -148,8 +149,10 @@ 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
+58 -84
View File
@@ -5,107 +5,81 @@
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 x_start = 0;
int y_start = 0;
int x_end = width_;
int y_end = height_;
int img_x0 = 0;
int img_y0 = 0;
int w = width_;
int h = height_;
auto clipping = display->get_clipping();
if (clipping.is_set()) {
if (clipping.x > x)
x_start += clipping.x - x;
img_x0 += clipping.x - x;
if (clipping.y > 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;
img_y0 += clipping.y - y;
if (w > clipping.x2() - x)
w = clipping.x2() - x;
if (h > clipping.y2() - y)
h = clipping.y2() - y;
}
// 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
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);
}
} 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_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) {
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;
}
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_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);
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);
}
}
});
}
break;
}
}
-4
View File
@@ -43,10 +43,6 @@ 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;
-1
View File
@@ -204,7 +204,6 @@ 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,6 +118,9 @@ 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
@@ -186,6 +189,13 @@ 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");
+13 -5
View File
@@ -119,8 +119,19 @@ class MipiSpi : public display::Display,
}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
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; }
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;
}
void set_init_sequence(const std::vector<uint8_t> &sequence) { this->init_sequence_ = sequence; }
@@ -591,9 +602,6 @@ 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))
+12 -6
View File
@@ -5,11 +5,12 @@ 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 ID
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "noise"
noise_ns = cg.esphome_ns.namespace("noise")
@@ -70,11 +71,16 @@ 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."""
return cg.progmem_array(
ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8),
list(decode_encryption_key(key)),
)
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
def encryption_schema(config: ConfigType | None) -> ConfigType:
+1 -1
View File
@@ -211,7 +211,7 @@ bool Snapshot::take_snapshot(const char *filename) {
exact = !name.empty();
if (name_changed) {
ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename,
name.empty() ? "a name made from the time" : name.c_str());
name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str());
}
}
if (name.empty()) {
+5 -1
View File
@@ -185,7 +185,11 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
return 0;
}
#else
server->sin_addr.s_addr = inet_addr(ip_address);
// 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;
}
#endif
server->sin_port = htons(port);
return sizeof(sockaddr_in);
+11 -1
View File
@@ -4,7 +4,7 @@ from esphome.components.logger import LOG_LEVELS, is_log_level, request_log_list
from esphome.components.time import RealTimeClock
from esphome.components.udp import CONF_UDP_ID
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID
from esphome.const import CONF_FORMAT, CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID
from esphome.cpp_types import Component, Parented
from esphome.types import ConfigType
@@ -14,6 +14,12 @@ 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"
@@ -25,6 +31,9 @@ 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
),
}
)
@@ -41,3 +50,4 @@ 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]))
+24 -14
View File
@@ -49,27 +49,37 @@ 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
}
remaining -= offset;
// 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++] = '-';
}
if (this->format_ == SYSLOG_FORMAT_RFC5424) {
offset = buf_append_str(packet, sizeof(packet), offset, "1 ");
// 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);
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);
}
// Clamp to exclude null terminator position if buffer was filled
if (offset >= sizeof(packet)) {
offset = sizeof(packet) - 1;
@@ -7,6 +7,11 @@
#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) {}
@@ -14,6 +19,7 @@ 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_;
@@ -21,6 +27,7 @@ class Syslog final : public Component, public Parented<udp::UDPComponent> {
time::RealTimeClock *time_;
bool strip_{true};
int facility_{16};
SyslogFormat format_{SYSLOG_FORMAT_RFC3164};
};
} // namespace esphome::syslog
#endif
+4 -4
View File
@@ -200,10 +200,10 @@ void DeferredUpdateEventSource::process_deferred_queue_() {
deferred_queue_.erase(deferred_queue_.begin());
this->consecutive_send_failures_ = 0; // Reset failure count on successful send
} else {
// NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_()
// The 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.
// 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.
this->consecutive_send_failures_++;
if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) {
// Too many failures, connection is likely dead
@@ -6,6 +6,7 @@
#include <cctype>
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -728,7 +729,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->fd_.load() == 0) {
if (ses->safe_to_delete_()) {
// 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)
@@ -751,7 +752,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->fd_.load() == 0) {
if (rsp->safe_to_delete_()) {
delete rsp; // NOLINT(cppcoreguidelines-owning-memory)
continue;
}
@@ -865,10 +866,16 @@ 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 {
@@ -877,8 +884,64 @@ 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 (event_buffer_.empty()) {
if (this->close_requested_ || event_buffer_.empty()) {
return;
}
if (event_bytes_sent_ == event_buffer_.size()) {
@@ -892,32 +955,33 @@ 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 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();
// 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_();
}
return;
}
if (bytes_sent == HTTPD_SOCK_ERR_FAIL) {
// Real socket error - connection will be closed by httpd and destroy callback will be called
// Low-level asynchronous sends do not make HTTPD close the session automatically.
this->request_close_();
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 failure counter
this->consecutive_send_failures_ = 0;
// Successful send - reset stall tracking
this->send_failure_started_ms_ = 0;
event_bytes_sent_ += bytes_sent;
// Log partial sends for debugging
@@ -933,20 +997,26 @@ 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) {
if (this->fd_.load() == 0 || this->close_requested_) {
return false;
}
process_buffer_();
if (!event_buffer_.empty()) {
if (this->close_requested_ || !event_buffer_.empty()) {
// there is still pending event data to send first
return false;
}
@@ -1098,6 +1168,10 @@ 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,6 +301,14 @@ 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_;
@@ -311,8 +319,16 @@ class AsyncEventSourceResponse {
esphome::web_server::ListEntitiesIterator entities_iterator_;
std::string event_buffer_;
size_t event_bytes_sent_;
uint16_t consecutive_send_failures_{0};
static constexpr uint16_t MAX_CONSECUTIVE_SEND_FAILURES = 2500; // ~20 seconds at 125Hz loop rate
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;
};
using AsyncEventSourceClient = AsyncEventSourceResponse;
@@ -10,6 +10,7 @@ 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
@@ -289,6 +290,12 @@ async def esp32_to_code(config: ConfigType) -> "MockObj":
ref="2.0.4",
)
# Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly.
# Keep the esp32 component from trimming them out of mbedTLS.
require_mbedtls_tls_extras(
("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC")
)
if CONF_WIFI in CORE.config:
# zigbee_esp32.cpp uses esp_coexist.h when WiFi is present
include_builtin_idf_component("esp_coex")
-1
View File
@@ -283,7 +283,6 @@
#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
+85 -14
View File
@@ -74,6 +74,64 @@ 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 ---------------------------------------------------
@@ -269,8 +327,29 @@ 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
stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment]
stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment]
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()
# 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
@@ -288,19 +367,11 @@ 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 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:
# 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()):
runpy.run_path(script_path, run_name="__main__")
finally:
# Drain stderr from a finally so a surprise from the first one cannot
# strand the second.
try:
stdout_shim.drain()
finally:
stderr_shim.drain()
return 0
+1 -1
View File
@@ -272,7 +272,7 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger):
num = conf[CONF_NUMBER]
if num in strapping_pin_list and not conf.get(CONF_IGNORE_STRAPPING_WARNING):
logger.warning(
"GPIO%s is a strapping PIN and should only be used for I/O with care.\n"
"GPIO%s is a strapping pin and should only be used for I/O with care.\n"
"Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n"
"See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins",
num,
+5
View File
@@ -56,6 +56,7 @@ 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
@@ -76,6 +77,8 @@ 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):
@@ -169,6 +172,7 @@ 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()
@@ -180,6 +184,7 @@ class DashboardImportDiscovery:
project_name=project_name,
project_version=project_version,
network=network,
ota_signed=ota_signed,
)
is_new = name not in self.import_state
self.import_state[name] = discovered
+3 -3
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.4.0
click==8.3.3
aioesphomeapi==46.4.1
aioesphomeapi==46.5.0
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.8 # native esp-idf toolchain global cache dir
platformdirs==4.11.11 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native esp8266 arduino toolchain build driver
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
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
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.2
+1 -1
View File
@@ -1,6 +1,6 @@
pylint==4.0.8
flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.16.7 # .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
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
+140 -6
View File
@@ -3,6 +3,7 @@
import argparse
import codecs
import collections
from collections.abc import Iterator
import fnmatch
import functools
import os.path
@@ -1138,7 +1139,56 @@ def lint_no_std_bind(fname, match):
)
LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL)
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_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)')
@@ -1146,16 +1196,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_match in LOG_MULTILINE_RE.finditer(content):
log_text = log_match.group(0)
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 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
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)
lineno, col = _line_col(content, log_start + bad_match.start())
errs.append(
(
lineno,
@@ -1173,6 +1223,90 @@ 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"],
@@ -1,17 +0,0 @@
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
@@ -1,124 +0,0 @@
#include <benchmark/benchmark.h>
#include <cstdint>
#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]; }
};
// 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);
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
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32-c6-devkitc-1
framework:
type: esp-idf
zigbee:
model: zigbee_test
power_source: MAINS_SINGLE_PHASE
+25 -4
View File
@@ -1457,7 +1457,7 @@ def test_esp32_s31_gpio_validation(
pin = {CONF_NUMBER: 36, CONF_MODE: input_mode}
with caplog.at_level("WARNING"):
validate_supports(pin)
assert "GPIO36 is a strapping PIN" in caplog.text
assert "GPIO36 is a strapping pin" in caplog.text
_TLS_SERVER_OPTIONS = (
@@ -1488,7 +1488,7 @@ def test_mbedtls_tls_trim_sdkconfig(
assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras}
_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
_CCM_ECDSA_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
@@ -1500,7 +1500,19 @@ 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 _OPENTHREAD_EXTRAS else False)
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)
def test_mbedtls_tls_user_sdkconfig_wins(
@@ -1529,7 +1541,16 @@ 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] == _OPENTHREAD_EXTRAS
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
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
+10 -16
View File
@@ -476,43 +476,36 @@ def test_static_encryption_key() -> None:
("yaml_name", "defines_present", "defines_absent"),
[
# An api key alone compiles the transport in without requiring it;
# the device uses the api server's key, not a copy
# the ota keeps its own pointer to the key so safe mode, which never
# constructs the api server, can still use it
(
"api_key_offer",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
{"USE_OTA_ENCRYPTION"},
{"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_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"},
{"USE_OTA_ENCRYPTION", "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_FROM_API",
},
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
{"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_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"},
{"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_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_PROVISIONED"},
{"USE_OTA_ENCRYPTION_REQUIRED"},
),
# No api encryption at all keeps the noise glue out of the build
@@ -522,7 +515,6 @@ def test_static_encryption_key() -> None:
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_REQUIRED",
"USE_OTA_ENCRYPTION_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
),
@@ -541,8 +533,10 @@ 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_FROM_API" not in defines_present
own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" 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 @@
# 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,15 @@
# Signed OTA plus the native api: the only combination that emits ota_signed.
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
wifi:
ssid: MySSID
password: password1
api:
mdns:
+16
View File
@@ -0,0 +1,16 @@
# This file's presence makes pytest treat this directory as a package named "socket",
# shadowing the stdlib module -- no test_*.py (Python-level) tests can live here.
# required for cpp unit testing.
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
real_to_code = manifest.to_code
async def to_code_testing(config):
await real_to_code(config)
cg.add_define("USE_NETWORK_IPV6", True)
manifest.to_code = to_code_testing
-12
View File
@@ -1,12 +0,0 @@
"""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()
@@ -0,0 +1,146 @@
#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
@@ -0,0 +1,8 @@
<<: !include common.yaml
syslog:
port: 514
strip: true
level: info
facility: 16
format: RFC5424
@@ -0,0 +1,13 @@
esphome:
name: host-ota-test
host:
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
port: __OTA_PORT__
encryption:
safe_mode:
logger:
level: DEBUG
@@ -0,0 +1,11 @@
esphome:
name: host-ota-test
host:
api:
encryption:
ota:
- platform: esphome
port: __OTA_PORT__
safe_mode:
logger:
level: DEBUG
+1
View File
@@ -41,3 +41,4 @@ syslog:
level: DEBUG
strip: true
facility: 16
format: SYSLOG_FORMAT_PLACEHOLDER
+29 -6
View File
@@ -15,6 +15,12 @@ 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.
@@ -42,16 +48,33 @@ 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 += struct.pack("<IB", key, len(data)) + data
payload += _ENTRY.pack(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 write_host_pref(device_name: str, key: int, data: bytes) -> Path:
"""Write a single preference entry, replacing the file's contents.
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
Returns the path that was written.
"""
return write_host_prefs(device_name, {key: data})
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)
+153 -152
View File
@@ -1,155 +1,156 @@
{
"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_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_template_climate_basic.py": 20.51,
"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
"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
}
+75 -5
View File
@@ -29,6 +29,7 @@ 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"
@@ -166,6 +167,15 @@ 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,
@@ -227,6 +237,31 @@ 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,
@@ -305,11 +340,7 @@ async def test_host_ota_provisioned_api_key(
None, None, "plaintext upload to an unprovisioned device must succeed"
)
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)
await _provision_key(dev, api_client_connected)
key = PROVISIONING_PSK.decode()
await dev.ota(
@@ -319,6 +350,45 @@ 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,
+2 -10
View File
@@ -25,19 +25,13 @@ from __future__ import annotations
import asyncio
import re
import struct
import pytest
from .conftest import run_binary
from .host_prefs import clear_host_prefs, write_host_pref
from .host_prefs import clear_host_prefs, force_safe_mode
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"
@@ -56,9 +50,7 @@ async def test_safe_mode_loop_runs(
# Compile finished successfully; pre-populate prefs so the *next* run
# enters safe mode immediately.
write_host_pref(
DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack("<I", ENTER_SAFE_MODE_MAGIC)
)
force_safe_mode(DEVICE_NAME)
try:
loop = asyncio.get_running_loop()
+28 -9
View File
@@ -31,14 +31,25 @@ 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...
SYSLOG_PATTERN = re.compile(
RFC3164_PATTERN = re.compile(
r"<(\d+)>" # PRI (priority = facility * 8 + severity)
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+ +\d+ \d+:\d+:\d+) )?" # Optional BSD TIMESTAMP
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:
@@ -123,9 +134,10 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]:
sock.close()
def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None:
def parse_syslog_message(msg: str, format_: str) -> ParsedSyslogMessage | None:
"""Parse a syslog message and return its components."""
match = SYSLOG_PATTERN.match(msg)
pattern = RFC3164_PATTERN if format_ == "RFC3164" else RFC5424_PATTERN
match = pattern.fullmatch(msg)
if not match:
return None
pri, timestamp, hostname, tag, message = match.groups()
@@ -137,7 +149,7 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None:
pri=pri_val,
facility=facility,
severity=severity,
timestamp=timestamp,
timestamp=timestamp or "",
hostname=hostname,
tag=tag,
message=message,
@@ -145,15 +157,18 @@ def parse_syslog_message(msg: 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
@@ -176,7 +191,7 @@ async def test_syslog(
# Parse and validate all messages
parsed_messages: list[ParsedSyslogMessage] = []
for msg in receiver.messages:
parsed = parse_syslog_message(msg)
parsed = parse_syslog_message(msg, format_)
if parsed:
parsed_messages.append(parsed)
@@ -204,12 +219,16 @@ async def test_syslog(
f"Unexpected hostname: {parsed['hostname']}"
)
# Validate timestamp format (BSD or NILVALUE)
if parsed["timestamp"] != "-":
if format_ == "RFC3164" and parsed["timestamp"]:
assert re.match(
r"[A-Z][a-z]{2} +\d+ \d{2}:\d{2}:\d{2}",
parsed["timestamp"],
), f"Invalid timestamp format: {parsed['timestamp']}"
elif format_ == "RFC5424" and parsed["timestamp"] != "-":
assert re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}",
parsed["timestamp"],
), f"Invalid timestamp format: {parsed['timestamp']}"
# Verify we see different severity levels in the logs
severities_seen = {p["severity"] for p in parsed_messages}
+126
View File
@@ -4,12 +4,16 @@ 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")
@@ -145,3 +149,125 @@ def test_nolint_at_end_of_log_line_suppresses() -> None:
def test_nolint_on_control_line_suppresses() -> None:
assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n")
# --- ESP_LOG call scanner and bare-literal-ternary lint ---
def _calls(content: str) -> list[str | None]:
return [text for _, text in ci_custom._iter_log_calls(content)]
def _ternary_errors(content: str) -> list[tuple[int, int]]:
errs = ci_custom.lint_log_no_bare_literal_ternary(Path("x.cpp"), content)
return [(line, col) for line, col, _ in errs]
@pytest.mark.parametrize(
"content",
[
'ESP_LOGD(TAG, "a ) b ( c; d")',
'ESP_LOGD(TAG, "quote \\" inside")',
"ESP_LOGD(TAG, \"%s\", format_hex_pretty(x, '-', false).c_str())",
"ESP_LOGD(TAG, \"%c%c\", '(', ')')",
"ESP_LOGD(TAG, \"%d\", 1'000'000)",
'ESP_LOGD(TAG, // it\'s a comment with ) and (\n "x")',
'ESP_LOGD(TAG, /* :) */ "x")',
'ESP_LOGD(TAG, "%s", R"(say "hi" :) )")',
'ESP_LOGD(TAG, "%s", R"x(a)"b)x")',
],
)
def test_iter_log_calls_spans_whole_call(content: str) -> None:
calls = _calls(content + ";\nint other = (1);")
assert calls == [content]
def test_iter_log_calls_reports_unbalanced_call_once() -> None:
content = 'ESP_LOGD(TAG, "x";\nvoid f();'
assert _calls(content) == [None]
errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content)
assert len(errs) == 1
assert errs[0][:2] == (1, 1)
assert "no matching closing parenthesis" in errs[0][2]
assert _ternary_errors(content) == []
@pytest.mark.parametrize(
("content", "expected"),
[
# A ; inside the format string no longer cuts the call short
('ESP_LOGD(TAG, "a; b\\nc %s", x);', [(1, 20)]),
# A \n%s continuation is exempt since %s may expand to leading whitespace
('ESP_LOGD(TAG, "a\\n%s", x);', []),
('ESP_LOGD(TAG, "a\\n b");', []),
],
)
def test_multiline_continuation_detection(
content: str, expected: list[tuple[int, int]]
) -> None:
errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content)
assert [(line, col) for line, col, _ in errs] == expected
def test_exclusion_list_only_names_components_without_esp8266_tests() -> None:
root = Path(__file__).parent / ".." / ".."
for pattern in ci_custom.LOG_LITERAL_LINT_EXCLUDE:
if not pattern.startswith("esphome/components/"):
continue
prefix = pattern.removeprefix("esphome/components/").split("/")[0]
comps = list((root / "esphome" / "components").glob(prefix))
assert comps, f"{pattern!r} matches no component"
for comp in comps:
test = root / "tests" / "components" / comp.name / "test.esp8266-ard.yaml"
assert not test.exists(), (
f"{comp.name} builds for ESP8266, drop {pattern!r}"
)
def test_unbalanced_calls_are_reported_by_a_check_that_sees_every_file() -> None:
# lint_log_no_bare_literal_ternary skips unbalanced calls and relies on this
checks = {c["func"].__name__: c for c in ci_custom.LINT_CONTENT_CHECKS}
continuation = checks["lint_log_multiline_continuation"]
ternary = checks["lint_log_no_bare_literal_ternary"]
assert continuation["exclude"] == []
assert continuation["include"] == ternary["include"]
@pytest.mark.parametrize(
("content", "expected"),
[
('ESP_LOGD(TAG, "%s", x ? "on" : "off");', [(1, 25), (1, 32)]),
(
'ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));',
[],
),
('ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : "off");', [(1, 49)]),
('ESP_LOGD(TAG, "%s", x ? "on" : "");', [(1, 25)]),
(
'ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no");',
[(2, 14), (3, 14)],
),
("ESP_LOGD(TAG, \"%c\", x ? '1' : '0');", []),
('ESP_LOGD(TAG, "a ? b : c %s", x ? "on" : "off");', [(1, 35), (1, 42)]),
('ESP_LOGD(TAG, "x:" "y %s", p);', []),
('ESP_LOGD(TAG, "%s", x ? "on" : "off"); // NOLINT', []),
('ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no"); // NOLINT', []),
('ESP_LOGD(TAG, "%s", x ? /* c */ "on" : "off");', [(1, 33), (1, 40)]),
(
'ESP_LOGD(TAG, "%s",\n x ? "on" // NOLINT(some-clang-check)\n : "off");',
[(2, 14), (3, 14)],
),
],
)
def test_ternary_literal_detection(
content: str, expected: list[tuple[int, int]]
) -> None:
assert _ternary_errors(content) == expected
def test_ternary_error_message_names_the_literal() -> None:
errs = ci_custom.lint_log_no_bare_literal_ternary(
Path("x.cpp"), 'ESP_LOGD(TAG, "%s", x ? "enabled" : LOG_STR_LITERAL("off"));'
)
assert len(errs) == 1
assert 'LOG_STR_LITERAL("enabled")' in errs[0][2]
+104
View File
@@ -209,3 +209,107 @@ def test_runner_streams_output_before_the_build_finishes(
# Join before leaving the block, so the reader is done rather than
# racing ``Popen`` closing the pipe under it.
reader.join(1.0)
class _FakeKernel32:
"""Stand-in for the Windows kernel32 console code page calls."""
def __init__(self, input_cp: int, output_cp: int) -> None:
self.input_cp = input_cp
self.output_cp = output_cp
self.calls: list[tuple[str, int]] = []
def GetConsoleCP(self) -> int: # noqa: N802
return self.input_cp
def GetConsoleOutputCP(self) -> int: # noqa: N802
return self.output_cp
def SetConsoleCP(self, codepage: int) -> int: # noqa: N802
self.calls.append(("SetConsoleCP", codepage))
self.input_cp = codepage
return 1
def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802
self.calls.append(("SetConsoleOutputCP", codepage))
self.output_cp = codepage
return 1
def test_main_runs_the_build_with_a_utf8_console(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""An attached console is switched to UTF-8 and then put back."""
kernel32 = _FakeKernel32(850, 850)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == [
("SetConsoleCP", runner.UTF8_CODEPAGE),
("SetConsoleOutputCP", runner.UTF8_CODEPAGE),
("SetConsoleCP", 850),
("SetConsoleOutputCP", 850),
]
def test_main_restores_the_console_when_the_build_dies(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A failing build must not leave the user's console on UTF-8."""
kernel32 = _FakeKernel32(437, 437)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py")
with pytest.raises(SystemExit):
runner.main()
assert (kernel32.input_cp, kernel32.output_cp) == (437, 437)
def test_main_restores_the_console_when_the_switch_fails_part_way(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""A failed output page switch must not strand the changed input page."""
kernel32 = _FakeKernel32(850, 850)
def _refuse(codepage: int) -> int:
kernel32.calls.append(("SetConsoleOutputCP", codepage))
return 0
kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign]
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.input_cp == 850
assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)]
def test_main_leaves_the_console_alone_when_there_is_none(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""Without a console the code page calls return 0 and nothing is set."""
kernel32 = _FakeKernel32(0, 0)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == []
def test_main_leaves_a_console_already_on_utf8_alone(
monkeypatch: pytest.MonkeyPatch, fixture_path: Path
) -> None:
"""An overlapping build must not save UTF-8 as the page to restore."""
kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE)
monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32)
_run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py")
assert kernel32.calls == []
@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows")
def test_get_kernel32_is_none_off_windows() -> None:
assert runner._get_kernel32() is None
+18
View File
@@ -28,6 +28,7 @@ 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.
@@ -48,6 +49,8 @@ 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
@@ -77,6 +80,7 @@ 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)
@@ -206,6 +210,20 @@ 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.