Merge pull request #17532 from esphome/bump-2026.7.0b2

2026.7.0b2
This commit is contained in:
Jesse Hills
2026-07-13 13:24:45 +12:00
committed by GitHub
98 changed files with 1787 additions and 415 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2026.7.0b1 PROJECT_NUMBER = 2026.7.0b2
# Using the PROJECT_BRIEF tag one can provide an optional one line description # 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 # for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt -r /requirements.txt
# Install the ESPHome Device Builder dashboard. # Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1 RUN uv pip install --no-cache-dir esphome-device-builder==1.4.3
RUN \ RUN \
platformio settings set enable_telemetry No \ platformio settings set enable_telemetry No \
+5 -2
View File
@@ -488,8 +488,11 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_NOISE_PSK_FROM_YAML") cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else: else:
# No key provided, but encryption desired # No key provided, but encryption desired
# This will allow a plaintext client to provide a noise key, # Until a key is set, the device accepts both Noise connections
# send it to the device, and then switch to noise. # using the well-known all-zeros PSK (preferred: the key travels
# encrypted, protecting against passive sniffing) and plaintext
# connections (deprecated, remove after 2027.2.0) so a client can
# provide a noise key and the device then switches to noise only.
# The key will be saved in flash and used for future connections # The key will be saved in flash and used for future connections
# and plaintext disabled. Only a factory reset can remove it. # and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_PLAINTEXT")
+5
View File
@@ -310,6 +310,11 @@ message DeviceInfoResponse {
// Serial proxy instance metadata // Serial proxy instance metadata
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
// Device is unprovisioned and accepts Noise handshakes with the well-known
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
} }
message ListEntitiesRequest { message ListEntitiesRequest {
+50 -1
View File
@@ -198,6 +198,29 @@ APIConnection::~APIConnection() {
#endif #endif
} }
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
void APIConnection::upgrade_helper_to_noise_() {
// The client opened with a Noise hello while this device has no encryption
// key set. Replace the plaintext helper with a Noise helper so the key can
// be provisioned over an encrypted channel: the noise context PSK is all
// zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519
// exchange, so a passive listener cannot read the session. A publicly known
// PSK authenticates nobody; this protects against sniffing only.
auto *plaintext = static_cast<APIPlaintextFrameHelper *>(this->helper_.get());
uint8_t header[3];
uint8_t header_len = plaintext->get_consumed_header(header);
auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx());
// Carry over the peername-based client name (Hello has not arrived yet)
const char *name = plaintext->get_client_name();
noise->set_client_name(name, strlen(name));
this->helper_.reset(noise); // destroys the plaintext helper
APIError err = noise->init_from_handoff(header, header_len);
if (err != APIError::OK) {
this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err);
}
}
#endif // USE_API_NOISE && USE_API_PLAINTEXT
void APIConnection::destroy_active_iterator_() { void APIConnection::destroy_active_iterator_() {
switch (this->active_iterator_) { switch (this->active_iterator_) {
case ActiveIterator::LIST_ENTITIES: case ActiveIterator::LIST_ENTITIES:
@@ -256,6 +279,15 @@ void APIConnection::loop() {
// No more data available // No more data available
break; break;
} else if (err != APIError::OK) { } else if (err != APIError::OK) {
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Checked inside the error branch to keep the hot err == OK path
// free of it; this can only fire on the first bytes of a plaintext
// helper on an unprovisioned device
if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) {
this->upgrade_helper_to_noise_();
return;
}
#endif
this->fatal_error_with_log_(LOG_STR("Reading failed"), err); this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
return; return;
} else { } else {
@@ -1351,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet
#ifdef USE_ZWAVE_PROXY #ifdef USE_ZWAVE_PROXY
void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len);
} }
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
@@ -1860,6 +1892,12 @@ bool APIConnection::send_device_info_response_() {
#endif #endif
#ifdef USE_API_NOISE #ifdef USE_API_NOISE
resp.api_encryption_supported = true; resp.api_encryption_supported = true;
#ifndef USE_API_NOISE_PSK_FROM_YAML
// No key from YAML: while no key is set, the key can be provisioned over a
// zero-PSK Noise connection. Gated on the YAML define (not the plaintext
// one) so this advertisement survives the plaintext removal in 2027.2.0.
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
#endif
#endif #endif
#ifdef USE_DEVICES #ifdef USE_DEVICES
size_t device_index = 0; size_t device_index = 0;
@@ -2037,10 +2075,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
} }
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length"); ESP_LOGW(TAG, "Invalid encryption key length");
} else if (APINoiseContext::is_all_zeros(psk)) {
// Accepting the reserved provisioning PSK would report success without
// enabling encryption (or silently clear an existing key)
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
} else if (!this->parent_->save_noise_psk(psk, true)) { } else if (!this->parent_->save_noise_psk(psk, true)) {
ESP_LOGW(TAG, "Failed to save encryption key"); ESP_LOGW(TAG, "Failed to save encryption key");
} else { } else {
resp.success = true; resp.success = true;
#ifdef USE_API_PLAINTEXT
if (this->helper_->frame_footer_size() == 0) {
// Plaintext transport has no frame footer; Noise always has the MAC footer.
// Remove after 2027.2.0 together with plaintext support on keyless devices.
ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0");
}
#endif
} }
return this->send_message(resp); return this->send_message(resp);
+5
View File
@@ -626,6 +626,11 @@ class APIConnection final : public APIServerConnectionBase {
void destroy_active_iterator_(); void destroy_active_iterator_();
void begin_iterator_(ActiveIterator type); void begin_iterator_(ActiveIterator type);
void finalize_iterator_sync_(); void finalize_iterator_sync_();
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Swap the plaintext helper for a Noise helper after the client opened
// with a Noise hello on an unprovisioned device (zero-PSK provisioning).
void upgrade_helper_to_noise_();
#endif
#ifdef USE_CAMERA #ifdef USE_CAMERA
std::unique_ptr<camera::CameraImageReader> image_reader_; std::unique_ptr<camera::CameraImageReader> image_reader_;
#endif #endif
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE"); return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
} }
#endif #endif
// PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before
// any logging can happen, so it intentionally has no entry here.
return LOG_STR("UNKNOWN"); return LOG_STR("UNKNOWN");
} }
+11
View File
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
HANDSHAKESTATE_SPLIT_FAILED = 1020, HANDSHAKESTATE_SPLIT_FAILED = 1020,
BAD_HANDSHAKE_ERROR_BYTE = 1021, BAD_HANDSHAKE_ERROR_BYTE = 1021,
#endif #endif
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Not an error: an unprovisioned device received a Noise client hello on a
// plaintext connection; the caller must hand the socket off to a Noise helper.
PROTOCOL_SWITCH_TO_NOISE = 1023,
#endif
}; };
const LogString *api_error_to_logstr(APIError err); const LogString *api_error_to_logstr(APIError err);
@@ -200,6 +205,12 @@ class APIFrameHelper {
// or track that they stopped early and retry without this check. // or track that they stopped early and retry without this check.
// See Socket::ready() for details. // See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Move the socket out of this helper so a replacement helper can take it
// over (plaintext to Noise handoff on unprovisioned devices). The drained
// helper must be destroyed right after.
std::unique_ptr<socket::Socket> release_socket_for_switch() { return std::move(this->socket_); }
#endif
// Release excess memory from internal buffers after initial sync // Release excess memory from internal buffers after initial sync
void release_buffers() { void release_buffers() {
// rx_buf_: Safe to clear only if no partial read in progress. // rx_buf_: Safe to clear only if no partial read in progress.
@@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO; state_ = State::CLIENT_HELLO;
return APIError::OK; return APIError::OK;
} }
#ifdef USE_API_PLAINTEXT
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
APIError err = this->init();
if (err != APIError::OK) {
return err;
}
// Seed the header bytes the plaintext helper consumed before detecting the
// Noise indicator; try_read_frame_ resumes from rx_header_buf_len_.
std::memcpy(this->rx_header_buf_, header, header_len);
this->rx_header_buf_len_ = header_len;
// Pump the handshake without gating on socket_->ready(): on LWIP the
// plaintext helper's partial read can drain rcvevent while the rest of the
// client hello sits in the lastdata cache, so ready() may report false even
// though data is available.
return this->pump_handshake_();
}
#endif // USE_API_PLAINTEXT
/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal
/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK
/// and resume on the next loop().
APIError APINoiseFrameHelper::pump_handshake_() {
while (this->state_ != State::DATA) {
APIError err = this->state_action_();
if (err == APIError::WOULD_BLOCK) {
break;
}
if (err != APIError::OK) {
return err;
}
}
return APIError::OK;
}
// Helper for handling handshake frame errors // Helper for handling handshake frame errors
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
if (aerr == APIError::BAD_INDICATOR) { if (aerr == APIError::BAD_INDICATOR) {
@@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase) /// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() { APIError APINoiseFrameHelper::loop() {
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once // Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
// the rx buffer is consumed. Re-checking each iteration would block handshake writes // ready() returns false once the rx buffer is consumed. Re-checking each
// that must follow reads, deadlocking the handshake. state_action() will return // iteration would block handshake writes that must follow reads,
// WOULD_BLOCK when no more data is available to read. // deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
bool socket_ready = this->socket_->ready(); // no more data is available to read.
while (state_ != State::DATA && socket_ready) { if (state_ != State::DATA && this->socket_->ready()) {
APIError err = state_action_(); APIError err = this->pump_handshake_();
if (err == APIError::WOULD_BLOCK) {
break;
}
if (err != APIError::OK) { if (err != APIError::OK) {
return err; return err;
} }
@@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper {
} }
~APINoiseFrameHelper() override; ~APINoiseFrameHelper() override;
APIError init() override; APIError init() override;
#ifdef USE_API_PLAINTEXT
// Take over a connection whose first bytes were consumed by a plaintext
// helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE).
// Seeds the already-read header bytes and pumps the handshake state machine
// until it would block.
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
#endif
APIError loop() override; APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override; APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
protected: protected:
APIError pump_handshake_();
APIError state_action_(); APIError state_action_();
APIError state_action_client_hello_(); APIError state_action_client_hello_();
APIError state_action_server_hello_(); APIError state_action_server_hello_();
@@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// If this was the first read, validate the indicator byte // If this was the first read, validate the indicator byte
if (rx_header_buf_pos_ == 0 && received > 0) { if (rx_header_buf_pos_ == 0 && received > 0) {
if (rx_header_buf_[0] != 0x00) { if (rx_header_buf_[0] != 0x00) {
#ifdef USE_API_NOISE
// Dual build (encryption supported but no key set): a 0x01 first byte
// is a Noise client hello. Hand the connection off to a Noise helper
// running the all-zeros provisioning PSK so the encryption key can be
// set without crossing the wire in plaintext. Preserve the bytes we
// already consumed; they are the start of the Noise 3-byte header.
if (rx_header_buf_[0] == 0x01) {
rx_header_buf_pos_ = static_cast<uint8_t>(received);
return APIError::PROTOCOL_SWITCH_TO_NOISE;
}
#endif
state_ = State::FAILED; state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR; return APIError::BAD_INDICATOR;
@@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
APIError read_packet(ReadPacketBuffer *buffer) override; APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
#ifdef USE_API_NOISE
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
// header bytes already consumed from the socket (at most 3, the size of the
// Noise fixed header) so the replacement Noise helper can be seeded with them.
uint8_t get_consumed_header(uint8_t out[3]) const {
memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_);
return this->rx_header_buf_pos_;
}
#endif
protected: protected:
APIError try_read_frame_(); APIError try_read_frame_();
+12 -5
View File
@@ -10,13 +10,20 @@ using psk_t = std::array<uint8_t, 32>;
class APINoiseContext { class APINoiseContext {
public: public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) { void set_psk(psk_t psk) {
this->psk_ = psk; this->psk_ = psk;
bool has_psk = false; this->has_psk_ = !is_all_zeros(psk);
for (auto i : psk) {
has_psk |= i;
}
this->has_psk_ = has_psk;
} }
const psk_t &get_psk() const { return this->psk_; } const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; } bool has_psk() const { return this->has_psk_; }
+6
View File
@@ -170,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
for (const auto &it : this->serial_proxies) { for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it);
} }
#endif
#ifdef USE_API_NOISE
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif #endif
return pos; return pos;
} }
@@ -232,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
for (const auto &it : this->serial_proxies) { for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(2, it.calculate_size()); size += ProtoSize::calc_message_force(2, it.calculate_size());
} }
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif #endif
return size; return size;
} }
+4 -1
View File
@@ -533,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage {
public: public:
static constexpr uint8_t MESSAGE_TYPE = 10; static constexpr uint8_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 309; static constexpr uint16_t ESTIMATED_SIZE = 312;
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); } const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif #endif
@@ -588,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif #endif
#ifdef USE_SERIAL_PROXY #ifdef USE_SERIAL_PROXY
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{}; std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif #endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const; uint32_t calculate_size() const;
+3
View File
@@ -982,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
it.dump_to(out); it.dump_to(out);
out.append("\n"); out.append("\n");
} }
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif #endif
return out.c_str(); return out.c_str();
} }
+3 -1
View File
@@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = (
cv.Exclusive( cv.Exclusive(
CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
): cv.boolean, ): cv.boolean,
cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
+3 -1
View File
@@ -50,7 +50,9 @@ _BUTTON_SCHEMA = (
.extend( .extend(
{ {
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
} }
) )
+3 -1
View File
@@ -131,7 +131,9 @@ _COVER_SCHEMA = (
cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All( cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All(
cv.requires_component("mqtt"), cv.boolean cv.requires_component("mqtt"), cv.boolean
), ),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.subscribe_topic cv.requires_component("mqtt"), cv.subscribe_topic
), ),
@@ -1,13 +1,36 @@
#include "deep_sleep_component.h" #include "deep_sleep_component.h"
#ifdef USE_ZEPHYR #ifdef USE_ZEPHYR
#include "esphome/core/hal.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "esphome/core/wake.h" #include "esphome/core/wake.h"
#include <zephyr/sys/poweroff.h> #include <zephyr/sys/poweroff.h>
#include <algorithm>
namespace esphome::deep_sleep { namespace esphome::deep_sleep {
static const char *const TAG = "deep_sleep"; static const char *const TAG = "deep_sleep";
// The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and
// WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a
// radio thread (e.g. the Zigbee stack) keeps the CPU busy in k_sem_take(). Feed
// it at least this often while waiting so it does not reset the device.
static const uint32_t WDT_FEED_INTERVAL_MS = 1000;
static bool wakeable_delay_feed_wdt(uint32_t ms) {
while (ms > 0) {
const uint32_t step = std::min(ms, WDT_FEED_INTERVAL_MS);
esphome::internal::wakeable_delay(step);
esphome::arch_feed_wdt();
if (esphome::wake_request_take()) {
return true;
}
if (ms != UINT32_MAX) {
ms -= step;
}
}
return false;
}
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {} void DeepSleepComponent::dump_config_platform_() {}
@@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {}
bool DeepSleepComponent::prepare_to_sleep_() { return true; } bool DeepSleepComponent::prepare_to_sleep_() { return true; }
void DeepSleepComponent::deep_sleep_() { void DeepSleepComponent::deep_sleep_() {
bool woke = false;
if (this->sleep_duration_.has_value()) { if (this->sleep_duration_.has_value()) {
esphome::internal::wakeable_delay(static_cast<uint32_t>(*this->sleep_duration_ / 1000)); woke = wakeable_delay_feed_wdt(static_cast<uint32_t>(*this->sleep_duration_ / 1000));
} else { } else {
#ifndef USE_ZIGBEE #ifndef USE_ZIGBEE
// the device can be woken up through one of the following signals: // the device can be woken up through one of the following signals:
@@ -29,10 +53,9 @@ void DeepSleepComponent::deep_sleep_() {
// The system is reset when it wakes up from System OFF mode. // The system is reset when it wakes up from System OFF mode.
sys_poweroff(); sys_poweroff();
#else #else
esphome::internal::wakeable_delay(UINT32_MAX); woke = wakeable_delay_feed_wdt(UINT32_MAX);
#endif #endif
} }
const bool woke = esphome::wake_request_take();
if (woke) { if (woke) {
ESP_LOGD(TAG, "Woken up by another thread"); ESP_LOGD(TAG, "Woken up by another thread");
} else { } else {
+3 -1
View File
@@ -50,7 +50,9 @@ _EVENT_SCHEMA = (
{ {
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent),
cv.GenerateID(): cv.declare_id(Event), cv.GenerateID(): cv.declare_id(Event),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_ON_EVENT): automation.validate_automation({}), cv.Optional(CONF_ON_EVENT): automation.validate_automation({}),
} }
) )
+2 -2
View File
@@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) {
ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx); ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx);
uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx);
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::INCREMENT); uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::INCREMENT);
auto err = this->write(&this->address_, reg); auto err = this->write(&reg, 1);
if (err != i2c::ERROR_OK) { if (err != i2c::ERROR_OK) {
this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->error_code_ = MCP4461_STATUS_I2C_ERROR;
this->status_set_warning(); this->status_set_warning();
@@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) {
ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx); ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx);
uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx);
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::DECREMENT); uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::DECREMENT);
auto err = this->write(&this->address_, reg); auto err = this->write(&reg, 1);
if (err != i2c::ERROR_OK) { if (err != i2c::ERROR_OK) {
this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->error_code_ = MCP4461_STATUS_I2C_ERROR;
this->status_set_warning(); this->status_set_warning();
+36 -10
View File
@@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi
auto &services = services_storage; auto &services = services_storage;
#endif #endif
#ifdef USE_API #ifdef USE_MDNS_DEVICE_INFO_TXT
#ifdef USE_MDNS_STORE_SERVICES #ifdef USE_MDNS_STORE_SERVICES
get_mac_address_into_buffer(this->mac_address_); get_mac_address_into_buffer(this->mac_address_);
char *mac_ptr = this->mac_address_; char *mac_ptr = this->mac_address_;
@@ -70,17 +70,20 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi
platform_register(this, services); platform_register(this, services);
} }
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf, void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services,
char *config_hash_buf) { const char *mac_address_buf, const char *config_hash_buf) {
// IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES
// in mdns/__init__.py. If you add a new service here, update both locations. // in mdns/__init__.py. If you add a new service here, update both locations.
#ifdef USE_MDNS_DEVICE_INFO_TXT
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash");
#endif
#ifdef USE_API #ifdef USE_API
MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib"); MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib");
MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name"); MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name");
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash");
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform"); MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform");
MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board"); MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board");
MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network"); MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network");
@@ -107,7 +110,13 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
txt_count++; // network txt_count++; // network
#endif #endif
#ifdef USE_API_NOISE #ifdef USE_API_NOISE
const bool api_has_psk = api::global_api_server->get_noise_ctx().has_psk();
txt_count++; // api_encryption or api_encryption_supported txt_count++; // api_encryption or api_encryption_supported
#ifndef USE_API_NOISE_PSK_FROM_YAML
if (!api_has_psk) {
txt_count++; // api_provisioning
}
#endif
#endif #endif
#ifdef ESPHOME_PROJECT_NAME #ifdef ESPHOME_PROJECT_NAME
txt_count += 2; // project_name and project_version txt_count += 2; // project_name and project_version
@@ -163,9 +172,18 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION, "api_encryption"); MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION, "api_encryption");
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION_SUPPORTED, "api_encryption_supported"); MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION_SUPPORTED, "api_encryption_supported");
MDNS_STATIC_CONST_CHAR(NOISE_ENCRYPTION, "Noise_NNpsk0_25519_ChaChaPoly_SHA256"); MDNS_STATIC_CONST_CHAR(NOISE_ENCRYPTION, "Noise_NNpsk0_25519_ChaChaPoly_SHA256");
bool has_psk = api::global_api_server->get_noise_ctx().has_psk(); const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)}); txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)});
#ifndef USE_API_NOISE_PSK_FROM_YAML
if (!api_has_psk) {
// Unprovisioned device without a YAML key: advertise that the encryption
// key can be provisioned over a zero-PSK Noise connection. Gated on the
// YAML define so this survives the plaintext removal in 2027.2.0.
MDNS_STATIC_CONST_CHAR(TXT_API_PROVISIONING, "api_provisioning");
MDNS_STATIC_CONST_CHAR(VALUE_ZERO_PSK, "zero-psk");
txt_records.push_back({MDNS_STR(TXT_API_PROVISIONING), MDNS_STR(VALUE_ZERO_PSK)});
}
#endif
#endif #endif
#ifdef ESPHOME_PROJECT_NAME #ifdef ESPHOME_PROJECT_NAME
@@ -212,12 +230,18 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
web_service.service_type = MDNS_STR(SERVICE_HTTP); web_service.service_type = MDNS_STR(SERVICE_HTTP);
web_service.proto = MDNS_STR(SERVICE_TCP); web_service.proto = MDNS_STR(SERVICE_TCP);
web_service.port = []() -> uint16_t { return USE_WEBSERVER_PORT; }; web_service.port = []() -> uint16_t { return USE_WEBSERVER_PORT; };
#ifndef USE_API
// Without the native API there is no _esphomelib service, so publish the
// device info here for the device builder to discover.
web_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)},
{MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)},
{MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}};
#endif
#endif #endif
#if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \
!defined(USE_MDNS_EXTRA_SERVICES) !defined(USE_MDNS_EXTRA_SERVICES)
MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http");
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
// Publish "http" service if not using native API or any other services // Publish "http" service if not using native API or any other services
// This is just to have *some* mDNS service so that .local resolution works // This is just to have *some* mDNS service so that .local resolution works
@@ -225,7 +249,9 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
fallback_service.service_type = MDNS_STR(SERVICE_HTTP); fallback_service.service_type = MDNS_STR(SERVICE_HTTP);
fallback_service.proto = MDNS_STR(SERVICE_TCP); fallback_service.proto = MDNS_STR(SERVICE_TCP);
fallback_service.port = []() -> uint16_t { return USE_WEBSERVER_PORT; }; fallback_service.port = []() -> uint16_t { return USE_WEBSERVER_PORT; };
fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)},
{MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)},
{MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}};
#endif #endif
} }
+12 -3
View File
@@ -22,6 +22,15 @@
#endif #endif
#endif #endif
// Device info TXT records (version, mac, config_hash) are published on the _esphomelib service
// when the native API is enabled, otherwise on the _http service (web_server's or the fallback one).
// When neither applies (only prometheus, sendspin or user-defined services are configured), no
// device info records are published and the buffers below are not needed.
#if defined(USE_API) || defined(USE_WEBSERVER) || \
(!defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_MDNS_EXTRA_SERVICES))
#define USE_MDNS_DEVICE_INFO_TXT
#endif
namespace esphome::mdns { namespace esphome::mdns {
// Helper struct that identifies strings that may be stored in flash storage (similar to LogString) // Helper struct that identifies strings that may be stored in flash storage (similar to LogString)
@@ -136,7 +145,7 @@ class MDNSComponent final : public Component
StaticVector<std::string, MDNS_DYNAMIC_TXT_COUNT> dynamic_txt_values_; StaticVector<std::string, MDNS_DYNAMIC_TXT_COUNT> dynamic_txt_values_;
#endif #endif
#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) #if defined(USE_MDNS_DEVICE_INFO_TXT) && defined(USE_MDNS_STORE_SERVICES)
/// Fixed buffer for MAC address (only needed when services are stored) /// Fixed buffer for MAC address (only needed when services are stored)
char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; char mac_address_[MAC_ADDRESS_BUFFER_SIZE];
/// Fixed buffer for config hash hex string (only needed when services are stored) /// Fixed buffer for config hash hex string (only needed when services are stored)
@@ -149,8 +158,8 @@ class MDNSComponent final : public Component
// RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that.
bool initialized_{false}; bool initialized_{false};
#endif #endif
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf, void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, const char *mac_address_buf,
char *config_hash_buf); const char *config_hash_buf);
}; };
} // namespace esphome::mdns } // namespace esphome::mdns
+1 -1
View File
@@ -12,7 +12,7 @@ namespace esphome::mdns {
void MDNSComponent::setup() { void MDNSComponent::setup() {
#ifdef USE_MDNS_STORE_SERVICES #ifdef USE_MDNS_STORE_SERVICES
#ifdef USE_API #ifdef USE_MDNS_DEVICE_INFO_TXT
get_mac_address_into_buffer(this->mac_address_); get_mac_address_into_buffer(this->mac_address_);
char *mac_ptr = this->mac_address_; char *mac_ptr = this->mac_address_;
format_hex_to(this->config_hash_str_, App.get_config_hash()); format_hex_to(this->config_hash_str_, App.get_config_hash());
+84 -13
View File
@@ -26,16 +26,22 @@ from esphome.const import (
CONF_OFFSET_HEIGHT, CONF_OFFSET_HEIGHT,
CONF_OFFSET_WIDTH, CONF_OFFSET_WIDTH,
CONF_PAGES, CONF_PAGES,
CONF_RESET_PIN,
CONF_ROTATION, CONF_ROTATION,
CONF_SWAP_XY, CONF_SWAP_XY,
CONF_TRANSFORM, CONF_TRANSFORM,
CONF_WIDTH, CONF_WIDTH,
) )
from esphome.core import TimePeriod from esphome.core import CORE, TimePeriod
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
LOGGER = cv.logging.getLogger(__name__) LOGGER = cv.logging.getLogger(__name__)
CONF_TRANSFORMS = "transforms"
# All axis transforms a model may support, in the order they appear in the schema.
ALL_TRANSFORMS = (CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY)
ColorOrder = display_ns.enum("ColorMode") ColorOrder = display_ns.enum("ColorMode")
NOP = 0x00 NOP = 0x00
@@ -302,7 +308,8 @@ class DriverChip:
""" """
A class representing a MIPI DBI driver chip model. A class representing a MIPI DBI driver chip model.
The parameters supplied as defaults will be used to provide default values for the display configuration. The parameters supplied as defaults will be used to provide default values for the display configuration.
Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes. Pass a ``transforms`` set to restrict which axis transforms (mirror_x, mirror_y, swap_xy) the model
supports; by default all three are available.
""" """
models: dict[str, Self] = {} models: dict[str, Self] = {}
@@ -387,11 +394,15 @@ class DriverChip:
""" """
Return the available transforms for this model. Return the available transforms for this model.
""" """
if (transforms := self.get_default(CONF_TRANSFORMS, None)) is not None:
return transforms
if self.get_default("no_transform", False): if self.get_default("no_transform", False):
return set() return set()
if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED: if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED:
return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
return {CONF_MIRROR_X, CONF_MIRROR_Y} raise ValueError(
"Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead"
)
def has_hardware_transform(self, config) -> bool: def has_hardware_transform(self, config) -> bool:
""" """
@@ -533,17 +544,31 @@ class DriverChip:
transform[CONF_TRANSFORM] = self.rotation_as_transform(config) transform[CONF_TRANSFORM] = self.rotation_as_transform(config)
return transform return transform
def swap_xy_schema(self): def transform_schema(self):
uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED """
Build the schema for the ``transform`` config option of this model.
def validator(value): Each transform the model supports is a required boolean. A transform the model does not
if value: support may be omitted or set to ``false``; setting it to ``true`` reports a clear error
raise cv.Invalid("Axis swapping not supported by this model") naming the unsupported transform instead of a generic "extra keys not allowed".
return cv.boolean(value) """
supported = self.transforms
if uses_swap: def unsupported(name):
return {cv.Required(CONF_SWAP_XY): cv.boolean} def validator(value):
return {cv.Optional(CONF_SWAP_XY, default=False): validator} if cv.boolean(value):
raise cv.Invalid(f"'{name}' is not supported by this model")
return False
return validator
schema = {}
for name in ALL_TRANSFORMS:
if name in supported:
schema[cv.Required(name)] = cv.boolean
else:
schema[cv.Optional(name, default=False)] = unsupported(name)
return cv.Any(cv.Schema(schema), cv.one_of(CONF_DISABLED, lower=True))
def get_madctl(self, transform: dict, config: dict) -> int: def get_madctl(self, transform: dict, config: dict) -> int:
""" """
@@ -577,12 +602,15 @@ class DriverChip:
""" """
return self.get_default(f"no_{command.lower()}", False) return self.get_default(f"no_{command.lower()}", False)
def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]: def get_sequence(self, config, add_madctl=True, add_reset=False) -> tuple[int, ...]:
""" """
Create the init sequence for the display. Create the init sequence for the display.
Use the default sequence from the model, if any, and append any custom sequence provided in the config. Use the default sequence from the model, if any, and append any custom sequence provided in the config.
Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence
MADCTL will be set if add_madctl is True MADCTL will be set if add_madctl is True
If add_reset is True, a reset is prepended: a software reset when no reset pin
is configured (and the model doesn't skip it), followed by a settling delay that
both a software and a hardware reset require.
Returns the init sequence Returns the init sequence
""" """
sequence = list(self.initsequence or ()) sequence = list(self.initsequence or ())
@@ -591,6 +619,15 @@ class DriverChip:
# Ensure each command is a tuple # Ensure each command is a tuple
sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] sequence = [x if isinstance(x, tuple) else (x,) for x in sequence]
if add_reset:
reset: list = []
# A software reset is only needed when there is no hardware reset pin.
if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"):
reset.append((SWRESET,))
# Both a software and a hardware reset need a settling delay before further commands.
reset.append(delay(10))
sequence = reset + sequence
# Set pixel format if not already in the custom sequence # Set pixel format if not already in the custom sequence
pixel_mode = config[CONF_PIXEL_MODE] pixel_mode = config[CONF_PIXEL_MODE]
if not isinstance(pixel_mode, int): if not isinstance(pixel_mode, int):
@@ -611,13 +648,47 @@ class DriverChip:
sequence.append((BRIGHTNESS, brightness)) sequence.append((BRIGHTNESS, brightness))
# Add a SLPOUT command if required. # Add a SLPOUT command if required.
if not self.skip_command("SLPOUT"): if not self.skip_command("SLPOUT"):
# A zero delay will delay until 120ms after reset
sequence.append(delay(0))
sequence.append((SLPOUT,)) sequence.append((SLPOUT,))
sequence.append(delay(10))
sequence.append((DISPON,)) sequence.append((DISPON,))
# Add a delay here because additional commands may be added after this at runtime.
sequence.append(delay(10))
# Flatten the sequence into a list of bytes, with the length of each command # Flatten the sequence into a list of bytes, with the length of each command
# or the delay flag inserted where needed # or the delay flag inserted where needed
return flatten_sequence(sequence) return flatten_sequence(sequence)
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
Also logs a warning if the model is deprecated.
"""
if deprecation_reason := self.get_default("deprecation_reason"):
LOGGER.warning(
"Display model %s is deprecated: %s", self.name, deprecation_reason
)
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
def requires_buffer(config) -> bool: def requires_buffer(config) -> bool:
""" """
+4 -18
View File
@@ -41,24 +41,21 @@ from esphome.const import (
CONF_AUTO_CLEAR_ENABLED, CONF_AUTO_CLEAR_ENABLED,
CONF_COLOR_ORDER, CONF_COLOR_ORDER,
CONF_DIMENSIONS, CONF_DIMENSIONS,
CONF_DISABLED,
CONF_ENABLE_PIN, CONF_ENABLE_PIN,
CONF_ID, CONF_ID,
CONF_INIT_SEQUENCE, CONF_INIT_SEQUENCE,
CONF_INVERT_COLORS, CONF_INVERT_COLORS,
CONF_LAMBDA, CONF_LAMBDA,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL, CONF_MODEL,
CONF_RESET_PIN, CONF_RESET_PIN,
CONF_ROTATION, CONF_ROTATION,
CONF_SWAP_XY,
CONF_TRANSFORM, CONF_TRANSFORM,
CONF_WIDTH, CONF_WIDTH,
) )
from esphome.final_validate import full_config from esphome.final_validate import full_config
from . import mipi_dsi_ns, models from . import mipi_dsi_ns, models
from .models import DsiDriverChip
# Currently only ESP32-P4 is supported, so esp_ldo and psram are required # Currently only ESP32-P4 is supported, so esp_ldo and psram are required
DEPENDENCIES = ["esp32", "esp_ldo", "psram"] DEPENDENCIES = ["esp32", "esp_ldo", "psram"]
@@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness")
CONF_LANE_BIT_RATE = "lane_bit_rate" CONF_LANE_BIT_RATE = "lane_bit_rate"
CONF_LANES = "lanes" CONF_LANES = "lanes"
DriverChip("CUSTOM") DsiDriverChip("CUSTOM")
# Import all models dynamically from the models package # Import all models dynamically from the models package
@@ -90,19 +87,7 @@ COLOR_DEPTHS = {
def model_schema(config): def model_schema(config):
model = MODELS[config[CONF_MODEL].upper()] model = MODELS[config[CONF_MODEL].upper()]
model.defaults[CONF_SWAP_XY] = cv.UNDEFINED transform = model.transform_schema()
transform = cv.Any(
cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
cv.Optional(CONF_SWAP_XY): cv.invalid(
"Axis swapping not supported by DSI displays"
),
}
),
cv.one_of(CONF_DISABLED, lower=True),
)
# CUSTOM model will need to provide a custom init sequence # CUSTOM model will need to provide a custom init sequence
iseqconf = ( iseqconf = (
cv.Required(CONF_INIT_SEQUENCE) cv.Required(CONF_INIT_SEQUENCE)
@@ -172,6 +157,7 @@ def _config_schema(config):
)(config) )(config)
config = model_schema(config)(config) config = model_schema(config)(config)
model = MODELS[config[CONF_MODEL].upper()] model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config) model.get_dimensions(config)
) )
@@ -0,0 +1,14 @@
from esphome.components.mipi import DriverChip
from esphome.const import CONF_SWAP_XY
class DsiDriverChip(DriverChip):
"""A driver chip for MIPI DSI displays."""
@property
def transforms(self) -> set[str]:
"""
Return the set of transformations supported by this driver chip.
DSI displays do not support axis swapping, so this method removes CONF_SWAP_XY
"""
return super().transforms - {CONF_SWAP_XY}
@@ -1,8 +1,7 @@
from esphome.components.mipi import DriverChip from . import DsiDriverChip
import esphome.config_validation as cv
# fmt: off # fmt: off
DriverChip( DsiDriverChip(
"JC1060P470", "JC1060P470",
width=1024, width=1024,
height=600, height=600,
@@ -14,7 +13,6 @@ DriverChip(
vsync_front_porch=12, vsync_front_porch=12,
pclk_frequency="54MHz", pclk_frequency="54MHz",
lane_bit_rate="750Mbps", lane_bit_rate="750Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
initsequence=[ initsequence=[
(0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00), (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00),
@@ -46,7 +44,7 @@ DriverChip(
# * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42) # * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42)
# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166) # * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166)
# ---------------------------------------------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------------------------------------------
DriverChip( DsiDriverChip(
"JC4880P443", "JC4880P443",
width=480, width=480,
height=800, height=800,
@@ -58,7 +56,6 @@ DriverChip(
vsync_front_porch=166, vsync_front_porch=166,
pclk_frequency="34MHz", pclk_frequency="34MHz",
lane_bit_rate="500Mbps", lane_bit_rate="500Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
reset_pin=5, reset_pin=5,
initsequence=[ initsequence=[
@@ -111,7 +108,7 @@ DriverChip(
# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) # * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40)
# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20) # * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20)
# ---------------------------------------------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------------------------------------------
DriverChip( DsiDriverChip(
"JC8012P4A1", "JC8012P4A1",
width=800, width=800,
height=1280, height=1280,
@@ -123,7 +120,6 @@ DriverChip(
vsync_front_porch=20, vsync_front_porch=20,
pclk_frequency="60MHz", pclk_frequency="60MHz",
lane_bit_rate="1Gbps", lane_bit_rate="1Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
reset_pin=27, reset_pin=27,
initsequence=[ initsequence=[
+59 -7
View File
@@ -1,8 +1,7 @@
from esphome.components.mipi import DriverChip from . import DsiDriverChip
import esphome.config_validation as cv
# fmt: off # fmt: off
DriverChip( DsiDriverChip(
"M5STACK-TAB5", "M5STACK-TAB5",
height=1280, height=1280,
width=720, width=720,
@@ -14,7 +13,6 @@ DriverChip(
vsync_front_porch=20, vsync_front_porch=20,
pclk_frequency="60MHz", pclk_frequency="60MHz",
lane_bit_rate="730Mbps", lane_bit_rate="730Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
initsequence=[ initsequence=[
(0xFF, 0x98, 0x81, 0x01), # Select Page 1 (0xFF, 0x98, 0x81, 0x01), # Select Page 1
@@ -56,8 +54,8 @@ DriverChip(
], ],
) )
DriverChip( TAB5_ST7123 = DsiDriverChip(
"M5STACK-TAB5-V2", "M5STACK-TAB5-ST7123",
height=1280, height=1280,
width=720, width=720,
hsync_back_porch=40, hsync_back_porch=40,
@@ -68,7 +66,6 @@ DriverChip(
vsync_front_porch=220, vsync_front_porch=220,
pclk_frequency="80MHz", pclk_frequency="80MHz",
lane_bit_rate="960Mbps", lane_bit_rate="960Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
initsequence=[ initsequence=[
(0x01,), (0x01,),
@@ -97,3 +94,58 @@ DriverChip(
(0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF), (0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF),
], ],
) )
TAB5_ST7123.extend(
"M5STACK-TAB5-V2",
deprecation_reason="Use 'M5STACK-TAB5-ST7123' or 'M5STACK-TAB5-ST7121' instead."
)
# Some Tab5 "v2" units ship with an ST7121 controller instead of the ST7123.
# The two are distinguishable at runtime by the touch controller firmware version (the M5
# factory firmware branches on it), but ESPHome selects the panel at compile time, so ST7121
# units must select this model explicitly. Values taken from M5's factory source
# (m5stack/M5Tab5-UserDemo: m5stack_tab5.c is_st7121 path + esp_lcd_st7121.c default table).
DsiDriverChip(
"M5STACK-TAB5-ST7121",
height=1280,
width=720,
hsync_back_porch=40,
hsync_pulse_width=2,
hsync_front_porch=40,
vsync_back_porch=24,
vsync_pulse_width=20,
vsync_front_porch=200,
pclk_frequency="70MHz",
lane_bit_rate="965Mbps",
color_order="RGB",
initsequence=[
(0x01,),
(0x60, 0x71, 0x21, 0xA2),
(0x60, 0x71, 0x21, 0xA3),
(0x60, 0x71, 0x21, 0xA4),
(0x78, 0x21),
(0x79, 0xEF),
(0xA4, 0x31),
(0xB7, 0x00, 0x00, 0x5F, 0x5F, 0x44, 0x1A),
(0xB0, 0x22, 0x6B, 0x11, 0x89, 0x25, 0x43, 0x43),
(0xBF, 0xA7, 0xA7),
(0xA5, 0xF0, 0x03),
(0xD7, 0x10, 0x2C, 0x14, 0x2A, 0x80, 0x80),
(0x90, 0x71, 0x23, 0x5A, 0x20, 0x24, 0x11, 0x21),
(0xA3, 0x80, 0x01, 0x8C, 0xFF, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0xEF, 0x58, 0x00, 0x00, 0x00, 0xFF),
(0xA6, 0x0A, 0x00, 0x24, 0x71, 0x36, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x37, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x00, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x2C, 0x71, 0x00, 0x01, 0x00, 0x00, 0x68, 0x68, 0xFF, 0xFF, 0x00, 0x08, 0x80, 0x08, 0x80, 0x06, 0x00, 0x00, 0x00, 0x00),
(0xA7, 0x1A, 0x1A, 0xC0, 0x64, 0x40, 0x04, 0x15, 0x40, 0x00, 0x40, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x26, 0x37, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x8C, 0x9D, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0xAE, 0xBF, 0x00, 0x00, 0x20, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x79),
(0xAC, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x04, 0x1C, 0x1D, 0x08, 0x0A, 0x10, 0x12, 0x0C, 0x0E, 0x14, 0x16, 0x00, 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x06, 0x1C, 0x1D, 0x09, 0x0B, 0x11, 0x13, 0x0D, 0x0F, 0x15, 0x17, 0x02, 0x1D, 0x1D, 0x1D, 0x1D),
(0xAD, 0x0C, 0x40, 0x46, 0x00, 0x07, 0x4B, 0x4B, 0xFF, 0xFF, 0xF0, 0x40, 0x0E, 0x01, 0x07, 0x42, 0x42, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF),
(0xAE, 0xF0, 0xFF, 0x03, 0xF0, 0xFF, 0x03, 0x00),
(0xB2, 0x15, 0x19, 0x05, 0x23, 0x49, 0x2D, 0x03, 0x2E, 0x5C, 0xD2, 0xFF, 0x10, 0x60, 0xFD, 0x20, 0xC0, 0x00),
(0xE8, 0x20, 0x60, 0x04, 0x8E, 0x8E, 0x3E, 0x04, 0xDC, 0xDC, 0x3E, 0x06, 0xFA, 0x26, 0x3E),
(0x75, 0x03, 0x04),
(0xE7, 0x4B, 0x00, 0x00, 0xBE, 0x4B, 0x8C, 0x20, 0x1A, 0xF0, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0xFF, 0x00, 0x32, 0x30, 0x73, 0x00, 0x00, 0xC8, 0x6A, 0xFF, 0x5A, 0x64, 0x38, 0x88, 0x15, 0xB1, 0x01, 0x01, 0x64, 0x01, 0x01, 0x7C, 0xFF, 0x1A, 0x51),
(0xE1, 0x0C, 0x0C),
(0xEA, 0x15, 0x00, 0x01),
(0xC8, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF),
(0xC9, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF),
(0x60, 0x71, 0x21, 0x00),
],
)
+3 -4
View File
@@ -1,9 +1,8 @@
from esphome.components.mipi import DriverChip from . import DsiDriverChip
import esphome.config_validation as cv
# Standalone display # Standalone display
# Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html # Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html
DriverChip( DsiDriverChip(
"SEEED-RETERMINAL-D1001", "SEEED-RETERMINAL-D1001",
height=1280, height=1280,
width=800, width=800,
@@ -15,10 +14,10 @@ DriverChip(
vsync_front_porch=30, vsync_front_porch=30,
pclk_frequency="80MHz", pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps", lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
reset_pin={"xl9535": None, "number": 2}, reset_pin={"xl9535": None, "number": 2},
requires={"psram", "xl9535"},
initsequence=( initsequence=(
(0xE0, 0x00), (0xE0, 0x00),
(0xE1, 0x93), (0xE1, 0x93),
@@ -1,12 +1,11 @@
from esphome.components.mipi import DriverChip from . import DsiDriverChip
import esphome.config_validation as cv
# fmt: off # fmt: off
# Source for parameters and initsequence: # Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1 # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1
# Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage # Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage
JD9365_10_1_DSI_TOUCH_A = DriverChip( JD9365_10_1_DSI_TOUCH_A = DsiDriverChip(
"WAVESHARE-P4-NANO-10.1", "WAVESHARE-P4-NANO-10.1",
height=1280, height=1280,
width=800, width=800,
@@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip(
vsync_front_porch=30, vsync_front_porch=30,
pclk_frequency="80MHz", pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps", lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
initsequence=[ initsequence=[
(0xE0, 0x00), # select userpage (0xE0, 0x00), # select userpage
@@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend(
# Source for parameters and initsequence: # Source for parameters and initsequence:
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703 # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703
# Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO # Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO
DriverChip( DsiDriverChip(
"WAVESHARE-P4-86-PANEL", "WAVESHARE-P4-86-PANEL",
height=720, height=720,
width=720, width=720,
@@ -77,7 +75,6 @@ DriverChip(
vsync_front_porch=20, vsync_front_porch=20,
pclk_frequency="38MHz", pclk_frequency="38MHz",
lane_bit_rate="480Mbps", lane_bit_rate="480Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
reset_pin=27, reset_pin=27,
initsequence=[ initsequence=[
@@ -109,7 +106,7 @@ DriverChip(
# Source for parameters and initsequence: # Source for parameters and initsequence:
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007 # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B
DriverChip( DsiDriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B", "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B",
height=600, height=600,
width=1024, width=1024,
@@ -139,7 +136,7 @@ DriverChip(
# Source for parameters and initsequence: # Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C
JD9365_3_4_DSI_TOUCH_C = DriverChip( JD9365_3_4_DSI_TOUCH_C = DsiDriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C", "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C",
height=800, height=800,
width=800, width=800,
@@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip(
vsync_front_porch=24, vsync_front_porch=24,
pclk_frequency="80MHz", pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps", lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
initsequence=[ initsequence=[
(0xE0, 0x00), # select userpage (0xE0, 0x00), # select userpage
@@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend(
# Source for parameters and initsequence: # Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C
JD9365_4_DSI_TOUCH_C = DriverChip( JD9365_4_DSI_TOUCH_C = DsiDriverChip(
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C", "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C",
height=720, height=720,
width=720, width=720,
@@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip(
vsync_front_porch=24, vsync_front_porch=24,
pclk_frequency="80MHz", pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps", lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
initsequence=[ initsequence=[
(0xE0, 0x00), # select userpage (0xE0, 0x00), # select userpage
@@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend(
# Source for parameters and initsequence: # Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
# Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A # Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A
DriverChip( DsiDriverChip(
"WAVESHARE-8-DSI-TOUCH-A", "WAVESHARE-8-DSI-TOUCH-A",
height=1280, height=1280,
width=800, width=800,
@@ -267,7 +262,6 @@ DriverChip(
vsync_front_porch=30, vsync_front_porch=30,
pclk_frequency="80MHz", pclk_frequency="80MHz",
lane_bit_rate="1.5Gbps", lane_bit_rate="1.5Gbps",
swap_xy=cv.UNDEFINED,
color_order="RGB", color_order="RGB",
initsequence=[ initsequence=[
(0xE0, 0x00), # select userpage (0xE0, 0x00), # select userpage
@@ -304,7 +298,7 @@ DriverChip(
# Source for parameters and initsequence: # Source for parameters and initsequence:
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c
# Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A # Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A
DriverChip( DsiDriverChip(
"WAVESHARE-7-DSI-TOUCH-A", "WAVESHARE-7-DSI-TOUCH-A",
height=1280, height=1280,
width=720, width=720,
+12 -19
View File
@@ -18,6 +18,8 @@ from esphome.components.mipi import (
CONF_HSYNC_BACK_PORCH, CONF_HSYNC_BACK_PORCH,
CONF_HSYNC_FRONT_PORCH, CONF_HSYNC_FRONT_PORCH,
CONF_HSYNC_PULSE_WIDTH, CONF_HSYNC_PULSE_WIDTH,
CONF_PCLK_FREQUENCY,
CONF_PCLK_INVERTED,
CONF_PCLK_PIN, CONF_PCLK_PIN,
CONF_PIXEL_MODE, CONF_PIXEL_MODE,
CONF_USE_AXIS_FLIPS, CONF_USE_AXIS_FLIPS,
@@ -34,9 +36,11 @@ from esphome.components.mipi import (
power_of_two, power_of_two,
requires_buffer, requires_buffer,
) )
from esphome.components.rpi_dpi_rgb.display import ( from esphome.components.spi import (
CONF_PCLK_FREQUENCY, CONF_SPI_MODE,
CONF_PCLK_INVERTED, SPI_DATA_RATE_SCHEMA,
SPI_MODE_OPTIONS,
SPIComponent,
) )
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
@@ -48,7 +52,6 @@ from esphome.const import (
CONF_DATA_RATE, CONF_DATA_RATE,
CONF_DC_PIN, CONF_DC_PIN,
CONF_DIMENSIONS, CONF_DIMENSIONS,
CONF_DISABLED,
CONF_ENABLE_PIN, CONF_ENABLE_PIN,
CONF_GREEN, CONF_GREEN,
CONF_HSYNC_PIN, CONF_HSYNC_PIN,
@@ -57,8 +60,6 @@ from esphome.const import (
CONF_INIT_SEQUENCE, CONF_INIT_SEQUENCE,
CONF_INVERT_COLORS, CONF_INVERT_COLORS,
CONF_LAMBDA, CONF_LAMBDA,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL, CONF_MODEL,
CONF_NUMBER, CONF_NUMBER,
CONF_RED, CONF_RED,
@@ -72,10 +73,10 @@ from esphome.const import (
) )
from esphome.final_validate import full_config from esphome.final_validate import full_config
from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent
from . import models from . import models
from .models import RgbDriverChip
DEPENDENCIES = ["esp32", "psram"] DEPENDENCIES = ["esp32"]
mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb") mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb")
mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component) mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component)
@@ -86,7 +87,7 @@ ColorOrder = display.display_ns.enum("ColorMode")
DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema
DriverChip("CUSTOM") RgbDriverChip("CUSTOM")
# Import all models dynamically from the models package # Import all models dynamically from the models package
@@ -120,16 +121,7 @@ def data_pin_set(length):
def model_schema(config): def model_schema(config):
model = MODELS[config[CONF_MODEL].upper()] model = MODELS[config[CONF_MODEL].upper()]
transform = cv.Any( transform = model.transform_schema()
cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
**model.swap_xy_schema(),
}
),
cv.one_of(CONF_DISABLED, lower=True),
)
# RPI model does not use an init sequence, indicates with empty list # RPI model does not use an init sequence, indicates with empty list
if model.initsequence is None: if model.initsequence is None:
# Custom model requires an init sequence # Custom model requires an init sequence
@@ -235,6 +227,7 @@ def _config_schema(config):
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
)(config) )(config)
model = MODELS[config[CONF_MODEL].upper()] model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config) model.get_dimensions(config)
) )
@@ -0,0 +1,14 @@
from esphome.components.mipi import DriverChip
from esphome.const import CONF_SWAP_XY
class RgbDriverChip(DriverChip):
"""A driver chip for MIPI RGB displays."""
@property
def transforms(self) -> set[str]:
"""
Return the set of transformations supported by this driver chip.
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY
"""
return super().transforms - {CONF_SWAP_XY}
@@ -5,6 +5,7 @@ st7701s.extend(
width=480, width=480,
height=480, height=480,
data_rate="2MHz", data_rate="2MHz",
requires={"psram"},
cs_pin=39, cs_pin=39,
de_pin=18, de_pin=18,
hsync_pin=16, hsync_pin=16,
+2 -4
View File
@@ -1,5 +1,3 @@
from esphome.config_validation import UNDEFINED
from .st7701s import ST7701S from .st7701s import ST7701S
# fmt: off # fmt: off
@@ -8,10 +6,10 @@ ST7701S(
width=480, width=480,
height=480, height=480,
invert_colors=False, invert_colors=False,
swap_xy=UNDEFINED,
spi_mode="MODE3", spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 17}, cs_pin={"xl9535": None, "number": 17},
reset_pin={"xl9535": None, "number": 5}, reset_pin={"xl9535": None, "number": 5},
requires={"psram", "xl9535"},
hsync_pin=39, hsync_pin=39,
vsync_pin=40, vsync_pin=40,
pclk_pin=41, pclk_pin=41,
@@ -57,9 +55,9 @@ t_rgb = ST7701S(
height=480, height=480,
pixel_mode="18bit", pixel_mode="18bit",
invert_colors=False, invert_colors=False,
swap_xy=UNDEFINED,
spi_mode="MODE3", spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 3}, cs_pin={"xl9535": None, "number": 3},
requires={"psram", "xl9535"},
de_pin=45, de_pin=45,
hsync_pin=47, hsync_pin=47,
vsync_pin=41, vsync_pin=41,
+2 -4
View File
@@ -1,9 +1,7 @@
from esphome.components.mipi import DriverChip from . import RgbDriverChip
from esphome.config_validation import UNDEFINED
# A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence # A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence
DriverChip( RgbDriverChip(
"RPI", "RPI",
swap_xy=UNDEFINED,
initsequence=(), initsequence=(),
) )
+8 -10
View File
@@ -1,17 +1,12 @@
from esphome.components.mipi import ( from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR
MADCTL,
MADCTL_ML,
MADCTL_XFLIP,
MODE_BGR,
DriverChip,
)
from esphome.config_validation import UNDEFINED
from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y
from . import RgbDriverChip
SDIR_CMD = 0xC7 SDIR_CMD = 0xC7
class ST7701S(DriverChip): class ST7701S(RgbDriverChip):
# The ST7701s does not use the standard MADCTL bits for x/y mirroring # The ST7701s does not use the standard MADCTL bits for x/y mirroring
def add_madctl(self, sequence: list, config: dict): def add_madctl(self, sequence: list, config: dict):
transform = self.get_transform(config) transform = self.get_transform(config)
@@ -45,7 +40,6 @@ st7701s = ST7701S(
"ST7701S", "ST7701S",
width=480, width=480,
height=864, height=864,
swap_xy=UNDEFINED,
hsync_front_porch=20, hsync_front_porch=20,
hsync_back_porch=10, hsync_back_porch=10,
hsync_pulse_width=10, hsync_pulse_width=10,
@@ -85,6 +79,7 @@ st7701s.extend(
height=480, height=480,
invert_colors=True, invert_colors=True,
pixel_mode="18bit", pixel_mode="18bit",
requires={"psram"},
cs_pin=1, cs_pin=1,
de_pin={ de_pin={
"number": 45, "number": 45,
@@ -117,6 +112,7 @@ st7701s.extend(
vsync_pulse_width=8, vsync_pulse_width=8,
vsync_back_porch=20, vsync_back_porch=20,
cs_pin={"pca9554": None, "number": 4}, cs_pin={"pca9554": None, "number": 4},
requires={"psram", "pca9554"},
de_pin=18, de_pin=18,
hsync_pin=16, hsync_pin=16,
vsync_pin=17, vsync_pin=17,
@@ -134,6 +130,7 @@ st7701s.extend(
width=480, width=480,
height=480, height=480,
pixel_mode="18bit", pixel_mode="18bit",
requires={"psram"},
cs_pin=18, cs_pin=18,
reset_pin=8, reset_pin=8,
de_pin=17, de_pin=17,
@@ -177,6 +174,7 @@ st7701s.extend(
width=480, width=480,
height=480, height=480,
pixel_mode="18bit", pixel_mode="18bit",
requires={"psram"},
cs_pin=21, cs_pin=21,
de_pin=39, de_pin=39,
vsync_pin=48, vsync_pin=48,
+3 -5
View File
@@ -1,14 +1,13 @@
from esphome.components.mipi import DriverChip from . import RgbDriverChip
from esphome.config_validation import UNDEFINED
# fmt: off # fmt: off
sunton = DriverChip( sunton = RgbDriverChip(
"ESP32-8048S070", "ESP32-8048S070",
swap_xy=UNDEFINED,
initsequence=(), initsequence=(),
width=800, width=800,
height=480, height=480,
pclk_frequency="12.5MHz", pclk_frequency="12.5MHz",
requires={"psram"},
de_pin=41, de_pin=41,
hsync_pin=39, hsync_pin=39,
vsync_pin=40, vsync_pin=40,
@@ -28,7 +27,6 @@ sunton = DriverChip(
sunton.extend( sunton.extend(
"ESP32-8048S050", "ESP32-8048S050",
swap_xy=UNDEFINED,
initsequence=(), initsequence=(),
width=800, width=800,
height=480, height=480,
@@ -1,18 +1,18 @@
from esphome.components.mipi import DriverChip, delay from esphome.components.mipi import delay
from esphome.config_validation import UNDEFINED
from . import RgbDriverChip
from .st7701s import st7701s from .st7701s import st7701s
# fmt: off # fmt: off
wave_4_3 = DriverChip( wave_4_3 = RgbDriverChip(
"ESP32-S3-TOUCH-LCD-4.3", "ESP32-S3-TOUCH-LCD-4.3",
swap_xy=UNDEFINED,
initsequence=(), initsequence=(),
width=800, width=800,
height=480, height=480,
pclk_frequency="16MHz", pclk_frequency="16MHz",
reset_pin={"ch422g": None, "number": 3}, reset_pin={"ch422g": None, "number": 3},
enable_pin={"ch422g": None, "number": 2}, enable_pin={"ch422g": None, "number": 2},
requires={"psram", "ch422g"},
de_pin=5, de_pin=5,
hsync_pin={"number": 46, "ignore_strapping_warning": True}, hsync_pin={"number": 46, "ignore_strapping_warning": True},
vsync_pin={"number": 3, "ignore_strapping_warning": True}, vsync_pin={"number": 3, "ignore_strapping_warning": True},
@@ -69,6 +69,7 @@ st7701s.extend(
pclk_pin=41, pclk_pin=41,
pclk_frequency="12MHz", pclk_frequency="12MHz",
pclk_inverted=False, pclk_inverted=False,
requires={"psram"},
data_pins={ data_pins={
"red": [46, 3, 8, 18, 17], "red": [46, 3, 8, 18, 17],
"green": [14, 13, 12, 11, 10, 9], "green": [14, 13, 12, 11, 10, 9],
@@ -80,6 +81,7 @@ st7701s.extend(
"WAVESHARE-3.16-320X820", "WAVESHARE-3.16-320X820",
width=320, width=320,
height=820, height=820,
requires={"psram"},
de_pin=40, de_pin=40,
hsync_pin=38, hsync_pin=38,
vsync_pin=39, vsync_pin=39,
+3 -14
View File
@@ -41,14 +41,11 @@ from esphome.const import (
CONF_DATA_RATE, CONF_DATA_RATE,
CONF_DC_PIN, CONF_DC_PIN,
CONF_DIMENSIONS, CONF_DIMENSIONS,
CONF_DISABLED,
CONF_ENABLE_PIN, CONF_ENABLE_PIN,
CONF_ID, CONF_ID,
CONF_INIT_SEQUENCE, CONF_INIT_SEQUENCE,
CONF_INVERT_COLORS, CONF_INVERT_COLORS,
CONF_LAMBDA, CONF_LAMBDA,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL, CONF_MODEL,
CONF_RESET_PIN, CONF_RESET_PIN,
CONF_ROTATION, CONF_ROTATION,
@@ -138,16 +135,7 @@ def denominator(config):
def model_schema(config): def model_schema(config):
model = MODELS[config[CONF_MODEL]] model = MODELS[config[CONF_MODEL]]
bus_mode = config[CONF_BUS_MODE] bus_mode = config[CONF_BUS_MODE]
transform = cv.Any( transform = model.transform_schema()
cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
**model.swap_xy_schema(),
}
),
cv.one_of(CONF_DISABLED, lower=True),
)
# CUSTOM model will need to provide a custom init sequence # CUSTOM model will need to provide a custom init sequence
iseqconf = ( iseqconf = (
cv.Required(CONF_INIT_SEQUENCE) cv.Required(CONF_INIT_SEQUENCE)
@@ -265,6 +253,7 @@ def customise_schema(config):
extra=ALLOW_EXTRA, extra=ALLOW_EXTRA,
)(config) )(config)
model = MODELS[config[CONF_MODEL]] model = MODELS[config[CONF_MODEL]]
model.check_requirements()
bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL) bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL)
config = cv.Schema( config = cv.Schema(
{ {
@@ -408,7 +397,7 @@ def get_instance(config):
async def to_code(config): async def to_code(config):
model = MODELS[config[CONF_MODEL]] model = MODELS[config[CONF_MODEL]]
var_id = config[CONF_ID] var_id = config[CONF_ID]
init_sequence = model.get_sequence(config, False) init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True)
var_id.type, templateargs = get_instance(config) var_id.type, templateargs = get_instance(config)
var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs))
cg.add(var.set_init_sequence(init_sequence)) cg.add(var.set_init_sequence(init_sequence))
+6 -19
View File
@@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi";
// Maximum bytes to log for commands (truncated if larger) // Maximum bytes to log for commands (truncated if larger)
static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64;
// Command codes for MIPI SPI displays. Not all currently used, kept here for reference.
static constexpr uint8_t SW_RESET_CMD = 0x01; static constexpr uint8_t SW_RESET_CMD = 0x01;
static constexpr uint8_t SLEEP_OUT = 0x11; static constexpr uint8_t SLEEP_OUT = 0x11;
static constexpr uint8_t NORON = 0x13; static constexpr uint8_t NORON = 0x13;
@@ -151,14 +153,11 @@ class MipiSpi : public display::Display,
this->reset_pin_->digital_write(false); this->reset_pin_->digital_write(false);
delay(5); delay(5);
this->reset_pin_->digital_write(true); this->reset_pin_->digital_write(true);
} else { // required delay after reset is already in the init sequence, don't duplicate
// no reset pin, send software reset command
this->write_command_(SW_RESET_CMD);
} }
// need to know when the display is ready for SLPOUT command - will be 120ms after reset // need to know when the display is ready for SLPOUT command - will be 120ms after reset
auto when = millis() + 120; auto when = millis() + 120;
delay(10);
size_t index = 0; size_t index = 0;
auto &vec = this->init_sequence_; auto &vec = this->init_sequence_;
while (index != vec.size()) { while (index != vec.size()) {
@@ -170,6 +169,9 @@ class MipiSpi : public display::Display,
uint8_t cmd = vec[index++]; uint8_t cmd = vec[index++];
uint8_t x = vec[index++]; uint8_t x = vec[index++];
if (x == DELAY_FLAG) { if (x == DELAY_FLAG) {
if (cmd == 0) {
cmd = clamp_at_least((int) (when - millis()), 0);
}
esph_log_d(TAG, "Delay %dms", cmd); esph_log_d(TAG, "Delay %dms", cmd);
delay(cmd); delay(cmd);
} else { } else {
@@ -179,24 +181,9 @@ class MipiSpi : public display::Display,
this->mark_failed(); this->mark_failed();
return; return;
} }
switch (cmd) {
case SLEEP_OUT: {
// are we ready, boots?
int duration = when - millis();
if (duration > 0) {
esph_log_d(TAG, "Sleep %dms", duration);
delay(duration);
}
} break;
default:
break;
}
const auto *ptr = vec.data() + index; const auto *ptr = vec.data() + index;
this->write_command_(cmd, ptr, num_args); this->write_command_(cmd, ptr, num_args);
index += num_args; index += num_args;
if (cmd == SLEEP_OUT)
delay(10);
} }
} }
this->reset_params_(); this->reset_params_();
@@ -13,6 +13,7 @@ ST7789V.extend(
mirror_x=True, mirror_x=True,
mirror_y=True, mirror_y=True,
data_rate="80MHz", data_rate="80MHz",
requires={"psram"},
) )
ST7789V.extend( ST7789V.extend(
@@ -25,4 +26,5 @@ ST7789V.extend(
dc_pin=39, dc_pin=39,
reset_pin=40, reset_pin=40,
invert_colors=True, invert_colors=True,
requires={"psram"},
) )
+5 -2
View File
@@ -16,7 +16,7 @@ from esphome.components.mipi import (
delay, delay,
) )
from esphome.components.spi import TYPE_QUAD from esphome.components.spi import TYPE_QUAD
from esphome.config_validation import UNDEFINED from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y
DriverChip( DriverChip(
"T-DISPLAY-S3-AMOLED", "T-DISPLAY-S3-AMOLED",
@@ -29,6 +29,7 @@ DriverChip(
brightness=0xD0, brightness=0xD0,
color_order=MODE_RGB, color_order=MODE_RGB,
no_slpout=True, # SLPOUT is in the init sequence, early no_slpout=True, # SLPOUT is in the init sequence, early
requires={"psram"},
initsequence=(SLPOUT,), initsequence=(SLPOUT,),
) )
@@ -43,6 +44,7 @@ DriverChip(
data_rate="40MHz", data_rate="40MHz",
brightness=0xD0, brightness=0xD0,
color_order=MODE_RGB, color_order=MODE_RGB,
requires={"psram"},
initsequence=( initsequence=(
(PAGESEL, 4), (PAGESEL, 4),
(0x6A, 0x00), (0x6A, 0x00),
@@ -90,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend(
reset_pin=13, reset_pin=13,
enable_pin=9, enable_pin=9,
bus_mode=TYPE_QUAD, bus_mode=TYPE_QUAD,
requires={"psram"},
) )
CO5300 = DriverChip( CO5300 = DriverChip(
@@ -98,7 +101,7 @@ CO5300 = DriverChip(
color_order=MODE_RGB, color_order=MODE_RGB,
bus_mode=TYPE_QUAD, bus_mode=TYPE_QUAD,
no_slpout=True, no_slpout=True,
swap_xy=UNDEFINED, transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
width=480, width=480,
height=480, height=480,
initsequence=( initsequence=(
@@ -314,6 +314,7 @@ DriverChip(
data_rate="40MHz", data_rate="40MHz",
dc_pin=4, dc_pin=4,
cs_pin=5, cs_pin=5,
requires={"psram"},
# reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48}, # reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48},
initsequence=( initsequence=(
(0xEF, 0x03, 0x80, 0x02), (0xEF, 0x03, 0x80, 0x02),
@@ -379,6 +380,7 @@ DriverChip(
cs_pin=5, cs_pin=5,
dc_pin=4, dc_pin=4,
reset_pin=48, reset_pin=48,
requires={"psram"},
initsequence=( initsequence=(
(0xEF, 0x03, 0x80, 0x02), (0xEF, 0x03, 0x80, 0x02),
(0xCF, 0x00, 0xC1, 0x30), (0xCF, 0x00, 0xC1, 0x30),
@@ -711,6 +713,7 @@ ST7796.extend(
reset_pin=4, reset_pin=4,
dc_pin={"number": 0, "ignore_strapping_warning": True}, dc_pin={"number": 0, "ignore_strapping_warning": True},
invert_colors=True, invert_colors=True,
requires={"psram"},
) )
ST7789V.extend( ST7789V.extend(
+12 -3
View File
@@ -1,14 +1,19 @@
from esphome.components.mipi import MODE_RGB, DriverChip from esphome.components.mipi import MODE_RGB, DriverChip
from esphome.components.spi import TYPE_QUAD from esphome.components.spi import TYPE_QUAD
import esphome.config_validation as cv from esphome.const import (
from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER CONF_IGNORE_STRAPPING_WARNING,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_NUMBER,
)
AXS15231 = DriverChip( AXS15231 = DriverChip(
"AXS15231", "AXS15231",
draw_rounding=8, draw_rounding=8,
swap_xy=cv.UNDEFINED, transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
color_order=MODE_RGB, color_order=MODE_RGB,
bus_mode=TYPE_QUAD, bus_mode=TYPE_QUAD,
no_swreset=True,
initsequence=( initsequence=(
(0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5),
(0xC1, 0x33), (0xC1, 0x33),
@@ -22,6 +27,7 @@ AXS15231.extend(
height=480, height=480,
cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True}, cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True},
data_rate="40MHz", data_rate="40MHz",
requires={"psram"},
) )
DriverChip( DriverChip(
@@ -36,6 +42,7 @@ DriverChip(
color_order=MODE_RGB, color_order=MODE_RGB,
bus_mode=TYPE_QUAD, bus_mode=TYPE_QUAD,
data_rate="40MHz", data_rate="40MHz",
requires={"psram"},
initsequence=( initsequence=(
(0xF0, 0x08), (0xF0, 0x08),
(0xF2, 0x08), (0xF2, 0x08),
@@ -267,6 +274,7 @@ DriverChip(
color_order=MODE_RGB, color_order=MODE_RGB,
bus_mode=TYPE_QUAD, bus_mode=TYPE_QUAD,
data_rate="40MHz", data_rate="40MHz",
requires={"psram"},
initsequence=( initsequence=(
(0xF0, 0x28), (0xF0, 0x28),
(0xF2, 0x28), (0xF2, 0x28),
@@ -495,6 +503,7 @@ DriverChip(
color_order=MODE_RGB, color_order=MODE_RGB,
bus_mode=TYPE_QUAD, bus_mode=TYPE_QUAD,
data_rate="20MHz", data_rate="20MHz",
requires={"psram"},
initsequence=( initsequence=(
(0xFF, 0xA5), (0xFF, 0xA5),
(0x41, 0x03), (0x41, 0x03),
@@ -10,4 +10,5 @@ ST7789V.extend(
cs_pin=22, cs_pin=22,
dc_pin=21, dc_pin=21,
reset_pin=18, reset_pin=18,
requires={"psram"},
) )
@@ -15,6 +15,7 @@ ST7789V.extend(
dc_pin=13, dc_pin=13,
reset_pin=9, reset_pin=9,
data_rate="80MHz", data_rate="80MHz",
requires={"psram"},
) )
ST7789V.extend( ST7789V.extend(
@@ -42,6 +43,7 @@ ST7789V.extend(
enable_pin=[9, 15], enable_pin=[9, 15],
data_rate="10MHz", data_rate="10MHz",
bus_mode=TYPE_OCTAL, bus_mode=TYPE_OCTAL,
requires={"psram"},
) )
ST7796.extend( ST7796.extend(
@@ -55,4 +57,5 @@ ST7796.extend(
dc_pin=9, dc_pin=9,
backlight_pin=48, backlight_pin=48,
invert_colors=True, invert_colors=True,
requires={"psram"},
) )
@@ -49,6 +49,7 @@ ILI9341.extend(
invert_colors=True, invert_colors=True,
pixel_mode="18bit", pixel_mode="18bit",
data_rate="40MHz", data_rate="40MHz",
requires={"psram"},
) )
GC9107 = ST7789V.extend( GC9107 = ST7789V.extend(
@@ -68,4 +69,5 @@ GC9107.extend(
reset_pin=48, reset_pin=48,
dc_pin=42, dc_pin=42,
cs_pin=14, cs_pin=14,
requires={"psram"},
) )
@@ -12,7 +12,7 @@ from esphome.components.mipi import (
PWSET, PWSET,
DriverChip, DriverChip,
) )
import esphome.config_validation as cv from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y
from .amoled import CO5300 from .amoled import CO5300
from .ili import ILI9488_A, ST7789V from .ili import ILI9488_A, ST7789V
@@ -155,7 +155,7 @@ ST7789P = DriverChip(
ILI9488_A.extend( ILI9488_A.extend(
"PICO-RESTOUCH-LCD-3.5", "PICO-RESTOUCH-LCD-3.5",
swap_xy=cv.UNDEFINED, transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
spi_16=True, spi_16=True,
pixel_mode="16bit", pixel_mode="16bit",
mirror_x=True, mirror_x=True,
@@ -175,6 +175,7 @@ CO5300.extend(
offset_width=6, offset_width=6,
cs_pin=12, cs_pin=12,
reset_pin=39, reset_pin=39,
requires={"psram"},
) )
# Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) # Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller)
@@ -189,6 +190,7 @@ CO5300.extend(
cs_pin=12, cs_pin=12,
reset_pin=39, reset_pin=39,
data_rate="40MHz", data_rate="40MHz",
requires={"psram"},
) )
AXS15231.extend( AXS15231.extend(
@@ -198,6 +200,7 @@ AXS15231.extend(
data_rate="80MHz", data_rate="80MHz",
cs_pin=9, cs_pin=9,
reset_pin=21, reset_pin=21,
requires={"psram"},
) )
# Waveshare 1.83-v2 # Waveshare 1.83-v2
@@ -281,6 +284,7 @@ ST7789V.extend(
offset_height=40, offset_height=40,
invert_colors=True, invert_colors=True,
data_rate="40MHz", data_rate="40MHz",
requires={"psram"},
) )
CO5300.extend( CO5300.extend(
@@ -291,4 +295,5 @@ CO5300.extend(
cs_pin=9, cs_pin=9,
reset_pin=21, reset_pin=21,
enable_pin=1, enable_pin=1,
requires={"psram"},
) )
+9 -3
View File
@@ -212,9 +212,15 @@ _NUMBER_SCHEMA = (
}, },
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
), ),
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, cv.Optional(
cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True), CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
cv.Optional(CONF_DEVICE_CLASS): validate_device_class, ): validate_unit_of_measurement,
cv.Optional(
CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED
): cv.enum(NUMBER_MODES, upper=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
} }
) )
) )
+19 -7
View File
@@ -321,13 +321,25 @@ _SENSOR_SCHEMA = (
{ {
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent),
cv.GenerateID(): cv.declare_id(Sensor), cv.GenerateID(): cv.declare_id(Sensor),
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, cv.Optional(
cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals, CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
cv.Optional(CONF_DEVICE_CLASS): validate_device_class, ): validate_unit_of_measurement,
cv.Optional(CONF_STATE_CLASS): validate_state_class, cv.Optional(
cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category, CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, ): validate_accuracy_decimals,
cv.Optional(CONF_EXPIRE_AFTER): cv.All( cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(
CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_state_class,
cv.Optional(
CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED
): sensor_entity_category,
cv.Optional(
CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED
): cv.boolean,
cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All(
cv.requires_component("mqtt"), cv.requires_component("mqtt"),
cv.Any(None, cv.positive_time_period_milliseconds), cv.Any(None, cv.positive_time_period_milliseconds),
), ),
+3 -1
View File
@@ -78,7 +78,9 @@ _SWITCH_SCHEMA = (
cv.Optional(CONF_ON_STATE): automation.validate_automation({}), cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}),
cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
} }
) )
) )
+3 -1
View File
@@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = (
{ {
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor),
cv.GenerateID(): cv.declare_id(TextSensor), cv.GenerateID(): cv.declare_id(TextSensor),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
+6 -2
View File
@@ -54,7 +54,9 @@ _UPDATE_SCHEMA = (
.extend( .extend(
{ {
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation( cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation(
single=True single=True
), ),
@@ -136,7 +138,9 @@ async def to_code(config):
automation.maybe_simple_id( automation.maybe_simple_id(
{ {
cv.GenerateID(): cv.use_id(UpdateEntity), cv.GenerateID(): cv.use_id(UpdateEntity),
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean), cv.Optional(
CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED
): cv.templatable(cv.boolean),
} }
), ),
synchronous=True, synchronous=True,
+1 -1
View File
@@ -160,7 +160,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) {
} }
uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE);
memcpy(chunk->data, data, chunk_len); memcpy(chunk->data, data, chunk_len);
chunk->length = static_cast<uint8_t>(chunk_len); chunk->length = chunk_len;
// Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if
// allocate() returned non-null, the queue cannot be full. // allocate() returned non-null, the queue cannot be full.
this->output_queue_.push(chunk); this->output_queue_.push(chunk);
+3 -1
View File
@@ -87,7 +87,9 @@ _VALVE_SCHEMA = (
{ {
cv.GenerateID(): cv.declare_id(Valve), cv.GenerateID(): cv.declare_id(Valve),
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.subscribe_topic cv.requires_component("mqtt"), cv.subscribe_topic
), ),
+3 -1
View File
@@ -172,7 +172,9 @@ sorting_group = {
WEBSERVER_SORTING_SCHEMA = cv.Schema( WEBSERVER_SORTING_SCHEMA = cv.Schema(
{ {
cv.Optional(CONF_WEB_SERVER): cv.Schema( # The per-entity web_server block is cosmetic dashboard ordering —
# mark the whole block advanced; the children inherit via the cascade.
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
{ {
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer), cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer),
cv.Optional(CONF_SORTING_WEIGHT): cv.All( cv.Optional(CONF_SORTING_WEIGHT): cv.All(
+22 -30
View File
@@ -56,9 +56,8 @@ namespace esphome::web_server {
static const char *const TAG = "web_server"; static const char *const TAG = "web_server";
// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266.
static constexpr size_t PSTR_LOCAL_SIZE = 18; [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast<ProgmemStr>(s); }
#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1)
// Parse URL and return match info // Parse URL and return match info
// URL formats (disambiguated by HTTP method for 3-segment case): // URL formats (disambiguated by HTTP method for 3-segment case):
@@ -578,9 +577,9 @@ static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix
root[ESPHOME_F("value")] = value; root[ESPHOME_F("value")] = value;
} }
template<typename T> template<typename S, typename T>
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state, static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value,
const T &value, JsonDetail start_config) { JsonDetail start_config) {
set_json_value(root, obj, prefix, value, start_config); set_json_value(root, obj, prefix, value, start_config);
root[ESPHOME_F("state")] = state; root[ESPHOME_F("state")] = state;
} }
@@ -1073,8 +1072,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail
set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position,
start_config); start_config);
char buf[PSTR_LOCAL_SIZE]; root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation));
root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation));
if (obj->get_traits().get_supports_position()) if (obj->get_traits().get_supports_position())
root[ESPHOME_F("position")] = obj->position; root[ESPHOME_F("position")] = obj->position;
@@ -1530,17 +1528,16 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
const auto traits = obj->get_traits(); const auto traits = obj->get_traits();
int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals();
int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals();
char buf[PSTR_LOCAL_SIZE];
char temp_buf[VALUE_ACCURACY_MAX_LEN]; char temp_buf[VALUE_ACCURACY_MAX_LEN];
if (start_config == DETAIL_ALL) { if (start_config == DETAIL_ALL) {
JsonArray opt = root[ESPHOME_F("modes")].to<JsonArray>(); JsonArray opt = root[ESPHOME_F("modes")].to<JsonArray>();
for (climate::ClimateMode m : traits.get_supported_modes()) for (climate::ClimateMode m : traits.get_supported_modes())
opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); opt.add(json_state_str(climate::climate_mode_to_string(m)));
if (traits.get_supports_fan_modes()) { if (traits.get_supports_fan_modes()) {
JsonArray opt = root[ESPHOME_F("fan_modes")].to<JsonArray>(); JsonArray opt = root[ESPHOME_F("fan_modes")].to<JsonArray>();
for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) for (climate::ClimateFanMode m : traits.get_supported_fan_modes())
opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); opt.add(json_state_str(climate::climate_fan_mode_to_string(m)));
} }
if (!traits.get_supported_custom_fan_modes().empty()) { if (!traits.get_supported_custom_fan_modes().empty()) {
@@ -1551,12 +1548,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
if (traits.get_supports_swing_modes()) { if (traits.get_supports_swing_modes()) {
JsonArray opt = root[ESPHOME_F("swing_modes")].to<JsonArray>(); JsonArray opt = root[ESPHOME_F("swing_modes")].to<JsonArray>();
for (auto swing_mode : traits.get_supported_swing_modes()) for (auto swing_mode : traits.get_supported_swing_modes())
opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); opt.add(json_state_str(climate::climate_swing_mode_to_string(swing_mode)));
} }
if (traits.get_supports_presets()) { if (traits.get_supports_presets()) {
JsonArray opt = root[ESPHOME_F("presets")].to<JsonArray>(); JsonArray opt = root[ESPHOME_F("presets")].to<JsonArray>();
for (climate::ClimatePreset m : traits.get_supported_presets()) for (climate::ClimatePreset m : traits.get_supported_presets())
opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); opt.add(json_state_str(climate::climate_preset_to_string(m)));
} }
if (!traits.get_supported_custom_presets().empty()) { if (!traits.get_supported_custom_presets().empty()) {
JsonArray opt = root[ESPHOME_F("custom_presets")].to<JsonArray>(); JsonArray opt = root[ESPHOME_F("custom_presets")].to<JsonArray>();
@@ -1572,26 +1569,26 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
} }
bool has_state = false; bool has_state = false;
root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); root[ESPHOME_F("mode")] = json_state_str(climate_mode_to_string(obj->mode));
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); root[ESPHOME_F("action")] = json_state_str(climate_action_to_string(obj->action));
root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; root[ESPHOME_F("state")] = root[ESPHOME_F("action")];
has_state = true; has_state = true;
} }
if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) {
root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value()));
} }
if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) {
root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode();
} }
if (traits.get_supports_presets() && obj->preset.has_value()) { if (traits.get_supports_presets() && obj->preset.has_value()) {
root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value()));
} }
if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) {
root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); root[ESPHOME_F("custom_preset")] = obj->get_custom_preset();
} }
if (traits.get_supports_swing_modes()) { if (traits.get_supports_swing_modes()) {
root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode));
} }
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
root[ESPHOME_F("current_temperature")] = root[ESPHOME_F("current_temperature")] =
@@ -1695,8 +1692,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta
json::JsonBuilder builder; json::JsonBuilder builder;
JsonObject root = builder.root(); JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE]; set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config);
set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config);
if (start_config == DETAIL_ALL) { if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj); this->add_sorting_info_(root, obj);
} }
@@ -1777,8 +1773,7 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail
set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position,
start_config); start_config);
char buf[PSTR_LOCAL_SIZE]; root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation));
root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation));
if (obj->get_traits().get_supports_position()) if (obj->get_traits().get_supports_position())
root[ESPHOME_F("position")] = obj->position; root[ESPHOME_F("position")] = obj->position;
@@ -1863,9 +1858,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p
json::JsonBuilder builder; json::JsonBuilder builder;
JsonObject root = builder.root(); JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE]; set_json_icon_state_value(root, obj, "alarm-control-panel",
set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), json_state_str(alarm_control_panel_state_to_string(value)), value, start_config);
value, start_config);
if (start_config == DETAIL_ALL) { if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj); this->add_sorting_info_(root, obj);
} }
@@ -1937,10 +1931,9 @@ json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer
json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) {
json::JsonBuilder builder; json::JsonBuilder builder;
JsonObject root = builder.root(); JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE];
const auto mode = obj->get_mode(); const auto mode = obj->get_mode();
const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode)); ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode));
set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config);
@@ -1949,7 +1942,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea
if (start_config == DETAIL_ALL) { if (start_config == DETAIL_ALL) {
JsonArray modes = root[ESPHOME_F("modes")].to<JsonArray>(); JsonArray modes = root[ESPHOME_F("modes")].to<JsonArray>();
for (auto m : traits.get_supported_modes()) for (auto m : traits.get_supported_modes())
modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m))); modes.add(json_state_str(water_heater::water_heater_mode_to_string(m)));
root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("min_temp")] = traits.get_min_temperature();
root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature();
root[ESPHOME_F("step")] = traits.get_target_temperature_step(); root[ESPHOME_F("step")] = traits.get_target_temperature_step();
@@ -2277,8 +2270,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J
json::JsonBuilder builder; json::JsonBuilder builder;
JsonObject root = builder.root(); JsonObject root = builder.root();
char buf[PSTR_LOCAL_SIZE]; set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)),
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)),
obj->update_info.latest_version, start_config); obj->update_info.latest_version, start_config);
if (start_config == DETAIL_ALL) { if (start_config == DETAIL_ALL) {
root[ESPHOME_F("current_version")] = obj->update_info.current_version; root[ESPHOME_F("current_version")] = obj->update_info.current_version;
@@ -50,25 +50,6 @@ void ZigbeeAttribute::report_(bool has_lock) {
} }
} }
void ZigbeeAttribute::setup_reporting() {
ezb_zcl_reporting_info_t reporting_info = ezb_zcl_reporting_info_find(
this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE);
if (reporting_info == EZB_ZCL_INVALID_REPORTING_INFO) {
ESP_LOGD(TAG, "Could not find reporting info for attribute 0x%04X in cluster 0x%04X in endpoint %u", this->attr_id_,
this->cluster_id_, this->endpoint_id_);
this->report_enabled = false;
this->force_report_ = false;
} else {
ESP_LOGD(TAG, "Found reporting info for attr 0x%04X in cluster 0x%04X", this->attr_id_, this->cluster_id_);
ezb_zcl_attr_variable_t delta = {.u64 = 0};
ezb_zcl_reporting_info_update_default_interval(reporting_info, 0, 65000);
ezb_zcl_reporting_info_update(reporting_info, 0, 65000, &delta);
if (ezb_zcl_reporting_start_attr_report(reporting_info) != EZB_ERR_NONE) {
ESP_LOGE(TAG, "Could not start reporting for attribute");
}
}
}
void ZigbeeAttribute::set_report(ZigbeeReportT report) { void ZigbeeAttribute::set_report(ZigbeeReportT report) {
this->report_enabled = true; this->report_enabled = true;
if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) {
@@ -42,7 +42,6 @@ class ZigbeeAttribute final : public Component {
scale_(scale) {} scale_(scale) {}
void loop() override; void loop() override;
template<typename T> void add_attr(T value); template<typename T> void add_attr(T value);
void setup_reporting();
template<typename T> void set_attr(const T &value); template<typename T> void set_attr(const T &value);
uint8_t attr_type() { return attr_type_; } uint8_t attr_type() { return attr_type_; }
void set_report(ZigbeeReportT report); void set_report(ZigbeeReportT report);
+63 -45
View File
@@ -94,66 +94,73 @@ def get_next_ep_num(eps: list[int]) -> int:
return ep_num return ep_num
def merge_endpoint( def compare_clusters(
existing_ep: dict[str, Any], existing_ep: dict[str, Any],
ep_num: int | None,
ep: dict[str, Any], ep: dict[str, Any],
use_type: bool | None, ) -> tuple[str | int, str] | None:
skip_error: bool,
) -> bool:
add = True
existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]]
for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]:
if cl in existing_clusters: if cl in existing_clusters:
if not skip_error: return cl
raise cv.Invalid( return None
f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}."
)
add = False def merge_endpoints(
break existing_ep: dict[str, Any],
if not add: ep: dict[str, Any],
use_type: bool | None,
) -> bool:
if compare_clusters(existing_ep, ep):
return False return False
if (
use_type
and existing_ep.get(CONF_USE_DEVICE_TYPE)
and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE)
):
if not skip_error:
raise cv.Invalid(
f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both."
)
return False
if use_type:
existing_ep[CONF_USE_DEVICE_TYPE] = use_type
if ep.get(DEVICE_TYPE):
existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE]
else:
existing_ep.pop(DEVICE_TYPE, None)
existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS])
return True
if existing_ep.get(CONF_USE_DEVICE_TYPE):
existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS])
return True
if ( if (
ep.get(DEVICE_TYPE) ep.get(DEVICE_TYPE)
and existing_ep.get(DEVICE_TYPE) and existing_ep.get(DEVICE_TYPE)
and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE] and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE)
): ):
if not skip_error:
raise cv.Invalid(
f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both."
)
return False return False
if (
ep.get(DEVICE_TYPE)
and not existing_ep.get(DEVICE_TYPE)
and existing_ep.get(CONF_USE_DEVICE_TYPE)
):
return False
if existing_ep.get(DEVICE_TYPE) and not ep.get(DEVICE_TYPE) and use_type:
return False
if use_type:
existing_ep[CONF_USE_DEVICE_TYPE] = use_type
if ep.get(DEVICE_TYPE): if ep.get(DEVICE_TYPE):
existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE]
existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS])
return True return True
def validate_endpoints(ep_dict: dict[int, dict]) -> None:
for num, ep in ep_dict.items():
types_dict = ep.get(CONF_USE_DEVICE_TYPE)
if not types_dict:
continue
if len(types_dict) == 1:
ep[DEVICE_TYPE] = list(types_dict.keys())[0]
del ep[CONF_USE_DEVICE_TYPE]
continue
types_list = [t[0] for t in types_dict.items() if t[1]]
if len(types_list) > 1:
raise cv.Invalid(
f"There is more than one component with endpoint: {num} and {CONF_USE_DEVICE_TYPE}: True"
)
if not types_list:
raise cv.Invalid(
f"Multiple device types on endpoint: {num}. Set {CONF_USE_DEVICE_TYPE}: True on one component."
)
ep[DEVICE_TYPE] = types_list[0]
del ep[CONF_USE_DEVICE_TYPE]
def create_ep(router: bool) -> None: def create_ep(router: bool) -> None:
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {})
ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, [])
validate_endpoints(ep_dict)
# create dummy endpoint if list is empty # create dummy endpoint if list is empty
if not ep_dict and not ep_list: if not ep_dict and not ep_list:
ep_type = "CUSTOM_ATTR" ep_type = "CUSTOM_ATTR"
@@ -166,9 +173,7 @@ def create_ep(router: bool) -> None:
for ep in ep_list: for ep in ep_list:
added = False added = False
for existing_ep in ep_list_new: for existing_ep in ep_list_new:
if merge_endpoint( if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)):
existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True
):
added = True added = True
break break
if not added: if not added:
@@ -191,6 +196,8 @@ def create_ep(router: bool) -> None:
def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None:
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
if use_type is False:
ep.pop(DEVICE_TYPE, None)
if ep_num is None: if ep_num is None:
if use_type: if use_type:
ep[CONF_USE_DEVICE_TYPE] = use_type ep[CONF_USE_DEVICE_TYPE] = use_type
@@ -201,8 +208,19 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non
if ep_num in ep_dict: if ep_num in ep_dict:
# check if the existing endpoint has same clusters # check if the existing endpoint has same clusters
existing_ep = ep_dict[ep_num] existing_ep = ep_dict[ep_num]
merge_endpoint(existing_ep, ep_num, ep, use_type, False) if cl := compare_clusters(
existing_ep,
ep,
):
raise cv.Invalid(
f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}."
)
if ep.get(DEVICE_TYPE) or use_type:
types_dict = existing_ep.setdefault(CONF_USE_DEVICE_TYPE, {})
if not types_dict.get(ep.get(DEVICE_TYPE)) or use_type:
types_dict[ep.get(DEVICE_TYPE)] = use_type
existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS])
else: else:
if use_type is not None: if use_type or ep.get(DEVICE_TYPE):
ep[CONF_USE_DEVICE_TYPE] = use_type ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type}
ep_dict[ep_num] = ep ep_dict[ep_num] = ep
+21 -21
View File
@@ -53,11 +53,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) {
switch (signal_type) { switch (signal_type) {
case EZB_ZDO_SIGNAL_SKIP_STARTUP: case EZB_ZDO_SIGNAL_SKIP_STARTUP:
ESP_LOGD(TAG, "Zigbee stack initialized"); ESP_LOGD(TAG, "Zigbee stack initialized");
if (ezb_bdb_is_factory_new()) { ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION);
global_zigbee->defer([]() { global_zigbee->setup_reporting(); });
} else {
ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION);
}
break; break;
case EZB_BDB_SIGNAL_DEVICE_FIRST_START: case EZB_BDB_SIGNAL_DEVICE_FIRST_START:
case EZB_BDB_SIGNAL_DEVICE_REBOOT: { case EZB_BDB_SIGNAL_DEVICE_REBOOT: {
@@ -133,12 +129,12 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi
case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID:
zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message);
break; break;
#ifdef ESPHOME_LOG_HAS_VERBOSE
case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: {
#ifdef ESPHOME_LOG_HAS_VERBOSE
ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message;
ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code);
} break;
#endif #endif
} break;
default: default:
ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast<unsigned>(callback_id)); ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast<unsigned>(callback_id));
break; break;
@@ -206,21 +202,30 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) {
ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc);
} }
void ZigbeeComponent::setup_reporting() { bool ZigbeeComponent::register_device() {
ESP_LOGD(TAG, "Setting up reporting for all attributes"); if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) {
esp_zigbee_lock_acquire(portMAX_DELAY); ESP_LOGE(TAG, "Could not register the endpoint list");
for (auto &[_, attribute] : this->attributes_) { this->mark_failed();
attribute->setup_reporting(); return false;
} }
ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); return true;
esp_zigbee_lock_release();
} }
static void ezb_task(void *pv_parameters) { static void ezb_task(void *pv_parameters) {
if (!global_zigbee->register_device()) {
vTaskDelete(NULL);
return;
}
if (esp_zigbee_start(false) != ESP_OK) { if (esp_zigbee_start(false) != ESP_OK) {
ESP_LOGE(TAG, "Could not setup Zigbee"); ESP_LOGE(TAG, "Could not setup Zigbee");
global_zigbee->mark_failed();
vTaskDelete(NULL); vTaskDelete(NULL);
return; // vTaskDelete(NULL) never returns, but keep intent explicit
} }
// Increase priority to 5 to align with openthread or BLE
vTaskPrioritySet(NULL, 5);
esp_zigbee_launch_mainloop(); esp_zigbee_launch_mainloop();
esp_zigbee_deinit(); esp_zigbee_deinit();
@@ -274,12 +279,6 @@ void ZigbeeComponent::setup() {
return; return;
} }
if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) {
ESP_LOGE(TAG, "Could not register the endpoint list");
this->mark_failed();
return;
}
ezb_zcl_core_action_handler_register(zb_action_handler); ezb_zcl_core_action_handler_register(zb_action_handler);
if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) {
@@ -298,7 +297,8 @@ void ZigbeeComponent::setup() {
}; };
ezb_af_set_node_power_desc(&desc); ezb_af_set_node_power_desc(&desc);
xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 24, NULL); // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy
xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL);
this->disable_loop(); // loop is only needed for processing events, so disable until we join a network this->disable_loop(); // loop is only needed for processing events, so disable until we join a network
} }
+1 -1
View File
@@ -42,7 +42,7 @@ class ZigbeeComponent final : public Component {
void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source);
void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role);
void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id);
void setup_reporting(); bool register_device();
template<typename T> template<typename T>
void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
+98 -27
View File
@@ -18,13 +18,22 @@ static const char *const TAG = "zwave_proxy";
static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168; static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168;
static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20;
// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] // GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...]
// We only read the home ID, so the node ID (1 byte in 8-bit mode, 2 bytes in 16-bit mode) and
// anything after it are not required to be present
static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value
static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 7; // TYPE + CMD + HOME_ID(4) + checksum
static constexpr uint8_t ZWAVE_MIN_FRAME_LENGTH = 3; // TYPE + CMD + checksum (zero-payload frame)
static constexpr uint32_t ZWAVE_FRAME_TIMEOUT_MS = 1500; // Abandon a frame this long after its start (SOF) byte
static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup
static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect
static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect
static constexpr bool is_bootloader_menu_byte(uint8_t byte) {
// Bootloader menu output is printable ASCII plus CR/LF, ending with a NUL terminator
return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E);
}
static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) {
// Calculate Z-Wave frame checksum // Calculate Z-Wave frame checksum
// XOR all bytes between SOF and checksum position (exclusive) // XOR all bytes between SOF and checksum position (exclusive)
@@ -74,6 +83,11 @@ bool ZWaveProxy::can_proceed() {
const uint32_t now = App.get_loop_component_start_time(); const uint32_t now = App.get_loop_component_start_time();
if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) { if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) {
ESP_LOGW(TAG, "Timeout reading Home ID during setup"); ESP_LOGW(TAG, "Timeout reading Home ID during setup");
// The modem may simply still be booting; keep querying from loop() using the same retry
// machinery as a reconnect. This adds no setup delay — clients are notified of the home ID
// via the HOME_ID_CHANGE message whenever it finally arrives.
this->reconnect_time_ = now;
this->query_retries_ = 0;
return true; // Proceed anyway after timeout return true; // Proceed anyway after timeout
} }
@@ -98,7 +112,18 @@ void ZWaveProxy::loop() {
} }
this->process_uart_(); this->process_uart_();
this->status_clear_warning();
// Abandon a stalled frame reception. The Z-Wave API specification requires a receiver to abort
// a data frame reception lasting more than 1500 ms after the SOF byte, without sending a NAK.
// Without this, the stale bytes would silently corrupt the next frame. Any SEND_* state was
// already resolved by response_handler_() above, so a state other than WAIT_START here always
// means we are mid-frame.
if (this->parsing_state_ != ZWAVE_PARSING_STATE_WAIT_START &&
App.get_loop_component_start_time() - this->frame_start_time_ > ZWAVE_FRAME_TIMEOUT_MS) {
ESP_LOGW(TAG, "Timeout waiting for frame data; resetting parser");
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
this->buffer_index_ = 0;
}
} }
void ZWaveProxy::process_uart_slow_() { void ZWaveProxy::process_uart_slow_() {
@@ -112,19 +137,24 @@ void ZWaveProxy::process_uart_slow_() {
} }
if (this->parse_byte_(byte)) { if (this->parse_byte_(byte)) {
// Check if this is a GET_NETWORK_IDS response frame // Check if this is a GET_NETWORK_IDS response frame
// Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...]
// Bootloader output is excluded up front: a completed bootloader "frame" is menu text, so
// buffer_[1..3] would be meaningless (and possibly never written). Outside bootloader mode,
// the parser guarantees a completed frame starts with SOF, so buffer_[0] needs no check.
// We verify: // We verify:
// - buffer_[0]: Start of frame marker (0x01) // - buffer_[1]: Length field must be >= 7 so the frame contains the full home ID
// - buffer_[1]: Length field must be >= 9 to contain all required data
// - buffer_[2]: Command type (0x01 for response) // - buffer_[2]: Command type (0x01 for response)
// - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS)
if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && if (!this->in_bootloader_ && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH &&
this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS) {
// Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed
// The frame parser has already validated the checksum and ensured all bytes are present // The frame parser has already validated the checksum and ensured all bytes are present
if (this->set_home_id_(&this->buffer_[4])) { if (this->set_home_id_(&this->buffer_[4])) {
char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)];
ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()));
this->send_homeid_changed_msg_(); this->send_homeid_changed_msg_();
} }
this->home_id_ready_ = true;
} }
ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr));
if (this->api_connection_ != nullptr) { if (this->api_connection_ != nullptr) {
@@ -140,14 +170,19 @@ void ZWaveProxy::process_uart_slow_() {
} }
} }
} while (this->available()); } while (this->available());
// Reaching here means every read succeeded, so clear any earlier read-failure warning.
// (An early return on read failure skips this, leaving the warning visible until the
// next successful drain.)
this->status_clear_warning();
} }
void ZWaveProxy::dump_config() { void ZWaveProxy::dump_config() {
char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)];
ESP_LOGCONFIG(TAG, ESP_LOGCONFIG(
"Z-Wave Proxy:\n" TAG,
" Home ID: %s", "Z-Wave Proxy:\n"
format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); " Home ID: %s",
this->home_id_ready_ ? format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()) : "unknown");
} }
void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) {
@@ -160,10 +195,20 @@ void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) {
void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) {
switch (type) { switch (type) {
case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE:
if (this->api_connection_ != nullptr) { if (this->api_connection_ == api_connection) {
ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); ESP_LOGV(TAG, "API connection is already subscribed");
return; return;
} }
if (this->api_connection_ != nullptr) {
// A living subscriber keeps exclusive access. Its connection may be dead without
// loop() having noticed yet (e.g. the client crashed and reconnected quickly);
// in that case let the new client take over instead of locking it out.
if (this->api_connection_->is_connection_setup()) {
ESP_LOGE(TAG, "Only one API subscription is allowed at a time");
return;
}
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
}
this->api_connection_ = api_connection; this->api_connection_ = api_connection;
ESP_LOGV(TAG, "API connection is now subscribed"); ESP_LOGV(TAG, "API connection is now subscribed");
break; break;
@@ -222,6 +267,7 @@ void ZWaveProxy::retry_home_id_query_() {
void ZWaveProxy::clear_home_id_() { void ZWaveProxy::clear_home_id_() {
static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {}; static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {};
if (this->set_home_id_(ZERO_HOME_ID)) { if (this->set_home_id_(ZERO_HOME_ID)) {
ESP_LOGV(TAG, "Home ID cleared");
this->send_homeid_changed_msg_(); this->send_homeid_changed_msg_();
} }
this->home_id_ready_ = false; this->home_id_ready_ = false;
@@ -237,13 +283,20 @@ bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) {
return false; // No change return false; // No change
} }
std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size()); std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size());
char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)];
ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()));
this->home_id_ready_ = true;
return true; // Home ID was changed return true; // Home ID was changed
} }
void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { void ZWaveProxy::send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {
// Only the subscribed client may talk to the Z-Wave module; a frame from any other
// (authenticated but unsubscribed) client would interleave with the subscriber's traffic
if (api_connection != this->api_connection_) {
ESP_LOGW(TAG, "Ignoring frame from unsubscribed client");
return;
}
this->send_frame_(data, length);
}
void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) {
// Safety: validate pointer before any access // Safety: validate pointer before any access
if (data == nullptr) { if (data == nullptr) {
ESP_LOGE(TAG, "Null data pointer"); ESP_LOGE(TAG, "Null data pointer");
@@ -289,7 +342,7 @@ void ZWaveProxy::send_simple_command_(const uint8_t command_id) {
// Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM) // Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM)
uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00}; uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00};
cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd)); cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd));
this->send_frame(cmd, sizeof(cmd)); this->send_frame_(cmd, sizeof(cmd));
} }
bool ZWaveProxy::parse_byte_(uint8_t byte) { bool ZWaveProxy::parse_byte_(uint8_t byte) {
@@ -300,9 +353,12 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) {
this->parse_start_(byte); this->parse_start_(byte);
break; break;
case ZWAVE_PARSING_STATE_WAIT_LENGTH: case ZWAVE_PARSING_STATE_WAIT_LENGTH:
if (!byte) { if (byte < ZWAVE_MIN_FRAME_LENGTH) {
ESP_LOGW(TAG, "Invalid LENGTH: %u", byte); ESP_LOGW(TAG, "Invalid LENGTH: %u", byte);
this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK;
// Send the NAK now; otherwise any bytes already buffered behind this one would be
// silently discarded by the SEND_NAK case below until the next loop() iteration
this->response_handler_();
return false; return false;
} }
ESP_LOGVV(TAG, "Received LENGTH: %u", byte); ESP_LOGVV(TAG, "Received LENGTH: %u", byte);
@@ -319,7 +375,9 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) {
case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID:
this->buffer_[this->buffer_index_++] = byte; this->buffer_[this->buffer_index_++] = byte;
ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte);
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; // A zero-payload frame (LENGTH == 3) has its checksum immediately after the command ID
this->parsing_state_ = this->buffer_index_ >= this->end_frame_after_ ? ZWAVE_PARSING_STATE_WAIT_CHECKSUM
: ZWAVE_PARSING_STATE_WAIT_PAYLOAD;
break; break;
case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: case ZWAVE_PARSING_STATE_WAIT_PAYLOAD:
this->buffer_[this->buffer_index_++] = byte; this->buffer_[this->buffer_index_++] = byte;
@@ -347,12 +405,24 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) {
break; break;
} }
case ZWAVE_PARSING_STATE_READ_BL_MENU: case ZWAVE_PARSING_STATE_READ_BL_MENU:
if (this->buffer_index_ >= this->buffer_.size()) { // This state is tentative (see parse_start_): bootloader mode is committed only when a
// plausible menu — printable text ending in a NUL terminator — completes. A byte that
// cannot be menu text means the 0x0D that started this state was not a menu after all,
// so re-parse that byte as a frame start; it may be the SOF/ACK/NAK of real traffic.
if (this->buffer_index_ >= this->buffer_.size() || !is_bootloader_menu_byte(byte)) {
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
this->parse_start_(byte);
break; break;
} }
this->buffer_[this->buffer_index_++] = byte; this->buffer_[this->buffer_index_++] = byte;
if (!byte) { if (!byte) {
if (!this->in_bootloader_) {
ESP_LOGD(TAG, "Entered bootloader mode");
this->in_bootloader_ = true;
// Reset response deduplication: in bootloader mode, single-byte client writes (XMODEM
// ACK/NAK/CAN) are raw data and must never be suppressed as duplicate responses
this->last_response_ = 0;
}
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
frame_completed = true; frame_completed = true;
} }
@@ -378,15 +448,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) {
ESP_LOGD(TAG, "Exited bootloader mode"); ESP_LOGD(TAG, "Exited bootloader mode");
this->in_bootloader_ = false; this->in_bootloader_ = false;
} }
this->frame_start_time_ = App.get_loop_component_start_time();
this->buffer_[this->buffer_index_++] = byte; this->buffer_[this->buffer_index_++] = byte;
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH;
return; return;
case ZWAVE_FRAME_TYPE_BL_MENU: case ZWAVE_FRAME_TYPE_BL_MENU:
ESP_LOGV(TAG, "Received BL_MENU"); ESP_LOGV(TAG, "Received BL_MENU");
if (!this->in_bootloader_) { // Read the menu tentatively: a stray 0x0D can equally appear in garbled data after the
ESP_LOGD(TAG, "Entered bootloader mode"); // parser loses frame alignment, so bootloader mode is only committed once a plausible
this->in_bootloader_ = true; // menu completes (see READ_BL_MENU handling in parse_byte_)
} this->frame_start_time_ = App.get_loop_component_start_time();
this->buffer_[this->buffer_index_++] = byte; this->buffer_[this->buffer_index_++] = byte;
this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU;
return; return;
@@ -403,7 +474,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) {
ESP_LOGV(TAG, "Received CAN"); ESP_LOGV(TAG, "Received CAN");
break; break;
default: default:
ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); ESP_LOGV(TAG, "Unrecognized START: 0x%02X", byte);
return; return;
} }
// Forward response (ACK/NAK/CAN) back to client for processing // Forward response (ACK/NAK/CAN) back to client for processing
+9 -5
View File
@@ -68,13 +68,16 @@ class ZWaveProxy final : public uart::UARTDevice, public Component {
return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]);
} }
void send_frame(const uint8_t *data, size_t length); // Send a frame from an API client to the Z-Wave module. Frames from any connection other
// than the currently subscribed one are ignored.
void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length);
protected: protected:
bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. void send_frame_(const uint8_t *data, size_t length); // Write a frame to the Z-Wave module
void clear_home_id_(); // Clear home ID and notify API clients bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed.
void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions void clear_home_id_(); // Clear home ID and notify API clients
void retry_home_id_query_(); // Retry home ID query after reconnect void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions
void retry_home_id_query_(); // Retry home ID query after reconnect
void send_homeid_changed_msg_(api::APIConnection *conn = nullptr); void send_homeid_changed_msg_(api::APIConnection *conn = nullptr);
void send_simple_command_(uint8_t command_id); void send_simple_command_(uint8_t command_id);
bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer)
@@ -114,6 +117,7 @@ class ZWaveProxy final : public uart::UARTDevice, public Component {
api::APIConnection *api_connection_{nullptr}; // Current subscribed client api::APIConnection *api_connection_{nullptr}; // Current subscribed client
uint32_t setup_time_{0}; // Time when setup() was called uint32_t setup_time_{0}; // Time when setup() was called
uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query) uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query)
uint32_t frame_start_time_{0}; // Timestamp of the current frame's start byte (reception timeout)
// Small values (grouped by size to minimize padding) // Small values (grouped by size to minimize padding)
uint16_t buffer_index_{0}; // Index for populating the data buffer uint16_t buffer_index_{0}; // Index for populating the data buffer
+68 -34
View File
@@ -292,10 +292,14 @@ class Visibility(StrEnum):
the same way. ESPHome itself ignores the value at runtime; the same way. ESPHome itself ignores the value at runtime;
consumers downstream of the schema dump act on it. consumers downstream of the schema dump act on it.
A field with no ``visibility`` set (the default) renders on the Three points along a single axis of "how prominently to surface
editor's main form. The two values below are points along a this", from least to most hidden:
single axis of "how prominently to surface this":
- ``UI`` always render on the editor's main form. Use to
promote an ``Optional`` that would otherwise fall through to
the advanced disclosure (see the default rule below): the
"headline" config a user reaches for first (e.g. a sensor's
``name`` or its primary pin/address).
- ``ADVANCED`` render under the editor's "advanced settings" - ``ADVANCED`` render under the editor's "advanced settings"
disclosure. Use for fields whose default is right for ~all disclosure. Use for fields whose default is right for ~all
users (e.g. ``update_interval`` on time platforms 15 min is users (e.g. ``update_interval`` on time platforms 15 min is
@@ -307,25 +311,35 @@ class Visibility(StrEnum):
tweaks can break boot). The YAML escape hatch stays tweaks can break boot). The YAML escape hatch stays
available for the rare power-user override. available for the rare power-user override.
The single-axis shape encodes "yaml-only is strictly stronger Default when unset (``visibility=None``): resolved by the
than advanced" at the type level — there's no way to ask for consumer, not encoded on the marker. A schema-aware editor
both at once, and no way to set a contradictory state like treats an ``Optional`` with no setting as ``ADVANCED`` (most
"advanced=False, yaml_only=True". optional knobs have sensible defaults and would clutter the
form), and a ``Required`` with no setting as ``UI`` (a required
field needs the user's attention). Pass an explicit value to
override either default most commonly ``UI`` to keep a
high-value ``Optional`` on the main form.
The single-axis shape encodes the strictness ladder
(``UI`` < ``ADVANCED`` < ``YAML_ONLY``) at the type level
there's no way to set a contradictory state.
Per-field; the dumper walks recursively into nested schemas Per-field; the dumper walks recursively into nested schemas
and emits each field's setting independently. Cascading and emits each field's setting independently, omitting the key
semantics "a stricter parent makes its descendants at-least when unset so the dump stays compact and the per-field default
as strict" — belong on the consumer side: the schema marker is the consumer's to apply. Cascading semantics — "a stricter
is faithfully what the field author wrote, and a consumer that parent makes its descendants at-least as strict" — belong on the
cares about effective visibility walks the parent chain and consumer side: the schema marker is faithfully what the field
takes the strictest setting. ``YAML_ONLY`` is strictly stronger author wrote, and a consumer that cares about effective
than ``ADVANCED``, which is strictly stronger than no setting. visibility walks the parent chain and takes the strictest
Inner fields can declare their own visibility; an inner setting. Inner fields can declare their own visibility; an inner
``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``, ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``,
and the consumer's cascade keeps siblings under the parent at and the consumer's cascade keeps a ``UI`` sibling under an
``ADVANCED`` regardless of their own (less-strict) setting. ``ADVANCED`` parent at ``ADVANCED`` regardless of its own
(less-strict) setting.
""" """
UI = "ui"
ADVANCED = "advanced" ADVANCED = "advanced"
YAML_ONLY = "yaml_only" YAML_ONLY = "yaml_only"
@@ -347,6 +361,9 @@ class Optional(vol.Optional):
See :class:`Visibility` for the ``visibility`` kwarg a UI See :class:`Visibility` for the ``visibility`` kwarg a UI
hint for schema-driven editors that doesn't affect validation. hint for schema-driven editors that doesn't affect validation.
Left unset, an ``Optional`` is treated as ``Visibility.ADVANCED``
by schema-aware editors; pass ``Visibility.UI`` to keep it on the
main form.
""" """
def __init__( def __init__(
@@ -369,9 +386,11 @@ class Required(vol.Required):
See :class:`Visibility` for the ``visibility`` kwarg a UI See :class:`Visibility` for the ``visibility`` kwarg a UI
hint for schema-driven editors that doesn't affect validation. hint for schema-driven editors that doesn't affect validation.
Required fields rarely need it (a required field by definition Required fields rarely need it: left unset, a ``Required`` is
needs the user's attention) but the kwarg is exposed for treated as on the main form (``Visibility.UI``) by schema-aware
symmetry so consumers can apply uniform logic across key markers. editors, since a required field needs the user's attention. The
kwarg is exposed for symmetry so consumers can apply uniform
logic across key markers.
""" """
def __init__( def __init__(
@@ -2274,16 +2293,25 @@ MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema(
} }
) )
# Per-entity MQTT plumbing — integration metadata, never a primary UI field.
MQTT_COMPONENT_SCHEMA = Schema( MQTT_COMPONENT_SCHEMA = Schema(
{ {
Optional(CONF_QOS): All(requires_component("mqtt"), mqtt_qos), Optional(CONF_QOS, visibility=Visibility.ADVANCED): All(
Optional(CONF_RETAIN): All(requires_component("mqtt"), boolean), requires_component("mqtt"), mqtt_qos
Optional(CONF_DISCOVERY): All(requires_component("mqtt"), boolean), ),
Optional(CONF_SUBSCRIBE_QOS): All(requires_component("mqtt"), mqtt_qos), Optional(CONF_RETAIN, visibility=Visibility.ADVANCED): All(
Optional(CONF_STATE_TOPIC): All( requires_component("mqtt"), boolean
),
Optional(CONF_DISCOVERY, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), boolean
),
Optional(CONF_SUBSCRIBE_QOS, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), mqtt_qos
),
Optional(CONF_STATE_TOPIC, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), templatable(publish_topic) requires_component("mqtt"), templatable(publish_topic)
), ),
Optional(CONF_AVAILABILITY): All( Optional(CONF_AVAILABILITY, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA) requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA)
), ),
} }
@@ -2291,10 +2319,12 @@ MQTT_COMPONENT_SCHEMA = Schema(
MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend( MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend(
{ {
Optional(CONF_COMMAND_TOPIC): All( Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), templatable(subscribe_topic) requires_component("mqtt"), templatable(subscribe_topic)
), ),
Optional(CONF_COMMAND_RETAIN): All(requires_component("mqtt"), boolean), Optional(CONF_COMMAND_RETAIN, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), boolean
),
} }
) )
@@ -2369,12 +2399,16 @@ def string_no_slash(value):
ENTITY_BASE_SCHEMA = Schema( ENTITY_BASE_SCHEMA = Schema(
{ {
Optional(CONF_NAME): _validate_entity_name, # The name is every entity's headline field — keep it on the
Optional(CONF_INTERNAL): boolean, # main form rather than letting it fall through to advanced.
Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, Optional(CONF_NAME, visibility=Visibility.UI): _validate_entity_name,
Optional(CONF_ICON): icon, Optional(CONF_INTERNAL, visibility=Visibility.ADVANCED): boolean,
Optional(CONF_ENTITY_CATEGORY): entity_category, Optional(
Optional(CONF_DEVICE_ID): sub_device_id, CONF_DISABLED_BY_DEFAULT, default=False, visibility=Visibility.ADVANCED
): boolean,
Optional(CONF_ICON, visibility=Visibility.ADVANCED): icon,
Optional(CONF_ENTITY_CATEGORY, visibility=Visibility.ADVANCED): entity_category,
Optional(CONF_DEVICE_ID, visibility=Visibility.ADVANCED): sub_device_id,
} }
) )
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum from esphome.enum import StrEnum
__version__ = "2026.7.0b1" __version__ = "2026.7.0b2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = ( VALID_SUBSTITUTIONS_CHARACTERS = (
+22 -1
View File
@@ -8,6 +8,7 @@ import re
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from esphome.const import ( from esphome.const import (
CONF_BUILD_PATH,
CONF_COMMENT, CONF_COMMENT,
CONF_ESPHOME, CONF_ESPHOME,
CONF_ETHERNET, CONF_ETHERNET,
@@ -574,6 +575,9 @@ class EsphomeCore:
self.build_path: Path | None = None self.build_path: Path | None = None
# The validated configuration, this is None until the config has been validated # The validated configuration, this is None until the config has been validated
self.config: ConfigType | None = None self.config: ConfigType | None = None
# The raw configuration as read from YAML (after packages/substitutions),
# available during validation before the config is fully validated
self.raw_config: ConfigType | None = None
# YAML frontmatter loaded from user YAML files. Frontmatter is a leading # YAML frontmatter loaded from user YAML files. Frontmatter is a leading
# YAML document separated by `---` from the actual configuration. It is # YAML document separated by `---` from the actual configuration. It is
# ignored by config validation and code generation, but kept here so it # ignored by config validation and code generation, but kept here so it
@@ -650,6 +654,7 @@ class EsphomeCore:
self.config_path = None self.config_path = None
self.build_path = None self.build_path = None
self.config = None self.config = None
self.raw_config = None
self.frontmatter = {} self.frontmatter = {}
self.event_loop = _FakeEventLoop() self.event_loop = _FakeEventLoop()
self.task_counter = 0 self.task_counter = 0
@@ -727,12 +732,28 @@ class EsphomeCore:
The hash is computed lazily and cached for performance. The hash is computed lazily and cached for performance.
Uses sort_keys=True to ensure deterministic ordering. Uses sort_keys=True to ensure deterministic ordering.
The hash must be reproducible across machines so the device builder
can compare a locally computed hash against the one a device
advertises. Machine-local data is kept out of the input: build_path
(which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded,
and Path values are dumped relative to the config directory.
""" """
if self._config_hash is None: if self._config_hash is None:
from esphome import yaml_util from esphome import yaml_util
from esphome.helpers import fnv1a_32bit_hash from esphome.helpers import fnv1a_32bit_hash
config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True) config = dict(self.config)
if (esphome_conf := config.get(CONF_ESPHOME)) is not None:
esphome_conf = dict(esphome_conf)
esphome_conf.pop(CONF_BUILD_PATH, None)
config[CONF_ESPHOME] = esphome_conf
config_str = yaml_util.dump(
config,
show_secrets=True,
sort_keys=True,
relative_to=self.config_dir if self.config_path is not None else None,
)
self._config_hash = fnv1a_32bit_hash(config_str) self._config_hash = fnv1a_32bit_hash(config_str)
return self._config_hash return self._config_hash
+30 -6
View File
@@ -840,17 +840,22 @@ def _load_yaml_internal_with_type(
loader.dispose() loader.dispose()
def dump(dict_, show_secrets=False, sort_keys=False): def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None):
"""Dump YAML to a string and remove null.""" """Dump YAML to a string and remove null.
When ``relative_to`` is given, Path values are dumped relative to that
directory (POSIX form) so the output is machine independent.
"""
if show_secrets: if show_secrets:
_SECRET_VALUES.clear() _SECRET_VALUES.clear()
_SECRET_CACHE.clear() _SECRET_CACHE.clear()
# Per-call subclass so the redaction flag doesn't leak across calls. # Per-call subclass so the flags don't leak across calls.
# (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML
# processing is single-threaded today, so this isolates only the flag.) # processing is single-threaded today, so this isolates only the flags.)
class _Dumper(ESPHomeDumper): class _Dumper(ESPHomeDumper):
_redact_sensitive = not show_secrets _redact_sensitive = not show_secrets
_relative_to = relative_to
return yaml.dump( return yaml.dump(
dict_, dict_,
@@ -1002,9 +1007,13 @@ def format_path(path: DocumentPath, current_obj: Any) -> str:
class ESPHomeDumper(yaml.SafeDumper): class ESPHomeDumper(yaml.SafeDumper):
# Default for the base class; per-call subclass in ``dump()`` overrides. # Defaults for the base class; per-call subclass in ``dump()`` overrides.
# When True, ``represent_sensitive`` wraps values in ANSI conceal codes. # When True, ``represent_sensitive`` wraps values in ANSI conceal codes.
_redact_sensitive: bool = False _redact_sensitive: bool = False
# When set, ``represent_path`` dumps Path values relative to this
# directory (in POSIX form) so the output does not depend on where the
# config lives on the machine that produced it.
_relative_to: Path | None = None
def represent_mapping(self, tag, mapping, flow_style=None): def represent_mapping(self, tag, mapping, flow_style=None):
value = [] value = []
@@ -1040,6 +1049,21 @@ class ESPHomeDumper(yaml.SafeDumper):
return self.represent_secret(value) return self.represent_secret(value)
return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value))
def represent_path(self, value: Path) -> yaml.ScalarNode:
if self._relative_to is not None:
# Normalize both sides lexically (no symlink resolution) so ".."
# segments do not defeat the prefix match, and walk up so files
# referenced outside the anchor directory stay relative too. A
# path that still cannot be relativized (e.g. a different drive)
# keeps its POSIX form so separators stay stable across OSes.
path = Path(os.path.normpath(value))
with suppress(ValueError):
path = path.relative_to(
os.path.normpath(self._relative_to), walk_up=True
)
return self.represent_stringify(path.as_posix())
return self.represent_stringify(value)
def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode:
# Only the redact-and-not-a-secret branch is unique to sensitive # Only the redact-and-not-a-secret branch is unique to sensitive
# values; otherwise let ``represent_stringify`` handle ``!secret`` # values; otherwise let ``represent_stringify`` handle ``!secret``
@@ -1138,5 +1162,5 @@ ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend)
ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove)
ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id)
ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify)
ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_path)
ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file)
+1 -1
View File
@@ -9,7 +9,7 @@ pyserial==3.5
platformio==6.1.19 platformio==6.1.19
esptool==5.3.1 esptool==5.3.1
click==8.3.3 click==8.3.3
aioesphomeapi==45.5.2 aioesphomeapi==45.6.0
zeroconf==0.150.0 zeroconf==0.150.0
puremagic==2.2.0 puremagic==2.2.0
ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml==0.19.1 # dashboard_import
+1
View File
@@ -90,6 +90,7 @@ ISOLATED_COMPONENTS = {
"openthread_info": "Conflicts with wifi: used by most components", "openthread_info": "Conflicts with wifi: used by most components",
"matrix_keypad": "Needs isolation due to keypad", "matrix_keypad": "Needs isolation due to keypad",
"microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged",
"mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components",
"modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus",
"neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)",
"packages": "cannot merge packages", "packages": "cannot merge packages",
@@ -16,7 +16,7 @@ class ZWaveProxy {
public: public:
api::APIConnection *get_api_connection() { return nullptr; } api::APIConnection *get_api_connection() { return nullptr; }
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
void send_frame(const uint8_t *data, size_t length) {} void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {}
void api_connection_authenticated(api::APIConnection *conn) {} void api_connection_authenticated(api::APIConnection *conn) {}
uint32_t get_feature_flags() const { return 0; } uint32_t get_feature_flags() const { return 0; }
uint32_t get_home_id() { return 0; } uint32_t get_home_id() { return 0; }
@@ -24,6 +24,9 @@ spi:
mosi_pin: 6 mosi_pin: 6
clk_pin: 7 clk_pin: 7
psram:
mode: quad
display: display:
- platform: mipi_spi - platform: mipi_spi
id: lcd_display id: lcd_display
@@ -19,6 +19,9 @@ spi:
mosi_pin: 6 mosi_pin: 6
clk_pin: 7 clk_pin: 7
psram:
mode: quad
display: display:
- platform: mipi_spi - platform: mipi_spi
id: lcd_display id: lcd_display
@@ -4,6 +4,9 @@ esphome:
esp32: esp32:
board: esp32s3box board: esp32s3box
psram:
mode: octal
image: image:
defaults: defaults:
type: rgb565 type: rgb565
@@ -1,6 +1,7 @@
"""Tests for mpi_dsi configuration validation.""" """Tests for mpi_dsi configuration validation."""
from collections.abc import Callable from collections.abc import Callable
import logging
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -71,6 +72,18 @@ def test_configuration_errors(set_core_config: SetCoreConfigCallable) -> None:
} }
) )
# DSI displays cannot swap axes; enabling swap_xy reports a clear error.
with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"):
CONFIG_SCHEMA(
{
"model": "custom",
"init_sequence": [[0xA0, 0x01]],
"lane_bit_rate": "1.5Gbps",
"dimensions": {"width": 320, "height": 240},
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": True},
}
)
def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None:
"""Test successful configuration validation.""" """Test successful configuration validation."""
@@ -116,6 +129,59 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None:
CONFIG_SCHEMA(config) CONFIG_SCHEMA(config)
def test_deprecated_model_warning(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecated M5Stack-Tab5-v2 alias warns and points at the replacement models."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4},
)
from esphome.components.mipi_dsi.display import CONFIG_SCHEMA
with caplog.at_level(logging.WARNING):
CONFIG_SCHEMA({"id": "deprecated_display", "model": "M5Stack-Tab5-v2"})
assert "M5STACK-TAB5-V2 is deprecated" in caplog.text
# The warning names the replacement models so users know what to switch to.
assert "M5STACK-TAB5-ST7123" in caplog.text
# The replacement models validate without emitting a deprecation warning.
caplog.clear()
with caplog.at_level(logging.WARNING):
CONFIG_SCHEMA({"id": "st7123_display", "model": "M5Stack-Tab5-ST7123"})
CONFIG_SCHEMA({"id": "st7121_display", "model": "M5Stack-Tab5-ST7121"})
assert "deprecated" not in caplog.text
def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None:
"""A configured display rotation is recorded in the metadata.
LVGL relies on this to flag a rotation set in the display config (see the
mipi_spi tests for the end-to-end LVGL rejection).
"""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4},
)
from esphome.components.display import get_display_metadata
from esphome.components.mipi_dsi.display import CONFIG_SCHEMA
base = {
"model": "custom",
"init_sequence": [[0xA0, 0x01]],
"lane_bit_rate": "1.5Gbps",
"dimensions": {"width": 320, "height": 240},
}
config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90})
assert get_display_metadata(config["id"]).rotation == 90
config = CONFIG_SCHEMA({**base, "id": "unrotated"})
assert get_display_metadata(config["id"]).rotation == 0
def test_code_generation( def test_code_generation(
generate_main: Callable[[str | Path], str], generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path], component_fixture_path: Callable[[str], Path],
@@ -0,0 +1,89 @@
"""Tests for mipi_rgb configuration validation, in particular the per-model
``requires`` component check (see esphome.components.mipi.DriverChip.check_requirements)."""
from __future__ import annotations
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that
# models (e.g. SEEED-INDICATOR-D1) that reference pca9554-backed pins in their
# defaults can be validated by the mipi_rgb CONFIG_SCHEMA in this test.
import esphome.components.pca9554 # noqa: F401
from esphome.const import PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _validated(config: ConfigType) -> ConfigType:
"""Run the component config schema followed by the final validation."""
config = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(config)
return config
def test_model_requires_psram(set_core_config: SetCoreConfigCallable) -> None:
"""A model known to have PSRAM on its board rejects a config without it.
RGB parallel displays always need a full framebuffer, so every model in this
component is expected to carry ``requires={"psram", ...}``. This board has no
other requirements, so its check is exercised in isolation here.
"""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
CORE.raw_config = {}
with pytest.raises(
cv.Invalid,
match=r"ESP32-8048S070 requires component 'psram' to be configured",
):
_validated({"model": "ESP32-8048S070"})
def test_model_requires_psram_satisfied(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""The same board model validates once PSRAM is configured."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
config = _validated({"model": "ESP32-8048S070"})
assert config["model"] == "ESP32-8048S070"
def test_model_requires_psram_and_expander(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""A model that also depends on an I2C GPIO expander lists both when missing."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
# Only satisfy one of the two requirements.
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
with pytest.raises(
cv.Invalid,
match=r"SEEED-INDICATOR-D1 requires component 'pca9554' to be configured",
):
_validated(
{
"model": "SEEED-INDICATOR-D1",
"spi_id": "spi_bus",
}
)
@@ -0,0 +1,137 @@
"""Tests for mipi_rgb configuration validation."""
import pytest
from esphome import config_validation as cv
# Importing these registers their pin schemas with pins.PIN_SCHEMA_REGISTRY so that
# models referencing IO-expander-backed pins in their defaults (e.g. the LilyGO
# T-RGB boards via xl9535, SEEED-INDICATOR-D1 via pca9554, or the Waveshare panels
# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test.
import esphome.components.ch422g # noqa: F401
from esphome.components.display import get_display_metadata
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3
import esphome.components.pca9554 # noqa: F401
import esphome.components.xl9535 # noqa: F401
from esphome.const import (
CONF_BLUE,
CONF_DIMENSIONS,
CONF_GREEN,
CONF_HEIGHT,
CONF_INIT_SEQUENCE,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_RED,
CONF_SWAP_XY,
CONF_WIDTH,
KEY_VARIANT,
PlatformFramework,
)
from tests.component_tests.types import SetCoreConfigCallable
# A generic set of data pins so that models without a default pin assignment
# (e.g. CUSTOM and RPI) still validate.
DATA_PINS = {
CONF_RED: [1, 2, 3, 4, 5],
CONF_GREEN: [6, 7, 8, 9, 10, 11],
CONF_BLUE: [12, 13, 14, 15, 16],
}
def _set_s3(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None:
"""Every predefined model validates once required defaults are supplied."""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
for name, model in MODELS.items():
config = {"model": name, "data_pins": DATA_PINS, "pclk_pin": 21}
if model.initsequence is None:
config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]]
if not model.get_default(CONF_WIDTH):
config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480}
CONFIG_SCHEMA(config)
def test_transform_matches_model_support(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The transform schema only accepts the axes a model actually supports."""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
# ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays
# never support axis swapping).
model = MODELS["ESP32-8048S070"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}})
# An unsupported axis may be explicitly disabled (a harmless no-op)...
CONFIG_SCHEMA(
{**base, "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": False}}
)
# ...but enabling it reports a clear, model-specific error.
with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"):
CONFIG_SCHEMA(
{
**base,
"transform": {"mirror_x": True, "mirror_y": False, "swap_xy": True},
}
)
def test_st7701s_only_supports_mirror_x(
set_core_config: SetCoreConfigCallable,
) -> None:
"""ST7701S panels shorter than full height only expose mirror_x.
mirror_y only works at full height (864px), so the LilyGO 480px panels must
reject a mirror_y transform.
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
model = MODELS["T-RGB-2.1"]
assert model.transforms == {CONF_MIRROR_X}
assert CONF_SWAP_XY not in model.transforms
base = {"model": "T-RGB-2.1"}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}})
with pytest.raises(cv.Invalid, match="'mirror_y' is not supported by this model"):
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}})
def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A configured display rotation is recorded in the metadata.
LVGL relies on this to flag a rotation set in the display config (see the
mipi_spi tests for the end-to-end LVGL rejection).
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90})
assert get_display_metadata(config["id"]).rotation == 90
config = CONFIG_SCHEMA({**base, "id": "unrotated"})
assert get_display_metadata(config["id"]).rotation == 0
@@ -3,6 +3,9 @@
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.const import BYTE_ORDER_BIG from esphome.components.const import BYTE_ORDER_BIG
from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.display import get_all_display_metadata, get_display_metadata
from esphome.components.esp32 import ( from esphome.components.esp32 import (
@@ -13,6 +16,7 @@ from esphome.components.esp32 import (
) )
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
from esphome.const import PlatformFramework from esphome.const import PlatformFramework
from esphome.core import ID
from tests.component_tests.types import SetCoreConfigCallable from tests.component_tests.types import SetCoreConfigCallable
@@ -23,6 +27,18 @@ def validated_config(config):
return config return config
def _lvgl_config(display_id: str) -> dict:
"""Build a minimal LVGL config dict referencing the given display id."""
return {
"displays": [ID(display_id, True)],
"log_level": "WARN",
"color_depth": 16,
"transparency_key": 0x000400,
"draw_rounding": 2,
"buffer_size": 0,
}
def test_metadata_native_quad_default_test_card( def test_metadata_native_quad_default_test_card(
set_core_config: SetCoreConfigCallable, set_core_config: SetCoreConfigCallable,
) -> None: ) -> None:
@@ -91,7 +107,7 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation(
PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
) )
# JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only # JC3248W535 has transforms={mirror_x, mirror_y} only
config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"})
meta = get_display_metadata(config["id"]) meta = get_display_metadata(config["id"])
assert meta is not None assert meta is not None
@@ -166,3 +182,69 @@ def test_metadata_via_code_generation_lvgl(
assert meta.height == 160 assert meta.height == 160
assert meta.has_hardware_rotation is True assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG assert meta.byte_order == BYTE_ORDER_BIG
def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A configured display rotation is recorded in the metadata."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = CONFIG_SCHEMA(
{"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90}
)
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.rotation == 90
def test_metadata_rotation_defaults_to_zero(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A display without a rotation reports rotation 0 in its metadata."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"})
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.rotation == 0
def test_rotation_flagged_when_used_with_lvgl(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A display with a rotation is rejected when driven by LVGL.
LVGL manages its own rotation, so a rotation set in the display config must be
flagged and the user directed to configure it in the LVGL block instead. This
exercises the full chain: the mipi_spi schema records the rotation in the
display metadata, and LVGL's final validation reports it.
"""
from esphome.components.lvgl import final_validation
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90})
with pytest.raises(cv.Invalid, match="rotation.*not compatible with LVGL"):
final_validation([_lvgl_config("rotated")])
def test_no_rotation_accepted_with_lvgl(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A display without a rotation validates cleanly when driven by LVGL."""
from esphome.components.lvgl import final_validation
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"})
# Should not raise.
final_validation([_lvgl_config("unrotated")])
@@ -6,10 +6,13 @@ from typing import Any
import pytest import pytest
from esphome import config_validation as cv
from esphome.components.display import CONF_SHOW_TEST_CARD from esphome.components.display import CONF_SHOW_TEST_CARD
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.components.mipi import DriverChip
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
from esphome.const import CONF_BUFFER_SIZE, PlatformFramework from esphome.const import CONF_BUFFER_SIZE, PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable from tests.component_tests.types import SetCoreConfigCallable
@@ -183,3 +186,77 @@ def test_buffer_size_selected_when_lvgl_with_test_card(
) )
assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4)
def test_requires_missing_single_component_raises() -> None:
"""A model that requires a single component raises when it is absent."""
CORE.raw_config = {}
chip = DriverChip("TEST-REQUIRES-PSRAM", requires={"psram"})
with pytest.raises(
cv.Invalid,
match=r"TEST-REQUIRES-PSRAM requires component 'psram' to be configured",
):
chip.check_requirements()
def test_requires_missing_multiple_components_raises() -> None:
"""A model that requires several components lists all the missing ones, pluralized."""
CORE.raw_config = {}
chip = DriverChip("TEST-REQUIRES-MULTI", requires={"psram", "pca9554"})
with pytest.raises(
cv.Invalid,
match=r"TEST-REQUIRES-MULTI requires components '.*' to be configured",
) as excinfo:
chip.check_requirements()
assert "psram" in str(excinfo.value)
assert "pca9554" in str(excinfo.value)
def test_requires_satisfied_does_not_raise() -> None:
"""No error is raised once all the required components are configured."""
CORE.raw_config = {"psram": True, "pca9554": []}
chip = DriverChip("TEST-REQUIRES-SATISFIED", requires={"psram", "pca9554"})
chip.check_requirements() # Should not raise
def test_requires_absent_does_not_raise() -> None:
"""Models without a requires set are unaffected by the check."""
CORE.raw_config = {}
chip = DriverChip("TEST-REQUIRES-NONE")
chip.check_requirements() # Should not raise
def test_predefined_model_requires_psram(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A predefined board model known to have PSRAM rejects a config without it."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
CORE.raw_config = {}
with pytest.raises(
cv.Invalid, match=r"S3BOX requires component 'psram' to be configured"
):
_validated({"model": "s3box"})
def test_predefined_model_requires_psram_satisfied(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""The same board model validates once PSRAM is configured."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
config = _validated({"model": "s3box"})
assert config["model"] == "S3BOX"
+74 -3
View File
@@ -136,7 +136,7 @@ def test_dimension_validation(
"model": "JC3248W535", "model": "JC3248W535",
"transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True}, "transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True},
}, },
"Axis swapping not supported by this model", "'swap_xy' is not supported by this model",
id="axis_swapping_not_supported", id="axis_swapping_not_supported",
), ),
pytest.param( pytest.param(
@@ -361,7 +361,8 @@ def test_native_generation(
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()" "mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
in main_cpp in main_cpp
) )
assert "set_init_sequence({240, 1, 8, 242" in main_cpp # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands.
assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp
assert "show_test_card();" in main_cpp assert "show_test_card();" in main_cpp
assert "set_write_only(true);" in main_cpp assert "set_write_only(true);" in main_cpp
@@ -377,6 +378,76 @@ def test_lvgl_generation(
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();" "mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
in main_cpp in main_cpp
) )
assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands.
assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp
assert "show_test_card();" not in main_cpp assert "show_test_card();" not in main_cpp
assert "set_auto_clear(false);" in main_cpp assert "set_auto_clear(false);" in main_cpp
# A 10ms delay (flattened to {10, 0xFF}, where 0xFF is the delay marker byte) is
# always prepended to the init sequence, since both a software and a hardware reset
# need to settle before further commands. A custom model has no reset_pin default
# and does not set no_swreset, so when no reset pin is configured the SWRESET command
# ({1, 0}: command 0x01 with no parameters) is prepended ahead of that delay.
_SWRESET_YAML = """
esphome:
name: swreset-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
spi:
clk_pin: 1
mosi_pin: 2
display:
- platform: mipi_spi
model: custom
id: {display_id}
dc_pin: 4
cs_pin: 8
dimensions:
width: 320
height: 240
init_sequence:
- [0xA0, 0x01]
{reset_line}
"""
def test_swreset_prepended_without_reset_pin(
generate_main: Callable[[str | Path], str],
tmp_path: Path,
) -> None:
"""A model with no reset pin (and no no_swreset) gets SWRESET prepended."""
yaml_file = tmp_path / "swreset.yaml"
yaml_file.write_text(
_SWRESET_YAML.format(display_id="swreset_display", reset_line="")
)
main_cpp = generate_main(yaml_file)
# SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of
# the model's own commands.
assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp
def test_swreset_not_prepended_with_reset_pin(
generate_main: Callable[[str | Path], str],
tmp_path: Path,
) -> None:
"""A hardware reset pin performs the reset, so SWRESET must not be prepended.
The post-reset delay is still required, so the sequence starts with the delay.
"""
yaml_file = tmp_path / "hwreset.yaml"
yaml_file.write_text(
_SWRESET_YAML.format(
display_id="hwreset_display", reset_line=" reset_pin: 5"
)
)
main_cpp = generate_main(yaml_file)
# The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}).
assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp
assert "hwreset_display->set_init_sequence({1, 0," not in main_cpp
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
@@ -222,6 +223,7 @@ class TestNewModelVariants:
def test_m5core2_with_native_dimensions( def test_m5core2_with_native_dimensions(
self, self,
set_core_config: SetCoreConfigCallable, set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None: ) -> None:
"""Test M5CORE2 variant with reset native_width and native_height.""" """Test M5CORE2 variant with reset native_width and native_height."""
set_core_config( set_core_config(
@@ -231,6 +233,8 @@ class TestNewModelVariants:
KEY_VARIANT: VARIANT_ESP32S3, KEY_VARIANT: VARIANT_ESP32S3,
}, },
) )
# M5CORE2 has PSRAM on board and requires it to be configured
set_component_config("psram", True)
# M5CORE2 should validate successfully # M5CORE2 should validate successfully
config = validated_config({"model": "M5CORE2"}) config = validated_config({"model": "M5CORE2"})
@@ -24,6 +24,9 @@ spi:
mosi_pin: 6 mosi_pin: 6
clk_pin: 7 clk_pin: 7
psram:
mode: quad
display: display:
- platform: mipi_spi - platform: mipi_spi
id: lcd_display id: lcd_display
@@ -23,6 +23,9 @@ spi:
mosi_pin: 6 mosi_pin: 6
clk_pin: 7 clk_pin: 7
psram:
mode: quad
display: display:
- platform: mipi_spi - platform: mipi_spi
id: lcd_display id: lcd_display
@@ -1,5 +1,11 @@
<<: !include common-base.yaml packages:
common: !include common-base.yaml
wifi: wifi:
ssid: MySSID ssid: MySSID
password: password1 password: password1
# Encryption enabled without a key: compiles both frame helpers so the key
# can be provisioned at runtime (zero-PSK noise or deprecated plaintext)
api:
encryption:
@@ -0,0 +1,7 @@
# No api, web_server or extra services so the fallback _http service
# (with version, mac and config_hash TXT records) is compiled.
wifi:
ssid: MySSID
password: password1
mdns:
@@ -0,0 +1,9 @@
# web_server without the native api so the version, mac and config_hash
# TXT records are attached to the web_server _http service.
wifi:
ssid: MySSID
password: password1
web_server:
mdns:
@@ -1,5 +1,5 @@
packages: packages:
- !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
psram: psram:
mode: octal mode: octal
@@ -5,6 +5,7 @@ binary_sensor:
- platform: template - platform: template
name: "Garage Door Open 10" name: "Garage Door Open 10"
report: "default" report: "default"
use_device_type: false
- platform: template - platform: template
name: "Garage Door Open 12" name: "Garage Door Open 12"
report: "force" report: "force"
@@ -0,0 +1,6 @@
esphome:
name: zero-psk-provision-test
host:
api:
encryption:
logger:
@@ -0,0 +1,6 @@
esphome:
name: zero-psk-plaintext-test
host:
api:
encryption:
logger:
@@ -0,0 +1,127 @@
"""Integration tests for provisioning the encryption key over a zero-PSK connection.
A device with `api: encryption:` but no key accepts Noise handshakes using the
well-known all-zeros PSK. The ephemeral X25519 exchange protects the key from
passive sniffing while it is provisioned; plaintext provisioning still works
but is deprecated.
"""
from __future__ import annotations
import asyncio
import base64
from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# The well-known provisioning PSK: base64 of 32 zero bytes
ZERO_PSK = base64.b64encode(bytes(32)).decode()
# A real key to provision
NEW_KEY = base64.b64encode(b"n" * 32)
# Time for the device to activate a newly saved key (100ms timer plus margin)
KEY_ACTIVATION_DELAY = 0.5
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so every run starts unprovisioned."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@pytest.mark.asyncio
async def test_api_zero_psk_provisioning(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Exercise the reject paths, then provision a key over the zero-PSK channel."""
async with run_compiled(yaml_config):
# --- Pre-provisioning reject paths (device state is unchanged) ---
# A wrong (non-zero) PSK fails against the zero provisioning PSK
with pytest.raises(InvalidEncryptionKeyAPIError):
async with api_client_connected(
noise_psk=base64.b64encode(b"w" * 32).decode(), timeout=5
) as client:
await client.device_info()
# A plaintext client and a zero-PSK client can be connected at the
# same time while the device is unprovisioned
async with (
api_client_connected() as plaintext_client,
api_client_connected(noise_psk=ZERO_PSK) as noise_client,
):
plaintext_info = await plaintext_client.device_info()
noise_info = await noise_client.device_info()
# Both transports advertise provisioning support so old and new
# clients can decide how to provision
assert plaintext_info.api_encryption_provisionable is True
assert noise_info.api_encryption_provisionable is True
# The all-zeros key is reserved as the provisioning PSK and is
# rejected on both transports
zero_key = base64.b64encode(bytes(32))
assert await noise_client.noise_encryption_set_key(zero_key) is False
assert await plaintext_client.noise_encryption_set_key(zero_key) is False
# --- Provision over the zero-PSK channel ---
# The unprovisioned device accepts the all-zeros PSK; the handshake's
# ephemeral-ephemeral DH encrypts everything that follows
async with api_client_connected(noise_psk=ZERO_PSK) as client:
device_info = await client.device_info()
assert device_info.name == "zero-psk-provision-test"
assert device_info.api_encryption_supported is True
assert device_info.api_encryption_provisionable is True
assert await client.noise_encryption_set_key(NEW_KEY) is True
# The device activates the new key shortly after responding
await asyncio.sleep(KEY_ACTIVATION_DELAY)
# The new key now works, and the device is no longer provisionable
async with api_client_connected(noise_psk=NEW_KEY.decode()) as client:
device_info = await client.device_info()
assert device_info.name == "zero-psk-provision-test"
assert device_info.api_encryption_provisionable is False
# The zero PSK no longer works
with pytest.raises(InvalidEncryptionKeyAPIError):
async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client:
await client.device_info()
# Plaintext no longer works
with pytest.raises(RequiresEncryptionAPIError):
async with api_client_connected(timeout=5) as client:
await client.device_info()
@pytest.mark.asyncio
async def test_api_zero_psk_provisioning_plaintext(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The legacy plaintext provisioning path still works and warns."""
log_lines: list[str] = []
async with run_compiled(yaml_config, line_callback=log_lines.append):
async with api_client_connected() as client:
device_info = await client.device_info()
assert device_info.name == "zero-psk-plaintext-test"
assert await client.noise_encryption_set_key(NEW_KEY) is True
await asyncio.sleep(KEY_ACTIVATION_DELAY)
# The deprecation warning was logged
assert any("deprecated" in line for line in log_lines)
# The new key works; the zero PSK does not
async with api_client_connected(noise_psk=NEW_KEY.decode()) as client:
assert (await client.device_info()).name == "zero-psk-plaintext-test"
with pytest.raises(InvalidEncryptionKeyAPIError):
async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client:
await client.device_info()
+42
View File
@@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None:
assert hash1 != hash2 assert hash1 != hash2
def test_config_hash_ignores_build_path() -> None:
"""Test that config_hash does not depend on the build_path value.
build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must
not make the hash differ between machines.
"""
CORE.reset()
CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}}
hash1 = CORE.config_hash
CORE.reset()
CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}}
hash2 = CORE.config_hash
assert hash1 == hash2
def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None:
"""Test that Path values under the config dir hash the same everywhere.
Simulates the same project checked out at two different locations; the
absolute paths differ but the layout relative to the config dir is the
same, so the hashes must match.
"""
dir1 = tmp_path / "machine_a" / "project"
dir2 = tmp_path / "machine_b" / "somewhere" / "else"
dir1.mkdir(parents=True)
dir2.mkdir(parents=True)
CORE.reset()
CORE.config_path = dir1 / "device.yaml"
CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"}
hash1 = CORE.config_hash
CORE.reset()
CORE.config_path = dir2 / "device.yaml"
CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"}
hash2 = CORE.config_hash
assert hash1 == hash2
def test_make_app_name_cpp_no_mac_simple() -> None: def test_make_app_name_cpp_no_mac_simple() -> None:
"""Test simple name without MAC suffix returns string literal.""" """Test simple name without MAC suffix returns string literal."""
cpp_expr, global_decl, byte_len = make_app_name_cpp( cpp_expr, global_decl, byte_len = make_app_name_cpp(
+67 -3
View File
@@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None:
def test_optional_default_visibility_is_none() -> None: def test_optional_default_visibility_is_none() -> None:
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``. """An ``Optional`` with no ``visibility`` kwarg reports ``None``.
Consumers can read the attribute directly with plain attribute The marker stays faithful to what the author wrote: ESPHome does
access; absence (``None``) means "render on the editor's main not encode the default on it. Resolving ``None`` to an effective
form." visibility is the consumer's job — a schema-aware editor treats an
unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`).
""" """
o = cv.Optional("foo") o = cv.Optional("foo")
assert o.visibility is None assert o.visibility is None
@@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None:
assert o.visibility is cv.Visibility.YAML_ONLY assert o.visibility is cv.Visibility.YAML_ONLY
def test_optional_visibility_ui() -> None:
"""``visibility=Visibility.UI`` is recorded on the marker.
``UI`` promotes an ``Optional`` onto the editor's main form,
overriding the consumer's default of ``ADVANCED`` for unset
optionals.
"""
o = cv.Optional("foo", visibility=cv.Visibility.UI)
assert o.visibility is cv.Visibility.UI
def test_visibility_str_values_match_dump_emission() -> None: def test_visibility_str_values_match_dump_emission() -> None:
"""``Visibility`` is a ``StrEnum`` whose values are the literal """``Visibility`` is a ``StrEnum`` whose values are the literal
strings the schema dumper emits. strings the schema dumper emits.
@@ -1203,6 +1215,7 @@ def test_visibility_str_values_match_dump_emission() -> None:
field pinning the on-the-wire spelling here keeps the dump field pinning the on-the-wire spelling here keeps the dump
contract stable. contract stable.
""" """
assert str(cv.Visibility.UI) == "ui"
assert str(cv.Visibility.ADVANCED) == "advanced" assert str(cv.Visibility.ADVANCED) == "advanced"
assert str(cv.Visibility.YAML_ONLY) == "yaml_only" assert str(cv.Visibility.YAML_ONLY) == "yaml_only"
@@ -1325,6 +1338,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None:
assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY
def test_entity_metadata_visibility_hints() -> None:
"""Entity and value-describing metadata is classified for visual editors.
The headline ``name`` stays on the main form (``UI``); descriptive
metadata (device_class, unit, ), presentation options, and per-entity
integration plumbing (MQTT, web_server ordering) fall to the advanced
disclosure (``ADVANCED``).
"""
advanced = cv.Visibility.ADVANCED
entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema}
assert entity_base["name"].visibility is cv.Visibility.UI
for field in (
"icon",
"internal",
"disabled_by_default",
"entity_category",
"device_id",
):
assert entity_base[field].visibility is advanced, field
mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema}
for field in ("qos", "retain", "discovery", "state_topic", "availability"):
assert mqtt[field].visibility is advanced, field
from esphome.components import binary_sensor, number, sensor
from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA
sensor_markers = {str(k): k for k in sensor.sensor_schema().schema}
for field in (
"unit_of_measurement",
"accuracy_decimals",
"device_class",
"state_class",
"force_update",
):
assert sensor_markers[field].visibility is advanced, field
binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema}
assert binary["device_class"].visibility is advanced
number_markers = {str(k): k for k in number.number_schema(number.Number).schema}
assert number_markers["mode"].visibility is advanced
assert number_markers["device_class"].visibility is advanced
# The whole per-entity web_server block is advanced; children inherit
# via the consumer cascade, so only the parent key carries the hint.
web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema}
assert web["web_server"].visibility is advanced
def _wrap_str(value: str) -> ESPHomeDataBase: def _wrap_str(value: str) -> ESPHomeDataBase:
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
return make_data_base(value) return make_data_base(value)
+2 -2
View File
@@ -167,9 +167,9 @@ def setup_core(
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
if tmp_path is not None: if tmp_path is not None:
CORE.config_path = str(tmp_path / f"{name}.yaml") CORE.config_path = tmp_path / f"{name}.yaml"
CORE.name = name CORE.name = name
CORE.build_path = str(tmp_path / ".esphome" / "build" / name) CORE.build_path = tmp_path / ".esphome" / "build" / name
@pytest.fixture @pytest.fixture
+51
View File
@@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None:
assert value == "hunter2" assert value == "hunter2"
def test_dump_path_without_relative_to_is_unchanged() -> None:
"""Test that Path values dump as str(path) when relative_to is not given."""
path = Path("some") / "dir" / "file.ttf"
output = yaml_util.dump({"file": path})
assert output.strip() == f"file: {path}"
def test_dump_path_relative_to_anchor_dir() -> None:
"""Test that Path values under relative_to dump as relative POSIX paths."""
anchor = Path("/config/esphome").absolute()
data = {"file": anchor / "fonts" / "arial.ttf"}
output = yaml_util.dump(data, relative_to=anchor)
assert output.strip() == "file: fonts/arial.ttf"
def test_dump_path_outside_anchor_dir_walks_up() -> None:
"""Test that Path values outside relative_to walk up with ".." segments."""
anchor = Path("/config/esphome").absolute()
outside = Path("/config/fonts/file.ttf").absolute()
output = yaml_util.dump({"file": outside}, relative_to=anchor)
assert output.strip() == "file: ../fonts/file.ttf"
def test_dump_path_with_dotdot_segments_is_normalized() -> None:
"""Test that ".." segments do not defeat relativization.
A path like /config/other/../esphome/fonts/x.ttf is under the anchor
once normalized, so it must dump as a plain relative path.
"""
anchor = Path("/config/esphome").absolute()
path = Path("/config/other/../esphome/fonts/x.ttf").absolute()
output = yaml_util.dump({"file": path}, relative_to=anchor)
assert output.strip() == "file: fonts/x.ttf"
def test_dump_path_dotdot_reference_outside_anchor() -> None:
"""Test the relative_config_path("../...") shape stays relative."""
anchor = Path("/config/esphome").absolute()
path = anchor / ".." / "shared" / "font.ttf"
output = yaml_util.dump({"file": path}, relative_to=anchor)
assert output.strip() == "file: ../shared/font.ttf"
def test_dump_relative_to_does_not_leak_between_calls() -> None:
"""Test that the relative_to flag is scoped to a single dump call."""
anchor = Path("/config/esphome").absolute()
path = anchor / "fonts" / "arial.ttf"
assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor)
assert yaml_util.dump({"file": path}).strip() == f"file: {path}"
def test_dump__redacts_sensitive_str_by_default() -> None: def test_dump__redacts_sensitive_str_by_default() -> None:
out = yaml_util.dump({"password": SensitiveStr("hunter2")}) out = yaml_util.dump({"password": SensitiveStr("hunter2")})
assert "\\033[8mhunter2\\033[28m" in out assert "\\033[8mhunter2\\033[28m" in out