mirror of
https://github.com/esphome/esphome.git
synced 2026-09-04 03:56:04 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c29fd9e0 | ||
|
|
567a981078 | ||
|
|
81ecb87253 | ||
|
|
f0e2eb96bd | ||
|
|
567f7f9196 | ||
|
|
ecbde8ddf4 | ||
|
|
da16c01351 | ||
|
|
6099ac7b53 | ||
|
|
b8480b8424 | ||
|
|
8bef5b22e1 | ||
|
|
379e077b5f | ||
|
|
3f68930001 | ||
|
|
0aff9e1c54 | ||
|
|
f9824ee83f | ||
|
|
68ffd5a773 | ||
|
|
fe3788ff47 | ||
|
|
d6758377d1 | ||
|
|
5dbc8ffe4c | ||
|
|
0f982f03b2 | ||
|
|
afb0022dd0 |
@@ -374,8 +374,9 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||
# path. Packages and version must match seed-apt-cache exactly;
|
||||
# libsdl2-dev is unused here and carried only for cache-key parity.
|
||||
# path. Packages and version must match seed-apt-cache exactly.
|
||||
# libsdl2-dev is needed by the headless display tests, which capture
|
||||
# screenshots.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
@@ -438,6 +439,16 @@ jobs:
|
||||
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \
|
||||
--junitxml=junit-integration.xml "${test_files[@]}"
|
||||
- name: Upload test artifacts
|
||||
# Tests that compare rendered output write the image they actually got here, so a
|
||||
# failure can be looked at without reproducing the whole build locally.
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: integration-test-artifacts-${{ matrix.bucket.name }}
|
||||
path: test_artifacts/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Upload junit timings
|
||||
# Consumed by sync-integration-durations.yml through
|
||||
# script/update_integration_test_durations.py; only full matrix dev
|
||||
|
||||
@@ -137,6 +137,8 @@ config/
|
||||
!tests/component_tests/**/config/
|
||||
tests/build/
|
||||
tests/.esphome/
|
||||
# Output kept by failing tests for inspection; uploaded by CI
|
||||
test_artifacts/
|
||||
/.temp-clang-tidy.cpp
|
||||
/.temp/
|
||||
.pio/
|
||||
|
||||
@@ -44,6 +44,16 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
## 4. Coding Conventions & Style Guide
|
||||
|
||||
**Read the developer documentation before writing a component.** https://developers.esphome.io covers the
|
||||
component lifecycle, the main loop, and the reasoning behind the rules below in far more depth than this
|
||||
file does, and it is the authority when they disagree. The most useful starting points:
|
||||
|
||||
* https://developers.esphome.io/architecture/components/ - component lifecycle, `setup()`, `loop()`,
|
||||
setup priorities, and how a component is registered.
|
||||
* https://developers.esphome.io/architecture/components/advanced/ - choosing between `loop()`,
|
||||
`set_interval`, `set_timeout` and `defer`; waking the loop from another thread; the RAM cost of each.
|
||||
* https://developers.esphome.io/contributing/code/ - contribution rules, public API and breaking changes.
|
||||
|
||||
* **Formatting:**
|
||||
* **Python:** Uses `ruff` and `flake8` for linting and formatting. Configuration is in `pyproject.toml`.
|
||||
* **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`.
|
||||
@@ -142,6 +152,47 @@ This document provides essential context for AI models interacting with this pro
|
||||
* **Indentation:** Use spaces (two per indentation level), not tabs
|
||||
* **Type aliases:** Prefer `using type_t = int;` over `typedef int type_t;`
|
||||
* **Line length:** Wrap lines at no more than 120 characters
|
||||
* **Timing in `loop()`:** Never call `millis()` in a `loop()` body. The current tick's timestamp is
|
||||
already cached - use `App.get_loop_component_start_time()` (from `esphome/core/application.h`).
|
||||
Only reach for `millis()` when you genuinely need sub-tick resolution inside a long operation.
|
||||
* **The main loop runs every 16 ms.** A rate-limit gate shorter than that does nothing: the check
|
||||
passes on essentially every pass of the loop, so it costs a comparison and buys nothing. Pick an
|
||||
interval comfortably coarser than 16 ms, or drop the gate entirely and accept running every loop.
|
||||
```cpp
|
||||
// Bad - a 10ms gate against a 16ms loop never holds anything back
|
||||
static constexpr uint32_t POLL_INTERVAL_MS = 10;
|
||||
const uint32_t now = millis();
|
||||
if (now - this->last_poll_ < POLL_INTERVAL_MS)
|
||||
return;
|
||||
this->last_poll_ = now;
|
||||
```
|
||||
```cpp
|
||||
// Good - an interval that actually rate limits, off the cached timestamp
|
||||
static constexpr uint32_t POLL_INTERVAL_MS = 100;
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_poll_ < POLL_INTERVAL_MS)
|
||||
return;
|
||||
this->last_poll_ = now;
|
||||
```
|
||||
Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use
|
||||
`set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms:
|
||||
https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb
|
||||
* **Don't override a default with the same value:** if a base class method already returns what you
|
||||
want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a
|
||||
component that wants `DATA` should simply leave it alone.
|
||||
```cpp
|
||||
// Bad - this is exactly what the base class already does
|
||||
float get_setup_priority() const override { return setup_priority::DATA; }
|
||||
```
|
||||
* **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they
|
||||
can be stored in flash rather than RAM.
|
||||
```cpp
|
||||
// Bad
|
||||
ESP_LOGV(TAG, "Key %u %s", key, pressed ? "pressed" : "released");
|
||||
|
||||
// Good
|
||||
ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released"));
|
||||
```
|
||||
* **Constructor parameters vs setters:** Component properties that are both **required** and **invariant**
|
||||
(never change after construction) should be constructor parameters rather than set via setter methods.
|
||||
This makes the dependency explicit and prevents use of the object in an incompletely-initialized state.
|
||||
@@ -562,6 +613,33 @@ This document provides essential context for AI models interacting with this pro
|
||||
Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration.
|
||||
Like `std::array` but with vector-like API (`push_back()`, `size()`) and no STL reallocation code.
|
||||
|
||||
**Listener and child-entity registration lists are the most common case, and the most commonly
|
||||
missed.** A `register_*()` method called once per child at code generation time has a count that
|
||||
is known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it
|
||||
returns a function that each consumer calls once per slot it will occupy, and after every
|
||||
`to_code` has run it emits the define with the final count. When nothing registers, no define is
|
||||
emitted and the storage plus its registration method compile out entirely.
|
||||
```python
|
||||
# hub component's __init__.py
|
||||
_request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def register_listener(hub: MockObj, var: MockObj) -> None:
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
#endif
|
||||
protected:
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
StaticVector<MyComponentListener *, MY_COMPONENT_LISTENER_COUNT> listeners_;
|
||||
#endif
|
||||
```
|
||||
Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL` - a late
|
||||
request raises rather than silently undercounting.
|
||||
|
||||
3. **Runtime-known sizes:** Use `FixedVector` from `esphome/core/helpers.h` when the size is only known at runtime initialization.
|
||||
```cpp
|
||||
// Bad - generates STL realloc code (_M_realloc_insert)
|
||||
@@ -599,9 +677,25 @@ This document provides essential context for AI models interacting with this pro
|
||||
```
|
||||
Linear search on small datasets (1-16 elements) is often faster than hashing/tree overhead, but this depends on lookup frequency and access patterns. For frequent lookups in hot code paths, the O(1) vs O(n) complexity difference may still matter even for small datasets. `std::vector` with simple structs is usually fine—it's the heavy containers (`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows otherwise.
|
||||
|
||||
5. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices.
|
||||
5. **Strings set once from configuration:** Use `StringRef` (`esphome/core/string_ref.h`) rather than
|
||||
`std::string`. Code generation passes a string literal that lives in flash for the life of the
|
||||
program, so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a
|
||||
non-owning pointer plus length; it does not copy, and it must only ever refer to storage that
|
||||
outlives it (a string literal, or a buffer owned elsewhere).
|
||||
```cpp
|
||||
// Bad - heap copy of a literal that is already in flash
|
||||
void set_keys(std::string keys) { this->keys_ = std::move(keys); }
|
||||
std::string keys_;
|
||||
```
|
||||
```cpp
|
||||
// Good - no allocation
|
||||
void set_keys(const char *keys) { this->keys_ = StringRef(keys); }
|
||||
StringRef keys_;
|
||||
```
|
||||
|
||||
6. **Detection:** Look for these patterns in compiler output:
|
||||
6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices.
|
||||
|
||||
7. **Detection:** Look for these patterns in compiler output:
|
||||
- Large code sections with STL symbols (vector, map, set)
|
||||
- `alloc`, `realloc`, `dealloc` in symbol names
|
||||
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
|
||||
|
||||
@@ -131,6 +131,7 @@ esphome/components/cst816/* @clydebarrow
|
||||
esphome/components/cst9220/* @clydebarrow
|
||||
esphome/components/ct_clamp/* @jesserockz
|
||||
esphome/components/current_based/* @djwmarcx
|
||||
esphome/components/d01/* @ch604
|
||||
esphome/components/dac7678/* @NickB1
|
||||
esphome/components/daikin_arc/* @MagicBear
|
||||
esphome/components/daikin_brc/* @hagak
|
||||
@@ -148,6 +149,7 @@ esphome/components/display_menu_base/* @numo68
|
||||
esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
|
||||
esphome/components/dps310/* @kbx81
|
||||
esphome/components/ds1307/* @badbadc0ffee
|
||||
esphome/components/ds1603l/* @JakeLC15
|
||||
esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
@@ -494,6 +496,7 @@ esphome/components/sm2335/* @Cossid
|
||||
esphome/components/sml/* @alengwenus
|
||||
esphome/components/smt100/* @piechade
|
||||
esphome/components/sn74hc165/* @jesserockz
|
||||
esphome/components/snapshot/* @clydebarrow
|
||||
esphome/components/socket/* @esphome/core
|
||||
esphome/components/sonoff_d1/* @anatoly-savchenkov
|
||||
esphome/components/sound_level/* @kahrendt
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.9.0-dev
|
||||
PROJECT_NUMBER = 2026.9.0b1
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+35
-3
@@ -23,7 +23,8 @@ For this repository there are two trusted inputs by design:
|
||||
1. **The configuration.** Anyone who can supply or edit a YAML config is trusted
|
||||
(see below).
|
||||
2. **Authenticated peers of a running device** — clients holding the device's
|
||||
API encryption key / password, OTA password, or web server credentials.
|
||||
API/OTA encryption key, API password, OTA password, or web server
|
||||
credentials.
|
||||
|
||||
The security boundary is therefore **unauthenticated network traffic vs. those
|
||||
trusted inputs.** A bug that lets an unauthenticated attacker cross it is a
|
||||
@@ -76,8 +77,8 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
||||
captive portal, etc.) **without** valid credentials.
|
||||
- Authentication or encryption bypass on the device — reaching API calls, OTA
|
||||
updates, or the web server without the configured key/password.
|
||||
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
|
||||
below their documented guarantees.
|
||||
- Flaws that weaken the device's API or OTA encryption (Noise), OTA auth, or
|
||||
web server auth below their documented guarantees.
|
||||
|
||||
## The web server is an open HTTP API by design
|
||||
|
||||
@@ -121,6 +122,37 @@ and any memory-safety or protocol bug in the server reachable without credential
|
||||
This section documents the current design and scope; it is not a judgment that the
|
||||
design is optimal or that it will not change.
|
||||
|
||||
## OTA update encryption
|
||||
|
||||
The `esphome` OTA platform optionally encrypts updates with the same Noise
|
||||
`NNpsk0` pattern the native API uses; one key protects the device. With an
|
||||
`encryption:` block configured the guarantees are: the firmware image is
|
||||
confidential in transit, the uploader is authenticated by the pre-shared key,
|
||||
and the plaintext negotiation preceding the handshake is bound into the
|
||||
handshake prologue, so stripping or tampering with it fails the first MAC.
|
||||
Both ends fail closed with no override: a device built with a key refuses
|
||||
plaintext uploads, and the CLI refuses to send plaintext when a key is
|
||||
configured.
|
||||
|
||||
Defeating any of that without the key is in scope: a keyed device accepting a
|
||||
plaintext or downgraded upload, getting past the MAC, or recovering image
|
||||
contents from captured traffic.
|
||||
|
||||
The following are **not** vulnerabilities, by design:
|
||||
|
||||
- Plaintext OTA on a device with no `encryption:` block. That is the
|
||||
documented default, authenticated (if at all) by the OTA password.
|
||||
- The enablement window: turning encryption on takes one last upload of the
|
||||
encryption-enabled firmware over the existing plaintext channel, with the
|
||||
pre-existing plaintext exposure.
|
||||
- The web OTA `/update` endpoint alongside encryption. The `web_server`
|
||||
component keeps it always reachable, and `captive_portal:` auto-loads it
|
||||
for the fallback AP window; validation warns about both combinations, and
|
||||
the operator keeps the recovery path.
|
||||
- CLI retry behavior on transport or MAC failures; every attempt renegotiates
|
||||
a fresh handshake with fresh ephemerals, so retrying does not weaken
|
||||
authentication.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- Local attackers who already have shell access on the host that runs `esphome`.
|
||||
|
||||
+28
-1
@@ -26,7 +26,9 @@ from esphome.const import (
|
||||
CONF_DEASSERT_RTS_DTR,
|
||||
CONF_DISABLED,
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_ESPHOME,
|
||||
CONF_KEY,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
@@ -1336,6 +1338,19 @@ def _upload_via_native_api(
|
||||
|
||||
remote_port = int(ota_conf[CONF_PORT])
|
||||
password = ota_conf.get(CONF_PASSWORD)
|
||||
# Fail closed: an encryption block whose key did not resolve must never
|
||||
# fall back to a plaintext upload
|
||||
noise_psk = None
|
||||
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||
noise_psk = encryption_conf.get(CONF_KEY)
|
||||
if not noise_psk:
|
||||
raise EsphomeError(
|
||||
"OTA encryption is configured but no key was resolved; "
|
||||
"set the key under 'ota: encryption:' or 'api: encryption:'"
|
||||
)
|
||||
# Ensure the key is a string, as required by the underlying OTA implementation.
|
||||
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
|
||||
noise_psk = str(noise_psk)
|
||||
|
||||
def check_partition_access(option_string: str) -> None:
|
||||
if not ota_conf.get("allow_partition_access"):
|
||||
@@ -1366,7 +1381,9 @@ def _upload_via_native_api(
|
||||
if ota_type == espota2.OTA_TYPE_UPDATE_BOOTLOADER:
|
||||
_validate_bootloader_binary(binary)
|
||||
|
||||
return espota2.run_ota(network_devices, remote_port, password, binary, ota_type)
|
||||
return espota2.run_ota(
|
||||
network_devices, remote_port, password, binary, ota_type, noise_psk
|
||||
)
|
||||
|
||||
|
||||
def _upload_via_web_server(
|
||||
@@ -1375,6 +1392,16 @@ def _upload_via_web_server(
|
||||
from esphome import web_server_ota
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
if any(
|
||||
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
and ota_item.get(CONF_ENCRYPTION) is not None
|
||||
for ota_item in config.get(CONF_OTA, [])
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"This config has OTA encryption, but the web_server OTA path sends "
|
||||
"the image over plaintext HTTP; use the esphome OTA platform to "
|
||||
"keep it confidential"
|
||||
)
|
||||
remote_port, username, password = get_web_server_connection(config)
|
||||
return web_server_ota.run_ota(
|
||||
network_devices, remote_port, username, password, binary
|
||||
|
||||
@@ -162,8 +162,9 @@ void Alpha3::send_request_(uint8_t *request, size_t len) {
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->geni_handle_, len,
|
||||
request, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status)
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
|
||||
void Alpha3::update() {
|
||||
|
||||
@@ -2391,8 +2391,9 @@ void APIConnection::process_batch_() {
|
||||
} else if (payload_size == 0) {
|
||||
// payload_size == 0 with remove set means encoding hit OOM and the
|
||||
// connection is being dropped; warn only for a genuinely oversized message
|
||||
if (!this->flags_.remove)
|
||||
if (!this->flags_.remove) {
|
||||
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
|
||||
}
|
||||
this->clear_batch_();
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -62,8 +62,9 @@ BdkActivityState bdk_scan_state(uint8_t activity_idx) {
|
||||
|
||||
uint8_t bdk_scan_acquire_activity() {
|
||||
uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
|
||||
if (idx == INVALID_ACTIVITY_IDX)
|
||||
if (idx == INVALID_ACTIVITY_IDX) {
|
||||
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,8 +181,9 @@ void BK72xxBLE::enable() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bdaddr_live)
|
||||
if (!bdaddr_live) {
|
||||
ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started");
|
||||
}
|
||||
#endif
|
||||
|
||||
this->state_ = BLEComponentState::ACTIVE;
|
||||
@@ -210,8 +211,9 @@ void BK72xxBLE::loop() {
|
||||
// Re-check a settled scan; scan_start() refills the bring-up budget.
|
||||
// WARN: the only report of a drop that recovers inside its budget.
|
||||
if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) !=
|
||||
ScanOpResult::SETTLED)
|
||||
ScanOpResult::SETTLED) {
|
||||
ESP_LOGW(TAG, "Controller dropped the scan; restarting");
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the lock-free ring filled by the BLE task; all per-report work runs
|
||||
@@ -230,8 +232,9 @@ void BK72xxBLE::loop() {
|
||||
// Log dropped reports — only reachable when reports were processed; drops can
|
||||
// only occur while the queue is full, and only this loop drains it.
|
||||
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
|
||||
if (dropped > 0)
|
||||
if (dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
|
||||
}
|
||||
}
|
||||
|
||||
void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
|
||||
@@ -449,8 +452,9 @@ ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) {
|
||||
if (!ready) {
|
||||
// Acting mid-operation could delete an activity whose start lands
|
||||
// afterwards, leaking the slot with the radio on; wait.
|
||||
if (this->last_result_ == ScanOpResult::SETTLED)
|
||||
if (this->last_result_ == ScanOpResult::SETTLED) {
|
||||
ESP_LOGD(TAG, "Scan stop deferred (controller busy)");
|
||||
}
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
// Settled, so CREATED unambiguously means "never started".
|
||||
@@ -474,8 +478,9 @@ ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) {
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
if (!ready) {
|
||||
if (this->last_result_ == ScanOpResult::SETTLED)
|
||||
if (this->last_result_ == ScanOpResult::SETTLED) {
|
||||
ESP_LOGD(TAG, "Scan start deferred (controller busy)");
|
||||
}
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
if (state == BdkActivityState::CREATED) {
|
||||
|
||||
@@ -69,8 +69,9 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress,
|
||||
this->stop_scan();
|
||||
// The transfer starves the loop; a deferred stop would leave the radio
|
||||
// scanning for the whole update, so drain it here, bounded.
|
||||
if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS))
|
||||
if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS)) {
|
||||
ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update");
|
||||
}
|
||||
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
|
||||
// On success the device reboots, so restore only on a failed/aborted update;
|
||||
// loop() restarts the scan on its next iteration (continuous idle branch).
|
||||
|
||||
@@ -80,8 +80,9 @@ void BLEBinaryOutput::write_state(bool state) {
|
||||
esp_err_t err =
|
||||
esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_,
|
||||
sizeof(state_as_uint), &state_as_uint, this->write_type_, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (err != ESP_GATT_OK)
|
||||
if (err != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "[%s] Write error, err=%d", this->char_uuid_.to_str(char_buf), err);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
@@ -327,10 +327,12 @@ void BME680Component::read_data_() {
|
||||
|
||||
ESP_LOGD(TAG, "Got temperature=%.1f°C pressure=%.1fhPa humidity=%.1f%% gas_resistance=%.1fΩ", temperature, pressure,
|
||||
humidity, gas_resistance);
|
||||
if (!gas_valid)
|
||||
if (!gas_valid) {
|
||||
ESP_LOGW(TAG, "Gas measurement unsuccessful, reading invalid!");
|
||||
if (!heat_stable)
|
||||
}
|
||||
if (!heat_stable) {
|
||||
ESP_LOGW(TAG, "Heater unstable, reading invalid! (Normal for a few readings after a power cycle)");
|
||||
}
|
||||
|
||||
if (this->temperature_sensor_ != nullptr)
|
||||
this->temperature_sensor_->publish_state(temperature);
|
||||
|
||||
@@ -749,33 +749,39 @@ void Climate::dump_traits_(const char *tag) {
|
||||
}
|
||||
if (!traits.get_supported_modes().empty()) {
|
||||
ESP_LOGCONFIG(tag, " Supported modes:");
|
||||
for (ClimateMode m : traits.get_supported_modes())
|
||||
for (ClimateMode m : traits.get_supported_modes()) {
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_mode_to_string(m)));
|
||||
}
|
||||
}
|
||||
if (!traits.get_supported_fan_modes().empty()) {
|
||||
ESP_LOGCONFIG(tag, " Supported fan modes:");
|
||||
for (ClimateFanMode m : traits.get_supported_fan_modes())
|
||||
for (ClimateFanMode m : traits.get_supported_fan_modes()) {
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_fan_mode_to_string(m)));
|
||||
}
|
||||
}
|
||||
if (!traits.get_supported_custom_fan_modes().empty()) {
|
||||
ESP_LOGCONFIG(tag, " Supported custom fan modes:");
|
||||
for (const char *s : traits.get_supported_custom_fan_modes())
|
||||
for (const char *s : traits.get_supported_custom_fan_modes()) {
|
||||
ESP_LOGCONFIG(tag, " - %s", s);
|
||||
}
|
||||
}
|
||||
if (!traits.get_supported_presets().empty()) {
|
||||
ESP_LOGCONFIG(tag, " Supported presets:");
|
||||
for (ClimatePreset p : traits.get_supported_presets())
|
||||
for (ClimatePreset p : traits.get_supported_presets()) {
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_preset_to_string(p)));
|
||||
}
|
||||
}
|
||||
if (!traits.get_supported_custom_presets().empty()) {
|
||||
ESP_LOGCONFIG(tag, " Supported custom presets:");
|
||||
for (const char *s : traits.get_supported_custom_presets())
|
||||
for (const char *s : traits.get_supported_custom_presets()) {
|
||||
ESP_LOGCONFIG(tag, " - %s", s);
|
||||
}
|
||||
}
|
||||
if (!traits.get_supported_swing_modes().empty()) {
|
||||
ESP_LOGCONFIG(tag, " Supported swing modes:");
|
||||
for (ClimateSwingMode m : traits.get_supported_swing_modes())
|
||||
for (ClimateSwingMode m : traits.get_supported_swing_modes()) {
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_swing_mode_to_string(m)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma
|
||||
void CM1106Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "CM1106:");
|
||||
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
|
||||
this->check_uart_settings(9600);
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ CONFIG_SCHEMA = (
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"cm1106",
|
||||
baud_rate=9600,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
"""Code generation entry point."""
|
||||
|
||||
@@ -14,6 +14,7 @@ CONF_CHANNEL_COLORS = "channel_colors"
|
||||
CONF_CLIMATE_ID = "climate_id"
|
||||
CONF_CO2_EQUIVALENT = "co2_equivalent"
|
||||
CONF_COLOR_DEPTH = "color_depth"
|
||||
CONF_COLUMNS = "columns"
|
||||
CONF_CRC_ENABLE = "crc_enable"
|
||||
CONF_DATA_BITS = "data_bits"
|
||||
CONF_DESCRIPTION = "description"
|
||||
@@ -25,6 +26,7 @@ CONF_GYROSCOPE_RANGE = "gyroscope_range"
|
||||
CONF_IAQ = "iaq"
|
||||
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
|
||||
CONF_IS_WRGB = "is_wrgb"
|
||||
CONF_KEYS = "keys"
|
||||
CONF_LABEL = "label"
|
||||
CONF_LIBRETINY = "libretiny"
|
||||
CONF_LOOP = "loop"
|
||||
|
||||
@@ -58,7 +58,6 @@ void CSE7761Component::dump_config() {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
this->check_uart_settings(38400, 1, uart::UART_CONFIG_PARITY_EVEN, 8);
|
||||
}
|
||||
|
||||
void CSE7761Component::update() {
|
||||
|
||||
@@ -68,7 +68,13 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"cse7761", baud_rate=38400, require_rx=True, require_tx=True
|
||||
"cse7761",
|
||||
baud_rate=38400,
|
||||
require_rx=True,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="EVEN",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -255,7 +255,6 @@ void CSE7766Component::dump_config() {
|
||||
LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_);
|
||||
LOG_SENSOR(" ", "Reactive Power", this->reactive_power_sensor_);
|
||||
LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_);
|
||||
this->check_uart_settings(4800, 1, uart::UART_CONFIG_PARITY_EVEN);
|
||||
}
|
||||
|
||||
} // namespace esphome::cse7766
|
||||
|
||||
@@ -84,7 +84,12 @@ CONFIG_SCHEMA = (
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"cse7766", baud_rate=4800, parity="EVEN", require_rx=True
|
||||
"cse7766",
|
||||
baud_rate=4800,
|
||||
require_rx=True,
|
||||
data_bits=8,
|
||||
parity="EVEN",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "d01.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// uart specification for d01 sensor from https://manuals.plus/ae/1005006417362019:
|
||||
//
|
||||
// A frame of serial output data includes 4 bytes, formatted as follows:
|
||||
// __Characteristic byte: Fixed value 0xA5.
|
||||
// __Data byte: DATAH is the high 7 bits of the concentration value, and DATAL is the low 7 bits of the concentration
|
||||
// value.
|
||||
// __Check byte: The low 7 bits of the sum of all bytes before the check byte.
|
||||
//
|
||||
// If the serial output is 4 bytes of data: 0*A5 0*01 0*2C 0*52, then DATAH = 0*01 = 1, DATAL = 0*2C = 44.
|
||||
// Concentration value = 1*128 + 44 = 172 µg/m³.
|
||||
//
|
||||
// The PM2.5 dust concentration value obtained from the dust sensor needs to be calibrated with a K value coefficient
|
||||
// based on the TSI instrument's photometric method. It is generally recommended to use 0.4.
|
||||
|
||||
namespace esphome::d01 {
|
||||
|
||||
static const char *const TAG = "d01";
|
||||
|
||||
static const uint8_t D01_FRAME_HEADER = 0xA5;
|
||||
|
||||
void D01SensorComponent::dump_config() { LOG_SENSOR(" ", "D01 PM2.5", this); }
|
||||
|
||||
void D01SensorComponent::loop() {
|
||||
uint8_t buf[4];
|
||||
while (this->available() >= 4) {
|
||||
if (this->peek() != D01_FRAME_HEADER) {
|
||||
this->read();
|
||||
continue;
|
||||
}
|
||||
this->read_array(buf, 4);
|
||||
uint8_t sum = (buf[0] + buf[1] + buf[2]) & 0x7F;
|
||||
if (sum != buf[3]) {
|
||||
ESP_LOGW(TAG, "checksum mismatch");
|
||||
continue;
|
||||
}
|
||||
uint16_t latest_concentration = (buf[1] & 0x7F) * 128 + (buf[2] & 0x7F);
|
||||
ESP_LOGV(TAG, "Unadjusted PM2.5 Concentration: %d µg/m³", latest_concentration);
|
||||
this->publish_state(latest_concentration);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::d01
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
namespace esphome::d01 {
|
||||
|
||||
class D01SensorComponent final : public sensor::Sensor, public Component, public uart::UARTDevice {
|
||||
public:
|
||||
void dump_config() override;
|
||||
void loop() override;
|
||||
};
|
||||
|
||||
} // namespace esphome::d01
|
||||
@@ -0,0 +1,45 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
DEVICE_CLASS_PM25,
|
||||
ICON_BLUR,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@ch604"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
d01_ns = cg.esphome_ns.namespace("d01")
|
||||
D01SensorComponent = d01_ns.class_(
|
||||
"D01SensorComponent", sensor.Sensor, uart.UARTDevice, cg.Component
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
sensor.sensor_schema(
|
||||
D01SensorComponent,
|
||||
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
icon=ICON_BLUR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_PM25,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"d01",
|
||||
baud_rate=9600,
|
||||
require_rx=True,
|
||||
require_tx=False,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
@@ -26,6 +26,14 @@ CONFIG_SCHEMA = (
|
||||
.extend(cv.polling_component_schema("30s"))
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"daly_bms",
|
||||
baud_rate=9600,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -22,10 +22,7 @@ static const uint8_t DALY_REQUEST_TEMPERATURE = 0x96;
|
||||
|
||||
void DalyBmsComponent::setup() { this->next_request_ = 1; }
|
||||
|
||||
void DalyBmsComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Daly BMS:");
|
||||
this->check_uart_settings(9600);
|
||||
}
|
||||
void DalyBmsComponent::dump_config() { ESP_LOGCONFIG(TAG, "Daly BMS:"); }
|
||||
|
||||
void DalyBmsComponent::update() {
|
||||
this->trigger_next_ = true;
|
||||
|
||||
@@ -60,7 +60,12 @@ CONFIG_SCHEMA = cv.All(
|
||||
).extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"dfplayer", baud_rate=9600, require_tx=True
|
||||
"dfplayer",
|
||||
baud_rate=9600,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -277,9 +277,6 @@ void DFPlayer::loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
void DFPlayer::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "DFPlayer:");
|
||||
this->check_uart_settings(9600);
|
||||
}
|
||||
void DFPlayer::dump_config() { ESP_LOGCONFIG(TAG, "DFPlayer:"); }
|
||||
|
||||
} // namespace esphome::dfplayer
|
||||
|
||||
@@ -154,8 +154,9 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r
|
||||
}
|
||||
}
|
||||
if (error_code != 0) {
|
||||
if (report_errors)
|
||||
if (report_errors) {
|
||||
ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "ds1603l.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ds1603l {
|
||||
|
||||
static const char *const TAG = "ds1603l.sensor";
|
||||
|
||||
void DS1603L::loop() {
|
||||
// Assemble frames one byte at a time so a stream that starts mid-frame can realign
|
||||
uint8_t byte;
|
||||
while (this->available() > 0 && this->read_byte(&byte)) {
|
||||
if (this->rx_count_ == 0 && byte != HEADER_BYTE) {
|
||||
ESP_LOGV(TAG, "Skipping byte 0x%02X while looking for header", byte);
|
||||
continue;
|
||||
}
|
||||
|
||||
this->rx_buffer_[this->rx_count_++] = byte;
|
||||
if (this->rx_count_ < FRAME_SIZE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this->parse_data_()) {
|
||||
this->rx_count_ = 0;
|
||||
} else {
|
||||
// The header byte was part of the payload of a misaligned frame, so realign instead of dropping everything
|
||||
this->resync_();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DS1603L::dump_config() { LOG_SENSOR("", "DS1603L", this); }
|
||||
|
||||
bool DS1603L::parse_data_() {
|
||||
uint8_t header = this->rx_buffer_[0];
|
||||
uint8_t data_h = this->rx_buffer_[1];
|
||||
uint8_t data_l = this->rx_buffer_[2];
|
||||
uint8_t checksum = this->rx_buffer_[3];
|
||||
|
||||
uint8_t computed_checksum = (header + data_h + data_l) & 0xFF;
|
||||
|
||||
ESP_LOGV(TAG, "Data: Header=0x%02X, Data_H=0x%02X, Data_L=0x%02X, Checksum=0x%02X", header, data_h, data_l, checksum);
|
||||
|
||||
if (checksum != computed_checksum) {
|
||||
ESP_LOGW(TAG, "Checksum mismatch: received 0x%02X, expected 0x%02X", checksum, computed_checksum);
|
||||
return false;
|
||||
}
|
||||
|
||||
this->publish_state(encode_uint16(data_h, data_l));
|
||||
return true;
|
||||
}
|
||||
|
||||
void DS1603L::resync_() {
|
||||
// Drop the byte that was treated as the header, then look for the next candidate header in what is left
|
||||
size_t start = 1;
|
||||
while (start < this->rx_count_ && this->rx_buffer_[start] != HEADER_BYTE) {
|
||||
start++;
|
||||
}
|
||||
this->rx_count_ -= start;
|
||||
if (this->rx_count_ > 0) {
|
||||
memmove(this->rx_buffer_, this->rx_buffer_ + start, this->rx_count_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ds1603l
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::ds1603l {
|
||||
|
||||
class DS1603L final : public sensor::Sensor, public Component, public uart::UARTDevice {
|
||||
public:
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
static constexpr uint8_t HEADER_BYTE = 0xFF;
|
||||
static constexpr size_t FRAME_SIZE = 4;
|
||||
|
||||
// Validates the checksum of the frame in rx_buffer_ and publishes it. Returns false if the frame is invalid.
|
||||
bool parse_data_();
|
||||
// Drops the first buffered byte and realigns the buffer on the next possible header byte.
|
||||
void resync_();
|
||||
|
||||
uint8_t rx_buffer_[FRAME_SIZE]; // Buffer for the frame being assembled
|
||||
size_t rx_count_{0}; // Number of bytes currently in rx_buffer_
|
||||
};
|
||||
|
||||
} // namespace esphome::ds1603l
|
||||
@@ -0,0 +1,43 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
DEVICE_CLASS_DISTANCE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_MILLIMETER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@JakeLC15"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
ds1603l_ns = cg.esphome_ns.namespace("ds1603l")
|
||||
DS1603L = ds1603l_ns.class_("DS1603L", sensor.Sensor, cg.Component, uart.UARTDevice)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
sensor.sensor_schema(
|
||||
DS1603L,
|
||||
unit_of_measurement=UNIT_MILLIMETER,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"ds1603l",
|
||||
baud_rate=9600,
|
||||
require_tx=False,
|
||||
require_rx=True,
|
||||
data_bits=8,
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "epaper_spi_uc8179.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::epaper_spi {
|
||||
|
||||
static constexpr const char *const TAG = "epaper_spi.uc8179";
|
||||
|
||||
bool EPaperUC8179::initialise(bool partial) {
|
||||
EPaperBase::initialise(partial); // send the model init sequence
|
||||
this->partial_ = partial;
|
||||
ESP_LOGV(TAG, "Power on");
|
||||
// POWER ON must precede the waveform/mode registers and the data transfer
|
||||
// (the original driver powers on and busy-waits before writing them).
|
||||
// The state machine busy-waits before entering TRANSFER_DATA.
|
||||
this->command(0x04);
|
||||
// Give the busy line time to assert before the state machine polls it
|
||||
this->next_delay_ = 100;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Set up the refresh mode. Must be called after power-on has completed.
|
||||
void EPaperUC8179::set_refresh_mode_() {
|
||||
if (!this->is_using_partial_update_()) {
|
||||
return; // plain full refresh uses the mode set by the init sequence
|
||||
}
|
||||
// Fast and partial refresh use flipped data polarity and a floating border
|
||||
this->cmd_data(0x50, {0xA9, 0x07});
|
||||
// Force the waveform via the temperature registers: 0x5A selects the fast
|
||||
// full-refresh waveform, 0x6E the partial-refresh waveform
|
||||
this->cmd_data(0xE0, {0x02});
|
||||
if (this->partial_) {
|
||||
this->cmd_data(0xE5, {0x6E});
|
||||
this->command(0x91); // enter partial mode
|
||||
// Set the partial window to the full screen
|
||||
const uint16_t x_end = this->width_ - 1;
|
||||
const uint16_t y_end = this->height_ - 1;
|
||||
this->cmd_data(0x90, {0x00, 0x00, static_cast<uint8_t>(x_end >> 8), static_cast<uint8_t>(x_end & 0xFF), 0x00, 0x00,
|
||||
static_cast<uint8_t>(y_end >> 8), static_cast<uint8_t>(y_end & 0xFF), 0x01});
|
||||
} else {
|
||||
this->cmd_data(0xE5, {0x5A});
|
||||
this->command(0x92); // exit partial mode
|
||||
}
|
||||
}
|
||||
|
||||
bool HOT EPaperUC8179::transfer_data() {
|
||||
const uint32_t start_time = millis();
|
||||
const size_t buffer_length = this->buffer_length_;
|
||||
if (this->current_data_index_ == 0) {
|
||||
this->set_refresh_mode_();
|
||||
}
|
||||
// Fast full refresh sends the previous-image plane as well, so that every pixel transitions
|
||||
const bool two_pass = this->is_using_partial_update_() && !this->partial_;
|
||||
// Plain full refresh sends inverted data (buffer is 1=white, the wire wants 0=white);
|
||||
// in fast/partial mode the data polarity is flipped via the VCOM/data-interval
|
||||
// register instead, so the new-image plane is sent unmodified
|
||||
const bool invert_new_data = !this->is_using_partial_update_();
|
||||
|
||||
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
|
||||
|
||||
// Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image
|
||||
if (two_pass && this->current_data_index_ < buffer_length) {
|
||||
if (this->current_data_index_ == 0) {
|
||||
this->command(0x10); // DATA START TRANSMISSION 1 (previous image)
|
||||
}
|
||||
this->start_data_();
|
||||
while (this->current_data_index_ < buffer_length) {
|
||||
const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_);
|
||||
for (size_t i = 0; i < bytes_to_copy; i++) {
|
||||
bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i];
|
||||
}
|
||||
this->write_array(bytes_to_send, bytes_to_copy);
|
||||
this->current_data_index_ += bytes_to_copy;
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
this->disable();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this->disable();
|
||||
}
|
||||
|
||||
// Phase 2: new image via 0x13 (DTM2)
|
||||
const size_t offset = two_pass ? buffer_length : 0;
|
||||
const size_t total = offset + buffer_length;
|
||||
if (this->current_data_index_ < total) {
|
||||
if (this->current_data_index_ == offset) {
|
||||
this->command(0x13); // DATA START TRANSMISSION 2 (new image)
|
||||
}
|
||||
this->start_data_();
|
||||
while (this->current_data_index_ < total) {
|
||||
const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, total - this->current_data_index_);
|
||||
const size_t data_idx = this->current_data_index_ - offset;
|
||||
for (size_t i = 0; i < bytes_to_copy; i++) {
|
||||
const uint8_t byte = this->buffer_[data_idx + i];
|
||||
bytes_to_send[i] = invert_new_data ? ~byte : byte;
|
||||
}
|
||||
this->write_array(bytes_to_send, bytes_to_copy);
|
||||
this->current_data_index_ += bytes_to_copy;
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
this->disable();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this->disable();
|
||||
}
|
||||
|
||||
this->current_data_index_ = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
void EPaperUC8179::power_on() {
|
||||
// Power-on is sent at the end of initialise() instead, because the
|
||||
// waveform/mode registers and the data transfer must follow it
|
||||
}
|
||||
|
||||
void EPaperUC8179::refresh_screen(bool /*partial*/) {
|
||||
ESP_LOGV(TAG, "Refresh");
|
||||
this->command(0x12); // DISPLAY REFRESH
|
||||
// Delay the next busy poll: the busy line takes a short time to assert after
|
||||
// the refresh command, and polling too early would read it as already idle
|
||||
this->next_delay_ = 100;
|
||||
}
|
||||
|
||||
void EPaperUC8179::power_off() {
|
||||
ESP_LOGV(TAG, "Power off");
|
||||
this->command(0x02); // POWER OFF
|
||||
}
|
||||
|
||||
void EPaperUC8179::deep_sleep() {
|
||||
// Deep sleep loses the previous-image RAM that partial refresh compares against
|
||||
if (!this->is_using_partial_update_()) {
|
||||
ESP_LOGV(TAG, "Deep sleep");
|
||||
this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::epaper_spi
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include "epaper_spi.h"
|
||||
|
||||
namespace esphome::epaper_spi {
|
||||
|
||||
/**
|
||||
* Monochrome e-paper displays using the UC8179 controller.
|
||||
* Supports: 7.5" V2 (EPD_7in5_V2), 800x480 pixels, as used by the
|
||||
* Waveshare 7.5" V2 HAT and the Seeed reTerminal E1001.
|
||||
*
|
||||
* Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default).
|
||||
*
|
||||
* The INITIALISE state sends the panel configuration followed by power-on
|
||||
* (0x04); the state machine busy-waits for power-on to complete before
|
||||
* TRANSFER_DATA, which first writes the waveform/mode registers (these are
|
||||
* only accepted while powered) and then the image data. The state machine
|
||||
* busy-waits again before triggering REFRESH_SCREEN (0x12).
|
||||
*
|
||||
* Three refresh modes are used, following the Waveshare EPD_7in5_V2 examples:
|
||||
* - full_update_every == 1: plain full refresh. The new image is sent
|
||||
* inverted to DTM2 (0x13) and the controller uses its normal waveform.
|
||||
* - full_update_every > 1, full update: fast full refresh. The data polarity
|
||||
* is flipped via the VCOM/data-interval register, a fast waveform is forced
|
||||
* via the temperature registers, and the image is sent to both DTM1 (0x10,
|
||||
* inverted) and DTM2 (0x13) so that every pixel transitions.
|
||||
* - full_update_every > 1, partial update: partial refresh. A partial-update
|
||||
* waveform is forced, partial mode is entered with a full-screen window and
|
||||
* only DTM2 is sent; the controller compares against its previous-image RAM.
|
||||
*/
|
||||
class EPaperUC8179 final : public EPaperBase {
|
||||
public:
|
||||
EPaperUC8179(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
|
||||
size_t init_sequence_length)
|
||||
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {
|
||||
this->buffer_length_ = this->row_width_ * height;
|
||||
}
|
||||
|
||||
protected:
|
||||
bool initialise(bool partial) override;
|
||||
bool transfer_data() override;
|
||||
void refresh_screen(bool partial) override;
|
||||
void power_on() override;
|
||||
void power_off() override;
|
||||
void deep_sleep() override;
|
||||
void set_refresh_mode_();
|
||||
|
||||
// Set by initialise() so transfer_data() knows which planes to send
|
||||
bool partial_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::epaper_spi
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Monochrome e-paper displays using the UC8179 controller.
|
||||
|
||||
Supported models:
|
||||
- waveshare-7.5in-v2: 7.5" mono display, 800x480 pixels (EPD_7in5_V2)
|
||||
- seeed-reterminal-e1001: Seeed reTerminal E1001, which uses the same
|
||||
7.5" 800x480 panel on an integrated ESP32-S3 board
|
||||
|
||||
Panel configuration and power-on (0x04) are both sent during the INITIALISE
|
||||
state; the state machine's built-in busy wait then covers the power-on delay
|
||||
before the waveform/mode registers and image data are transferred.
|
||||
|
||||
These displays support fast full and partial refresh: set ``full_update_every``
|
||||
greater than 1 to enable it. Every ``full_update_every``-th update is a fast
|
||||
full refresh, with partial refreshes in between.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from esphome.const import CONF_DATA_RATE
|
||||
|
||||
from . import EpaperModel
|
||||
|
||||
|
||||
class UC8179(EpaperModel):
|
||||
"""EpaperModel class for monochrome displays using the UC8179 controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
class_name: str = "EPaperUC8179",
|
||||
data_rate: str = "10MHz",
|
||||
**defaults: Any,
|
||||
) -> None:
|
||||
defaults.setdefault(CONF_DATA_RATE, data_rate)
|
||||
super().__init__(name, class_name, **defaults)
|
||||
|
||||
def get_init_sequence(self, config: dict) -> tuple:
|
||||
"""Generate the initialization sequence for UC8179 mono displays.
|
||||
|
||||
Panel configuration only — the driver appends power-on (0x04) at the
|
||||
end of the INITIALISE state, and the state machine busy-waits for it
|
||||
to complete before the data transfer starts.
|
||||
"""
|
||||
width, height = self.get_dimensions(config)
|
||||
return (
|
||||
# POWER SETTING
|
||||
(0x01, 0x07, 0x07, 0x3F, 0x3F),
|
||||
# BOOSTER SOFT START
|
||||
(0x06, 0x17, 0x17, 0x28, 0x17),
|
||||
# PANEL SETTING (black/white mode, LUT from OTP)
|
||||
(0x00, 0x1F),
|
||||
# RESOLUTION SETTING (width x height)
|
||||
(
|
||||
0x61,
|
||||
(width >> 8) & 0xFF,
|
||||
width & 0xFF,
|
||||
(height >> 8) & 0xFF,
|
||||
height & 0xFF,
|
||||
),
|
||||
# DUAL SPI MODE (disabled)
|
||||
(0x15, 0x00),
|
||||
# VCOM AND DATA INTERVAL SETTING
|
||||
(0x50, 0x10, 0x07),
|
||||
# TCON SETTING
|
||||
(0x60, 0x22),
|
||||
)
|
||||
|
||||
|
||||
uc8179 = UC8179("uc8179")
|
||||
|
||||
# Waveshare 7.5" V2 mono (EPD_7in5_V2) — 800x480, UC8179 controller
|
||||
waveshare_7_5_v2 = uc8179.extend(
|
||||
"waveshare-7.5in-v2",
|
||||
width=800,
|
||||
height=480,
|
||||
)
|
||||
|
||||
# Seeed reTerminal E1001 — 7.5" mono e-paper (800x480), same panel as the
|
||||
# Waveshare 7.5" V2, driven by an integrated ESP32-S3 board
|
||||
waveshare_7_5_v2.extend(
|
||||
"seeed-reterminal-e1001",
|
||||
cs_pin=10,
|
||||
dc_pin=11,
|
||||
reset_pin=12,
|
||||
busy_pin={
|
||||
"number": 13,
|
||||
"inverted": True,
|
||||
"mode": {
|
||||
"input": True,
|
||||
"pullup": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -182,6 +182,13 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = {
|
||||
VARIANT_ESP32,
|
||||
}
|
||||
|
||||
# Variants that support execution from PSRAM
|
||||
PSRAM_XIP_VARIANTS = {
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S31,
|
||||
}
|
||||
|
||||
# NVS encryption (HMAC peripheral scheme) is only available on variants that
|
||||
# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original
|
||||
# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral
|
||||
@@ -1523,7 +1530,7 @@ def final_validate(config) -> None:
|
||||
)
|
||||
)
|
||||
if advanced[CONF_EXECUTE_FROM_PSRAM]:
|
||||
if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}:
|
||||
if config[CONF_VARIANT] not in PSRAM_XIP_VARIANTS:
|
||||
errs.append(
|
||||
cv.Invalid(
|
||||
f"'{CONF_EXECUTE_FROM_PSRAM}' is not available on this esp32 variant",
|
||||
@@ -2727,13 +2734,7 @@ async def to_code(config):
|
||||
_configure_lwip_max_sockets(conf)
|
||||
|
||||
if advanced[CONF_EXECUTE_FROM_PSRAM]:
|
||||
if variant == VARIANT_ESP32S3:
|
||||
add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True)
|
||||
add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True)
|
||||
elif variant == VARIANT_ESP32P4:
|
||||
add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True)
|
||||
else:
|
||||
raise ValueError("Unhandled ESP32 variant")
|
||||
add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True)
|
||||
|
||||
# Apply LWIP core locking for better socket performance
|
||||
# This is already enabled by default in Arduino framework, where it provides
|
||||
|
||||
@@ -210,8 +210,9 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) {
|
||||
if (!image) {
|
||||
// A shutdown is not a lost frame: wait_for_image_() returns empty as soon
|
||||
// as running_ clears, and the loop condition below ends the stream anyway.
|
||||
if (this->running_)
|
||||
if (this->running_) {
|
||||
ESP_LOGW(TAG, "STREAM: failed to acquire frame");
|
||||
}
|
||||
res = ESP_FAIL;
|
||||
}
|
||||
if (res == ESP_OK) {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.noise import (
|
||||
decode_encryption_key,
|
||||
encryption_schema,
|
||||
is_reserved_key,
|
||||
)
|
||||
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
|
||||
from esphome.config_helpers import merge_config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_ESPHOME,
|
||||
CONF_ID,
|
||||
CONF_KEY,
|
||||
CONF_NUM_ATTEMPTS,
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
@@ -15,6 +23,7 @@ from esphome.const import (
|
||||
CONF_REBOOT_TIMEOUT,
|
||||
CONF_SAFE_MODE,
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
@@ -22,6 +31,7 @@ import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access"
|
||||
CONF_CAPTIVE_PORTAL = "captive_portal"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,7 +40,15 @@ CODEOWNERS = ["@esphome/core"]
|
||||
DEPENDENCIES = ["network"]
|
||||
|
||||
|
||||
AUTO_LOAD = ["sha256", "socket"]
|
||||
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
||||
"""Auto-load noise only when encryption is configured."""
|
||||
base = ["sha256", "socket"]
|
||||
# A falsy config is a tooling probe for the maximal set (None from
|
||||
# dependency resolution, {} from the components-graph platform probe);
|
||||
# a validated config always carries defaults, never empty
|
||||
if not config or CONF_ENCRYPTION in config:
|
||||
return base + ["noise"]
|
||||
return base
|
||||
|
||||
|
||||
esphome = cg.esphome_ns.namespace("esphome")
|
||||
@@ -67,11 +85,24 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
CONF_PASSWORD in merged_ota_esphome_configs_by_port[conf_port]
|
||||
and CONF_PASSWORD in ota_conf
|
||||
and merged_ota_esphome_configs_by_port[conf_port][CONF_PASSWORD]
|
||||
!= ota_conf.get(CONF_PASSWORD)
|
||||
!= ota_conf[CONF_PASSWORD]
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Found multiple configurations but {CONF_PASSWORD} is inconsistent"
|
||||
)
|
||||
# Encryption blocks conflict only when both pin a key; a bare
|
||||
# `encryption:` (a package/device split) is compatible with a
|
||||
# keyed one, and merge_config yields the keyed result
|
||||
merged_key = (
|
||||
merged_ota_esphome_configs_by_port[conf_port]
|
||||
.get(CONF_ENCRYPTION, {})
|
||||
.get(CONF_KEY)
|
||||
)
|
||||
other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
|
||||
if merged_key and other_key and merged_key != other_key:
|
||||
raise cv.Invalid(
|
||||
f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent"
|
||||
)
|
||||
|
||||
ports_with_merged_configs.append(conf_port)
|
||||
merged_ota_esphome_configs_by_port[conf_port] = merge_config(
|
||||
@@ -94,6 +125,20 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
|
||||
new_ota_conf.extend(merged_ota_esphome_configs_by_port.values())
|
||||
|
||||
api_conf = full_conf.get(CONF_API) or {}
|
||||
for ota_conf in merged_ota_esphome_configs_by_port.values():
|
||||
# Merging same-port blocks can combine a password from one block with
|
||||
# encryption from another; re-check the exclusion on the merged result.
|
||||
_validate_no_password_with_encryption(ota_conf)
|
||||
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||
_resolve_encryption_key(encryption_conf, api_conf)
|
||||
if any(
|
||||
conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf
|
||||
) and any(
|
||||
CONF_ENCRYPTION in conf for conf in merged_ota_esphome_configs_by_port.values()
|
||||
):
|
||||
_warn_web_server_ota(full_conf)
|
||||
|
||||
full_conf[CONF_OTA] = new_ota_conf
|
||||
fv.full_config.set(full_conf)
|
||||
|
||||
@@ -107,6 +152,73 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _warn_web_server_ota(full_conf: ConfigType) -> None:
|
||||
"""The web_server ota platform accepts the same image over plaintext HTTP
|
||||
with basic auth, bypassing the encryption; warn rather than fail so the
|
||||
operator keeps the recovery path."""
|
||||
if CONF_CAPTIVE_PORTAL in full_conf and CONF_WEB_SERVER not in full_conf:
|
||||
# The captive_portal auto-load: the endpoint only exists while the
|
||||
# fallback AP is active
|
||||
_LOGGER.warning(
|
||||
"OTA encryption does not cover the %s OTA platform (auto-loaded "
|
||||
"by captive_portal); the plaintext /update endpoint stays "
|
||||
"reachable while the fallback AP is active",
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"OTA encryption does not cover the %s OTA platform; its "
|
||||
"plaintext /update endpoint accepts the same image",
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -> None:
|
||||
"""Resolve the one encryption key per device into the ota block.
|
||||
|
||||
An explicit ota key must match the api key, a bare block inherits it,
|
||||
a runtime provisioned api key cannot be inherited, and the all-zeros
|
||||
provisioning sentinel is rejected (the device treats it as no key).
|
||||
"""
|
||||
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
|
||||
if ota_key := encryption_conf.get(CONF_KEY):
|
||||
if api_key and ota_key != api_key:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY} must match the "
|
||||
f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY}; omit the "
|
||||
f"'{CONF_OTA}' {CONF_KEY} to use the '{CONF_API}' one"
|
||||
)
|
||||
elif not api_key:
|
||||
if CONF_ENCRYPTION in api_conf:
|
||||
raise cv.Invalid(
|
||||
f"the '{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} is provisioned at "
|
||||
f"runtime and cannot be inherited at build time; set an explicit "
|
||||
f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY}"
|
||||
)
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_OTA}' {CONF_ENCRYPTION} has no {CONF_KEY} and there is no "
|
||||
f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} to inherit; set one of them"
|
||||
)
|
||||
else:
|
||||
encryption_conf[CONF_KEY] = api_key
|
||||
if is_reserved_key(encryption_conf[CONF_KEY]):
|
||||
raise cv.Invalid(
|
||||
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
|
||||
f"generate a real key with: openssl rand -base64 32"
|
||||
)
|
||||
|
||||
|
||||
# Also called on merged same-port configs in final validate, where schemas
|
||||
# do not run
|
||||
def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType:
|
||||
if CONF_PASSWORD in config and CONF_ENCRYPTION in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the "
|
||||
f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _consume_ota_sockets(config: ConfigType) -> ConfigType:
|
||||
"""Register socket needs for OTA component."""
|
||||
from esphome.components import socket
|
||||
@@ -134,6 +246,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
): cv.port,
|
||||
cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean,
|
||||
cv.Optional(CONF_PASSWORD): cv.sensitive(),
|
||||
cv.Optional(CONF_ENCRYPTION): encryption_schema,
|
||||
cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid(
|
||||
f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode"
|
||||
),
|
||||
@@ -147,12 +260,24 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
.extend(BASE_OTA_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
_validate_no_password_with_encryption,
|
||||
_consume_ota_sockets,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
"""Filter out the noise transport when no ota entry configures encryption."""
|
||||
for ota_conf in CORE.config.get(CONF_OTA, []):
|
||||
if (
|
||||
ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
and ota_conf.get(CONF_ENCRYPTION) is not None
|
||||
):
|
||||
return []
|
||||
return ["ota_esphome_noise.cpp"]
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -171,6 +296,12 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config.get(CONF_ALLOW_PARTITION_ACCESS):
|
||||
cg.add_define("USE_OTA_PARTITIONS")
|
||||
|
||||
if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None:
|
||||
# A missing key was resolved from the api component in final validate.
|
||||
key = encryption_conf[CONF_KEY]
|
||||
cg.add_define("USE_OTA_ENCRYPTION")
|
||||
cg.add(var.set_noise_psk(list(decode_encryption_key(key))))
|
||||
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ namespace esphome {
|
||||
|
||||
static const char *const TAG = "esphome.ota";
|
||||
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
||||
static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
|
||||
|
||||
@@ -105,6 +104,11 @@ void ESPHomeOTAComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, " Password configured");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ctx_.has_psk()) {
|
||||
ESP_LOGCONFIG(TAG, " Encryption configured");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Partition access allowed\n"
|
||||
@@ -149,8 +153,10 @@ void ESPHomeOTAComponent::loop() {
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
|
||||
|
||||
void ESPHomeOTAComponent::handle_handshake_() {
|
||||
/// Handle the OTA handshake and authentication.
|
||||
@@ -202,8 +208,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
}
|
||||
|
||||
// Validate magic bytes
|
||||
static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
|
||||
if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) {
|
||||
if (memcmp(this->handshake_buf_, MAGIC_BYTES, sizeof(MAGIC_BYTES)) != 0) {
|
||||
ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0],
|
||||
this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]);
|
||||
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC);
|
||||
@@ -235,6 +240,19 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
}
|
||||
this->ota_features_ = this->handshake_buf_[0];
|
||||
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// Fail closed: with a PSK configured the client must negotiate encryption
|
||||
// (which requires the extended protocol); refuse plaintext uploads.
|
||||
static constexpr uint8_t NOISE_REQUIRED_FEATURES =
|
||||
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
|
||||
if (this->noise_ctx_.has_psk() && (this->ota_features_ & NOISE_REQUIRED_FEATURES) != NOISE_REQUIRED_FEATURES) {
|
||||
ESP_LOGW(TAG, "Client does not support encryption");
|
||||
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
this->transition_ota_state_(OTAState::FEATURE_ACK);
|
||||
|
||||
const bool supports_compression =
|
||||
@@ -250,6 +268,11 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ctx_.has_psk()) {
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
this->handshake_buf_[0] =
|
||||
@@ -265,6 +288,20 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
|
||||
return;
|
||||
}
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// With a PSK configured the rest of the session runs inside the noise
|
||||
// transport; the client sends the first handshake frame next, so there
|
||||
// is nothing to do until data arrives.
|
||||
if (this->noise_ctx_.has_psk()) {
|
||||
// handshake_buf_ still holds the feature ack composed above; a
|
||||
// would-block re-entry lands here without rebuilding it
|
||||
if (!this->noise_start_session_(this->handshake_buf_[1])) {
|
||||
return;
|
||||
}
|
||||
this->transition_ota_state_(OTAState::NOISE_HANDSHAKE);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
// If password is set, move to auth phase
|
||||
if (!this->password_.empty()) {
|
||||
@@ -302,6 +339,16 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
this->handle_data_();
|
||||
return;
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
case OTAState::NOISE_HANDSHAKE:
|
||||
if (!this->handle_noise_handshake_()) {
|
||||
return;
|
||||
}
|
||||
this->transition_ota_state_(OTAState::DATA);
|
||||
this->handle_data_();
|
||||
return;
|
||||
#endif
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -340,6 +387,8 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
/// Raw TCP (8266, RP2040): setblocking is no-op; SO_RCVTIMEO uses
|
||||
/// wakeable_delay() in read();
|
||||
/// write() always returns immediately
|
||||
// Backend calls overwrite this with OK; reset to UNKNOWN before any
|
||||
// goto error that follows a successful begin()/write()
|
||||
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
size_t total = 0;
|
||||
uint32_t last_progress = 0;
|
||||
@@ -361,11 +410,11 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
this->client_->setblocking(true);
|
||||
|
||||
// Acknowledge auth OK - 1 byte
|
||||
this->write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
||||
|
||||
if (this->extended_proto_) {
|
||||
// Read ota type, 1 byte
|
||||
if (!this->readall_(buf, 1)) {
|
||||
if (!this->data_readall_(buf, 1)) {
|
||||
this->log_read_error_(LOG_STR("OTA type"));
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
@@ -374,7 +423,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
|
||||
|
||||
// Read size, 4 bytes MSB first
|
||||
if (!this->readall_(buf, 4)) {
|
||||
if (!this->data_readall_(buf, 4)) {
|
||||
this->log_read_error_(LOG_STR("size"));
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
@@ -405,11 +454,12 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
|
||||
// Acknowledge prepare OK - 1 byte
|
||||
this->write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK);
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK);
|
||||
|
||||
// Read binary MD5, 32 bytes
|
||||
if (!this->readall_(buf, 32)) {
|
||||
if (!this->data_readall_(buf, 32)) {
|
||||
this->log_read_error_(LOG_STR("MD5 checksum"));
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
sbuf[32] = '\0';
|
||||
@@ -417,7 +467,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
this->backend_->set_update_md5(sbuf);
|
||||
|
||||
// Acknowledge MD5 OK - 1 byte
|
||||
this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
|
||||
|
||||
// Track when we last received data so a silently-vanished peer (no FIN/RST
|
||||
// delivered, e.g. uploader killed mid-transfer or NAT/router dropped state)
|
||||
@@ -433,19 +483,35 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
}
|
||||
size_t remaining = ota_size - total;
|
||||
size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE;
|
||||
ssize_t read = this->client_->read(buf, requested);
|
||||
if (read == -1) {
|
||||
const int err = errno;
|
||||
if (this->would_block_(err)) {
|
||||
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
|
||||
App.feed_wdt();
|
||||
continue;
|
||||
ssize_t read;
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr) {
|
||||
// One frame per call; noise_read_data_ waits internally (readall_), so
|
||||
// there is no would-block retry here and failures are already logged.
|
||||
read = this->noise_read_data_(buf, requested);
|
||||
if (read <= 0) {
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
read = this->client_->read(buf, requested);
|
||||
if (read == -1) {
|
||||
const int err = errno;
|
||||
if (this->would_block_(err)) {
|
||||
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
|
||||
App.feed_wdt();
|
||||
continue;
|
||||
}
|
||||
ESP_LOGW(TAG, "Read err %d", err);
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
} else if (read == 0) {
|
||||
ESP_LOGW(TAG, "Remote closed");
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
ESP_LOGW(TAG, "Read err %d", err);
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
} else if (read == 0) {
|
||||
ESP_LOGW(TAG, "Remote closed");
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
|
||||
last_data_ms = millis();
|
||||
@@ -457,7 +523,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
total += read;
|
||||
#if USE_OTA_VERSION == 2
|
||||
while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) {
|
||||
this->write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
|
||||
size_acknowledged += OTA_BLOCK_SIZE;
|
||||
}
|
||||
#endif
|
||||
@@ -476,7 +542,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
}
|
||||
|
||||
// Acknowledge receive OK - 1 byte
|
||||
this->write_byte_(ota::OTA_RESPONSE_RECEIVE_OK);
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_RECEIVE_OK);
|
||||
|
||||
error_code = this->backend_->end();
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
@@ -485,10 +551,10 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
}
|
||||
|
||||
// Acknowledge Update end OK - 1 byte
|
||||
this->write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK);
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK);
|
||||
|
||||
// Read ACK
|
||||
if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
|
||||
if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
|
||||
this->log_read_error_(LOG_STR("ack"));
|
||||
// do not go to error, this is not fatal
|
||||
}
|
||||
@@ -511,7 +577,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
App.safe_reboot();
|
||||
|
||||
error:
|
||||
this->write_byte_(static_cast<uint8_t>(error_code));
|
||||
this->data_write_byte_(static_cast<uint8_t>(error_code));
|
||||
|
||||
// Abort backend before cleanup - cleanup_connection_() destroys the backend.
|
||||
// Always call abort() unconditionally: backends register external partitions before
|
||||
@@ -678,6 +744,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
|
||||
this->backend_ = nullptr;
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
this->cleanup_auth_();
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
this->noise_ = nullptr;
|
||||
#endif
|
||||
// Intentionally no disable_loop() — letting loop() run one more iteration catches
|
||||
// any connection that queued on the listener mid-session (otherwise the wake flag,
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
#ifdef USE_OTA
|
||||
#include "esphome/components/ota/ota_backend_factory.h"
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
#include "esphome/components/noise/noise_handshake.h"
|
||||
#endif
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
@@ -24,7 +27,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
AUTH_SEND, // Sending authentication request
|
||||
AUTH_READ, // Reading authentication data
|
||||
#endif // USE_OTA_PASSWORD
|
||||
DATA, // BLOCKING! Processing OTA data (update, etc.)
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
NOISE_HANDSHAKE, // Exchanging Noise handshake frames
|
||||
#endif
|
||||
DATA, // BLOCKING! Processing OTA data (update, etc.)
|
||||
};
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
void set_auth_password(const std::string &password) { password_ = password; }
|
||||
@@ -38,6 +44,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
}
|
||||
#endif // USE_OTA_PASSWORD
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
|
||||
#endif
|
||||
|
||||
/// Manually set the port OTA should listen on
|
||||
void set_port(uint16_t port) { this->port_ = port; }
|
||||
|
||||
@@ -63,6 +73,48 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
bool writeall_(const uint8_t *buf, size_t len);
|
||||
inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); }
|
||||
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// Heap-allocated only while an encrypted OTA session is active.
|
||||
struct NoiseSession {
|
||||
~NoiseSession();
|
||||
noise::NoiseResponderHandshake handshake;
|
||||
NoiseCipherState *send_cipher{nullptr};
|
||||
NoiseCipherState *recv_cipher{nullptr};
|
||||
uint16_t frame_len{0}; // total frame size once the header is parsed, 0 until then
|
||||
uint16_t frame_pos{0}; // bytes read or written so far
|
||||
bool writing{false}; // a produced handshake frame is still being flushed
|
||||
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
|
||||
};
|
||||
bool noise_start_session_(uint8_t server_feature_flags);
|
||||
bool handle_noise_handshake_();
|
||||
bool noise_try_read_frame_();
|
||||
bool noise_try_write_frame_();
|
||||
void noise_send_reject_(const LogString *reason);
|
||||
ssize_t noise_decrypt_(uint8_t *buf, size_t len);
|
||||
ssize_t noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext);
|
||||
bool noise_readall_(uint8_t *buf, size_t len);
|
||||
ssize_t noise_read_data_(uint8_t *buf, size_t capacity);
|
||||
bool noise_write_byte_(uint8_t byte);
|
||||
#endif // USE_OTA_ENCRYPTION
|
||||
|
||||
// Data-phase I/O dispatch: through the noise transport when a session is
|
||||
// active, straight to the socket otherwise.
|
||||
inline bool data_write_byte_(uint8_t byte) {
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr)
|
||||
return this->noise_write_byte_(byte);
|
||||
#endif
|
||||
return this->write_byte_(byte);
|
||||
}
|
||||
// When encrypted, buf must have room for len + noise::MAC_SIZE bytes.
|
||||
inline bool data_readall_(uint8_t *buf, size_t len) {
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr)
|
||||
return this->noise_readall_(buf, len);
|
||||
#endif
|
||||
return this->readall_(buf, len);
|
||||
}
|
||||
|
||||
bool try_read_(size_t to_read, const LogString *desc);
|
||||
bool try_write_(size_t to_write, const LogString *desc);
|
||||
|
||||
@@ -91,6 +143,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
std::string password_;
|
||||
std::unique_ptr<uint8_t[]> auth_buf_;
|
||||
#endif // USE_OTA_PASSWORD
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
noise::NoiseContext noise_ctx_;
|
||||
std::unique_ptr<NoiseSession> noise_;
|
||||
#endif // USE_OTA_ENCRYPTION
|
||||
|
||||
socket::ListenSocket *server_{nullptr};
|
||||
std::unique_ptr<socket::Socket> client_;
|
||||
@@ -98,6 +154,18 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
|
||||
uint32_t client_connect_time_{0};
|
||||
static constexpr size_t HANDSHAKE_BUF_SIZE = 5;
|
||||
// Buffer size for OTA data transfer. The upload client derives its maximum
|
||||
// encrypted frame plaintext from this (espota2.NOISE_MAX_PLAINTEXT is this
|
||||
// minus the 16-byte MAC); both must change together.
|
||||
static constexpr size_t OTA_BUFFER_SIZE = 1040;
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// espota2.NOISE_MAX_PLAINTEXT; shrinking the buffer would reject every
|
||||
// frame a current CLI sends
|
||||
static constexpr size_t NOISE_CLIENT_MAX_PLAINTEXT = 1024;
|
||||
static_assert(OTA_BUFFER_SIZE >= NOISE_CLIENT_MAX_PLAINTEXT + noise::MAC_SIZE,
|
||||
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
|
||||
#endif
|
||||
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
uint32_t running_app_offset_{0};
|
||||
size_t running_app_size_{0};
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
#include "ota_esphome.h"
|
||||
#ifdef USE_OTA
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
#include "esphome/components/noise/noise.h"
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
#include <pgmspace.h>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
static const char *const TAG = "esphome.ota";
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
static constexpr char OTA_NOISE_PROLOGUE_INIT[] PROGMEM = "NoiseOTAInit";
|
||||
#else
|
||||
static constexpr char OTA_NOISE_PROLOGUE_INIT[] = "NoiseOTAInit";
|
||||
#endif
|
||||
static constexpr size_t OTA_NOISE_PROLOGUE_INIT_LEN = sizeof(OTA_NOISE_PROLOGUE_INIT) - 1;
|
||||
|
||||
ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
|
||||
if (this->send_cipher != nullptr) {
|
||||
noise_cipherstate_free(this->send_cipher);
|
||||
}
|
||||
if (this->recv_cipher != nullptr) {
|
||||
noise_cipherstate_free(this->recv_cipher);
|
||||
}
|
||||
}
|
||||
|
||||
/** Allocate the session and start the responder handshake.
|
||||
*
|
||||
* The prologue binds the whole plaintext preamble, so any tampering with the
|
||||
* negotiation (a stripped feature flag, a changed version) breaks the first
|
||||
* handshake MAC on either side:
|
||||
* "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags
|
||||
*/
|
||||
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession());
|
||||
if (this->noise_ == nullptr) {
|
||||
ESP_LOGW(TAG, "Session allocation failed");
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
|
||||
static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version
|
||||
static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1;
|
||||
static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags
|
||||
uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + sizeof(MAGIC_BYTES) + PROLOGUE_ACK_LEN + PROLOGUE_CLIENT_FEATURES_LEN +
|
||||
PROLOGUE_FEATURE_ACK_LEN];
|
||||
#ifdef USE_ESP8266
|
||||
memcpy_P(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
|
||||
#else
|
||||
std::memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
|
||||
#endif
|
||||
uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN;
|
||||
// Magic bytes, already validated in MAGIC_READ
|
||||
std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES));
|
||||
p += sizeof(MAGIC_BYTES);
|
||||
// Our magic ack
|
||||
*p++ = ota::OTA_RESPONSE_OK;
|
||||
*p++ = USE_OTA_VERSION;
|
||||
// The feature byte the client sent
|
||||
*p++ = this->ota_features_;
|
||||
// The feature ack we sent (noise requires the extended protocol)
|
||||
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
||||
*p++ = server_feature_flags;
|
||||
|
||||
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drive the non-blocking handshake from loop(); returns true once the
|
||||
* transport ciphers are ready. A would-block returns false and the next
|
||||
* loop() resumes from the NoiseSession cursors; on failure the connection
|
||||
* is cleaned up.
|
||||
*/
|
||||
bool ESPHomeOTAComponent::handle_noise_handshake_() {
|
||||
NoiseSession &s = *this->noise_;
|
||||
while (true) {
|
||||
if (s.writing) {
|
||||
if (!this->noise_try_write_frame_()) {
|
||||
return false; // would block, or errored and cleaned up
|
||||
}
|
||||
s.writing = false;
|
||||
s.frame_pos = 0;
|
||||
s.frame_len = 0;
|
||||
}
|
||||
switch (s.handshake.action()) {
|
||||
case noise::NoiseResponderHandshake::Action::ACTION_READ: {
|
||||
if (!this->noise_try_read_frame_()) {
|
||||
return false;
|
||||
}
|
||||
const uint16_t payload_len = s.frame_len - noise::FRAME_HEADER_SIZE;
|
||||
s.frame_pos = 0;
|
||||
s.frame_len = 0;
|
||||
if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) {
|
||||
ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
this->noise_send_reject_(noise::reject_reason_for(err));
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case noise::NoiseResponderHandshake::Action::ACTION_WRITE: {
|
||||
size_t msg_len = 0;
|
||||
int err =
|
||||
s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
const uint16_t payload_len = msg_len + 1;
|
||||
noise::write_frame_header(s.frame_buf, payload_len);
|
||||
s.frame_buf[noise::FRAME_HEADER_SIZE] = noise::HANDSHAKE_STATUS_OK;
|
||||
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
|
||||
s.frame_pos = 0;
|
||||
s.writing = true;
|
||||
break;
|
||||
}
|
||||
case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: {
|
||||
int err = s.handshake.split(s.send_cipher, s.recv_cipher);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
ESP_LOGD(TAG, "Noise handshake complete");
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
ESP_LOGW(TAG, "Bad handshake state");
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking read of one handshake frame into the session buffer.
|
||||
bool ESPHomeOTAComponent::noise_try_read_frame_() {
|
||||
NoiseSession &s = *this->noise_;
|
||||
while (s.frame_pos < noise::FRAME_HEADER_SIZE) {
|
||||
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos);
|
||||
if (!this->handle_read_error_(read, LOG_STR("read noise header"))) {
|
||||
return false;
|
||||
}
|
||||
s.frame_pos += read;
|
||||
}
|
||||
if (s.frame_len == 0) {
|
||||
const uint16_t payload_len = encode_uint16(s.frame_buf[1], s.frame_buf[2]);
|
||||
if (s.frame_buf[0] != noise::FRAME_INDICATOR || payload_len < 1 || payload_len > 1 + noise::MAX_HANDSHAKE_SIZE) {
|
||||
ESP_LOGW(TAG, "Bad handshake frame: 0x%02X, %u bytes", s.frame_buf[0], payload_len);
|
||||
this->cleanup_connection_();
|
||||
return false;
|
||||
}
|
||||
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
|
||||
}
|
||||
while (s.frame_pos < s.frame_len) {
|
||||
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
|
||||
if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) {
|
||||
return false;
|
||||
}
|
||||
s.frame_pos += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Non-blocking write of the pending session-buffer frame.
|
||||
bool ESPHomeOTAComponent::noise_try_write_frame_() {
|
||||
NoiseSession &s = *this->noise_;
|
||||
while (s.frame_pos < s.frame_len) {
|
||||
ssize_t written = this->client_->write(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
|
||||
if (!this->handle_write_error_(written, LOG_STR("write noise frame"))) {
|
||||
return false;
|
||||
}
|
||||
s.frame_pos += written;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Best-effort explicit reject frame so the client can log a readable reason.
|
||||
void ESPHomeOTAComponent::noise_send_reject_(const LogString *reason) {
|
||||
// Every reason here comes from noise::reject_reason_for(), so the exported
|
||||
// floor is the exact capacity needed
|
||||
uint8_t data[noise::FRAME_HEADER_SIZE + noise::MAC_FAILURE_PAYLOAD_SIZE];
|
||||
const size_t payload_len =
|
||||
noise::format_reject_payload(data + noise::FRAME_HEADER_SIZE, sizeof(data) - noise::FRAME_HEADER_SIZE, reason);
|
||||
noise::write_frame_header(data, payload_len);
|
||||
this->client_->write(data, noise::FRAME_HEADER_SIZE + payload_len); // Best effort, non-blocking
|
||||
}
|
||||
|
||||
/// Decrypt a ciphertext in place; returns the plaintext size or -1.
|
||||
ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) {
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_inout(mbuf, buf, len, len);
|
||||
int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
return -1;
|
||||
}
|
||||
return mbuf.size;
|
||||
}
|
||||
|
||||
/** Blocking read of one frame whose ciphertext size must be within the given
|
||||
* bounds, decrypted in place; returns the plaintext size, or -1 on error.
|
||||
* buf needs max_ciphertext capacity.
|
||||
*/
|
||||
ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext) {
|
||||
uint8_t header[noise::FRAME_HEADER_SIZE];
|
||||
if (!this->readall_(header, sizeof(header))) {
|
||||
return -1;
|
||||
}
|
||||
const size_t ciphertext_len = encode_uint16(header[1], header[2]);
|
||||
if (header[0] != noise::FRAME_INDICATOR || ciphertext_len < min_ciphertext || ciphertext_len > max_ciphertext) {
|
||||
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], ciphertext_len);
|
||||
return -1;
|
||||
}
|
||||
if (!this->readall_(buf, ciphertext_len)) {
|
||||
return -1;
|
||||
}
|
||||
return this->noise_decrypt_(buf, ciphertext_len);
|
||||
}
|
||||
|
||||
/** Blocking read of one frame whose plaintext must be exactly len bytes
|
||||
* (control units are one unit per frame). buf needs len + noise::MAC_SIZE
|
||||
* capacity; the plaintext lands at buf[0..len).
|
||||
*/
|
||||
bool ESPHomeOTAComponent::noise_readall_(uint8_t *buf, size_t len) {
|
||||
return this->noise_read_frame_blocking_(buf, len + noise::MAC_SIZE, len + noise::MAC_SIZE) == (ssize_t) len;
|
||||
}
|
||||
|
||||
/** Blocking read of one data-phase frame, decrypted in place; returns the
|
||||
* plaintext size, or -1 on error. buf is the OTA_BUFFER_SIZE data buffer.
|
||||
* The ciphertext must fit that buffer and its plaintext must fit what the
|
||||
* caller accepts (the remaining image bytes).
|
||||
*/
|
||||
ssize_t ESPHomeOTAComponent::noise_read_data_(uint8_t *buf, size_t capacity) {
|
||||
const size_t max_ciphertext = std::min(capacity + noise::MAC_SIZE, OTA_BUFFER_SIZE);
|
||||
return this->noise_read_frame_blocking_(buf, noise::MAC_SIZE + 1, max_ciphertext);
|
||||
}
|
||||
|
||||
/// Blocking write of one response byte as an encrypted frame.
|
||||
bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) {
|
||||
uint8_t frame[noise::FRAME_HEADER_SIZE + 1 + noise::MAC_SIZE];
|
||||
frame[noise::FRAME_HEADER_SIZE] = byte;
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
|
||||
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
|
||||
return false;
|
||||
}
|
||||
noise::write_frame_header(frame, mbuf.size);
|
||||
return this->writeall_(frame, noise::FRAME_HEADER_SIZE + mbuf.size);
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
#endif // USE_OTA_ENCRYPTION
|
||||
#endif // USE_OTA
|
||||
@@ -334,8 +334,9 @@ void Fan::dump_traits_(const char *tag, const char *prefix) {
|
||||
}
|
||||
if (traits.supports_preset_modes()) {
|
||||
ESP_LOGCONFIG(tag, "%s Supported presets:", prefix);
|
||||
for (const char *s : traits.supported_preset_modes())
|
||||
for (const char *s : traits.supported_preset_modes()) {
|
||||
ESP_LOGCONFIG(tag, "%s - %s", prefix, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ void HBridgeSwitch::dump_config() {
|
||||
LOG_PIN(" On Pin: ", this->on_pin_);
|
||||
LOG_PIN(" Off Pin: ", this->off_pin_);
|
||||
ESP_LOGCONFIG(TAG, " Pulse length: %" PRId32 " ms", this->pulse_length_);
|
||||
if (this->wait_time_)
|
||||
if (this->wait_time_) {
|
||||
ESP_LOGCONFIG(TAG, " Wait time %" PRId32 " ms", this->wait_time_);
|
||||
}
|
||||
}
|
||||
|
||||
void HBridgeSwitch::write_state(bool state) {
|
||||
|
||||
@@ -96,7 +96,6 @@ void HC8Component::dump_config() {
|
||||
" Warmup time: %" PRIu32 " s",
|
||||
this->warmup_seconds_);
|
||||
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
|
||||
this->check_uart_settings(9600);
|
||||
}
|
||||
|
||||
} // namespace esphome::hc8
|
||||
|
||||
@@ -47,6 +47,9 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
baud_rate=9600,
|
||||
require_rx=True,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -38,14 +38,14 @@ CoverTraits HE60rCover::get_traits() {
|
||||
|
||||
void HE60rCover::dump_config() {
|
||||
LOG_COVER("", "HE60R Cover", this);
|
||||
this->check_uart_settings(1200, 1, uart::UART_CONFIG_PARITY_EVEN, 8);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Open Duration: %.1fs\n"
|
||||
" Close Duration: %.1fs",
|
||||
this->open_duration_ / 1e3f, this->close_duration_ / 1e3f);
|
||||
auto restore = this->restore_state_();
|
||||
if (restore.has_value())
|
||||
if (restore.has_value()) {
|
||||
ESP_LOGCONFIG(TAG, " Saved position %d%%", (int) (restore->position * 100.f));
|
||||
}
|
||||
}
|
||||
|
||||
void HE60rCover::endstop_reached_(CoverOperation operation) {
|
||||
@@ -77,8 +77,9 @@ void HE60rCover::process_rx_(uint8_t data) {
|
||||
ESP_LOGV(TAG, "Process RX data %X", data);
|
||||
if (!this->query_seen_) {
|
||||
this->query_seen_ = data == QUERY_BYTE;
|
||||
if (!this->query_seen_)
|
||||
if (!this->query_seen_) {
|
||||
ESP_LOGD(TAG, "RX Byte %02X", data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (data) {
|
||||
|
||||
@@ -257,8 +257,9 @@ void HoermannHcp::on_state_reg_(uint16_t value) {
|
||||
}
|
||||
}
|
||||
// The low byte can change on its own, so only report a state we cannot decode once.
|
||||
if (state != (previous >> 8))
|
||||
if (state != (previous >> 8)) {
|
||||
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
|
||||
}
|
||||
}
|
||||
|
||||
// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
|
||||
|
||||
@@ -68,8 +68,6 @@ void HrxlMaxsonarWrComponent::check_buffer_() {
|
||||
void HrxlMaxsonarWrComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "HRXL MaxSonar WR Sensor:");
|
||||
LOG_SENSOR(" ", "Distance", this);
|
||||
// As specified in the sensor's data sheet
|
||||
this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8);
|
||||
}
|
||||
|
||||
} // namespace esphome::hrxl_maxsonar_wr
|
||||
|
||||
@@ -23,6 +23,14 @@ CONFIG_SCHEMA = sensor.sensor_schema(
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
).extend(uart.UART_DEVICE_SCHEMA)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"hrxl_maxsonar_wr",
|
||||
baud_rate=9600,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
|
||||
@@ -11,7 +11,6 @@ static const char *const PROTOCOL_NAMES[] = {HYDREON_RGXX_PROTOCOL_LIST(, HYDREO
|
||||
static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_RGXX_COMMA)};
|
||||
|
||||
void HydreonRGxxComponent::dump_config() {
|
||||
this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8);
|
||||
ESP_LOGCONFIG(TAG, "hydreon_rgxx:");
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, "Connection with hydreon_rgxx failed!");
|
||||
|
||||
@@ -130,6 +130,14 @@ CONFIG_SCHEMA = cv.All(
|
||||
_validate,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"hydreon_rgxx",
|
||||
baud_rate=9600,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -26,8 +26,6 @@ void KamstrupKMPComponent::dump_config() {
|
||||
LOG_SENSOR(" ", "Custom Sensor", this->custom_sensors_[i]);
|
||||
ESP_LOGCONFIG(TAG, " Command: 0x%04X", this->custom_commands_[i]);
|
||||
}
|
||||
|
||||
this->check_uart_settings(1200, 2, uart::UART_CONFIG_PARITY_NONE, 8);
|
||||
}
|
||||
|
||||
void KamstrupKMPComponent::update() {
|
||||
|
||||
@@ -102,7 +102,13 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"kamstrup_kmp", baud_rate=1200, require_rx=True, require_tx=True
|
||||
"kamstrup_kmp",
|
||||
baud_rate=1200,
|
||||
require_rx=True,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=2,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,26 +16,33 @@ void KeyCollector::loop() {
|
||||
void KeyCollector::dump_config() {
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
|
||||
ESP_LOGCONFIG(TAG, "Key Collector:");
|
||||
if (this->min_length_ > 0)
|
||||
if (this->min_length_ > 0) {
|
||||
ESP_LOGCONFIG(TAG, " min length: %d", this->min_length_);
|
||||
if (this->max_length_ > 0)
|
||||
}
|
||||
if (this->max_length_ > 0) {
|
||||
ESP_LOGCONFIG(TAG, " max length: %d", this->max_length_);
|
||||
if (!this->back_keys_.empty())
|
||||
}
|
||||
if (!this->back_keys_.empty()) {
|
||||
ESP_LOGCONFIG(TAG, " erase keys '%s'", this->back_keys_.c_str());
|
||||
if (!this->clear_keys_.empty())
|
||||
}
|
||||
if (!this->clear_keys_.empty()) {
|
||||
ESP_LOGCONFIG(TAG, " clear keys '%s'", this->clear_keys_.c_str());
|
||||
if (!this->start_keys_.empty())
|
||||
}
|
||||
if (!this->start_keys_.empty()) {
|
||||
ESP_LOGCONFIG(TAG, " start keys '%s'", this->start_keys_.c_str());
|
||||
}
|
||||
if (!this->end_keys_.empty()) {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" end keys '%s'\n"
|
||||
" end key is required: %s",
|
||||
this->end_keys_.c_str(), ONOFF(this->end_key_required_));
|
||||
}
|
||||
if (!this->allowed_keys_.empty())
|
||||
if (!this->allowed_keys_.empty()) {
|
||||
ESP_LOGCONFIG(TAG, " allowed keys '%s'", this->allowed_keys_.c_str());
|
||||
if (this->timeout_ > 0)
|
||||
}
|
||||
if (this->timeout_ > 0) {
|
||||
ESP_LOGCONFIG(TAG, " entry timeout: %0.1f", this->timeout_ / 1000.0);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -333,8 +333,9 @@ void LN882HBLE::loop() {
|
||||
// the queue empty — from the very first report on. Checking here keeps that
|
||||
// failure visible instead of producing a scanner that is silently dead.
|
||||
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
|
||||
if (dropped > 0)
|
||||
if (dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u scan reports (queue full or out of memory for a report slot)", dropped);
|
||||
}
|
||||
// Drain the lock-free ring filled by the rw task; all per-report work runs
|
||||
// here on the main task, then the report returns to the pool.
|
||||
BLEScanReport *report = this->report_queue_.pop();
|
||||
|
||||
@@ -1059,8 +1059,9 @@ static void *lv_alloc_draw_buf(size_t size, bool internal) {
|
||||
void *buffer;
|
||||
size = LV_ROUND_UP(size, LV_DRAW_BUF_ALIGN);
|
||||
buffer = heap_caps_aligned_alloc(LV_DRAW_BUF_ALIGN, size, internal ? MALLOC_CAP_8BIT : cap_bits); // NOLINT
|
||||
if (buffer == nullptr)
|
||||
if (buffer == nullptr) {
|
||||
ESP_LOGW(esphome::lvgl::TAG, "Failed to allocate %zu bytes for %sdraw buffer", size, internal ? "internal " : "");
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from contextlib import ExitStack
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ROWS
|
||||
from esphome.components.const import CONF_COLUMNS, CONF_ROWS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROW, CONF_TEXT, CONF_WIDTH
|
||||
from esphome.core import ID
|
||||
@@ -20,7 +20,6 @@ from .label import CONF_LABEL
|
||||
|
||||
CONF_TABLE = "table"
|
||||
CONF_CELLS = "cells"
|
||||
CONF_COLUMNS = "columns"
|
||||
CONF_ROW_COUNT = "row_count"
|
||||
CONF_COLUMN_COUNT = "column_count"
|
||||
CONF_MERGE_RIGHT = "merge_right"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import key_provider
|
||||
from esphome.components.const import CONF_ROWS
|
||||
from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID
|
||||
from esphome.types import ConfigType
|
||||
@@ -21,8 +21,6 @@ MatrixKeyTrigger = matrix_keypad_ns.class_(
|
||||
)
|
||||
|
||||
CONF_KEYPAD_ID = "keypad_id"
|
||||
CONF_COLUMNS = "columns"
|
||||
CONF_KEYS = "keys"
|
||||
CONF_DEBOUNCE_TIME = "debounce_time"
|
||||
CONF_HAS_DIODES = "has_diodes"
|
||||
CONF_HAS_PULLDOWNS = "has_pulldowns"
|
||||
|
||||
@@ -143,8 +143,6 @@ void MHZ19Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "MH-Z19:");
|
||||
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
|
||||
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
|
||||
this->check_uart_settings(9600);
|
||||
|
||||
if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) {
|
||||
ESP_LOGCONFIG(TAG, " Automatic baseline calibration enabled on boot");
|
||||
} else if (this->abc_boot_logic_ == MHZ19_ABC_DISABLED) {
|
||||
|
||||
@@ -80,6 +80,14 @@ CONFIG_SCHEMA = (
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"mhz19",
|
||||
baud_rate=9600,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -237,8 +237,9 @@ void MipiDsi::write_to_display_(int x_start, int y_start, int w, int h, const ui
|
||||
xSemaphoreTake(this->io_lock_, portMAX_DELAY);
|
||||
}
|
||||
}
|
||||
if (err != ESP_OK)
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
bool MipiDsi::check_buffer_() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <driver/gpio.h>
|
||||
#include <esp_lcd_panel_rgb.h>
|
||||
#include <esp_lcd_panel_ops.h>
|
||||
#include <span>
|
||||
|
||||
namespace esphome::mipi_rgb {
|
||||
@@ -177,11 +177,6 @@ void MipiRgb::common_setup_() {
|
||||
ESP_LOGCONFIG(TAG, "MipiRgb setup complete");
|
||||
}
|
||||
|
||||
void MipiRgb::loop() {
|
||||
if (this->handle_ != nullptr)
|
||||
esp_lcd_rgb_panel_restart(this->handle_);
|
||||
}
|
||||
|
||||
void MipiRgb::update() {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
@@ -243,8 +238,9 @@ void MipiRgb::write_to_display_(int x_start, int y_start, int w, int h, const ui
|
||||
ptr += stride; // next line
|
||||
}
|
||||
}
|
||||
if (err != ESP_OK)
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
bool MipiRgb::check_buffer_() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S31)
|
||||
#include "esphome/core/gpio.h"
|
||||
#include "esphome/components/display/display.h"
|
||||
#include "esp_lcd_panel_ops.h"
|
||||
#include <esp_lcd_panel_rgb.h>
|
||||
#ifdef USE_SPI
|
||||
#include "esphome/components/spi/spi.h"
|
||||
#endif
|
||||
@@ -25,7 +25,12 @@ class MipiRgb : public display::Display {
|
||||
public:
|
||||
MipiRgb(int width, int height) : width_(width), height_(height) {}
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
#ifdef USE_ESP32_VARIANT_ESP32S3
|
||||
void loop() override {
|
||||
if (this->handle_ != nullptr)
|
||||
esp_lcd_rgb_panel_restart(this->handle_);
|
||||
}
|
||||
#endif
|
||||
void update() override;
|
||||
void fill(Color color) override;
|
||||
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
|
||||
|
||||
@@ -31,12 +31,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w
|
||||
LOG_PIN(" CS Pin: ", cs);
|
||||
LOG_PIN(" Reset Pin: ", reset);
|
||||
LOG_PIN(" DC Pin: ", dc);
|
||||
if (offset_width != 0)
|
||||
if (offset_width != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Offset width: %d", offset_width);
|
||||
if (offset_height != 0)
|
||||
}
|
||||
if (offset_height != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Offset height: %d", offset_height);
|
||||
if (brightness.has_value())
|
||||
}
|
||||
if (brightness.has_value()) {
|
||||
ESP_LOGCONFIG(TAG, " Brightness: %u", brightness.value());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::mipi_spi
|
||||
|
||||
@@ -163,10 +163,7 @@ void Mk2PVRouter::publish_value_(const char *tag, const char *val) {
|
||||
#endif
|
||||
}
|
||||
|
||||
void Mk2PVRouter::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Mk2PVRouter:");
|
||||
this->check_uart_settings(BAUD_RATE, 1, uart::UART_CONFIG_PARITY_EVEN, 7);
|
||||
}
|
||||
void Mk2PVRouter::dump_config() { ESP_LOGCONFIG(TAG, "Mk2PVRouter:"); }
|
||||
|
||||
#ifdef MK2PVROUTER_LISTENER_COUNT
|
||||
void Mk2PVRouter::register_mk2pvrouter_listener(Mk2PVRouterListener *listener) {
|
||||
|
||||
@@ -43,7 +43,6 @@ class Mk2PVRouter final : public Component, public uart::UARTDevice {
|
||||
|
||||
protected:
|
||||
static constexpr size_t CRC_SUFFIX_LEN = 1;
|
||||
static constexpr uint32_t BAUD_RATE = 9600;
|
||||
|
||||
enum class State : uint8_t {
|
||||
WAITING_FOR_START,
|
||||
|
||||
@@ -1199,15 +1199,17 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
|
||||
this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() {
|
||||
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
|
||||
this->deferred_payload_len_ - 1);
|
||||
if (!this->send_frame_(frame))
|
||||
if (!this->send_frame_(frame)) {
|
||||
ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ModbusFrame frame(payload[0], payload + 1, len - 1);
|
||||
if (!this->send_frame_(frame))
|
||||
if (!this->send_frame_(frame)) {
|
||||
ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay");
|
||||
}
|
||||
}
|
||||
|
||||
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) {
|
||||
|
||||
@@ -39,10 +39,12 @@ inline char *append_char(char *p, char c) {
|
||||
// Function implementation of LOG_MQTT_COMPONENT macro to reduce code size
|
||||
void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) {
|
||||
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
|
||||
if (state_topic)
|
||||
if (state_topic) {
|
||||
ESP_LOGCONFIG(tag, " State Topic: '%s'", obj->get_state_topic_to_(buf).c_str());
|
||||
if (command_topic)
|
||||
}
|
||||
if (command_topic) {
|
||||
ESP_LOGCONFIG(tag, " Command Topic: '%s'", obj->get_command_topic_to_(buf).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void MQTTComponent::set_qos(uint8_t qos) { this->qos_ = qos; }
|
||||
|
||||
@@ -45,6 +45,15 @@ def decode_encryption_key(value: str) -> bytes:
|
||||
return decoded
|
||||
|
||||
|
||||
def is_reserved_key(value: str) -> bool:
|
||||
"""Whether the key is the reserved all-zeros provisioning sentinel.
|
||||
|
||||
The device treats it as no key configured, so consumers that require a
|
||||
real key must reject it.
|
||||
"""
|
||||
return not any(decode_encryption_key(value))
|
||||
|
||||
|
||||
ENCRYPTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
|
||||
|
||||
@@ -18,8 +18,9 @@ const std::vector<uint64_t> &OneWireBus::get_devices() { return this->devices_;
|
||||
|
||||
bool OneWireBus::reset_() {
|
||||
int res = this->reset_int();
|
||||
if (res == -1)
|
||||
if (res == -1) {
|
||||
ESP_LOGE(TAG, "1-wire bus is held low");
|
||||
}
|
||||
return res == 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ enum OTAResponseTypes {
|
||||
OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91,
|
||||
OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92,
|
||||
OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93,
|
||||
OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94,
|
||||
OTA_RESPONSE_ERROR_UNKNOWN = 0xFF,
|
||||
};
|
||||
|
||||
|
||||
@@ -551,12 +551,14 @@ void PacketTransport::dump_config() {
|
||||
" Ping-pong: %s",
|
||||
this->platform_name_, YESNO(this->is_encrypted_()), YESNO(this->ping_pong_enable_));
|
||||
#ifdef USE_SENSOR
|
||||
for (const auto &sensor : this->sensors_)
|
||||
for (const auto &sensor : this->sensors_) {
|
||||
ESP_LOGCONFIG(TAG, " Sensor: %s", sensor.id);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
for (const auto &sensor : this->binary_sensors_)
|
||||
for (const auto &sensor : this->binary_sensors_) {
|
||||
ESP_LOGCONFIG(TAG, " Binary Sensor: %s", sensor.id);
|
||||
}
|
||||
#endif
|
||||
for (const auto &host : this->providers_) {
|
||||
ESP_LOGCONFIG(TAG, " Remote host: %s", host.first.c_str());
|
||||
@@ -564,15 +566,17 @@ void PacketTransport::dump_config() {
|
||||
#ifdef USE_SENSOR
|
||||
auto rs = this->remote_sensors_.find(host.first.c_str());
|
||||
if (rs != this->remote_sensors_.end()) {
|
||||
for (const auto &key : rs->second | std::views::keys)
|
||||
for (const auto &key : rs->second | std::views::keys) {
|
||||
ESP_LOGCONFIG(TAG, " Sensor: %s", key.c_str());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
auto rbs = this->remote_binary_sensors_.find(host.first.c_str());
|
||||
if (rbs != this->remote_binary_sensors_.end()) {
|
||||
for (const auto &key : rbs->second | std::views::keys)
|
||||
for (const auto &key : rbs->second | std::views::keys) {
|
||||
ESP_LOGCONFIG(TAG, " Binary Sensor: %s", key.c_str());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ void PM1006Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "PM1006:");
|
||||
LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
this->check_uart_settings(9600);
|
||||
}
|
||||
|
||||
void PM1006Component::update() {
|
||||
|
||||
@@ -48,6 +48,9 @@ def validate_interval_uart(config: ConfigType) -> None:
|
||||
baud_rate=9600,
|
||||
require_rx=True,
|
||||
require_tx=interval.total_milliseconds != SCHEDULER_DONT_RUN,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)(config)
|
||||
|
||||
|
||||
|
||||
@@ -46,8 +46,6 @@ void PMSX003Component::dump_config() {
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Mode: passive with sleep/wake cycles");
|
||||
}
|
||||
|
||||
this->check_uart_settings(9600);
|
||||
}
|
||||
|
||||
void PMSX003Component::loop() {
|
||||
|
||||
@@ -302,7 +302,13 @@ CONFIG_SCHEMA = cv.All(
|
||||
def final_validate(config: ConfigType) -> None:
|
||||
require_tx = config[CONF_UPDATE_INTERVAL] > cv.time_period("0s")
|
||||
schema = uart.final_validate_device_schema(
|
||||
"pmsx003", baud_rate=9600, require_rx=True, require_tx=require_tx
|
||||
"pmsx003",
|
||||
baud_rate=9600,
|
||||
require_rx=True,
|
||||
require_tx=require_tx,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
schema(config)
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ CONFIG_SCHEMA = cv.All(
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"pylontech",
|
||||
baud_rate=115200,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -33,7 +33,6 @@ static const uint8_t ASCII_LF = 0x0A;
|
||||
PylontechComponent::PylontechComponent() {}
|
||||
|
||||
void PylontechComponent::dump_config() {
|
||||
this->check_uart_settings(115200, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8);
|
||||
ESP_LOGCONFIG(TAG, "pylontech:");
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, "Connection with pylontech failed!");
|
||||
|
||||
@@ -124,8 +124,9 @@ void QwiicPIRComponent::dump_config() {
|
||||
|
||||
void QwiicPIRComponent::clear_events_() {
|
||||
// Clear event status register
|
||||
if (!this->write_byte(QWIIC_PIR_EVENT_STATUS, 0x00))
|
||||
if (!this->write_byte(QWIIC_PIR_EVENT_STATUS, 0x00)) {
|
||||
ESP_LOGW(TAG, "Failed to clear events");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::qwiic_pir
|
||||
|
||||
@@ -75,8 +75,9 @@ void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uin
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (err != ESP_OK)
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
int RpiDpiRgb::get_width() {
|
||||
|
||||
@@ -255,12 +255,17 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
|
||||
}
|
||||
|
||||
void SafeModeComponent::write_rtc_(uint32_t val) {
|
||||
this->rtc_.save(&val);
|
||||
global_preferences->sync();
|
||||
if (!this->rtc_.save(&val)) {
|
||||
ESP_LOGE(TAG, "Failed to set rtc value (%" PRIu32 ")", val);
|
||||
return;
|
||||
}
|
||||
if (!global_preferences->sync()) {
|
||||
ESP_LOGE(TAG, "Failed to persist rtc value (%" PRIu32 ")", val);
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t SafeModeComponent::read_rtc_() {
|
||||
uint32_t val;
|
||||
uint32_t val = 0;
|
||||
if (!this->rtc_.load(&val))
|
||||
return 0;
|
||||
return val;
|
||||
@@ -272,7 +277,9 @@ void SafeModeComponent::clean_rtc() {
|
||||
// before sync, the boot wasn't really successful anyway and the counter should
|
||||
// remain incremented.
|
||||
uint32_t val = 0;
|
||||
this->rtc_.save(&val);
|
||||
if (!this->rtc_.save(&val)) {
|
||||
ESP_LOGE(TAG, "Failed to clear boot loop counter");
|
||||
}
|
||||
}
|
||||
|
||||
void SafeModeComponent::on_safe_shutdown() {
|
||||
|
||||
@@ -1 +1,254 @@
|
||||
import esphome.codegen as cg
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
|
||||
SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode")
|
||||
|
||||
SDL_KEYS = (
|
||||
"SDLK_UNKNOWN",
|
||||
"SDLK_RETURN",
|
||||
"SDLK_ESCAPE",
|
||||
"SDLK_BACKSPACE",
|
||||
"SDLK_TAB",
|
||||
"SDLK_SPACE",
|
||||
"SDLK_EXCLAIM",
|
||||
"SDLK_QUOTEDBL",
|
||||
"SDLK_HASH",
|
||||
"SDLK_PERCENT",
|
||||
"SDLK_DOLLAR",
|
||||
"SDLK_AMPERSAND",
|
||||
"SDLK_QUOTE",
|
||||
"SDLK_LEFTPAREN",
|
||||
"SDLK_RIGHTPAREN",
|
||||
"SDLK_ASTERISK",
|
||||
"SDLK_PLUS",
|
||||
"SDLK_COMMA",
|
||||
"SDLK_MINUS",
|
||||
"SDLK_PERIOD",
|
||||
"SDLK_SLASH",
|
||||
"SDLK_0",
|
||||
"SDLK_1",
|
||||
"SDLK_2",
|
||||
"SDLK_3",
|
||||
"SDLK_4",
|
||||
"SDLK_5",
|
||||
"SDLK_6",
|
||||
"SDLK_7",
|
||||
"SDLK_8",
|
||||
"SDLK_9",
|
||||
"SDLK_COLON",
|
||||
"SDLK_SEMICOLON",
|
||||
"SDLK_LESS",
|
||||
"SDLK_EQUALS",
|
||||
"SDLK_GREATER",
|
||||
"SDLK_QUESTION",
|
||||
"SDLK_AT",
|
||||
"SDLK_LEFTBRACKET",
|
||||
"SDLK_BACKSLASH",
|
||||
"SDLK_RIGHTBRACKET",
|
||||
"SDLK_CARET",
|
||||
"SDLK_UNDERSCORE",
|
||||
"SDLK_BACKQUOTE",
|
||||
"SDLK_a",
|
||||
"SDLK_b",
|
||||
"SDLK_c",
|
||||
"SDLK_d",
|
||||
"SDLK_e",
|
||||
"SDLK_f",
|
||||
"SDLK_g",
|
||||
"SDLK_h",
|
||||
"SDLK_i",
|
||||
"SDLK_j",
|
||||
"SDLK_k",
|
||||
"SDLK_l",
|
||||
"SDLK_m",
|
||||
"SDLK_n",
|
||||
"SDLK_o",
|
||||
"SDLK_p",
|
||||
"SDLK_q",
|
||||
"SDLK_r",
|
||||
"SDLK_s",
|
||||
"SDLK_t",
|
||||
"SDLK_u",
|
||||
"SDLK_v",
|
||||
"SDLK_w",
|
||||
"SDLK_x",
|
||||
"SDLK_y",
|
||||
"SDLK_z",
|
||||
"SDLK_CAPSLOCK",
|
||||
"SDLK_F1",
|
||||
"SDLK_F2",
|
||||
"SDLK_F3",
|
||||
"SDLK_F4",
|
||||
"SDLK_F5",
|
||||
"SDLK_F6",
|
||||
"SDLK_F7",
|
||||
"SDLK_F8",
|
||||
"SDLK_F9",
|
||||
"SDLK_F10",
|
||||
"SDLK_F11",
|
||||
"SDLK_F12",
|
||||
"SDLK_PRINTSCREEN",
|
||||
"SDLK_SCROLLLOCK",
|
||||
"SDLK_PAUSE",
|
||||
"SDLK_INSERT",
|
||||
"SDLK_HOME",
|
||||
"SDLK_PAGEUP",
|
||||
"SDLK_DELETE",
|
||||
"SDLK_END",
|
||||
"SDLK_PAGEDOWN",
|
||||
"SDLK_RIGHT",
|
||||
"SDLK_LEFT",
|
||||
"SDLK_DOWN",
|
||||
"SDLK_UP",
|
||||
"SDLK_NUMLOCKCLEAR",
|
||||
"SDLK_KP_DIVIDE",
|
||||
"SDLK_KP_MULTIPLY",
|
||||
"SDLK_KP_MINUS",
|
||||
"SDLK_KP_PLUS",
|
||||
"SDLK_KP_ENTER",
|
||||
"SDLK_KP_1",
|
||||
"SDLK_KP_2",
|
||||
"SDLK_KP_3",
|
||||
"SDLK_KP_4",
|
||||
"SDLK_KP_5",
|
||||
"SDLK_KP_6",
|
||||
"SDLK_KP_7",
|
||||
"SDLK_KP_8",
|
||||
"SDLK_KP_9",
|
||||
"SDLK_KP_0",
|
||||
"SDLK_KP_PERIOD",
|
||||
"SDLK_APPLICATION",
|
||||
"SDLK_POWER",
|
||||
"SDLK_KP_EQUALS",
|
||||
"SDLK_F13",
|
||||
"SDLK_F14",
|
||||
"SDLK_F15",
|
||||
"SDLK_F16",
|
||||
"SDLK_F17",
|
||||
"SDLK_F18",
|
||||
"SDLK_F19",
|
||||
"SDLK_F20",
|
||||
"SDLK_F21",
|
||||
"SDLK_F22",
|
||||
"SDLK_F23",
|
||||
"SDLK_F24",
|
||||
"SDLK_EXECUTE",
|
||||
"SDLK_HELP",
|
||||
"SDLK_MENU",
|
||||
"SDLK_SELECT",
|
||||
"SDLK_STOP",
|
||||
"SDLK_AGAIN",
|
||||
"SDLK_UNDO",
|
||||
"SDLK_CUT",
|
||||
"SDLK_COPY",
|
||||
"SDLK_PASTE",
|
||||
"SDLK_FIND",
|
||||
"SDLK_MUTE",
|
||||
"SDLK_VOLUMEUP",
|
||||
"SDLK_VOLUMEDOWN",
|
||||
"SDLK_KP_COMMA",
|
||||
"SDLK_KP_EQUALSAS400",
|
||||
"SDLK_ALTERASE",
|
||||
"SDLK_SYSREQ",
|
||||
"SDLK_CANCEL",
|
||||
"SDLK_CLEAR",
|
||||
"SDLK_PRIOR",
|
||||
"SDLK_RETURN2",
|
||||
"SDLK_SEPARATOR",
|
||||
"SDLK_OUT",
|
||||
"SDLK_OPER",
|
||||
"SDLK_CLEARAGAIN",
|
||||
"SDLK_CRSEL",
|
||||
"SDLK_EXSEL",
|
||||
"SDLK_KP_00",
|
||||
"SDLK_KP_000",
|
||||
"SDLK_THOUSANDSSEPARATOR",
|
||||
"SDLK_DECIMALSEPARATOR",
|
||||
"SDLK_CURRENCYUNIT",
|
||||
"SDLK_CURRENCYSUBUNIT",
|
||||
"SDLK_KP_LEFTPAREN",
|
||||
"SDLK_KP_RIGHTPAREN",
|
||||
"SDLK_KP_LEFTBRACE",
|
||||
"SDLK_KP_RIGHTBRACE",
|
||||
"SDLK_KP_TAB",
|
||||
"SDLK_KP_BACKSPACE",
|
||||
"SDLK_KP_A",
|
||||
"SDLK_KP_B",
|
||||
"SDLK_KP_C",
|
||||
"SDLK_KP_D",
|
||||
"SDLK_KP_E",
|
||||
"SDLK_KP_F",
|
||||
"SDLK_KP_XOR",
|
||||
"SDLK_KP_POWER",
|
||||
"SDLK_KP_PERCENT",
|
||||
"SDLK_KP_LESS",
|
||||
"SDLK_KP_GREATER",
|
||||
"SDLK_KP_AMPERSAND",
|
||||
"SDLK_KP_DBLAMPERSAND",
|
||||
"SDLK_KP_VERTICALBAR",
|
||||
"SDLK_KP_DBLVERTICALBAR",
|
||||
"SDLK_KP_COLON",
|
||||
"SDLK_KP_HASH",
|
||||
"SDLK_KP_SPACE",
|
||||
"SDLK_KP_AT",
|
||||
"SDLK_KP_EXCLAM",
|
||||
"SDLK_KP_MEMSTORE",
|
||||
"SDLK_KP_MEMRECALL",
|
||||
"SDLK_KP_MEMCLEAR",
|
||||
"SDLK_KP_MEMADD",
|
||||
"SDLK_KP_MEMSUBTRACT",
|
||||
"SDLK_KP_MEMMULTIPLY",
|
||||
"SDLK_KP_MEMDIVIDE",
|
||||
"SDLK_KP_PLUSMINUS",
|
||||
"SDLK_KP_CLEAR",
|
||||
"SDLK_KP_CLEARENTRY",
|
||||
"SDLK_KP_BINARY",
|
||||
"SDLK_KP_OCTAL",
|
||||
"SDLK_KP_DECIMAL",
|
||||
"SDLK_KP_HEXADECIMAL",
|
||||
"SDLK_LCTRL",
|
||||
"SDLK_LSHIFT",
|
||||
"SDLK_LALT",
|
||||
"SDLK_LGUI",
|
||||
"SDLK_RCTRL",
|
||||
"SDLK_RSHIFT",
|
||||
"SDLK_RALT",
|
||||
"SDLK_RGUI",
|
||||
"SDLK_MODE",
|
||||
"SDLK_AUDIONEXT",
|
||||
"SDLK_AUDIOPREV",
|
||||
"SDLK_AUDIOSTOP",
|
||||
"SDLK_AUDIOPLAY",
|
||||
"SDLK_AUDIOMUTE",
|
||||
"SDLK_MEDIASELECT",
|
||||
"SDLK_WWW",
|
||||
"SDLK_MAIL",
|
||||
"SDLK_CALCULATOR",
|
||||
"SDLK_COMPUTER",
|
||||
"SDLK_AC_SEARCH",
|
||||
"SDLK_AC_HOME",
|
||||
"SDLK_AC_BACK",
|
||||
"SDLK_AC_FORWARD",
|
||||
"SDLK_AC_STOP",
|
||||
"SDLK_AC_REFRESH",
|
||||
"SDLK_AC_BOOKMARKS",
|
||||
"SDLK_BRIGHTNESSDOWN",
|
||||
"SDLK_BRIGHTNESSUP",
|
||||
"SDLK_DISPLAYSWITCH",
|
||||
"SDLK_KBDILLUMTOGGLE",
|
||||
"SDLK_KBDILLUMDOWN",
|
||||
"SDLK_KBDILLUMUP",
|
||||
"SDLK_EJECT",
|
||||
"SDLK_SLEEP",
|
||||
"SDLK_APP1",
|
||||
"SDLK_APP2",
|
||||
"SDLK_AUDIOREWIND",
|
||||
"SDLK_AUDIOFASTFORWARD",
|
||||
"SDLK_SOFTLEFT",
|
||||
"SDLK_SOFTRIGHT",
|
||||
"SDLK_CALL",
|
||||
"SDLK_ENDCALL",
|
||||
)
|
||||
|
||||
SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS}
|
||||
|
||||
@@ -7,262 +7,15 @@ from esphome.core import Lambda
|
||||
from esphome.cpp_generator import ExpressionStatement, RawExpression
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .display import CONF_SDL_ID, Sdl
|
||||
from . import SDL_KEYMAP
|
||||
from .display import CONF_SDL_ID, Sdl, headless_final_validate
|
||||
|
||||
CODEOWNERS = ["@bdm310"]
|
||||
|
||||
STATE_ARG = "state"
|
||||
|
||||
SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode")
|
||||
FINAL_VALIDATE_SCHEMA = headless_final_validate("binary_sensor")
|
||||
|
||||
SDL_KEYS = (
|
||||
"SDLK_UNKNOWN",
|
||||
"SDLK_RETURN",
|
||||
"SDLK_ESCAPE",
|
||||
"SDLK_BACKSPACE",
|
||||
"SDLK_TAB",
|
||||
"SDLK_SPACE",
|
||||
"SDLK_EXCLAIM",
|
||||
"SDLK_QUOTEDBL",
|
||||
"SDLK_HASH",
|
||||
"SDLK_PERCENT",
|
||||
"SDLK_DOLLAR",
|
||||
"SDLK_AMPERSAND",
|
||||
"SDLK_QUOTE",
|
||||
"SDLK_LEFTPAREN",
|
||||
"SDLK_RIGHTPAREN",
|
||||
"SDLK_ASTERISK",
|
||||
"SDLK_PLUS",
|
||||
"SDLK_COMMA",
|
||||
"SDLK_MINUS",
|
||||
"SDLK_PERIOD",
|
||||
"SDLK_SLASH",
|
||||
"SDLK_0",
|
||||
"SDLK_1",
|
||||
"SDLK_2",
|
||||
"SDLK_3",
|
||||
"SDLK_4",
|
||||
"SDLK_5",
|
||||
"SDLK_6",
|
||||
"SDLK_7",
|
||||
"SDLK_8",
|
||||
"SDLK_9",
|
||||
"SDLK_COLON",
|
||||
"SDLK_SEMICOLON",
|
||||
"SDLK_LESS",
|
||||
"SDLK_EQUALS",
|
||||
"SDLK_GREATER",
|
||||
"SDLK_QUESTION",
|
||||
"SDLK_AT",
|
||||
"SDLK_LEFTBRACKET",
|
||||
"SDLK_BACKSLASH",
|
||||
"SDLK_RIGHTBRACKET",
|
||||
"SDLK_CARET",
|
||||
"SDLK_UNDERSCORE",
|
||||
"SDLK_BACKQUOTE",
|
||||
"SDLK_a",
|
||||
"SDLK_b",
|
||||
"SDLK_c",
|
||||
"SDLK_d",
|
||||
"SDLK_e",
|
||||
"SDLK_f",
|
||||
"SDLK_g",
|
||||
"SDLK_h",
|
||||
"SDLK_i",
|
||||
"SDLK_j",
|
||||
"SDLK_k",
|
||||
"SDLK_l",
|
||||
"SDLK_m",
|
||||
"SDLK_n",
|
||||
"SDLK_o",
|
||||
"SDLK_p",
|
||||
"SDLK_q",
|
||||
"SDLK_r",
|
||||
"SDLK_s",
|
||||
"SDLK_t",
|
||||
"SDLK_u",
|
||||
"SDLK_v",
|
||||
"SDLK_w",
|
||||
"SDLK_x",
|
||||
"SDLK_y",
|
||||
"SDLK_z",
|
||||
"SDLK_CAPSLOCK",
|
||||
"SDLK_F1",
|
||||
"SDLK_F2",
|
||||
"SDLK_F3",
|
||||
"SDLK_F4",
|
||||
"SDLK_F5",
|
||||
"SDLK_F6",
|
||||
"SDLK_F7",
|
||||
"SDLK_F8",
|
||||
"SDLK_F9",
|
||||
"SDLK_F10",
|
||||
"SDLK_F11",
|
||||
"SDLK_F12",
|
||||
"SDLK_PRINTSCREEN",
|
||||
"SDLK_SCROLLLOCK",
|
||||
"SDLK_PAUSE",
|
||||
"SDLK_INSERT",
|
||||
"SDLK_HOME",
|
||||
"SDLK_PAGEUP",
|
||||
"SDLK_DELETE",
|
||||
"SDLK_END",
|
||||
"SDLK_PAGEDOWN",
|
||||
"SDLK_RIGHT",
|
||||
"SDLK_LEFT",
|
||||
"SDLK_DOWN",
|
||||
"SDLK_UP",
|
||||
"SDLK_NUMLOCKCLEAR",
|
||||
"SDLK_KP_DIVIDE",
|
||||
"SDLK_KP_MULTIPLY",
|
||||
"SDLK_KP_MINUS",
|
||||
"SDLK_KP_PLUS",
|
||||
"SDLK_KP_ENTER",
|
||||
"SDLK_KP_1",
|
||||
"SDLK_KP_2",
|
||||
"SDLK_KP_3",
|
||||
"SDLK_KP_4",
|
||||
"SDLK_KP_5",
|
||||
"SDLK_KP_6",
|
||||
"SDLK_KP_7",
|
||||
"SDLK_KP_8",
|
||||
"SDLK_KP_9",
|
||||
"SDLK_KP_0",
|
||||
"SDLK_KP_PERIOD",
|
||||
"SDLK_APPLICATION",
|
||||
"SDLK_POWER",
|
||||
"SDLK_KP_EQUALS",
|
||||
"SDLK_F13",
|
||||
"SDLK_F14",
|
||||
"SDLK_F15",
|
||||
"SDLK_F16",
|
||||
"SDLK_F17",
|
||||
"SDLK_F18",
|
||||
"SDLK_F19",
|
||||
"SDLK_F20",
|
||||
"SDLK_F21",
|
||||
"SDLK_F22",
|
||||
"SDLK_F23",
|
||||
"SDLK_F24",
|
||||
"SDLK_EXECUTE",
|
||||
"SDLK_HELP",
|
||||
"SDLK_MENU",
|
||||
"SDLK_SELECT",
|
||||
"SDLK_STOP",
|
||||
"SDLK_AGAIN",
|
||||
"SDLK_UNDO",
|
||||
"SDLK_CUT",
|
||||
"SDLK_COPY",
|
||||
"SDLK_PASTE",
|
||||
"SDLK_FIND",
|
||||
"SDLK_MUTE",
|
||||
"SDLK_VOLUMEUP",
|
||||
"SDLK_VOLUMEDOWN",
|
||||
"SDLK_KP_COMMA",
|
||||
"SDLK_KP_EQUALSAS400",
|
||||
"SDLK_ALTERASE",
|
||||
"SDLK_SYSREQ",
|
||||
"SDLK_CANCEL",
|
||||
"SDLK_CLEAR",
|
||||
"SDLK_PRIOR",
|
||||
"SDLK_RETURN2",
|
||||
"SDLK_SEPARATOR",
|
||||
"SDLK_OUT",
|
||||
"SDLK_OPER",
|
||||
"SDLK_CLEARAGAIN",
|
||||
"SDLK_CRSEL",
|
||||
"SDLK_EXSEL",
|
||||
"SDLK_KP_00",
|
||||
"SDLK_KP_000",
|
||||
"SDLK_THOUSANDSSEPARATOR",
|
||||
"SDLK_DECIMALSEPARATOR",
|
||||
"SDLK_CURRENCYUNIT",
|
||||
"SDLK_CURRENCYSUBUNIT",
|
||||
"SDLK_KP_LEFTPAREN",
|
||||
"SDLK_KP_RIGHTPAREN",
|
||||
"SDLK_KP_LEFTBRACE",
|
||||
"SDLK_KP_RIGHTBRACE",
|
||||
"SDLK_KP_TAB",
|
||||
"SDLK_KP_BACKSPACE",
|
||||
"SDLK_KP_A",
|
||||
"SDLK_KP_B",
|
||||
"SDLK_KP_C",
|
||||
"SDLK_KP_D",
|
||||
"SDLK_KP_E",
|
||||
"SDLK_KP_F",
|
||||
"SDLK_KP_XOR",
|
||||
"SDLK_KP_POWER",
|
||||
"SDLK_KP_PERCENT",
|
||||
"SDLK_KP_LESS",
|
||||
"SDLK_KP_GREATER",
|
||||
"SDLK_KP_AMPERSAND",
|
||||
"SDLK_KP_DBLAMPERSAND",
|
||||
"SDLK_KP_VERTICALBAR",
|
||||
"SDLK_KP_DBLVERTICALBAR",
|
||||
"SDLK_KP_COLON",
|
||||
"SDLK_KP_HASH",
|
||||
"SDLK_KP_SPACE",
|
||||
"SDLK_KP_AT",
|
||||
"SDLK_KP_EXCLAM",
|
||||
"SDLK_KP_MEMSTORE",
|
||||
"SDLK_KP_MEMRECALL",
|
||||
"SDLK_KP_MEMCLEAR",
|
||||
"SDLK_KP_MEMADD",
|
||||
"SDLK_KP_MEMSUBTRACT",
|
||||
"SDLK_KP_MEMMULTIPLY",
|
||||
"SDLK_KP_MEMDIVIDE",
|
||||
"SDLK_KP_PLUSMINUS",
|
||||
"SDLK_KP_CLEAR",
|
||||
"SDLK_KP_CLEARENTRY",
|
||||
"SDLK_KP_BINARY",
|
||||
"SDLK_KP_OCTAL",
|
||||
"SDLK_KP_DECIMAL",
|
||||
"SDLK_KP_HEXADECIMAL",
|
||||
"SDLK_LCTRL",
|
||||
"SDLK_LSHIFT",
|
||||
"SDLK_LALT",
|
||||
"SDLK_LGUI",
|
||||
"SDLK_RCTRL",
|
||||
"SDLK_RSHIFT",
|
||||
"SDLK_RALT",
|
||||
"SDLK_RGUI",
|
||||
"SDLK_MODE",
|
||||
"SDLK_AUDIONEXT",
|
||||
"SDLK_AUDIOPREV",
|
||||
"SDLK_AUDIOSTOP",
|
||||
"SDLK_AUDIOPLAY",
|
||||
"SDLK_AUDIOMUTE",
|
||||
"SDLK_MEDIASELECT",
|
||||
"SDLK_WWW",
|
||||
"SDLK_MAIL",
|
||||
"SDLK_CALCULATOR",
|
||||
"SDLK_COMPUTER",
|
||||
"SDLK_AC_SEARCH",
|
||||
"SDLK_AC_HOME",
|
||||
"SDLK_AC_BACK",
|
||||
"SDLK_AC_FORWARD",
|
||||
"SDLK_AC_STOP",
|
||||
"SDLK_AC_REFRESH",
|
||||
"SDLK_AC_BOOKMARKS",
|
||||
"SDLK_BRIGHTNESSDOWN",
|
||||
"SDLK_BRIGHTNESSUP",
|
||||
"SDLK_DISPLAYSWITCH",
|
||||
"SDLK_KBDILLUMTOGGLE",
|
||||
"SDLK_KBDILLUMDOWN",
|
||||
"SDLK_KBDILLUMUP",
|
||||
"SDLK_EJECT",
|
||||
"SDLK_SLEEP",
|
||||
"SDLK_APP1",
|
||||
"SDLK_APP2",
|
||||
"SDLK_AUDIOREWIND",
|
||||
"SDLK_AUDIOFASTFORWARD",
|
||||
"SDLK_SOFTLEFT",
|
||||
"SDLK_SOFTRIGHT",
|
||||
"SDLK_CALL",
|
||||
"SDLK_ENDCALL",
|
||||
)
|
||||
|
||||
SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS}
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
binary_sensor.binary_sensor_schema(BinarySensor)
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import display
|
||||
from esphome.components.snapshot import Snapshot, register_snapshot
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DIMENSIONS,
|
||||
@@ -16,14 +17,21 @@ from esphome.const import (
|
||||
CONF_Y,
|
||||
PLATFORM_HOST,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import SDL_KEYMAP
|
||||
|
||||
AUTO_LOAD = ["snapshot"]
|
||||
|
||||
sdl_ns = cg.esphome_ns.namespace("sdl")
|
||||
Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component)
|
||||
Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component, Snapshot)
|
||||
sdl_window_flags = cg.global_ns.enum("SDL_WindowFlags")
|
||||
|
||||
|
||||
CONF_CENTERED_ON_DISPLAY = "centered_on_display"
|
||||
CONF_HEADLESS = "headless"
|
||||
CONF_SNAPSHOT_KEY = "snapshot_key"
|
||||
CONF_SDL_OPTIONS = "sdl_options"
|
||||
CONF_SDL_ID = "sdl_id"
|
||||
CONF_WINDOW_OPTIONS = "window_options"
|
||||
@@ -67,12 +75,29 @@ def _validate_position(config: dict) -> dict:
|
||||
raise cv.Invalid("Must specify either 'x' and 'y' or 'centered_on_display'")
|
||||
|
||||
|
||||
def _validate_headless(config: ConfigType) -> ConfigType:
|
||||
if not config[CONF_HEADLESS]:
|
||||
return config
|
||||
if CONF_WINDOW_OPTIONS in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_WINDOW_OPTIONS}' has no effect when '{CONF_HEADLESS}' is set - there is no window"
|
||||
)
|
||||
if CONF_SNAPSHOT_KEY in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_SNAPSHOT_KEY}' cannot be used when '{CONF_HEADLESS}' is set - "
|
||||
f"there is no keyboard. Use the 'snapshot.take' action instead"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
display.FULL_DISPLAY_SCHEMA.extend(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Sdl),
|
||||
cv.Optional(CONF_SDL_OPTIONS, default=""): get_sdl_options,
|
||||
cv.Optional(CONF_HEADLESS, default=False): cv.boolean,
|
||||
cv.Optional(CONF_SNAPSHOT_KEY): cv.enum(SDL_KEYMAP),
|
||||
cv.Required(CONF_DIMENSIONS): cv.Any(
|
||||
cv.dimensions,
|
||||
cv.Schema(
|
||||
@@ -99,16 +124,42 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
)
|
||||
),
|
||||
_validate_headless,
|
||||
cv.only_on(PLATFORM_HOST),
|
||||
)
|
||||
|
||||
|
||||
def headless_final_validate(platform: str) -> cv.Schema:
|
||||
"""Build a FINAL_VALIDATE_SCHEMA rejecting a platform whose sdl display is headless.
|
||||
|
||||
Mouse and keyboard platforms are driven by window events, so under a headless display they
|
||||
would never report anything.
|
||||
"""
|
||||
|
||||
def validate_display(display_config: ConfigType) -> ConfigType:
|
||||
if display_config.get(CONF_HEADLESS):
|
||||
raise cv.Invalid(
|
||||
f"The sdl {platform} platform needs a window, but its display has "
|
||||
f"'{CONF_HEADLESS}' set"
|
||||
)
|
||||
return display_config
|
||||
|
||||
return cv.Schema(
|
||||
{cv.Required(CONF_SDL_ID): fv.id_declaration_match_schema(validate_display)},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
for option in config[CONF_SDL_OPTIONS].split():
|
||||
cg.add_build_flag(option)
|
||||
cg.add_build_flag("-DSDL_BYTEORDER=4321")
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await display.register_display(var, config)
|
||||
await register_snapshot(var, config)
|
||||
cg.add(var.set_headless(config[CONF_HEADLESS]))
|
||||
if (key := config.get(CONF_SNAPSHOT_KEY)) is not None:
|
||||
cg.add(var.set_snapshot_key(key))
|
||||
|
||||
dimensions = config[CONF_DIMENSIONS]
|
||||
if isinstance(dimensions, dict):
|
||||
|
||||
@@ -2,8 +2,17 @@
|
||||
#include "sdl_esphome.h"
|
||||
#include "esphome/components/display/display_color_utils.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace esphome::sdl {
|
||||
|
||||
namespace {
|
||||
|
||||
// Key under which each window keeps a pointer back to its Sdl instance.
|
||||
constexpr const char *const WINDOW_DATA_KEY = "esphome_sdl";
|
||||
|
||||
} // namespace
|
||||
|
||||
int Sdl::get_width() {
|
||||
switch (this->rotation_) {
|
||||
case display::DISPLAY_ROTATION_90_DEGREES:
|
||||
@@ -28,17 +37,96 @@ int Sdl::get_height() {
|
||||
}
|
||||
}
|
||||
|
||||
void Sdl::setup() {
|
||||
SDL_Init(SDL_INIT_VIDEO);
|
||||
this->window_ = SDL_CreateWindow(App.get_name().c_str(), this->pos_x_, this->pos_y_, this->width_, this->height_,
|
||||
this->window_options_);
|
||||
this->renderer_ = SDL_CreateRenderer(this->window_, -1, SDL_RENDERER_SOFTWARE);
|
||||
SDL_RenderSetLogicalSize(this->renderer_, this->width_, this->height_);
|
||||
void Sdl::destroy_renderer_() {
|
||||
// Reverse order of creation: the renderer refers to the window or surface it was made from.
|
||||
if (this->shot_target_ != nullptr) {
|
||||
SDL_DestroyTexture(this->shot_target_);
|
||||
this->shot_target_ = nullptr;
|
||||
}
|
||||
if (this->texture_ != nullptr) {
|
||||
SDL_DestroyTexture(this->texture_);
|
||||
this->texture_ = nullptr;
|
||||
}
|
||||
if (this->renderer_ != nullptr) {
|
||||
SDL_DestroyRenderer(this->renderer_);
|
||||
this->renderer_ = nullptr;
|
||||
}
|
||||
if (this->window_ != nullptr) {
|
||||
SDL_DestroyWindow(this->window_);
|
||||
this->window_ = nullptr;
|
||||
}
|
||||
if (this->surface_ != nullptr) {
|
||||
SDL_FreeSurface(this->surface_);
|
||||
this->surface_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool Sdl::setup_failed_(const char *what) {
|
||||
ESP_LOGE(TAG, "%s: %s", what, SDL_GetError());
|
||||
// Give back whatever was created before the failure. Without this a half set up display leaves an
|
||||
// empty window on screen for the life of the process, still registered as an event target.
|
||||
this->destroy_renderer_();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Sdl::setup_renderer_() {
|
||||
SDL_SetMainReady();
|
||||
if (this->headless_) {
|
||||
// SDL_INIT_VIDEO is deliberately not requested: a software renderer bound to a surface needs no
|
||||
// video device, so this works on a machine with no display server at all.
|
||||
if (SDL_Init(0) != 0)
|
||||
return this->setup_failed_("SDL_Init failed");
|
||||
this->surface_ = SDL_CreateRGBSurfaceWithFormat(0, this->width_, this->height_, 16, SDL_PIXELFORMAT_RGB565);
|
||||
if (this->surface_ == nullptr)
|
||||
return this->setup_failed_("Could not create offscreen surface");
|
||||
this->renderer_ = SDL_CreateSoftwareRenderer(this->surface_);
|
||||
} else {
|
||||
if (SDL_Init(SDL_INIT_VIDEO) != 0)
|
||||
return this->setup_failed_("SDL_Init failed");
|
||||
this->window_ = SDL_CreateWindow(App.get_name().c_str(), this->pos_x_, this->pos_y_, this->width_, this->height_,
|
||||
this->window_options_);
|
||||
if (this->window_ == nullptr)
|
||||
return this->setup_failed_("Could not create window");
|
||||
// Lets loop() find the display an event belongs to, so one display does not act on another's
|
||||
// input when several windows are open.
|
||||
SDL_SetWindowData(this->window_, WINDOW_DATA_KEY, this);
|
||||
this->renderer_ = SDL_CreateRenderer(this->window_, -1, SDL_RENDERER_SOFTWARE);
|
||||
}
|
||||
if (this->renderer_ == nullptr)
|
||||
return this->setup_failed_("Could not create renderer");
|
||||
if (SDL_RenderSetLogicalSize(this->renderer_, this->width_, this->height_) != 0)
|
||||
return this->setup_failed_("Could not set renderer logical size");
|
||||
this->texture_ =
|
||||
SDL_CreateTexture(this->renderer_, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STATIC, this->width_, this->height_);
|
||||
SDL_SetTextureBlendMode(this->texture_, SDL_BLENDMODE_BLEND);
|
||||
if (this->texture_ == nullptr)
|
||||
return this->setup_failed_("Could not create texture");
|
||||
// The texture has no alpha channel, so blending is pointless. Headless it would also force a
|
||||
// different software blit path onto the 16 bit target surface.
|
||||
if (SDL_SetTextureBlendMode(this->texture_, this->headless_ ? SDL_BLENDMODE_NONE : SDL_BLENDMODE_BLEND) != 0)
|
||||
return this->setup_failed_("Could not set texture blend mode");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Sdl::setup() {
|
||||
if (!this->setup_renderer_()) {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
if (this->headless_) {
|
||||
// Nothing generates events, so there is nothing for loop() to do.
|
||||
this->disable_loop();
|
||||
} else if (this->snapshot_key_ != 0) {
|
||||
this->add_key_listener(this->snapshot_key_, [this](bool down) {
|
||||
if (down && !this->take_snapshot(nullptr)) {
|
||||
ESP_LOGW(TAG, "snapshot key did not write a file");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void Sdl::update() {
|
||||
if (this->texture_ == nullptr)
|
||||
return;
|
||||
this->do_update_();
|
||||
if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_))
|
||||
return;
|
||||
@@ -51,12 +139,19 @@ void Sdl::update() {
|
||||
}
|
||||
|
||||
void Sdl::redraw_(SDL_Rect &rect) {
|
||||
// Nothing to present when headless - a snapshot blits the whole texture when it needs it, so
|
||||
// doing it here as well would just burn CPU. draw_pixels_at() calls this on every partial
|
||||
// update, so it is worth skipping.
|
||||
if (this->headless_)
|
||||
return;
|
||||
SDL_RenderCopy(this->renderer_, this->texture_, &rect, &rect);
|
||||
SDL_RenderPresent(this->renderer_);
|
||||
}
|
||||
|
||||
void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
|
||||
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
|
||||
if (this->texture_ == nullptr)
|
||||
return;
|
||||
SDL_Rect rect{x_start, y_start, w, h};
|
||||
if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || big_endian) {
|
||||
Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
|
||||
@@ -69,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *
|
||||
}
|
||||
|
||||
void Sdl::draw_pixel_at(int x, int y, Color color) {
|
||||
if (!this->get_clipping().inside(x, y))
|
||||
if (this->texture_ == nullptr || !this->get_clipping().inside(x, y))
|
||||
return;
|
||||
|
||||
if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) {
|
||||
@@ -104,61 +199,148 @@ void Sdl::process_key(uint32_t keycode, bool down) {
|
||||
callback->second(down);
|
||||
}
|
||||
|
||||
Sdl *Sdl::instance_for_window_(uint32_t window_id) {
|
||||
SDL_Window *window = SDL_GetWindowFromID(window_id);
|
||||
if (window == nullptr)
|
||||
return nullptr;
|
||||
return static_cast<Sdl *>(SDL_GetWindowData(window, WINDOW_DATA_KEY));
|
||||
}
|
||||
|
||||
void Sdl::handle_event_(const SDL_Event &event) {
|
||||
switch (event.type) {
|
||||
case SDL_MOUSEBUTTONDOWN:
|
||||
case SDL_MOUSEBUTTONUP:
|
||||
if (event.button.button == 1) {
|
||||
this->mouse_x = event.button.x;
|
||||
this->mouse_y = event.button.y;
|
||||
this->mouse_down = event.button.state != 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case SDL_MOUSEMOTION:
|
||||
if (event.motion.state & 1) {
|
||||
this->mouse_x = event.motion.x;
|
||||
this->mouse_y = event.motion.y;
|
||||
this->mouse_down = true;
|
||||
} else {
|
||||
this->mouse_down = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case SDL_KEYDOWN:
|
||||
// Ignore auto-repeat, otherwise holding a key floods the listeners.
|
||||
if (event.key.repeat != 0)
|
||||
break;
|
||||
ESP_LOGD(TAG, "keydown %d", event.key.keysym.sym);
|
||||
this->process_key(event.key.keysym.sym, true);
|
||||
break;
|
||||
|
||||
case SDL_KEYUP:
|
||||
ESP_LOGD(TAG, "keyup %d", event.key.keysym.sym);
|
||||
this->process_key(event.key.keysym.sym, false);
|
||||
break;
|
||||
|
||||
case SDL_WINDOWEVENT:
|
||||
switch (event.window.event) {
|
||||
case SDL_WINDOWEVENT_SIZE_CHANGED:
|
||||
case SDL_WINDOWEVENT_EXPOSED:
|
||||
case SDL_WINDOWEVENT_RESIZED: {
|
||||
SDL_Rect rect{0, 0, this->width_, this->height_};
|
||||
this->redraw_(rect);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Sdl::loop() {
|
||||
SDL_Event e;
|
||||
if (SDL_PollEvent(&e)) {
|
||||
switch (e.type) {
|
||||
case SDL_QUIT:
|
||||
exit(0);
|
||||
// Take everything that is waiting, not one event per loop. A touch drag produces a burst of
|
||||
// motion events, and consuming them one at a time lets the queue grow without bound, so the
|
||||
// pointer ends up acting on input from further and further in the past. Draining collapses a
|
||||
// burst to the position it ended at, which is the one the user is asking for anyway.
|
||||
while (SDL_PollEvent(&e)) {
|
||||
if (e.type == SDL_QUIT)
|
||||
exit(0);
|
||||
|
||||
// Events carry the window they happened in, so send each one to the display that owns it.
|
||||
uint32_t window_id;
|
||||
switch (e.type) {
|
||||
case SDL_MOUSEBUTTONDOWN:
|
||||
case SDL_MOUSEBUTTONUP:
|
||||
if (e.button.button == 1) {
|
||||
this->mouse_x = e.button.x;
|
||||
this->mouse_y = e.button.y;
|
||||
this->mouse_down = e.button.state != 0;
|
||||
}
|
||||
window_id = e.button.windowID;
|
||||
break;
|
||||
|
||||
case SDL_MOUSEMOTION:
|
||||
if (e.motion.state & 1) {
|
||||
this->mouse_x = e.button.x;
|
||||
this->mouse_y = e.button.y;
|
||||
this->mouse_down = true;
|
||||
} else {
|
||||
this->mouse_down = false;
|
||||
}
|
||||
window_id = e.motion.windowID;
|
||||
break;
|
||||
|
||||
case SDL_KEYDOWN:
|
||||
ESP_LOGD(TAG, "keydown %d", e.key.keysym.sym);
|
||||
this->process_key(e.key.keysym.sym, true);
|
||||
break;
|
||||
|
||||
case SDL_KEYUP:
|
||||
ESP_LOGD(TAG, "keyup %d", e.key.keysym.sym);
|
||||
this->process_key(e.key.keysym.sym, false);
|
||||
window_id = e.key.windowID;
|
||||
break;
|
||||
|
||||
case SDL_WINDOWEVENT:
|
||||
switch (e.window.event) {
|
||||
case SDL_WINDOWEVENT_SIZE_CHANGED:
|
||||
case SDL_WINDOWEVENT_EXPOSED:
|
||||
case SDL_WINDOWEVENT_RESIZED: {
|
||||
SDL_Rect rect{0, 0, this->width_, this->height_};
|
||||
this->redraw_(rect);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
window_id = e.window.windowID;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Anything else, including the touch events SDL reports alongside the mouse events it
|
||||
// synthesises from them, is not used here.
|
||||
ESP_LOGV(TAG, "Event %d", e.type);
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
Sdl *target = instance_for_window_(window_id);
|
||||
if (target == nullptr) {
|
||||
// Nothing to route this to: the window has gone, or it is not one of ours. Say so, otherwise
|
||||
// input that stops working leaves no trace at all.
|
||||
ESP_LOGV(TAG, "Event %d for unknown window %u", e.type, window_id);
|
||||
continue;
|
||||
}
|
||||
target->handle_event_(e);
|
||||
}
|
||||
}
|
||||
|
||||
bool Sdl::capture_bgr(uint8_t *dest, size_t row_stride) {
|
||||
if (this->texture_ == nullptr || this->renderer_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Snapshot requested but SDL is not set up");
|
||||
return false;
|
||||
}
|
||||
if (this->shot_target_ == nullptr) {
|
||||
this->shot_target_ = SDL_CreateTexture(this->renderer_, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_TARGET,
|
||||
this->width_, this->height_);
|
||||
if (this->shot_target_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Could not create capture texture: %s", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
SDL_SetTextureBlendMode(this->shot_target_, SDL_BLENDMODE_NONE);
|
||||
}
|
||||
|
||||
// Render into an offscreen target first. SDL_RenderReadPixels works in physical output pixels and
|
||||
// ignores the logical size, so reading straight off a resizable window would read more pixels than
|
||||
// there is room for.
|
||||
// Every step is checked: a failed clear or copy would otherwise be read back as a blank or stale
|
||||
// picture, written out, and reported as a snapshot that worked.
|
||||
bool ok = false;
|
||||
if (SDL_SetRenderTarget(this->renderer_, this->shot_target_) == 0) {
|
||||
ok = SDL_SetRenderDrawColor(this->renderer_, 0, 0, 0, SDL_ALPHA_OPAQUE) == 0 &&
|
||||
SDL_RenderClear(this->renderer_) == 0 &&
|
||||
SDL_RenderCopy(this->renderer_, this->texture_, nullptr, nullptr) == 0 &&
|
||||
SDL_RenderReadPixels(this->renderer_, nullptr, SDL_PIXELFORMAT_BGR24, dest, static_cast<int>(row_stride)) == 0;
|
||||
if (SDL_SetRenderTarget(this->renderer_, nullptr) != 0) {
|
||||
// Stuck rendering into shot_target_ from here on, so there's no point continuing.
|
||||
ESP_LOGE(TAG, "Could not restore the render target: %s", SDL_GetError());
|
||||
this->mark_failed();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
ESP_LOGE(TAG, "Could not capture the screen: %s", SDL_GetError());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace esphome::sdl
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_HOST
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/components/display/display.h"
|
||||
#include "esphome/components/snapshot/snapshot.h"
|
||||
#define SDL_MAIN_HANDLED
|
||||
#include "SDL.h"
|
||||
#include <map>
|
||||
@@ -13,7 +15,7 @@ namespace esphome::sdl {
|
||||
|
||||
constexpr static const char *const TAG = "sdl";
|
||||
|
||||
class Sdl final : public display::Display {
|
||||
class Sdl final : public display::Display, public snapshot::Snapshot {
|
||||
public:
|
||||
display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; }
|
||||
void update() override;
|
||||
@@ -32,6 +34,9 @@ class Sdl final : public display::Display {
|
||||
this->pos_x_ = pos_x;
|
||||
this->pos_y_ = pos_y;
|
||||
}
|
||||
void set_headless(bool headless) { this->headless_ = headless; }
|
||||
void set_snapshot_key(int32_t keycode) { this->snapshot_key_ = keycode; }
|
||||
|
||||
int get_width() override;
|
||||
int get_height() override;
|
||||
float get_setup_priority() const override { return setup_priority::HARDWARE; }
|
||||
@@ -51,20 +56,40 @@ class Sdl final : public display::Display {
|
||||
int get_width_internal() override { return this->width_; }
|
||||
int get_height_internal() override { return this->height_; }
|
||||
void redraw_(SDL_Rect &rect);
|
||||
bool setup_renderer_();
|
||||
/// Release the window, surface, renderer and textures, and forget them.
|
||||
void destroy_renderer_();
|
||||
/// Log an SDL failure during setup, release anything already created, and return false.
|
||||
bool setup_failed_(const char *what);
|
||||
int snapshot_width() override { return this->width_; }
|
||||
int snapshot_height() override { return this->height_; }
|
||||
bool capture_bgr(uint8_t *dest, size_t row_stride) override;
|
||||
void handle_event_(const SDL_Event &event);
|
||||
/// The display owning the given window, or nullptr if it is not one of ours.
|
||||
static Sdl *instance_for_window_(uint32_t window_id);
|
||||
SDL_Renderer *renderer_{};
|
||||
SDL_Window *window_{};
|
||||
SDL_Texture *texture_{};
|
||||
// Offscreen render target used when headless. SDL_CreateSoftwareRenderer only borrows the
|
||||
// surface, and the renderer goes back to using it as its output whenever the capture target is
|
||||
// released, so it has to stay alive as long as the renderer does.
|
||||
SDL_Surface *surface_{};
|
||||
// Capture target, created on first snapshot.
|
||||
SDL_Texture *shot_target_{};
|
||||
std::map<int32_t, CallbackManager<void(bool)>> key_callbacks_{};
|
||||
int width_{};
|
||||
int height_{};
|
||||
uint32_t window_options_{0};
|
||||
int32_t pos_x_{SDL_WINDOWPOS_UNDEFINED};
|
||||
int32_t pos_y_{SDL_WINDOWPOS_UNDEFINED};
|
||||
SDL_Renderer *renderer_{};
|
||||
SDL_Window *window_{};
|
||||
SDL_Texture *texture_{};
|
||||
int32_t snapshot_key_{0};
|
||||
uint16_t x_low_{0};
|
||||
uint16_t y_low_{0};
|
||||
uint16_t x_high_{0};
|
||||
uint16_t y_high_{0};
|
||||
std::map<int32_t, CallbackManager<void(bool)>> key_callbacks_{};
|
||||
bool headless_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::sdl
|
||||
|
||||
#endif
|
||||
|
||||
@@ -4,10 +4,12 @@ import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ..display import CONF_SDL_ID, Sdl, sdl_ns
|
||||
from ..display import CONF_SDL_ID, Sdl, headless_final_validate, sdl_ns
|
||||
|
||||
SdlTouchscreen = sdl_ns.class_("SdlTouchscreen", touchscreen.Touchscreen)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = headless_final_validate("touchscreen")
|
||||
|
||||
|
||||
CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend(
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
require_tx=True,
|
||||
require_rx=True,
|
||||
baud_rate=115200,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
@@ -33,8 +33,6 @@ void MR60FDA2Component::dump_config() {
|
||||
|
||||
// Initialisation functions
|
||||
void MR60FDA2Component::setup() {
|
||||
this->check_uart_settings(115200);
|
||||
|
||||
this->current_frame_locate_ = LOCATE_FRAME_HEADER;
|
||||
this->current_frame_id_ = 0;
|
||||
this->current_frame_len_ = 0;
|
||||
|
||||
@@ -130,17 +130,26 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
// Apply validated parameters
|
||||
uart_comp->set_baud_rate(baudrate);
|
||||
uart_comp->set_stop_bits(stop_bits);
|
||||
uart_comp->set_data_bits(data_size);
|
||||
|
||||
// Map parity value to UARTParityOptions
|
||||
// Skip a no-op reconfigure. Clients routinely re-send identical settings on every
|
||||
// port open, and on a USB UART each apply is a CDC SET_LINE_CODING control transfer.
|
||||
// Some bridges watch line-coding changes as a signalling channel (a magic baud
|
||||
// sequence to enter a bootloader, say), so redundant applies are not harmless.
|
||||
static const uart::UARTParityOptions PARITY_MAP[] = {
|
||||
uart::UART_CONFIG_PARITY_NONE,
|
||||
uart::UART_CONFIG_PARITY_EVEN,
|
||||
uart::UART_CONFIG_PARITY_ODD,
|
||||
};
|
||||
if (uart_comp->get_baud_rate() == baudrate && uart_comp->get_stop_bits() == stop_bits &&
|
||||
uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity]) {
|
||||
ESP_LOGV(TAG, "Settings unchanged, skipping reconfigure [%" PRIu32 "]", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
// Apply validated parameters
|
||||
uart_comp->set_baud_rate(baudrate);
|
||||
uart_comp->set_stop_bits(stop_bits);
|
||||
uart_comp->set_data_bits(data_size);
|
||||
|
||||
uart_comp->set_parity(PARITY_MAP[parity]);
|
||||
|
||||
// load_settings() is available on ESP8266 and ESP32 platforms
|
||||
|
||||
@@ -629,8 +629,9 @@ stm32_unique_ptr stm32_init(uart::UARTDevice *stream, const uint8_t flags, const
|
||||
stm->pid = (buf[1] << 8) | buf[2];
|
||||
if (returned > 2) {
|
||||
ESP_LOGD(TAG, "This bootloader returns %d extra bytes in PID:", returned);
|
||||
for (auto i = 2; i <= returned; i++)
|
||||
for (auto i = 2; i <= returned; i++) {
|
||||
ESP_LOGD(TAG, " %02x", buf[i]);
|
||||
}
|
||||
}
|
||||
if (stm32_get_ack(stm) != STM32_ERR_OK) {
|
||||
return make_stm32_with_deletor(nullptr);
|
||||
|
||||
@@ -68,7 +68,13 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
"smt100", baud_rate=9600, require_rx=True, require_tx=True
|
||||
"smt100",
|
||||
baud_rate=9600,
|
||||
require_rx=True,
|
||||
require_tx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ void SMT100Component::dump_config() {
|
||||
LOG_SENSOR(TAG, "Temperature", this->temperature_sensor_);
|
||||
LOG_SENSOR(TAG, "Moisture", this->moisture_sensor_);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
this->check_uart_settings(9600);
|
||||
}
|
||||
|
||||
int SMT100Component::readline_(int readch, char *buffer, int len) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Shared support for writing what a display is showing out to an image file.
|
||||
|
||||
The component itself has no configuration. It provides the ``snapshot.take`` action and the C++
|
||||
base class behind it, so any display that can hand over its pixels - the in memory display in this
|
||||
component, or an SDL window - saves files the same way, under the same directory, with the same
|
||||
rules about names.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
|
||||
DOMAIN = "snapshot"
|
||||
|
||||
CONF_FILENAME = "filename"
|
||||
|
||||
snapshot_ns = cg.esphome_ns.namespace("snapshot")
|
||||
Snapshot = snapshot_ns.class_("Snapshot")
|
||||
SnapshotAction = snapshot_ns.class_("SnapshotAction", automation.Action)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"snapshot.take",
|
||||
SnapshotAction,
|
||||
automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Snapshot),
|
||||
cv.Optional(CONF_FILENAME): cv.templatable(cv.string),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def snapshot_take_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
if (filename := config.get(CONF_FILENAME)) is not None:
|
||||
cg.add(var.set_filename(await cg.templatable(filename, args, cg.std_string)))
|
||||
return var
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotData:
|
||||
directory_defined: bool = False
|
||||
|
||||
|
||||
def _get_data() -> SnapshotData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = SnapshotData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
async def register_snapshot(var: MockObj, config: ConfigType) -> None:
|
||||
"""Set up a component so that the snapshot action can write its picture to a file."""
|
||||
data = _get_data()
|
||||
# Only once, however many displays there are: two defines that say the same thing do not
|
||||
# compare equal, so asking for this per display repeats the line in defines.h.
|
||||
if not data.directory_defined:
|
||||
data.directory_defined = True
|
||||
cg.add_define(
|
||||
"ESPHOME_SNAPSHOT_DIR",
|
||||
(CORE.data_dir / "snapshots" / CORE.name).as_posix(),
|
||||
)
|
||||
cg.add(var.set_snapshot_prefix(str(config[CONF_ID])))
|
||||
@@ -0,0 +1,61 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import display
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DIMENSIONS,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
CONF_LAMBDA,
|
||||
CONF_WIDTH,
|
||||
PLATFORM_HOST,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import Snapshot, register_snapshot, snapshot_ns
|
||||
|
||||
# The base class and the file writing live in the parent component, which nothing else in a
|
||||
# configuration using only this platform would pull in.
|
||||
AUTO_LOAD = ["snapshot"]
|
||||
|
||||
SnapshotDisplay = snapshot_ns.class_(
|
||||
"SnapshotDisplay", display.DisplayBuffer, cg.Component, Snapshot
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
display.FULL_DISPLAY_SCHEMA.extend(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SnapshotDisplay),
|
||||
cv.Required(CONF_DIMENSIONS): cv.Any(
|
||||
cv.dimensions,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_WIDTH): cv.positive_not_null_int,
|
||||
cv.Required(CONF_HEIGHT): cv.positive_not_null_int,
|
||||
}
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
),
|
||||
cv.only_on(PLATFORM_HOST),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await display.register_display(var, config)
|
||||
await register_snapshot(var, config)
|
||||
|
||||
dimensions = config[CONF_DIMENSIONS]
|
||||
if isinstance(dimensions, dict):
|
||||
cg.add(var.set_dimensions(dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT]))
|
||||
else:
|
||||
(width, height) = dimensions
|
||||
cg.add(var.set_dimensions(width, height))
|
||||
|
||||
if lamb := config.get(CONF_LAMBDA):
|
||||
lambda_ = await cg.process_lambda(
|
||||
lamb, [(display.DisplayRef, "it")], return_type=cg.void
|
||||
)
|
||||
cg.add(var.set_writer(lambda_))
|
||||
@@ -0,0 +1,80 @@
|
||||
#ifdef USE_HOST
|
||||
#include "snapshot_display.h"
|
||||
#include "esphome/components/display/display_color_utils.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::snapshot {
|
||||
|
||||
static const char *const TAG = "snapshot.display";
|
||||
|
||||
namespace {
|
||||
|
||||
/// Spread a channel that only goes up to `max` over the whole 0 to 255 range, so that the
|
||||
/// brightest value stays the brightest. This is the same arithmetic SDL uses, which is what makes
|
||||
/// a picture taken here come out identical to the same picture taken from an SDL window.
|
||||
constexpr uint8_t expand_channel(uint16_t value, uint16_t max) { return static_cast<uint8_t>(value * 255 / max); }
|
||||
|
||||
constexpr uint16_t RED_MAX = 0x1F;
|
||||
constexpr uint16_t GREEN_MAX = 0x3F;
|
||||
constexpr uint16_t BLUE_MAX = 0x1F;
|
||||
|
||||
} // namespace
|
||||
|
||||
void SnapshotDisplay::setup() {
|
||||
this->init_internal_(static_cast<uint32_t>(this->width_) * this->height_ * 2);
|
||||
if (this->buffer_ == nullptr) {
|
||||
this->mark_failed(LOG_STR("Could not allocate display buffer"));
|
||||
}
|
||||
}
|
||||
|
||||
void SnapshotDisplay::dump_config() { LOG_DISPLAY("", "Snapshot", this); }
|
||||
|
||||
void SnapshotDisplay::draw_absolute_pixel_internal(int x, int y, Color color) {
|
||||
if (this->buffer_ == nullptr || x < 0 || x >= this->width_ || y < 0 || y >= this->height_)
|
||||
return;
|
||||
this->pixels_()[y * this->width_ + x] = display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB);
|
||||
}
|
||||
|
||||
void SnapshotDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr,
|
||||
display::ColorOrder order, display::ColorBitness bitness, bool big_endian,
|
||||
int x_offset, int y_offset, int x_pad) {
|
||||
if (this->buffer_ == nullptr)
|
||||
return;
|
||||
// Anything that is not already laid out the way the buffer is, or that would reach outside it,
|
||||
// goes through the base class, which turns it into one call per pixel with the bounds checked.
|
||||
const bool copyable = this->rotation_ == display::DISPLAY_ROTATION_0_DEGREES &&
|
||||
bitness == display::COLOR_BITNESS_565 && !big_endian && x_start >= 0 && y_start >= 0 &&
|
||||
x_start + w <= this->width_ && y_start + h <= this->height_;
|
||||
if (!copyable) {
|
||||
DisplayBuffer::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
|
||||
return;
|
||||
}
|
||||
const size_t stride = static_cast<size_t>(x_offset) + w + x_pad;
|
||||
const uint8_t *src = ptr + (stride * y_offset + x_offset) * 2;
|
||||
for (int y = 0; y != h; y++) {
|
||||
memcpy(&this->pixels_()[(y_start + y) * this->width_ + x_start], src + y * stride * 2, w * 2);
|
||||
}
|
||||
}
|
||||
|
||||
bool SnapshotDisplay::capture_bgr(uint8_t *dest, size_t row_stride) {
|
||||
if (this->buffer_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Snapshot requested but there is no buffer to read");
|
||||
return false;
|
||||
}
|
||||
const uint16_t *src = this->pixels_();
|
||||
for (int y = 0; y != this->height_; y++) {
|
||||
uint8_t *out = dest + y * row_stride;
|
||||
for (int x = 0; x != this->width_; x++) {
|
||||
const uint16_t pixel = *src++;
|
||||
*out++ = expand_channel(pixel & BLUE_MAX, BLUE_MAX);
|
||||
*out++ = expand_channel((pixel >> 5) & GREEN_MAX, GREEN_MAX);
|
||||
*out++ = expand_channel(pixel >> 11, RED_MAX);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::snapshot
|
||||
#endif
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_HOST
|
||||
#include "esphome/components/display/display_buffer.h"
|
||||
#include "esphome/components/snapshot/snapshot.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::snapshot {
|
||||
|
||||
/// A display with nowhere to show anything: it keeps the picture in memory, where the snapshot
|
||||
/// action can pick it up. That makes it a way to see what a configuration draws on a machine with
|
||||
/// no screen, and to check the result in a test.
|
||||
class SnapshotDisplay final : public display::DisplayBuffer, public Snapshot {
|
||||
public:
|
||||
void setup() override;
|
||||
void update() override { this->do_update_(); }
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::HARDWARE; }
|
||||
display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; }
|
||||
|
||||
void set_dimensions(uint16_t width, uint16_t height) {
|
||||
this->width_ = width;
|
||||
this->height_ = height;
|
||||
}
|
||||
|
||||
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
|
||||
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
|
||||
|
||||
protected:
|
||||
void draw_absolute_pixel_internal(int x, int y, Color color) override;
|
||||
int get_width_internal() override { return this->width_; }
|
||||
int get_height_internal() override { return this->height_; }
|
||||
|
||||
int snapshot_width() override { return this->width_; }
|
||||
int snapshot_height() override { return this->height_; }
|
||||
bool capture_bgr(uint8_t *dest, size_t row_stride) override;
|
||||
|
||||
/// The picture, one 16 bit RGB565 value per pixel, topmost row first. Owned by DisplayBuffer as
|
||||
/// a byte pointer; this is the same memory seen as what is actually stored in it.
|
||||
uint16_t *pixels_() { return reinterpret_cast<uint16_t *>(this->buffer_); }
|
||||
|
||||
int width_{};
|
||||
int height_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::snapshot
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user