mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.7.0b1
|
||||
PROJECT_NUMBER = 2026.7.0b2
|
||||
|
||||
# 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
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# 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 \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -488,8 +488,11 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||
else:
|
||||
# No key provided, but encryption desired
|
||||
# This will allow a plaintext client to provide a noise key,
|
||||
# send it to the device, and then switch to noise.
|
||||
# Until a key is set, the device accepts both Noise connections
|
||||
# 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
|
||||
# and plaintext disabled. Only a factory reset can remove it.
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
|
||||
@@ -310,6 +310,11 @@ message DeviceInfoResponse {
|
||||
|
||||
// Serial proxy instance metadata
|
||||
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 {
|
||||
|
||||
@@ -198,6 +198,29 @@ APIConnection::~APIConnection() {
|
||||
#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_() {
|
||||
switch (this->active_iterator_) {
|
||||
case ActiveIterator::LIST_ENTITIES:
|
||||
@@ -256,6 +279,15 @@ void APIConnection::loop() {
|
||||
// No more data available
|
||||
break;
|
||||
} 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);
|
||||
return;
|
||||
} else {
|
||||
@@ -1351,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet
|
||||
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
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) {
|
||||
@@ -1860,6 +1892,12 @@ bool APIConnection::send_device_info_response_() {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
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
|
||||
#ifdef USE_DEVICES
|
||||
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()) {
|
||||
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)) {
|
||||
ESP_LOGW(TAG, "Failed to save encryption key");
|
||||
} else {
|
||||
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);
|
||||
|
||||
@@ -626,6 +626,11 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void destroy_active_iterator_();
|
||||
void begin_iterator_(ActiveIterator type);
|
||||
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
|
||||
std::unique_ptr<camera::CameraImageReader> image_reader_;
|
||||
#endif
|
||||
|
||||
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
|
||||
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
|
||||
}
|
||||
#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");
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
|
||||
HANDSHAKESTATE_SPLIT_FAILED = 1020,
|
||||
BAD_HANDSHAKE_ERROR_BYTE = 1021,
|
||||
#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);
|
||||
@@ -200,6 +205,12 @@ class APIFrameHelper {
|
||||
// or track that they stopped early and retry without this check.
|
||||
// See Socket::ready() for details.
|
||||
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
|
||||
void release_buffers() {
|
||||
// rx_buf_: Safe to clear only if no partial read in progress.
|
||||
|
||||
@@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() {
|
||||
state_ = State::CLIENT_HELLO;
|
||||
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
|
||||
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
|
||||
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)
|
||||
APIError APINoiseFrameHelper::loop() {
|
||||
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
|
||||
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
|
||||
// that must follow reads, deadlocking the handshake. state_action() will return
|
||||
// WOULD_BLOCK when no more data is available to read.
|
||||
bool socket_ready = this->socket_->ready();
|
||||
while (state_ != State::DATA && socket_ready) {
|
||||
APIError err = state_action_();
|
||||
if (err == APIError::WOULD_BLOCK) {
|
||||
break;
|
||||
}
|
||||
// Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
|
||||
// ready() returns false once the rx buffer is consumed. Re-checking each
|
||||
// iteration would block handshake writes that must follow reads,
|
||||
// deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
|
||||
// no more data is available to read.
|
||||
if (state_ != State::DATA && this->socket_->ready()) {
|
||||
APIError err = this->pump_handshake_();
|
||||
if (err != APIError::OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
}
|
||||
~APINoiseFrameHelper() 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 read_packet(ReadPacketBuffer *buffer) override;
|
||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
||||
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
||||
|
||||
protected:
|
||||
APIError pump_handshake_();
|
||||
APIError state_action_();
|
||||
APIError state_action_client_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 (rx_header_buf_pos_ == 0 && received > 0) {
|
||||
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;
|
||||
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
|
||||
return APIError::BAD_INDICATOR;
|
||||
|
||||
@@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) 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:
|
||||
APIError try_read_frame_();
|
||||
|
||||
@@ -10,13 +10,20 @@ using psk_t = std::array<uint8_t, 32>;
|
||||
|
||||
class APINoiseContext {
|
||||
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) {
|
||||
this->psk_ = psk;
|
||||
bool has_psk = false;
|
||||
for (auto i : psk) {
|
||||
has_psk |= i;
|
||||
}
|
||||
this->has_psk_ = has_psk;
|
||||
this->has_psk_ = !is_all_zeros(psk);
|
||||
}
|
||||
const psk_t &get_psk() const { return this->psk_; }
|
||||
bool has_psk() const { return this->has_psk_; }
|
||||
|
||||
@@ -170,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
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
|
||||
return pos;
|
||||
}
|
||||
@@ -232,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
size += ProtoSize::calc_message_force(2, it.calculate_size());
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage {
|
||||
class DeviceInfoResponse final : public ProtoMessage {
|
||||
public:
|
||||
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
|
||||
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
||||
#endif
|
||||
@@ -588,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage {
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
bool api_encryption_provisionable{false};
|
||||
#endif
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
|
||||
@@ -982,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
it.dump_to(out);
|
||||
out.append("\n");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
|
||||
@@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = (
|
||||
cv.Exclusive(
|
||||
CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
|
||||
): 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_ON_PRESS): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
|
||||
|
||||
@@ -50,7 +50,9 @@ _BUTTON_SCHEMA = (
|
||||
.extend(
|
||||
{
|
||||
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({}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -131,7 +131,9 @@ _COVER_SCHEMA = (
|
||||
cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All(
|
||||
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.requires_component("mqtt"), cv.subscribe_topic
|
||||
),
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
#include "deep_sleep_component.h"
|
||||
#ifdef USE_ZEPHYR
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/wake.h"
|
||||
#include <zephyr/sys/poweroff.h>
|
||||
#include <algorithm>
|
||||
|
||||
namespace esphome::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_; }
|
||||
|
||||
void DeepSleepComponent::dump_config_platform_() {}
|
||||
@@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {}
|
||||
bool DeepSleepComponent::prepare_to_sleep_() { return true; }
|
||||
|
||||
void DeepSleepComponent::deep_sleep_() {
|
||||
bool woke = false;
|
||||
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 {
|
||||
#ifndef USE_ZIGBEE
|
||||
// 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.
|
||||
sys_poweroff();
|
||||
#else
|
||||
esphome::internal::wakeable_delay(UINT32_MAX);
|
||||
woke = wakeable_delay_feed_wdt(UINT32_MAX);
|
||||
#endif
|
||||
}
|
||||
const bool woke = esphome::wake_request_take();
|
||||
if (woke) {
|
||||
ESP_LOGD(TAG, "Woken up by another thread");
|
||||
} else {
|
||||
|
||||
@@ -50,7 +50,9 @@ _EVENT_SCHEMA = (
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent),
|
||||
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({}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) {
|
||||
ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx);
|
||||
uint8_t addr = this->get_wiper_address_(wiper_idx);
|
||||
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::INCREMENT);
|
||||
auto err = this->write(&this->address_, reg);
|
||||
auto err = this->write(®, 1);
|
||||
if (err != i2c::ERROR_OK) {
|
||||
this->error_code_ = MCP4461_STATUS_I2C_ERROR;
|
||||
this->status_set_warning();
|
||||
@@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) {
|
||||
ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx);
|
||||
uint8_t addr = this->get_wiper_address_(wiper_idx);
|
||||
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::DECREMENT);
|
||||
auto err = this->write(&this->address_, reg);
|
||||
auto err = this->write(®, 1);
|
||||
if (err != i2c::ERROR_OK) {
|
||||
this->error_code_ = MCP4461_STATUS_I2C_ERROR;
|
||||
this->status_set_warning();
|
||||
|
||||
@@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi
|
||||
auto &services = services_storage;
|
||||
#endif
|
||||
|
||||
#ifdef USE_API
|
||||
#ifdef USE_MDNS_DEVICE_INFO_TXT
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
get_mac_address_into_buffer(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);
|
||||
}
|
||||
|
||||
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
|
||||
char *config_hash_buf) {
|
||||
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services,
|
||||
const char *mac_address_buf, const char *config_hash_buf) {
|
||||
// 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.
|
||||
|
||||
#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
|
||||
MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib");
|
||||
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_BOARD, "board");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network");
|
||||
@@ -107,7 +110,13 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
|
||||
txt_count++; // network
|
||||
#endif
|
||||
#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
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
if (!api_has_psk) {
|
||||
txt_count++; // api_provisioning
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
#ifdef ESPHOME_PROJECT_NAME
|
||||
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_SUPPORTED, "api_encryption_supported");
|
||||
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 = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
|
||||
const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
|
||||
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
|
||||
|
||||
#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.proto = MDNS_STR(SERVICE_TCP);
|
||||
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
|
||||
|
||||
#if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \
|
||||
!defined(USE_MDNS_EXTRA_SERVICES)
|
||||
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
|
||||
// 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.proto = MDNS_STR(SERVICE_TCP);
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
#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 {
|
||||
|
||||
// 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_;
|
||||
#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)
|
||||
char mac_address_[MAC_ADDRESS_BUFFER_SIZE];
|
||||
/// 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.
|
||||
bool initialized_{false};
|
||||
#endif
|
||||
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
|
||||
char *config_hash_buf);
|
||||
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, const char *mac_address_buf,
|
||||
const char *config_hash_buf);
|
||||
};
|
||||
|
||||
} // namespace esphome::mdns
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace esphome::mdns {
|
||||
|
||||
void MDNSComponent::setup() {
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
#ifdef USE_API
|
||||
#ifdef USE_MDNS_DEVICE_INFO_TXT
|
||||
get_mac_address_into_buffer(this->mac_address_);
|
||||
char *mac_ptr = this->mac_address_;
|
||||
format_hex_to(this->config_hash_str_, App.get_config_hash());
|
||||
|
||||
@@ -26,16 +26,22 @@ from esphome.const import (
|
||||
CONF_OFFSET_HEIGHT,
|
||||
CONF_OFFSET_WIDTH,
|
||||
CONF_PAGES,
|
||||
CONF_RESET_PIN,
|
||||
CONF_ROTATION,
|
||||
CONF_SWAP_XY,
|
||||
CONF_TRANSFORM,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.core import TimePeriod
|
||||
from esphome.core import CORE, TimePeriod
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
|
||||
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")
|
||||
|
||||
NOP = 0x00
|
||||
@@ -302,7 +308,8 @@ class DriverChip:
|
||||
"""
|
||||
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.
|
||||
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] = {}
|
||||
@@ -387,11 +394,15 @@ class DriverChip:
|
||||
"""
|
||||
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):
|
||||
return set()
|
||||
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}
|
||||
raise ValueError(
|
||||
"Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead"
|
||||
)
|
||||
|
||||
def has_hardware_transform(self, config) -> bool:
|
||||
"""
|
||||
@@ -533,17 +544,31 @@ class DriverChip:
|
||||
transform[CONF_TRANSFORM] = self.rotation_as_transform(config)
|
||||
return transform
|
||||
|
||||
def swap_xy_schema(self):
|
||||
uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED
|
||||
def transform_schema(self):
|
||||
"""
|
||||
Build the schema for the ``transform`` config option of this model.
|
||||
|
||||
def validator(value):
|
||||
if value:
|
||||
raise cv.Invalid("Axis swapping not supported by this model")
|
||||
return cv.boolean(value)
|
||||
Each transform the model supports is a required boolean. A transform the model does not
|
||||
support may be omitted or set to ``false``; setting it to ``true`` reports a clear error
|
||||
naming the unsupported transform instead of a generic "extra keys not allowed".
|
||||
"""
|
||||
supported = self.transforms
|
||||
|
||||
if uses_swap:
|
||||
return {cv.Required(CONF_SWAP_XY): cv.boolean}
|
||||
return {cv.Optional(CONF_SWAP_XY, default=False): validator}
|
||||
def unsupported(name):
|
||||
def validator(value):
|
||||
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:
|
||||
"""
|
||||
@@ -577,12 +602,15 @@ class DriverChip:
|
||||
"""
|
||||
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.
|
||||
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
|
||||
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
|
||||
"""
|
||||
sequence = list(self.initsequence or ())
|
||||
@@ -591,6 +619,15 @@ class DriverChip:
|
||||
# Ensure each command is a tuple
|
||||
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
|
||||
pixel_mode = config[CONF_PIXEL_MODE]
|
||||
if not isinstance(pixel_mode, int):
|
||||
@@ -611,13 +648,47 @@ class DriverChip:
|
||||
sequence.append((BRIGHTNESS, brightness))
|
||||
# Add a SLPOUT command if required.
|
||||
if not self.skip_command("SLPOUT"):
|
||||
# A zero delay will delay until 120ms after reset
|
||||
sequence.append(delay(0))
|
||||
sequence.append((SLPOUT,))
|
||||
sequence.append(delay(10))
|
||||
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
|
||||
# or the delay flag inserted where needed
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -41,24 +41,21 @@ from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_COLOR_ORDER,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_DISABLED,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_ID,
|
||||
CONF_INIT_SEQUENCE,
|
||||
CONF_INVERT_COLORS,
|
||||
CONF_LAMBDA,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_RESET_PIN,
|
||||
CONF_ROTATION,
|
||||
CONF_SWAP_XY,
|
||||
CONF_TRANSFORM,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.final_validate import full_config
|
||||
|
||||
from . import mipi_dsi_ns, models
|
||||
from .models import DsiDriverChip
|
||||
|
||||
# Currently only ESP32-P4 is supported, so esp_ldo and psram are required
|
||||
DEPENDENCIES = ["esp32", "esp_ldo", "psram"]
|
||||
@@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness")
|
||||
CONF_LANE_BIT_RATE = "lane_bit_rate"
|
||||
CONF_LANES = "lanes"
|
||||
|
||||
DriverChip("CUSTOM")
|
||||
DsiDriverChip("CUSTOM")
|
||||
|
||||
# Import all models dynamically from the models package
|
||||
|
||||
@@ -90,19 +87,7 @@ COLOR_DEPTHS = {
|
||||
|
||||
def model_schema(config):
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.defaults[CONF_SWAP_XY] = cv.UNDEFINED
|
||||
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),
|
||||
)
|
||||
transform = model.transform_schema()
|
||||
# CUSTOM model will need to provide a custom init sequence
|
||||
iseqconf = (
|
||||
cv.Required(CONF_INIT_SEQUENCE)
|
||||
@@ -172,6 +157,7 @@ def _config_schema(config):
|
||||
)(config)
|
||||
config = model_schema(config)(config)
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
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
|
||||
import esphome.config_validation as cv
|
||||
from . import DsiDriverChip
|
||||
|
||||
# fmt: off
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"JC1060P470",
|
||||
width=1024,
|
||||
height=600,
|
||||
@@ -14,7 +13,6 @@ DriverChip(
|
||||
vsync_front_porch=12,
|
||||
pclk_frequency="54MHz",
|
||||
lane_bit_rate="750Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(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)
|
||||
# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166)
|
||||
# ----------------------------------------------------------------------------------------------------------------------
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"JC4880P443",
|
||||
width=480,
|
||||
height=800,
|
||||
@@ -58,7 +56,6 @@ DriverChip(
|
||||
vsync_front_porch=166,
|
||||
pclk_frequency="34MHz",
|
||||
lane_bit_rate="500Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
reset_pin=5,
|
||||
initsequence=[
|
||||
@@ -111,7 +108,7 @@ DriverChip(
|
||||
# * 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)
|
||||
# ----------------------------------------------------------------------------------------------------------------------
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"JC8012P4A1",
|
||||
width=800,
|
||||
height=1280,
|
||||
@@ -123,7 +120,6 @@ DriverChip(
|
||||
vsync_front_porch=20,
|
||||
pclk_frequency="60MHz",
|
||||
lane_bit_rate="1Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
reset_pin=27,
|
||||
initsequence=[
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
import esphome.config_validation as cv
|
||||
from . import DsiDriverChip
|
||||
|
||||
# fmt: off
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"M5STACK-TAB5",
|
||||
height=1280,
|
||||
width=720,
|
||||
@@ -14,7 +13,6 @@ DriverChip(
|
||||
vsync_front_porch=20,
|
||||
pclk_frequency="60MHz",
|
||||
lane_bit_rate="730Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
|
||||
@@ -56,8 +54,8 @@ DriverChip(
|
||||
],
|
||||
)
|
||||
|
||||
DriverChip(
|
||||
"M5STACK-TAB5-V2",
|
||||
TAB5_ST7123 = DsiDriverChip(
|
||||
"M5STACK-TAB5-ST7123",
|
||||
height=1280,
|
||||
width=720,
|
||||
hsync_back_porch=40,
|
||||
@@ -68,7 +66,6 @@ DriverChip(
|
||||
vsync_front_porch=220,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="960Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(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),
|
||||
],
|
||||
)
|
||||
|
||||
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),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
import esphome.config_validation as cv
|
||||
from . import DsiDriverChip
|
||||
|
||||
# Standalone display
|
||||
# Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"SEEED-RETERMINAL-D1001",
|
||||
height=1280,
|
||||
width=800,
|
||||
@@ -15,10 +14,10 @@ DriverChip(
|
||||
vsync_front_porch=30,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
|
||||
reset_pin={"xl9535": None, "number": 2},
|
||||
requires={"psram", "xl9535"},
|
||||
initsequence=(
|
||||
(0xE0, 0x00),
|
||||
(0xE1, 0x93),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
import esphome.config_validation as cv
|
||||
from . import DsiDriverChip
|
||||
|
||||
# fmt: off
|
||||
|
||||
# Source for parameters and initsequence:
|
||||
# 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
|
||||
JD9365_10_1_DSI_TOUCH_A = DriverChip(
|
||||
JD9365_10_1_DSI_TOUCH_A = DsiDriverChip(
|
||||
"WAVESHARE-P4-NANO-10.1",
|
||||
height=1280,
|
||||
width=800,
|
||||
@@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip(
|
||||
vsync_front_porch=30,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
@@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend(
|
||||
# Source for parameters and initsequence:
|
||||
# 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
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"WAVESHARE-P4-86-PANEL",
|
||||
height=720,
|
||||
width=720,
|
||||
@@ -77,7 +75,6 @@ DriverChip(
|
||||
vsync_front_porch=20,
|
||||
pclk_frequency="38MHz",
|
||||
lane_bit_rate="480Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
reset_pin=27,
|
||||
initsequence=[
|
||||
@@ -109,7 +106,7 @@ DriverChip(
|
||||
# Source for parameters and initsequence:
|
||||
# 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
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B",
|
||||
height=600,
|
||||
width=1024,
|
||||
@@ -139,7 +136,7 @@ DriverChip(
|
||||
# Source for parameters and initsequence:
|
||||
# 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
|
||||
JD9365_3_4_DSI_TOUCH_C = DriverChip(
|
||||
JD9365_3_4_DSI_TOUCH_C = DsiDriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C",
|
||||
height=800,
|
||||
width=800,
|
||||
@@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip(
|
||||
vsync_front_porch=24,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
@@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend(
|
||||
# Source for parameters and initsequence:
|
||||
# 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
|
||||
JD9365_4_DSI_TOUCH_C = DriverChip(
|
||||
JD9365_4_DSI_TOUCH_C = DsiDriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C",
|
||||
height=720,
|
||||
width=720,
|
||||
@@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip(
|
||||
vsync_front_porch=24,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
@@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend(
|
||||
# Source for parameters and initsequence:
|
||||
# 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
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"WAVESHARE-8-DSI-TOUCH-A",
|
||||
height=1280,
|
||||
width=800,
|
||||
@@ -267,7 +262,6 @@ DriverChip(
|
||||
vsync_front_porch=30,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
@@ -304,7 +298,7 @@ DriverChip(
|
||||
# Source for parameters and initsequence:
|
||||
# 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
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"WAVESHARE-7-DSI-TOUCH-A",
|
||||
height=1280,
|
||||
width=720,
|
||||
|
||||
@@ -18,6 +18,8 @@ from esphome.components.mipi import (
|
||||
CONF_HSYNC_BACK_PORCH,
|
||||
CONF_HSYNC_FRONT_PORCH,
|
||||
CONF_HSYNC_PULSE_WIDTH,
|
||||
CONF_PCLK_FREQUENCY,
|
||||
CONF_PCLK_INVERTED,
|
||||
CONF_PCLK_PIN,
|
||||
CONF_PIXEL_MODE,
|
||||
CONF_USE_AXIS_FLIPS,
|
||||
@@ -34,9 +36,11 @@ from esphome.components.mipi import (
|
||||
power_of_two,
|
||||
requires_buffer,
|
||||
)
|
||||
from esphome.components.rpi_dpi_rgb.display import (
|
||||
CONF_PCLK_FREQUENCY,
|
||||
CONF_PCLK_INVERTED,
|
||||
from esphome.components.spi import (
|
||||
CONF_SPI_MODE,
|
||||
SPI_DATA_RATE_SCHEMA,
|
||||
SPI_MODE_OPTIONS,
|
||||
SPIComponent,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -48,7 +52,6 @@ from esphome.const import (
|
||||
CONF_DATA_RATE,
|
||||
CONF_DC_PIN,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_DISABLED,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_GREEN,
|
||||
CONF_HSYNC_PIN,
|
||||
@@ -57,8 +60,6 @@ from esphome.const import (
|
||||
CONF_INIT_SEQUENCE,
|
||||
CONF_INVERT_COLORS,
|
||||
CONF_LAMBDA,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_NUMBER,
|
||||
CONF_RED,
|
||||
@@ -72,10 +73,10 @@ from esphome.const import (
|
||||
)
|
||||
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 .models import RgbDriverChip
|
||||
|
||||
DEPENDENCIES = ["esp32", "psram"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
|
||||
mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb")
|
||||
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
|
||||
|
||||
DriverChip("CUSTOM")
|
||||
RgbDriverChip("CUSTOM")
|
||||
|
||||
# Import all models dynamically from the models package
|
||||
|
||||
@@ -120,16 +121,7 @@ def data_pin_set(length):
|
||||
|
||||
def model_schema(config):
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
transform = cv.Any(
|
||||
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),
|
||||
)
|
||||
transform = model.transform_schema()
|
||||
# RPI model does not use an init sequence, indicates with empty list
|
||||
if model.initsequence is None:
|
||||
# Custom model requires an init sequence
|
||||
@@ -235,6 +227,7 @@ def _config_schema(config):
|
||||
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
|
||||
)(config)
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
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,
|
||||
height=480,
|
||||
data_rate="2MHz",
|
||||
requires={"psram"},
|
||||
cs_pin=39,
|
||||
de_pin=18,
|
||||
hsync_pin=16,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from esphome.config_validation import UNDEFINED
|
||||
|
||||
from .st7701s import ST7701S
|
||||
|
||||
# fmt: off
|
||||
@@ -8,10 +6,10 @@ ST7701S(
|
||||
width=480,
|
||||
height=480,
|
||||
invert_colors=False,
|
||||
swap_xy=UNDEFINED,
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 17},
|
||||
reset_pin={"xl9535": None, "number": 5},
|
||||
requires={"psram", "xl9535"},
|
||||
hsync_pin=39,
|
||||
vsync_pin=40,
|
||||
pclk_pin=41,
|
||||
@@ -57,9 +55,9 @@ t_rgb = ST7701S(
|
||||
height=480,
|
||||
pixel_mode="18bit",
|
||||
invert_colors=False,
|
||||
swap_xy=UNDEFINED,
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 3},
|
||||
requires={"psram", "xl9535"},
|
||||
de_pin=45,
|
||||
hsync_pin=47,
|
||||
vsync_pin=41,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from . import RgbDriverChip
|
||||
|
||||
# A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence
|
||||
DriverChip(
|
||||
RgbDriverChip(
|
||||
"RPI",
|
||||
swap_xy=UNDEFINED,
|
||||
initsequence=(),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
from esphome.components.mipi import (
|
||||
MADCTL,
|
||||
MADCTL_ML,
|
||||
MADCTL_XFLIP,
|
||||
MODE_BGR,
|
||||
DriverChip,
|
||||
)
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR
|
||||
from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y
|
||||
|
||||
from . import RgbDriverChip
|
||||
|
||||
SDIR_CMD = 0xC7
|
||||
|
||||
|
||||
class ST7701S(DriverChip):
|
||||
class ST7701S(RgbDriverChip):
|
||||
# The ST7701s does not use the standard MADCTL bits for x/y mirroring
|
||||
def add_madctl(self, sequence: list, config: dict):
|
||||
transform = self.get_transform(config)
|
||||
@@ -45,7 +40,6 @@ st7701s = ST7701S(
|
||||
"ST7701S",
|
||||
width=480,
|
||||
height=864,
|
||||
swap_xy=UNDEFINED,
|
||||
hsync_front_porch=20,
|
||||
hsync_back_porch=10,
|
||||
hsync_pulse_width=10,
|
||||
@@ -85,6 +79,7 @@ st7701s.extend(
|
||||
height=480,
|
||||
invert_colors=True,
|
||||
pixel_mode="18bit",
|
||||
requires={"psram"},
|
||||
cs_pin=1,
|
||||
de_pin={
|
||||
"number": 45,
|
||||
@@ -117,6 +112,7 @@ st7701s.extend(
|
||||
vsync_pulse_width=8,
|
||||
vsync_back_porch=20,
|
||||
cs_pin={"pca9554": None, "number": 4},
|
||||
requires={"psram", "pca9554"},
|
||||
de_pin=18,
|
||||
hsync_pin=16,
|
||||
vsync_pin=17,
|
||||
@@ -134,6 +130,7 @@ st7701s.extend(
|
||||
width=480,
|
||||
height=480,
|
||||
pixel_mode="18bit",
|
||||
requires={"psram"},
|
||||
cs_pin=18,
|
||||
reset_pin=8,
|
||||
de_pin=17,
|
||||
@@ -177,6 +174,7 @@ st7701s.extend(
|
||||
width=480,
|
||||
height=480,
|
||||
pixel_mode="18bit",
|
||||
requires={"psram"},
|
||||
cs_pin=21,
|
||||
de_pin=39,
|
||||
vsync_pin=48,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from . import RgbDriverChip
|
||||
|
||||
# fmt: off
|
||||
sunton = DriverChip(
|
||||
sunton = RgbDriverChip(
|
||||
"ESP32-8048S070",
|
||||
swap_xy=UNDEFINED,
|
||||
initsequence=(),
|
||||
width=800,
|
||||
height=480,
|
||||
pclk_frequency="12.5MHz",
|
||||
requires={"psram"},
|
||||
de_pin=41,
|
||||
hsync_pin=39,
|
||||
vsync_pin=40,
|
||||
@@ -28,7 +27,6 @@ sunton = DriverChip(
|
||||
|
||||
sunton.extend(
|
||||
"ESP32-8048S050",
|
||||
swap_xy=UNDEFINED,
|
||||
initsequence=(),
|
||||
width=800,
|
||||
height=480,
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
from esphome.components.mipi import DriverChip, delay
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from esphome.components.mipi import delay
|
||||
|
||||
from . import RgbDriverChip
|
||||
from .st7701s import st7701s
|
||||
|
||||
# fmt: off
|
||||
wave_4_3 = DriverChip(
|
||||
wave_4_3 = RgbDriverChip(
|
||||
"ESP32-S3-TOUCH-LCD-4.3",
|
||||
swap_xy=UNDEFINED,
|
||||
initsequence=(),
|
||||
width=800,
|
||||
height=480,
|
||||
pclk_frequency="16MHz",
|
||||
reset_pin={"ch422g": None, "number": 3},
|
||||
enable_pin={"ch422g": None, "number": 2},
|
||||
requires={"psram", "ch422g"},
|
||||
de_pin=5,
|
||||
hsync_pin={"number": 46, "ignore_strapping_warning": True},
|
||||
vsync_pin={"number": 3, "ignore_strapping_warning": True},
|
||||
@@ -69,6 +69,7 @@ st7701s.extend(
|
||||
pclk_pin=41,
|
||||
pclk_frequency="12MHz",
|
||||
pclk_inverted=False,
|
||||
requires={"psram"},
|
||||
data_pins={
|
||||
"red": [46, 3, 8, 18, 17],
|
||||
"green": [14, 13, 12, 11, 10, 9],
|
||||
@@ -80,6 +81,7 @@ st7701s.extend(
|
||||
"WAVESHARE-3.16-320X820",
|
||||
width=320,
|
||||
height=820,
|
||||
requires={"psram"},
|
||||
de_pin=40,
|
||||
hsync_pin=38,
|
||||
vsync_pin=39,
|
||||
|
||||
@@ -41,14 +41,11 @@ from esphome.const import (
|
||||
CONF_DATA_RATE,
|
||||
CONF_DC_PIN,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_DISABLED,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_ID,
|
||||
CONF_INIT_SEQUENCE,
|
||||
CONF_INVERT_COLORS,
|
||||
CONF_LAMBDA,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_RESET_PIN,
|
||||
CONF_ROTATION,
|
||||
@@ -138,16 +135,7 @@ def denominator(config):
|
||||
def model_schema(config):
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
bus_mode = config[CONF_BUS_MODE]
|
||||
transform = cv.Any(
|
||||
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),
|
||||
)
|
||||
transform = model.transform_schema()
|
||||
# CUSTOM model will need to provide a custom init sequence
|
||||
iseqconf = (
|
||||
cv.Required(CONF_INIT_SEQUENCE)
|
||||
@@ -265,6 +253,7 @@ def customise_schema(config):
|
||||
extra=ALLOW_EXTRA,
|
||||
)(config)
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
model.check_requirements()
|
||||
bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL)
|
||||
config = cv.Schema(
|
||||
{
|
||||
@@ -408,7 +397,7 @@ def get_instance(config):
|
||||
async def to_code(config):
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
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 = cg.new_Pvariable(var_id, TemplateArguments(*templateargs))
|
||||
cg.add(var.set_init_sequence(init_sequence))
|
||||
|
||||
@@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi";
|
||||
|
||||
// Maximum bytes to log for commands (truncated if larger)
|
||||
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 SLEEP_OUT = 0x11;
|
||||
static constexpr uint8_t NORON = 0x13;
|
||||
@@ -151,14 +153,11 @@ class MipiSpi : public display::Display,
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(true);
|
||||
} else {
|
||||
// no reset pin, send software reset command
|
||||
this->write_command_(SW_RESET_CMD);
|
||||
// required delay after reset is already in the init sequence, don't duplicate
|
||||
}
|
||||
|
||||
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
|
||||
auto when = millis() + 120;
|
||||
delay(10);
|
||||
size_t index = 0;
|
||||
auto &vec = this->init_sequence_;
|
||||
while (index != vec.size()) {
|
||||
@@ -170,6 +169,9 @@ class MipiSpi : public display::Display,
|
||||
uint8_t cmd = vec[index++];
|
||||
uint8_t x = vec[index++];
|
||||
if (x == DELAY_FLAG) {
|
||||
if (cmd == 0) {
|
||||
cmd = clamp_at_least((int) (when - millis()), 0);
|
||||
}
|
||||
esph_log_d(TAG, "Delay %dms", cmd);
|
||||
delay(cmd);
|
||||
} else {
|
||||
@@ -179,24 +181,9 @@ class MipiSpi : public display::Display,
|
||||
this->mark_failed();
|
||||
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;
|
||||
this->write_command_(cmd, ptr, num_args);
|
||||
index += num_args;
|
||||
if (cmd == SLEEP_OUT)
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
this->reset_params_();
|
||||
|
||||
@@ -13,6 +13,7 @@ ST7789V.extend(
|
||||
mirror_x=True,
|
||||
mirror_y=True,
|
||||
data_rate="80MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
ST7789V.extend(
|
||||
@@ -25,4 +26,5 @@ ST7789V.extend(
|
||||
dc_pin=39,
|
||||
reset_pin=40,
|
||||
invert_colors=True,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ from esphome.components.mipi import (
|
||||
delay,
|
||||
)
|
||||
from esphome.components.spi import TYPE_QUAD
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y
|
||||
|
||||
DriverChip(
|
||||
"T-DISPLAY-S3-AMOLED",
|
||||
@@ -29,6 +29,7 @@ DriverChip(
|
||||
brightness=0xD0,
|
||||
color_order=MODE_RGB,
|
||||
no_slpout=True, # SLPOUT is in the init sequence, early
|
||||
requires={"psram"},
|
||||
initsequence=(SLPOUT,),
|
||||
)
|
||||
|
||||
@@ -43,6 +44,7 @@ DriverChip(
|
||||
data_rate="40MHz",
|
||||
brightness=0xD0,
|
||||
color_order=MODE_RGB,
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(PAGESEL, 4),
|
||||
(0x6A, 0x00),
|
||||
@@ -90,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend(
|
||||
reset_pin=13,
|
||||
enable_pin=9,
|
||||
bus_mode=TYPE_QUAD,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
CO5300 = DriverChip(
|
||||
@@ -98,7 +101,7 @@ CO5300 = DriverChip(
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
no_slpout=True,
|
||||
swap_xy=UNDEFINED,
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
width=480,
|
||||
height=480,
|
||||
initsequence=(
|
||||
|
||||
@@ -314,6 +314,7 @@ DriverChip(
|
||||
data_rate="40MHz",
|
||||
dc_pin=4,
|
||||
cs_pin=5,
|
||||
requires={"psram"},
|
||||
# reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48},
|
||||
initsequence=(
|
||||
(0xEF, 0x03, 0x80, 0x02),
|
||||
@@ -379,6 +380,7 @@ DriverChip(
|
||||
cs_pin=5,
|
||||
dc_pin=4,
|
||||
reset_pin=48,
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xEF, 0x03, 0x80, 0x02),
|
||||
(0xCF, 0x00, 0xC1, 0x30),
|
||||
@@ -711,6 +713,7 @@ ST7796.extend(
|
||||
reset_pin=4,
|
||||
dc_pin={"number": 0, "ignore_strapping_warning": True},
|
||||
invert_colors=True,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
ST7789V.extend(
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
from esphome.components.mipi import MODE_RGB, DriverChip
|
||||
from esphome.components.spi import TYPE_QUAD
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER
|
||||
from esphome.const import (
|
||||
CONF_IGNORE_STRAPPING_WARNING,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_NUMBER,
|
||||
)
|
||||
|
||||
AXS15231 = DriverChip(
|
||||
"AXS15231",
|
||||
draw_rounding=8,
|
||||
swap_xy=cv.UNDEFINED,
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
no_swreset=True,
|
||||
initsequence=(
|
||||
(0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5),
|
||||
(0xC1, 0x33),
|
||||
@@ -22,6 +27,7 @@ AXS15231.extend(
|
||||
height=480,
|
||||
cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True},
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
DriverChip(
|
||||
@@ -36,6 +42,7 @@ DriverChip(
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xF0, 0x08),
|
||||
(0xF2, 0x08),
|
||||
@@ -267,6 +274,7 @@ DriverChip(
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xF0, 0x28),
|
||||
(0xF2, 0x28),
|
||||
@@ -495,6 +503,7 @@ DriverChip(
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
data_rate="20MHz",
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xFF, 0xA5),
|
||||
(0x41, 0x03),
|
||||
|
||||
@@ -10,4 +10,5 @@ ST7789V.extend(
|
||||
cs_pin=22,
|
||||
dc_pin=21,
|
||||
reset_pin=18,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ ST7789V.extend(
|
||||
dc_pin=13,
|
||||
reset_pin=9,
|
||||
data_rate="80MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
ST7789V.extend(
|
||||
@@ -42,6 +43,7 @@ ST7789V.extend(
|
||||
enable_pin=[9, 15],
|
||||
data_rate="10MHz",
|
||||
bus_mode=TYPE_OCTAL,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
ST7796.extend(
|
||||
@@ -55,4 +57,5 @@ ST7796.extend(
|
||||
dc_pin=9,
|
||||
backlight_pin=48,
|
||||
invert_colors=True,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ ILI9341.extend(
|
||||
invert_colors=True,
|
||||
pixel_mode="18bit",
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
GC9107 = ST7789V.extend(
|
||||
@@ -68,4 +69,5 @@ GC9107.extend(
|
||||
reset_pin=48,
|
||||
dc_pin=42,
|
||||
cs_pin=14,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from esphome.components.mipi import (
|
||||
PWSET,
|
||||
DriverChip,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y
|
||||
|
||||
from .amoled import CO5300
|
||||
from .ili import ILI9488_A, ST7789V
|
||||
@@ -155,7 +155,7 @@ ST7789P = DriverChip(
|
||||
|
||||
ILI9488_A.extend(
|
||||
"PICO-RESTOUCH-LCD-3.5",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
spi_16=True,
|
||||
pixel_mode="16bit",
|
||||
mirror_x=True,
|
||||
@@ -175,6 +175,7 @@ CO5300.extend(
|
||||
offset_width=6,
|
||||
cs_pin=12,
|
||||
reset_pin=39,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
# Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller)
|
||||
@@ -189,6 +190,7 @@ CO5300.extend(
|
||||
cs_pin=12,
|
||||
reset_pin=39,
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
AXS15231.extend(
|
||||
@@ -198,6 +200,7 @@ AXS15231.extend(
|
||||
data_rate="80MHz",
|
||||
cs_pin=9,
|
||||
reset_pin=21,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
# Waveshare 1.83-v2
|
||||
@@ -281,6 +284,7 @@ ST7789V.extend(
|
||||
offset_height=40,
|
||||
invert_colors=True,
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
CO5300.extend(
|
||||
@@ -291,4 +295,5 @@ CO5300.extend(
|
||||
cs_pin=9,
|
||||
reset_pin=21,
|
||||
enable_pin=1,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -212,9 +212,15 @@ _NUMBER_SCHEMA = (
|
||||
},
|
||||
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
|
||||
),
|
||||
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
|
||||
cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True),
|
||||
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
|
||||
cv.Optional(
|
||||
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
|
||||
): 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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -321,13 +321,25 @@ _SENSOR_SCHEMA = (
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent),
|
||||
cv.GenerateID(): cv.declare_id(Sensor),
|
||||
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
|
||||
cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals,
|
||||
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
|
||||
cv.Optional(CONF_STATE_CLASS): validate_state_class,
|
||||
cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category,
|
||||
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_EXPIRE_AFTER): cv.All(
|
||||
cv.Optional(
|
||||
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
|
||||
): validate_unit_of_measurement,
|
||||
cv.Optional(
|
||||
CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_accuracy_decimals,
|
||||
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.Any(None, cv.positive_time_period_milliseconds),
|
||||
),
|
||||
|
||||
@@ -78,7 +78,9 @@ _SWITCH_SCHEMA = (
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TURN_ON): 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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = (
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor),
|
||||
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_ON_VALUE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
|
||||
|
||||
@@ -54,7 +54,9 @@ _UPDATE_SCHEMA = (
|
||||
.extend(
|
||||
{
|
||||
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(
|
||||
single=True
|
||||
),
|
||||
@@ -136,7 +138,9 @@ async def to_code(config):
|
||||
automation.maybe_simple_id(
|
||||
{
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
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
|
||||
// allocate() returned non-null, the queue cannot be full.
|
||||
this->output_queue_.push(chunk);
|
||||
|
||||
@@ -87,7 +87,9 @@ _VALVE_SCHEMA = (
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Valve),
|
||||
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.requires_component("mqtt"), cv.subscribe_topic
|
||||
),
|
||||
|
||||
@@ -172,7 +172,9 @@ sorting_group = {
|
||||
|
||||
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.Optional(CONF_SORTING_WEIGHT): cv.All(
|
||||
|
||||
@@ -56,9 +56,8 @@ namespace esphome::web_server {
|
||||
|
||||
static const char *const TAG = "web_server";
|
||||
|
||||
// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up)
|
||||
static constexpr size_t PSTR_LOCAL_SIZE = 18;
|
||||
#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1)
|
||||
// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266.
|
||||
[[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast<ProgmemStr>(s); }
|
||||
|
||||
// Parse URL and return match info
|
||||
// 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;
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state,
|
||||
const T &value, JsonDetail start_config) {
|
||||
template<typename S, typename T>
|
||||
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value,
|
||||
JsonDetail start_config) {
|
||||
set_json_value(root, obj, prefix, value, start_config);
|
||||
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,
|
||||
start_config);
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation));
|
||||
root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation));
|
||||
|
||||
if (obj->get_traits().get_supports_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();
|
||||
int8_t target_accuracy = traits.get_target_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];
|
||||
|
||||
if (start_config == DETAIL_ALL) {
|
||||
JsonArray opt = root[ESPHOME_F("modes")].to<JsonArray>();
|
||||
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()) {
|
||||
JsonArray opt = root[ESPHOME_F("fan_modes")].to<JsonArray>();
|
||||
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()) {
|
||||
@@ -1551,12 +1548,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
|
||||
if (traits.get_supports_swing_modes()) {
|
||||
JsonArray opt = root[ESPHOME_F("swing_modes")].to<JsonArray>();
|
||||
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()) {
|
||||
JsonArray opt = root[ESPHOME_F("presets")].to<JsonArray>();
|
||||
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()) {
|
||||
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;
|
||||
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)) {
|
||||
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")];
|
||||
has_state = true;
|
||||
}
|
||||
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()) {
|
||||
root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode();
|
||||
}
|
||||
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()) {
|
||||
root[ESPHOME_F("custom_preset")] = obj->get_custom_preset();
|
||||
}
|
||||
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)) {
|
||||
root[ESPHOME_F("current_temperature")] =
|
||||
@@ -1695,8 +1692,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config);
|
||||
set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config);
|
||||
if (start_config == DETAIL_ALL) {
|
||||
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,
|
||||
start_config);
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation));
|
||||
root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation));
|
||||
|
||||
if (obj->get_traits().get_supports_position())
|
||||
root[ESPHOME_F("position")] = obj->position;
|
||||
@@ -1863,9 +1858,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)),
|
||||
value, start_config);
|
||||
set_json_icon_state_value(root, obj, "alarm-control-panel",
|
||||
json_state_str(alarm_control_panel_state_to_string(value)), value, start_config);
|
||||
if (start_config == DETAIL_ALL) {
|
||||
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::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
|
||||
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);
|
||||
|
||||
@@ -1949,7 +1942,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea
|
||||
if (start_config == DETAIL_ALL) {
|
||||
JsonArray modes = root[ESPHOME_F("modes")].to<JsonArray>();
|
||||
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("max_temp")] = traits.get_max_temperature();
|
||||
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;
|
||||
JsonObject root = builder.root();
|
||||
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)),
|
||||
set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)),
|
||||
obj->update_info.latest_version, start_config);
|
||||
if (start_config == DETAIL_ALL) {
|
||||
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) {
|
||||
this->report_enabled = true;
|
||||
if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) {
|
||||
|
||||
@@ -42,7 +42,6 @@ class ZigbeeAttribute final : public Component {
|
||||
scale_(scale) {}
|
||||
void loop() override;
|
||||
template<typename T> void add_attr(T value);
|
||||
void setup_reporting();
|
||||
template<typename T> void set_attr(const T &value);
|
||||
uint8_t attr_type() { return attr_type_; }
|
||||
void set_report(ZigbeeReportT report);
|
||||
|
||||
@@ -94,66 +94,73 @@ def get_next_ep_num(eps: list[int]) -> int:
|
||||
return ep_num
|
||||
|
||||
|
||||
def merge_endpoint(
|
||||
def compare_clusters(
|
||||
existing_ep: dict[str, Any],
|
||||
ep_num: int | None,
|
||||
ep: dict[str, Any],
|
||||
use_type: bool | None,
|
||||
skip_error: bool,
|
||||
) -> bool:
|
||||
add = True
|
||||
) -> tuple[str | int, str] | None:
|
||||
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]]:
|
||||
if cl in existing_clusters:
|
||||
if not skip_error:
|
||||
raise cv.Invalid(
|
||||
f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}."
|
||||
)
|
||||
add = False
|
||||
break
|
||||
if not add:
|
||||
return cl
|
||||
return None
|
||||
|
||||
|
||||
def merge_endpoints(
|
||||
existing_ep: dict[str, Any],
|
||||
ep: dict[str, Any],
|
||||
use_type: bool | None,
|
||||
) -> bool:
|
||||
if compare_clusters(existing_ep, ep):
|
||||
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 (
|
||||
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
|
||||
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):
|
||||
existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE]
|
||||
existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS])
|
||||
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:
|
||||
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
|
||||
ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {})
|
||||
ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, [])
|
||||
validate_endpoints(ep_dict)
|
||||
# create dummy endpoint if list is empty
|
||||
if not ep_dict and not ep_list:
|
||||
ep_type = "CUSTOM_ATTR"
|
||||
@@ -166,9 +173,7 @@ def create_ep(router: bool) -> None:
|
||||
for ep in ep_list:
|
||||
added = False
|
||||
for existing_ep in ep_list_new:
|
||||
if merge_endpoint(
|
||||
existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True
|
||||
):
|
||||
if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)):
|
||||
added = True
|
||||
break
|
||||
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:
|
||||
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
|
||||
if use_type is False:
|
||||
ep.pop(DEVICE_TYPE, None)
|
||||
if ep_num is None:
|
||||
if 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:
|
||||
# check if the existing endpoint has same clusters
|
||||
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:
|
||||
if use_type is not None:
|
||||
ep[CONF_USE_DEVICE_TYPE] = use_type
|
||||
if use_type or ep.get(DEVICE_TYPE):
|
||||
ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type}
|
||||
ep_dict[ep_num] = ep
|
||||
|
||||
@@ -53,11 +53,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) {
|
||||
switch (signal_type) {
|
||||
case EZB_ZDO_SIGNAL_SKIP_STARTUP:
|
||||
ESP_LOGD(TAG, "Zigbee stack initialized");
|
||||
if (ezb_bdb_is_factory_new()) {
|
||||
global_zigbee->defer([]() { global_zigbee->setup_reporting(); });
|
||||
} else {
|
||||
ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION);
|
||||
}
|
||||
ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION);
|
||||
break;
|
||||
case EZB_BDB_SIGNAL_DEVICE_FIRST_START:
|
||||
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:
|
||||
zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message);
|
||||
break;
|
||||
#ifdef ESPHOME_LOG_HAS_VERBOSE
|
||||
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;
|
||||
ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code);
|
||||
} break;
|
||||
#endif
|
||||
} break;
|
||||
default:
|
||||
ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast<unsigned>(callback_id));
|
||||
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);
|
||||
}
|
||||
|
||||
void ZigbeeComponent::setup_reporting() {
|
||||
ESP_LOGD(TAG, "Setting up reporting for all attributes");
|
||||
esp_zigbee_lock_acquire(portMAX_DELAY);
|
||||
for (auto &[_, attribute] : this->attributes_) {
|
||||
attribute->setup_reporting();
|
||||
bool ZigbeeComponent::register_device() {
|
||||
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 false;
|
||||
}
|
||||
ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION);
|
||||
esp_zigbee_lock_release();
|
||||
return true;
|
||||
}
|
||||
|
||||
static void ezb_task(void *pv_parameters) {
|
||||
if (!global_zigbee->register_device()) {
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
if (esp_zigbee_start(false) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not setup Zigbee");
|
||||
global_zigbee->mark_failed();
|
||||
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_deinit();
|
||||
@@ -274,12 +279,6 @@ void ZigbeeComponent::setup() {
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ class ZigbeeComponent final : public Component {
|
||||
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 create_default_cluster(uint8_t endpoint_id, uint16_t device_id);
|
||||
void setup_reporting();
|
||||
bool register_device();
|
||||
|
||||
template<typename T>
|
||||
void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
|
||||
|
||||
@@ -18,13 +18,22 @@ static const char *const TAG = "zwave_proxy";
|
||||
static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168;
|
||||
|
||||
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_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 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 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) {
|
||||
// Calculate Z-Wave frame checksum
|
||||
// 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();
|
||||
if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -98,7 +112,18 @@ void ZWaveProxy::loop() {
|
||||
}
|
||||
|
||||
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_() {
|
||||
@@ -112,19 +137,24 @@ void ZWaveProxy::process_uart_slow_() {
|
||||
}
|
||||
if (this->parse_byte_(byte)) {
|
||||
// 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:
|
||||
// - buffer_[0]: Start of frame marker (0x01)
|
||||
// - buffer_[1]: Length field must be >= 9 to contain all required data
|
||||
// - buffer_[1]: Length field must be >= 7 so the frame contains the full home ID
|
||||
// - buffer_[2]: Command type (0x01 for response)
|
||||
// - 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 &&
|
||||
this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) {
|
||||
if (!this->in_bootloader_ && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH &&
|
||||
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
|
||||
// The frame parser has already validated the checksum and ensured all bytes are present
|
||||
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->home_id_ready_ = true;
|
||||
}
|
||||
ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr));
|
||||
if (this->api_connection_ != nullptr) {
|
||||
@@ -140,14 +170,19 @@ void ZWaveProxy::process_uart_slow_() {
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Z-Wave Proxy:\n"
|
||||
" Home ID: %s",
|
||||
format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()));
|
||||
ESP_LOGCONFIG(
|
||||
TAG,
|
||||
"Z-Wave Proxy:\n"
|
||||
" 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) {
|
||||
@@ -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) {
|
||||
switch (type) {
|
||||
case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE:
|
||||
if (this->api_connection_ != nullptr) {
|
||||
ESP_LOGE(TAG, "Only one API subscription is allowed at a time");
|
||||
if (this->api_connection_ == api_connection) {
|
||||
ESP_LOGV(TAG, "API connection is already subscribed");
|
||||
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;
|
||||
ESP_LOGV(TAG, "API connection is now subscribed");
|
||||
break;
|
||||
@@ -222,6 +267,7 @@ void ZWaveProxy::retry_home_id_query_() {
|
||||
void ZWaveProxy::clear_home_id_() {
|
||||
static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {};
|
||||
if (this->set_home_id_(ZERO_HOME_ID)) {
|
||||
ESP_LOGV(TAG, "Home ID cleared");
|
||||
this->send_homeid_changed_msg_();
|
||||
}
|
||||
this->home_id_ready_ = false;
|
||||
@@ -237,13 +283,20 @@ bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) {
|
||||
return false; // No change
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
if (data == nullptr) {
|
||||
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)
|
||||
uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00};
|
||||
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) {
|
||||
@@ -300,9 +353,12 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) {
|
||||
this->parse_start_(byte);
|
||||
break;
|
||||
case ZWAVE_PARSING_STATE_WAIT_LENGTH:
|
||||
if (!byte) {
|
||||
if (byte < ZWAVE_MIN_FRAME_LENGTH) {
|
||||
ESP_LOGW(TAG, "Invalid LENGTH: %u", byte);
|
||||
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;
|
||||
}
|
||||
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:
|
||||
this->buffer_[this->buffer_index_++] = 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;
|
||||
case ZWAVE_PARSING_STATE_WAIT_PAYLOAD:
|
||||
this->buffer_[this->buffer_index_++] = byte;
|
||||
@@ -347,12 +405,24 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) {
|
||||
break;
|
||||
}
|
||||
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->parse_start_(byte);
|
||||
break;
|
||||
}
|
||||
this->buffer_[this->buffer_index_++] = 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;
|
||||
frame_completed = true;
|
||||
}
|
||||
@@ -378,15 +448,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) {
|
||||
ESP_LOGD(TAG, "Exited bootloader mode");
|
||||
this->in_bootloader_ = false;
|
||||
}
|
||||
this->frame_start_time_ = App.get_loop_component_start_time();
|
||||
this->buffer_[this->buffer_index_++] = byte;
|
||||
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH;
|
||||
return;
|
||||
case ZWAVE_FRAME_TYPE_BL_MENU:
|
||||
ESP_LOGV(TAG, "Received BL_MENU");
|
||||
if (!this->in_bootloader_) {
|
||||
ESP_LOGD(TAG, "Entered bootloader mode");
|
||||
this->in_bootloader_ = true;
|
||||
}
|
||||
// Read the menu tentatively: a stray 0x0D can equally appear in garbled data after the
|
||||
// parser loses frame alignment, so bootloader mode is only committed once a plausible
|
||||
// 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->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU;
|
||||
return;
|
||||
@@ -403,7 +474,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) {
|
||||
ESP_LOGV(TAG, "Received CAN");
|
||||
break;
|
||||
default:
|
||||
ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte);
|
||||
ESP_LOGV(TAG, "Unrecognized START: 0x%02X", byte);
|
||||
return;
|
||||
}
|
||||
// Forward response (ACK/NAK/CAN) back to client for processing
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
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:
|
||||
bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed.
|
||||
void clear_home_id_(); // Clear home ID and notify API clients
|
||||
void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions
|
||||
void retry_home_id_query_(); // Retry home ID query after reconnect
|
||||
void send_frame_(const uint8_t *data, size_t length); // Write a frame to the Z-Wave module
|
||||
bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed.
|
||||
void clear_home_id_(); // Clear home ID and notify API clients
|
||||
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_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)
|
||||
@@ -114,6 +117,7 @@ class ZWaveProxy final : public uart::UARTDevice, public Component {
|
||||
api::APIConnection *api_connection_{nullptr}; // Current subscribed client
|
||||
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 frame_start_time_{0}; // Timestamp of the current frame's start byte (reception timeout)
|
||||
|
||||
// Small values (grouped by size to minimize padding)
|
||||
uint16_t buffer_index_{0}; // Index for populating the data buffer
|
||||
|
||||
@@ -292,10 +292,14 @@ class Visibility(StrEnum):
|
||||
the same way. ESPHome itself ignores the value at runtime;
|
||||
consumers downstream of the schema dump act on it.
|
||||
|
||||
A field with no ``visibility`` set (the default) renders on the
|
||||
editor's main form. The two values below are points along a
|
||||
single axis of "how prominently to surface this":
|
||||
Three points along a single axis of "how prominently to surface
|
||||
this", from least to most hidden:
|
||||
|
||||
- ``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"
|
||||
disclosure. Use for fields whose default is right for ~all
|
||||
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
|
||||
available for the rare power-user override.
|
||||
|
||||
The single-axis shape encodes "yaml-only is strictly stronger
|
||||
than advanced" at the type level — there's no way to ask for
|
||||
both at once, and no way to set a contradictory state like
|
||||
"advanced=False, yaml_only=True".
|
||||
Default when unset (``visibility=None``): resolved by the
|
||||
consumer, not encoded on the marker. A schema-aware editor
|
||||
treats an ``Optional`` with no setting as ``ADVANCED`` (most
|
||||
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
|
||||
and emits each field's setting independently. Cascading
|
||||
semantics — "a stricter parent makes its descendants at-least
|
||||
as strict" — belong on the consumer side: the schema marker
|
||||
is faithfully what the field author wrote, and a consumer that
|
||||
cares about effective visibility walks the parent chain and
|
||||
takes the strictest setting. ``YAML_ONLY`` is strictly stronger
|
||||
than ``ADVANCED``, which is strictly stronger than no setting.
|
||||
Inner fields can declare their own visibility; an inner
|
||||
and emits each field's setting independently, omitting the key
|
||||
when unset so the dump stays compact and the per-field default
|
||||
is the consumer's to apply. Cascading semantics — "a stricter
|
||||
parent makes its descendants at-least as strict" — belong on the
|
||||
consumer side: the schema marker is faithfully what the field
|
||||
author wrote, and a consumer that cares about effective
|
||||
visibility walks the parent chain and takes the strictest
|
||||
setting. Inner fields can declare their own visibility; an inner
|
||||
``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``,
|
||||
and the consumer's cascade keeps siblings under the parent at
|
||||
``ADVANCED`` regardless of their own (less-strict) setting.
|
||||
and the consumer's cascade keeps a ``UI`` sibling under an
|
||||
``ADVANCED`` parent at ``ADVANCED`` regardless of its own
|
||||
(less-strict) setting.
|
||||
"""
|
||||
|
||||
UI = "ui"
|
||||
ADVANCED = "advanced"
|
||||
YAML_ONLY = "yaml_only"
|
||||
|
||||
@@ -347,6 +361,9 @@ class Optional(vol.Optional):
|
||||
|
||||
See :class:`Visibility` for the ``visibility`` kwarg — a UI
|
||||
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__(
|
||||
@@ -369,9 +386,11 @@ class Required(vol.Required):
|
||||
|
||||
See :class:`Visibility` for the ``visibility`` kwarg — a UI
|
||||
hint for schema-driven editors that doesn't affect validation.
|
||||
Required fields rarely need it (a required field by definition
|
||||
needs the user's attention) but the kwarg is exposed for
|
||||
symmetry so consumers can apply uniform logic across key markers.
|
||||
Required fields rarely need it: left unset, a ``Required`` is
|
||||
treated as on the main form (``Visibility.UI``) by schema-aware
|
||||
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__(
|
||||
@@ -2274,16 +2293,25 @@ MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema(
|
||||
}
|
||||
)
|
||||
|
||||
# Per-entity MQTT plumbing — integration metadata, never a primary UI field.
|
||||
MQTT_COMPONENT_SCHEMA = Schema(
|
||||
{
|
||||
Optional(CONF_QOS): All(requires_component("mqtt"), mqtt_qos),
|
||||
Optional(CONF_RETAIN): All(requires_component("mqtt"), boolean),
|
||||
Optional(CONF_DISCOVERY): All(requires_component("mqtt"), boolean),
|
||||
Optional(CONF_SUBSCRIBE_QOS): All(requires_component("mqtt"), mqtt_qos),
|
||||
Optional(CONF_STATE_TOPIC): All(
|
||||
Optional(CONF_QOS, visibility=Visibility.ADVANCED): All(
|
||||
requires_component("mqtt"), mqtt_qos
|
||||
),
|
||||
Optional(CONF_RETAIN, visibility=Visibility.ADVANCED): 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)
|
||||
),
|
||||
Optional(CONF_AVAILABILITY): All(
|
||||
Optional(CONF_AVAILABILITY, visibility=Visibility.ADVANCED): All(
|
||||
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(
|
||||
{
|
||||
Optional(CONF_COMMAND_TOPIC): All(
|
||||
Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All(
|
||||
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(
|
||||
{
|
||||
Optional(CONF_NAME): _validate_entity_name,
|
||||
Optional(CONF_INTERNAL): boolean,
|
||||
Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean,
|
||||
Optional(CONF_ICON): icon,
|
||||
Optional(CONF_ENTITY_CATEGORY): entity_category,
|
||||
Optional(CONF_DEVICE_ID): sub_device_id,
|
||||
# The name is every entity's headline field — keep it on the
|
||||
# main form rather than letting it fall through to advanced.
|
||||
Optional(CONF_NAME, visibility=Visibility.UI): _validate_entity_name,
|
||||
Optional(CONF_INTERNAL, visibility=Visibility.ADVANCED): boolean,
|
||||
Optional(
|
||||
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
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.7.0b1"
|
||||
__version__ = "2026.7.0b2"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from esphome.const import (
|
||||
CONF_BUILD_PATH,
|
||||
CONF_COMMENT,
|
||||
CONF_ESPHOME,
|
||||
CONF_ETHERNET,
|
||||
@@ -574,6 +575,9 @@ class EsphomeCore:
|
||||
self.build_path: Path | None = None
|
||||
# The validated configuration, this is None until the config has been validated
|
||||
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 document separated by `---` from the actual configuration. It is
|
||||
# ignored by config validation and code generation, but kept here so it
|
||||
@@ -650,6 +654,7 @@ class EsphomeCore:
|
||||
self.config_path = None
|
||||
self.build_path = None
|
||||
self.config = None
|
||||
self.raw_config = None
|
||||
self.frontmatter = {}
|
||||
self.event_loop = _FakeEventLoop()
|
||||
self.task_counter = 0
|
||||
@@ -727,12 +732,28 @@ class EsphomeCore:
|
||||
|
||||
The hash is computed lazily and cached for performance.
|
||||
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:
|
||||
from esphome import yaml_util
|
||||
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)
|
||||
return self._config_hash
|
||||
|
||||
|
||||
+30
-6
@@ -840,17 +840,22 @@ def _load_yaml_internal_with_type(
|
||||
loader.dispose()
|
||||
|
||||
|
||||
def dump(dict_, show_secrets=False, sort_keys=False):
|
||||
"""Dump YAML to a string and remove null."""
|
||||
def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None):
|
||||
"""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:
|
||||
_SECRET_VALUES.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
|
||||
# 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):
|
||||
_redact_sensitive = not show_secrets
|
||||
_relative_to = relative_to
|
||||
|
||||
return yaml.dump(
|
||||
dict_,
|
||||
@@ -1002,9 +1007,13 @@ def format_path(path: DocumentPath, current_obj: Any) -> str:
|
||||
|
||||
|
||||
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.
|
||||
_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):
|
||||
value = []
|
||||
@@ -1040,6 +1049,21 @@ class ESPHomeDumper(yaml.SafeDumper):
|
||||
return self.represent_secret(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:
|
||||
# Only the redact-and-not-a-secret branch is unique to sensitive
|
||||
# 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(core.ID, ESPHomeDumper.represent_id)
|
||||
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)
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==45.5.2
|
||||
aioesphomeapi==45.6.0
|
||||
zeroconf==0.150.0
|
||||
puremagic==2.2.0
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
|
||||
@@ -90,6 +90,7 @@ ISOLATED_COMPONENTS = {
|
||||
"openthread_info": "Conflicts with wifi: used by most components",
|
||||
"matrix_keypad": "Needs isolation due to keypad",
|
||||
"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",
|
||||
"neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)",
|
||||
"packages": "cannot merge packages",
|
||||
|
||||
@@ -16,7 +16,7 @@ class ZWaveProxy {
|
||||
public:
|
||||
api::APIConnection *get_api_connection() { return nullptr; }
|
||||
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) {}
|
||||
uint32_t get_feature_flags() const { return 0; }
|
||||
uint32_t get_home_id() { return 0; }
|
||||
|
||||
@@ -24,6 +24,9 @@ spi:
|
||||
mosi_pin: 6
|
||||
clk_pin: 7
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
id: lcd_display
|
||||
|
||||
@@ -19,6 +19,9 @@ spi:
|
||||
mosi_pin: 6
|
||||
clk_pin: 7
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
id: lcd_display
|
||||
|
||||
@@ -4,6 +4,9 @@ esphome:
|
||||
esp32:
|
||||
board: esp32s3box
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
image:
|
||||
defaults:
|
||||
type: rgb565
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for mpi_dsi configuration validation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
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:
|
||||
"""Test successful configuration validation."""
|
||||
@@ -116,6 +129,59 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None:
|
||||
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(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
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 pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.const import BYTE_ORDER_BIG
|
||||
from esphome.components.display import get_all_display_metadata, get_display_metadata
|
||||
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.const import PlatformFramework
|
||||
from esphome.core import ID
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@@ -23,6 +27,18 @@ def validated_config(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(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
@@ -91,7 +107,7 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
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"})
|
||||
meta = get_display_metadata(config["id"])
|
||||
assert meta is not None
|
||||
@@ -166,3 +182,69 @@ def test_metadata_via_code_generation_lvgl(
|
||||
assert meta.height == 160
|
||||
assert meta.has_hardware_rotation is True
|
||||
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
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.display import CONF_SHOW_TEST_CARD
|
||||
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.const import CONF_BUFFER_SIZE, PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
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)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -136,7 +136,7 @@ def test_dimension_validation(
|
||||
"model": "JC3248W535",
|
||||
"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",
|
||||
),
|
||||
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>()"
|
||||
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 "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>();"
|
||||
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 "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 pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -222,6 +223,7 @@ class TestNewModelVariants:
|
||||
def test_m5core2_with_native_dimensions(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
) -> None:
|
||||
"""Test M5CORE2 variant with reset native_width and native_height."""
|
||||
set_core_config(
|
||||
@@ -231,6 +233,8 @@ class TestNewModelVariants:
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
},
|
||||
)
|
||||
# M5CORE2 has PSRAM on board and requires it to be configured
|
||||
set_component_config("psram", True)
|
||||
|
||||
# M5CORE2 should validate successfully
|
||||
config = validated_config({"model": "M5CORE2"})
|
||||
|
||||
@@ -24,6 +24,9 @@ spi:
|
||||
mosi_pin: 6
|
||||
clk_pin: 7
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
id: lcd_display
|
||||
|
||||
@@ -23,6 +23,9 @@ spi:
|
||||
mosi_pin: 6
|
||||
clk_pin: 7
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
id: lcd_display
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<<: !include common-base.yaml
|
||||
packages:
|
||||
common: !include common-base.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
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:
|
||||
- !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
@@ -5,6 +5,7 @@ binary_sensor:
|
||||
- platform: template
|
||||
name: "Garage Door Open 10"
|
||||
report: "default"
|
||||
use_device_type: false
|
||||
- platform: template
|
||||
name: "Garage Door Open 12"
|
||||
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()
|
||||
@@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None:
|
||||
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:
|
||||
"""Test simple name without MAC suffix returns string literal."""
|
||||
cpp_expr, global_decl, byte_len = make_app_name_cpp(
|
||||
|
||||
@@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None:
|
||||
def test_optional_default_visibility_is_none() -> None:
|
||||
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``.
|
||||
|
||||
Consumers can read the attribute directly with plain attribute
|
||||
access; absence (``None``) means "render on the editor's main
|
||||
form."
|
||||
The marker stays faithful to what the author wrote: ESPHome does
|
||||
not encode the default on it. Resolving ``None`` to an effective
|
||||
visibility is the consumer's job — a schema-aware editor treats an
|
||||
unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`).
|
||||
"""
|
||||
o = cv.Optional("foo")
|
||||
assert o.visibility is None
|
||||
@@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None:
|
||||
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:
|
||||
"""``Visibility`` is a ``StrEnum`` whose values are the literal
|
||||
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
|
||||
contract stable.
|
||||
"""
|
||||
assert str(cv.Visibility.UI) == "ui"
|
||||
assert str(cv.Visibility.ADVANCED) == "advanced"
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
|
||||
return make_data_base(value)
|
||||
|
||||
@@ -167,9 +167,9 @@ def setup_core(
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
|
||||
|
||||
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.build_path = str(tmp_path / ".esphome" / "build" / name)
|
||||
CORE.build_path = tmp_path / ".esphome" / "build" / name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None:
|
||||
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:
|
||||
out = yaml_util.dump({"password": SensitiveStr("hunter2")})
|
||||
assert "\\033[8mhunter2\\033[28m" in out
|
||||
|
||||
Reference in New Issue
Block a user