Compare commits

..
87 changed files with 929 additions and 2174 deletions
+2 -2
View File
@@ -49,7 +49,7 @@ runs:
python -m venv venv
source venv/bin/activate
python --version
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -e .
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
@@ -58,5 +58,5 @@ runs:
python -m venv venv
source ./venv/Scripts/activate
python --version
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -e .
+3 -25
View File
@@ -41,32 +41,10 @@ jobs:
version: "0.11.15"
- name: Install apt dependencies
# PR-only workflow, so nothing on dev could seed a shared apt cache
# entry; the cached apt action would save one copy per PR. Plain apt
# with every call bounded: the apt.conf.d timeouts make a dead
# mirror fail over in seconds, and timeout runs under sudo so it can
# kill apt-get itself. Install without update first: image lists are
# fresh, and the index refresh is what a congested mirror makes slow.
timeout-minutes: 15
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
# Common path: the image's package lists are fresh enough.
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
apt-get install -y protobuf-compiler; then
protoc --version
exit 0
fi
# Rescue path: refresh the lists once with a generous bound; the
# apt config already fails a stalled mirror over quickly.
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
dpkg --configure -a || true
sudo timeout -k 15 300 apt-get update
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
apt-get install -y protobuf-compiler
sudo apt update
sudo apt-cache show protobuf-compiler
sudo apt install -y protobuf-compiler
protoc --version
- name: Install python dependencies
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
+37 -96
View File
@@ -68,22 +68,6 @@ jobs:
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
seed-apt-cache:
name: Seed apt package cache
runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version; keep both identical in every
# step that restores it. In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push'
timeout-minutes: 10
steps:
- name: Install apt packages (cached)
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
determine-jobs:
name: Determine which jobs to run
runs-on: ubuntu-24.04
@@ -339,8 +323,7 @@ jobs:
integration-tests:
name: Run integration tests (${{ matrix.bucket.name }})
# Must match seed-apt-cache's image: the apt cache key has no OS in it.
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
needs:
- common
- determine-jobs
@@ -352,16 +335,24 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly;
# libsdl2-dev is unused here and carried only for cache-key parity.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
- name: Install ccache
# Speeds up the host compiles: tests in a bucket compile overlapping
# component sets, so later tests reuse earlier tests' objects.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ccache
- name: Restore ccache (restore-only)
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py). The
# bucket-name prefix prefers a same-bucket seed; the bare prefix falls
# back to any seed when the bucket layout differs from dev.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
packages: libsdl2-dev ccache
version: 1.1
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
restore-keys: |
integration-ccache-${{ matrix.bucket.name }}-
integration-ccache-
- name: Set up Python 3.13
id: python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -410,6 +401,14 @@ jobs:
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
- name: Save ccache
# Pull request saves land in per-PR scopes nothing else can reuse;
# dev pushes seed the shared copy instead.
if: github.event_name != 'pull_request'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
import-time:
name: Check import esphome.__main__ time
@@ -442,7 +441,6 @@ jobs:
benchmarks:
name: Run CodSpeed benchmarks
runs-on: ubuntu-24.04
timeout-minutes: 30
needs:
- common
- determine-jobs
@@ -466,58 +464,12 @@ jobs:
- name: Build benchmarks
id: build
run: |
# pipefail: without it a failed build is masked by the grep/cut
# pipeline below, leaving BINARY empty and silently dropping every
# C++ benchmark from the run while the job still reports success.
set -o pipefail
. venv/bin/activate
BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
export BENCHMARK_LIB_CONFIG
# --build-only prints BUILD_BINARY=<path> to stdout; the grep is
# non-fatal so a missing marker reaches the check below instead of
# tripping errexit at this assignment
BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-)
if [ -z "$BINARY" ]; then
echo "::error::Benchmark build did not report a binary path"
exit 1
fi
export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
# --build-only prints BUILD_BINARY=<path> to stdout
BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-)
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Bound apt fetches and pre-install libc6-dbg
# The CodSpeed runner installs valgrind + libc6-dbg via its own
# unbounded apt-get update; per-invocation apt options cannot reach
# it. The apt.conf.d timeouts below bound every later apt call in
# this job, the runner's included. Pre-installing libc6-dbg lets the
# runner skip apt once its valgrind cache is restored (it checks
# ``dpkg -s libc6-dbg``, so the cache action's unregistered restores
# would not count). Install without update first: image lists are
# fresh, and the index refresh is what a congested mirror makes
# slow. Best effort; the job timeout is the last backstop.
timeout-minutes: 15
continue-on-error: true
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
if dpkg -s libc6-dbg >/dev/null 2>&1; then
echo "libc6-dbg already installed"
exit 0
fi
# Common path: the image's package lists are fresh enough.
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
apt-get install -y libc6-dbg; then
exit 0
fi
# Rescue path: refresh the lists once with a generous bound; the
# apt config already fails a stalled mirror over quickly.
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
dpkg --configure -a || true
sudo timeout -k 15 300 apt-get update
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
with:
@@ -602,29 +554,24 @@ jobs:
fetch-depth: 2
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
# Key on the exact Python version as well: LibreTiny creates a venv under
# ~/.platformio/penv whose interpreter is a symlink into the runner's
# hosted toolcache, so a cache saved on an older runner image breaks once
# a new image ships a newer patch release and drops the old interpreter.
- name: Cache platformio
if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache ESP-IDF install
if: matrix.cache_idf
@@ -941,17 +888,12 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. Packages and version must match seed-apt-cache
# exactly. The action has no --no-install-recommends; same package
# set this job used before #17463.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Install apt packages
# Not cached: this job is pull-request-only, so a cache save could
# never be shared and would only consume quota.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -1486,7 +1428,6 @@ jobs:
# this check.
needs:
- common
- seed-apt-cache
- determine-jobs
- ci-custom
- pylint
-10
View File
@@ -763,13 +763,3 @@ The project uses English for non-code content. When drafting documentation, code
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
audience, including non-native English speakers.
## 10. Code Comments
Code comments on individual lines should be used only where necessary to flag issues that may not be obvious
on a simple reading of the code. Keep them short (e.g. 1 or 2 lines).
Function and method comment blocks may include more detail as required to make
calling contracts clear and document parameter usage, but should still be kept concise.
Avoid redundancy and repetition; comments should never simply restate what the code already says.
+1 -1
View File
@@ -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.12.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0
RUN \
platformio settings set enable_telemetry No \
+1 -1
View File
@@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
cg.add_library("esphome/noise-c", "0.1.21")
cg.add_library("esphome/noise-c", "0.1.18")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+28 -1
View File
@@ -232,6 +232,7 @@ enum SerialProxyPortType {
message SerialProxyInfo {
string name = 1; // Human-readable port name
SerialProxyPortType port_type = 2; // Port type (RS232, RS485)
uint32 configured_line_states = 3; // Bitmask of SerialProxyLineStateFlags this instance can drive
}
// DeviceInfoResponse max_data_length values:
@@ -2622,6 +2623,22 @@ message ZWaveProxyRequest {
bytes data = 2;
}
enum ZWaveProxyStatus {
ZWAVE_PROXY_STATUS_OK = 0; // Request completed successfully
ZWAVE_PROXY_STATUS_IN_USE = 1; // Denied: another client is already subscribed
ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2; // Request type not supported
}
// Acknowledges a ZWaveProxyRequest (subscribe/unsubscribe). Sent since API 1.16.
message ZWaveProxyRequestResponse {
option (id) = 151;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_ZWAVE_PROXY";
ZWaveProxyRequestType type = 1; // Which request type this responds to
ZWaveProxyStatus status = 2; // Result status
}
// ==================== INFRARED ====================
// Note: Feature and capability flag enums are defined in
// esphome/components/infrared/infrared.h
@@ -2765,12 +2782,18 @@ message SerialProxyGetModemPinsResponse {
uint32 instance = 1; // Instance index (0-based)
uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags
SerialProxyStatus status = 3; // INVALID_ARGUMENT if the instance index is out of range (since API 1.16)
}
enum SerialProxyRequestType {
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; // Subscribe to receive data from this serial proxy instance
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent)
// Values below are only valid in SerialProxyRequestResponse.type, identifying which
// operation is being acknowledged. Sending them in SerialProxyRequest.type is an
// error the device answers with INVALID_ARGUMENT.
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
}
enum SerialProxyStatus {
@@ -2779,6 +2802,8 @@ enum SerialProxyStatus {
SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error
SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance
SERIAL_PROXY_STATUS_PORT_IN_USE = 5; // Denied: another client holds the port
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
}
// Generic request message for simple serial proxy operations
@@ -2791,7 +2816,9 @@ message SerialProxyRequest {
SerialProxyRequestType type = 2; // Request type
}
// Response to a SerialProxyRequest (e.g. flush completion or failure)
// Acknowledges a serial proxy operation; the type field identifies which
// operation is being acknowledged. Flush has been acknowledged since the
// message was introduced; all other acknowledgements are sent since API 1.16.
message SerialProxyRequestResponse {
option (id) = 147;
option (source) = SOURCE_SERVER;
+76 -33
View File
@@ -1384,7 +1384,12 @@ void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
}
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
ZWaveProxyRequestResponse resp{};
resp.type = msg.type;
resp.status = zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Z-Wave proxy response");
}
}
#endif
@@ -1553,15 +1558,50 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
#endif
#ifdef USE_SERIAL_PROXY
static enums::SerialProxyStatus serial_proxy_result_to_status(serial_proxy::SerialProxyResult result) {
switch (result) {
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_OK:
return enums::SERIAL_PROXY_STATUS_OK;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS:
return enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE:
return enums::SERIAL_PROXY_STATUS_PORT_IN_USE;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT:
return enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT:
return enums::SERIAL_PROXY_STATUS_TIMEOUT;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED:
return enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ERROR:
return enums::SERIAL_PROXY_STATUS_ERROR;
}
return enums::SERIAL_PROXY_STATUS_ERROR; // Unreachable; all enum values handled above
}
static void send_serial_proxy_ack(APIConnection *conn, uint32_t instance, enums::SerialProxyRequestType type,
enums::SerialProxyStatus status) {
SerialProxyRequestResponse resp{};
resp.instance = instance;
resp.type = type;
resp.status = status;
if (!conn->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
}
void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance,
static_cast<uint32_t>(proxies.size()));
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
msg.stop_bits, msg.data_size);
serial_proxy::SerialProxyResult result = proxies[msg.instance]->configure(
this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits, msg.data_size);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
serial_proxy_result_to_status(result));
}
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
@@ -1577,20 +1617,30 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
proxies[msg.instance]->set_modem_pins(this, msg.line_states);
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_modem_pins(this, msg.line_states);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
serial_proxy_result_to_status(result));
}
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
SerialProxyGetModemPinsResponse resp{};
resp.instance = msg.instance;
resp.line_states = proxies[msg.instance]->get_modem_pins();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
// Pre-1.16 clients do not read the status field and would take this error
// for a successful "both pins deasserted" answer; let them time out as before
if (!this->client_supports_api_version(1, 16)) {
return;
}
resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
} else {
resp.line_states = proxies[msg.instance]->get_modem_pins();
}
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
@@ -1600,40 +1650,31 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
send_serial_proxy_ack(this, msg.instance, msg.type, enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
auto *proxy = proxies[msg.instance];
enums::SerialProxyStatus status;
switch (msg.type) {
case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
proxies[msg.instance]->serial_proxy_request(this, msg.type);
status = serial_proxy_result_to_status(proxy->serial_proxy_request(this, msg.type));
break;
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: {
SerialProxyRequestResponse resp{};
resp.instance = msg.instance;
resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH;
switch (proxies[msg.instance]->flush_port()) {
case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
resp.status = enums::SERIAL_PROXY_STATUS_OK;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT;
break;
case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
break;
}
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
status = serial_proxy_result_to_status(proxy->flush_port(this));
break;
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
// Response-only discriminators; never valid in a request
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
break;
}
default:
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
break;
}
send_serial_proxy_ack(this, msg.instance, msg.type, status);
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
@@ -1760,7 +1801,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 15;
resp.api_version_minor = 16;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1894,6 +1935,7 @@ bool APIConnection::send_device_info_response_() {
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
info.configured_line_states = proxy->get_configured_modem_pins();
}
#endif
#ifdef USE_API_NOISE
@@ -1954,6 +1996,7 @@ bool APIConnection::send_device_capabilities_response_() {
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
info.configured_line_states = proxy->get_configured_modem_pins();
}
#endif
return this->send_message(resp);
+16
View File
@@ -102,12 +102,14 @@ uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PAR
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->port_type));
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states);
return pos;
}
uint32_t SerialProxyInfo::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->name.size());
size += this->port_type ? 2 : 0;
size += ProtoSize::calc_uint32(1, this->configured_line_states);
return size;
}
#endif
@@ -3945,6 +3947,18 @@ uint32_t ZWaveProxyRequest::calculate_size() const {
size += ProtoSize::calc_length(1, this->data_len);
return size;
}
uint8_t *ZWaveProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->type));
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->status));
return pos;
}
uint32_t ZWaveProxyRequestResponse::calculate_size() const {
uint32_t size = 0;
size += this->type ? 2 : 0;
size += this->status ? 2 : 0;
return size;
}
#endif
#ifdef USE_INFRARED
uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -4187,12 +4201,14 @@ uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast<uint32_t>(this->status));
return pos;
}
uint32_t SerialProxyGetModemPinsResponse::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->instance);
size += ProtoSize::calc_uint32(1, this->line_states);
size += this->status ? 2 : 0;
return size;
}
bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+29 -1
View File
@@ -334,6 +334,11 @@ enum ZWaveProxyRequestType : uint32_t {
ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1,
ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2,
};
enum ZWaveProxyStatus : uint32_t {
ZWAVE_PROXY_STATUS_OK = 0,
ZWAVE_PROXY_STATUS_IN_USE = 1,
ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2,
};
#endif
#ifdef USE_SERIAL_PROXY
enum SerialProxyParity : uint32_t {
@@ -345,6 +350,8 @@ enum SerialProxyRequestType : uint32_t {
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0,
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1,
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2,
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3,
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4,
};
enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_OK = 0,
@@ -352,6 +359,8 @@ enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_ERROR = 2,
SERIAL_PROXY_STATUS_TIMEOUT = 3,
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4,
SERIAL_PROXY_STATUS_PORT_IN_USE = 5,
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6,
};
#endif
@@ -523,6 +532,7 @@ class SerialProxyInfo final : public ProtoMessage {
public:
StringRef name{};
enums::SerialProxyPortType port_type{};
uint32_t configured_line_states{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
@@ -3130,6 +3140,23 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage {
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class ZWaveProxyRequestResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 151;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("z_wave_proxy_request_response"); }
#endif
enums::ZWaveProxyRequestType type{};
enums::ZWaveProxyStatus status{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_INFRARED
class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage {
@@ -3314,12 +3341,13 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage {
class SerialProxyGetModemPinsResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 143;
static constexpr uint8_t ESTIMATED_SIZE = 8;
static constexpr uint8_t ESTIMATED_SIZE = 10;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_response"); }
#endif
uint32_t instance{0};
uint32_t line_states{0};
enums::SerialProxyStatus status{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
+28
View File
@@ -816,6 +816,18 @@ template<> const char *proto_enum_to_string<enums::ZWaveProxyRequestType>(enums:
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::ZWaveProxyStatus>(enums::ZWaveProxyStatus value) {
switch (value) {
case enums::ZWAVE_PROXY_STATUS_OK:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_OK");
case enums::ZWAVE_PROXY_STATUS_IN_USE:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_IN_USE");
case enums::ZWAVE_PROXY_STATUS_NOT_SUPPORTED:
return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_NOT_SUPPORTED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
#ifdef USE_SERIAL_PROXY
template<> const char *proto_enum_to_string<enums::SerialProxyParity>(enums::SerialProxyParity value) {
@@ -838,6 +850,10 @@ template<> const char *proto_enum_to_string<enums::SerialProxyRequestType>(enums
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE");
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_FLUSH");
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -854,6 +870,10 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_TIMEOUT");
case enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_NOT_SUPPORTED");
case enums::SERIAL_PROXY_STATUS_PORT_IN_USE:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_PORT_IN_USE");
case enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_INVALID_ARGUMENT");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -914,6 +934,7 @@ const char *SerialProxyInfo::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyInfo"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("port_type"), static_cast<enums::SerialProxyPortType>(this->port_type));
dump_field(out, ESPHOME_PSTR("configured_line_states"), this->configured_line_states);
return out.c_str();
}
#endif
@@ -2644,6 +2665,12 @@ const char *ZWaveProxyRequest::dump_to(DumpBuffer &out) const {
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
const char *ZWaveProxyRequestResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyRequestResponse"));
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ZWaveProxyRequestType>(this->type));
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::ZWaveProxyStatus>(this->status));
return out.c_str();
}
#endif
#ifdef USE_INFRARED
const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
@@ -2753,6 +2780,7 @@ const char *SerialProxyGetModemPinsResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetModemPinsResponse"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("line_states"), this->line_states);
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SerialProxyStatus>(this->status));
return out.c_str();
}
const char *SerialProxyRequest::dump_to(DumpBuffer &out) const {
@@ -300,12 +300,46 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
}
light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const {
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
int32_t r = 0, g = 0, b = 0;
switch (this->rgb_order_) {
case ORDER_RGB:
r = 0;
g = 1;
b = 2;
break;
case ORDER_RBG:
r = 0;
g = 2;
b = 1;
break;
case ORDER_GRB:
r = 1;
g = 0;
b = 2;
break;
case ORDER_GBR:
r = 2;
g = 0;
b = 1;
break;
case ORDER_BGR:
r = 2;
g = 1;
b = 0;
break;
case ORDER_BRG:
r = 1;
g = 2;
b = 0;
break;
}
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
uint8_t white = this->is_wrgb_ ? 0 : 3;
return {this->buf_ + (index * multiplier) + r + this->is_wrgb_,
this->buf_ + (index * multiplier) + g + this->is_wrgb_,
this->buf_ + (index * multiplier) + b + this->is_wrgb_,
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
&this->effect_data_[index],
&this->correction_};
}
@@ -315,12 +349,35 @@ void BekenSPILEDStripLightOutput::dump_config() {
"Beken SPI LED Strip:\n"
" Pin: %u",
this->pin_);
char channel_colors[5];
const char *rgb_order;
switch (this->rgb_order_) {
case ORDER_RGB:
rgb_order = "RGB";
break;
case ORDER_RBG:
rgb_order = "RBG";
break;
case ORDER_GRB:
rgb_order = "GRB";
break;
case ORDER_GBR:
rgb_order = "GBR";
break;
case ORDER_BGR:
rgb_order = "BGR";
break;
case ORDER_BRG:
rgb_order = "BRG";
break;
default:
rgb_order = "UNKNOWN";
break;
}
ESP_LOGCONFIG(TAG,
" Channel colors: %s\n"
" RGB Order: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -3,7 +3,6 @@
#ifdef USE_BK72XX
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/components/light/light_output.h"
#include "esphome/core/color.h"
#include "esphome/core/component.h"
@@ -11,6 +10,15 @@
namespace esphome::beken_spi_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
ORDER_RBG,
ORDER_GRB,
ORDER_GBR,
ORDER_BGR,
ORDER_BRG,
};
class BekenSPILEDStripLightOutput final : public light::AddressableLight {
public:
void setup() override;
@@ -20,7 +28,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
if (this->channel_colors_.has_white()) {
if (this->is_rgbw_ || this->is_wrgb_) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
@@ -30,13 +38,16 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency);
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
void clear_effect_data() override {
for (int i = 0; i < this->size(); i++)
this->effect_data_[i] = 0;
@@ -47,7 +58,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); }
uint8_t *buf_{nullptr};
uint8_t *effect_data_{nullptr};
@@ -55,11 +66,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
uint8_t pin_;
uint16_t num_leds_;
bool is_rgbw_;
bool is_wrgb_;
uint32_t spi_frequency_{6666666};
uint8_t bit0_{0xE0};
uint8_t bit1_{0xFC};
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
RGBOrder rgb_order_;
uint32_t last_refresh_{0};
optional<uint32_t> max_refresh_rate_{};
+24 -17
View File
@@ -3,7 +3,6 @@ from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import libretiny, light
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
@@ -14,7 +13,6 @@ from esphome.const import (
CONF_PIN,
CONF_RGB_ORDER,
)
from esphome.types import ConfigType
CODEOWNERS = ["@Mat931"]
DEPENDENCIES = ["libretiny"]
@@ -24,6 +22,17 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_(
"BekenSPILEDStripLightOutput", light.AddressableLight
)
RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder")
RGB_ORDERS = {
"RGB": RGBOrder.ORDER_RGB,
"RBG": RGBOrder.ORDER_RBG,
"GRB": RGBOrder.ORDER_GRB,
"GBR": RGBOrder.ORDER_GBR,
"BGR": RGBOrder.ORDER_BGR,
"BRG": RGBOrder.ORDER_BRG,
}
@dataclass
class LEDStripTimings:
@@ -48,6 +57,8 @@ CHIPSETS = {
}
CONF_IS_WRGB = "is_wrgb"
SUPPORTED_PINS = {
libretiny.const.FAMILY_BK7231N: [16],
libretiny.const.FAMILY_BK7231T: [16],
@@ -68,9 +79,10 @@ def _validate_pin(value):
return value
def _validate_num_leds(value: ConfigType) -> ConfigType:
# A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer.
max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170
def _validate_num_leds(value):
max_num_leds = 165 # 170
if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]:
max_num_leds = 123 # 127
if value[CONF_NUM_LEDS] > max_num_leds:
raise cv.Invalid(
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
@@ -87,23 +99,18 @@ CONFIG_SCHEMA = cv.All(
pins.internal_gpio_output_pin_number, _validate_pin
),
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
cv.Optional(CONF_IS_RGBW): cv.boolean,
cv.Optional(CONF_IS_WRGB): cv.boolean,
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
cv.Optional(CONF_IS_WRGB, default=False): cv.boolean,
}
),
light.migrate_channel_colors(
removed_in="2027.3.0", component="beken_spi_led_strip"
),
_validate_num_leds,
)
async def to_code(config: ConfigType) -> None:
async def to_code(config):
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
await light.register_light(var, config)
await cg.register_component(var, config)
@@ -123,6 +130,6 @@ async def to_code(config: ConfigType) -> None:
)
)
cg.add(
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
)
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
-2
View File
@@ -10,7 +10,6 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_B_CONSTANT = "b_constant"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BYTE_ORDER = "byte_order"
CONF_CHANNEL_COLORS = "channel_colors"
CONF_CLIMATE_ID = "climate_id"
CONF_CO2_EQUIVALENT = "co2_equivalent"
CONF_COLOR_DEPTH = "color_depth"
@@ -23,7 +22,6 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
CONF_IAQ = "iaq"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_IS_WRGB = "is_wrgb"
CONF_LABEL = "label"
CONF_LIBRETINY = "libretiny"
CONF_LOOP = "loop"
+37 -41
View File
@@ -1073,26 +1073,6 @@ def _parse_pio_platform_version(value):
return value
def _normalize_p4_engineering_sample(value: ConfigType) -> bool:
"""Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production
silicon (rev3) is assumed. Returns the normalized flag."""
if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None:
_LOGGER.warning(
"Defaulting to ESP32-P4 production silicon (rev3).\n"
"If you have an early engineering sample (pre-rev3), add this to your config:\n"
"\n"
" esp32:\n"
" engineering_sample: true\n"
"\n"
"To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n"
"Engineering samples will show a revision below v3.0.\n"
"The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)."
)
engineering_sample = False
value[CONF_ENGINEERING_SAMPLE] = engineering_sample
return engineering_sample
def _detect_variant(value):
board = value.get(CONF_BOARD)
variant = value.get(CONF_VARIANT)
@@ -1105,8 +1085,6 @@ def _detect_variant(value):
# name rather than carrying a PIO board name through the IDF build.
if CORE.using_toolchain_esp_idf:
value = value.copy()
if variant == VARIANT_ESP32P4:
_normalize_p4_engineering_sample(value)
value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower()
return value
if variant not in STANDARD_BOARDS:
@@ -1117,8 +1095,22 @@ def _detect_variant(value):
)
value = value.copy()
value[CONF_BOARD] = STANDARD_BOARDS[variant]
if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value):
value[CONF_BOARD] = "esp32-p4-evboard"
if variant == VARIANT_ESP32P4:
engineering_sample = value.get(CONF_ENGINEERING_SAMPLE)
if engineering_sample is None:
_LOGGER.warning(
"No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n"
"If you have an early engineering sample (pre-rev3), add this to your config:\n"
"\n"
" esp32:\n"
" engineering_sample: true\n"
"\n"
"To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n"
"Engineering samples will show a revision below v3.0.\n"
"The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)."
)
elif engineering_sample:
value[CONF_BOARD] = "esp32-p4-evboard"
elif board in BOARDS:
variant = variant or BOARDS[board][KEY_VARIANT]
if variant != BOARDS[board][KEY_VARIANT]:
@@ -1128,14 +1120,6 @@ def _detect_variant(value):
)
value = value.copy()
value[CONF_VARIANT] = variant
if variant == VARIANT_ESP32P4:
board_is_es = BOARDS[board].get("engineering_sample", False)
engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es)
if engineering_sample != board_is_es:
raise cv.Invalid(
f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'",
path=[CONF_ENGINEERING_SAMPLE],
)
elif not variant:
raise cv.Invalid(
"This board is unknown, if you are sure you want to compile with this board selection, "
@@ -1147,9 +1131,6 @@ def _detect_variant(value):
"This board is unknown; the specified variant '%s' will be used but this may not work as expected.",
variant,
)
if variant == VARIANT_ESP32P4:
value = value.copy()
_normalize_p4_engineering_sample(value)
return value
@@ -1453,6 +1434,20 @@ def final_validate(config) -> None:
path=[CONF_ENGINEERING_SAMPLE],
)
)
if (
config[CONF_VARIANT] == VARIANT_ESP32P4
and config.get(CONF_ENGINEERING_SAMPLE) is not None
):
board_is_es = BOARDS.get(config[CONF_BOARD], {}).get(
"engineering_sample", False
)
if config[CONF_ENGINEERING_SAMPLE] != board_is_es:
errs.append(
cv.Invalid(
f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'",
path=[CONF_ENGINEERING_SAMPLE],
)
)
if advanced[CONF_EXECUTE_FROM_PSRAM]:
if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}:
errs.append(
@@ -2523,14 +2518,15 @@ async def to_code(config):
f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True
)
# ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible.
# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links;
# validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset.
# ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3
# from y to n. PlatformIO uses sections.ld.in (for rev <3) or
# sections.rev3.ld.in (for rev >=3) based on board definition.
# Set the sdkconfig option to match the board's chip revision.
if variant == VARIANT_ESP32P4:
add_idf_sdkconfig_option(
"CONFIG_ESP32P4_SELECTS_REV_LESS_V3",
config.get(CONF_ENGINEERING_SAMPLE, False),
is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get(
"engineering_sample", False
)
add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample)
# Set minimum chip revision for ESP32 variant
# Setting this to 3.0 or higher reduces flash size by excluding workaround code,
@@ -221,12 +221,46 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) {
}
light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const {
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
int32_t r = 0, g = 0, b = 0;
switch (this->rgb_order_) {
case ORDER_RGB:
r = 0;
g = 1;
b = 2;
break;
case ORDER_RBG:
r = 0;
g = 2;
b = 1;
break;
case ORDER_GRB:
r = 1;
g = 0;
b = 2;
break;
case ORDER_GBR:
r = 2;
g = 0;
b = 1;
break;
case ORDER_BGR:
r = 2;
g = 1;
b = 0;
break;
case ORDER_BRG:
r = 1;
g = 2;
b = 0;
break;
}
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
uint8_t white = this->is_wrgb_ ? 0 : this->white_index_;
return {this->buf_ + (index * multiplier) + r + (white <= r),
this->buf_ + (index * multiplier) + g + (white <= g),
this->buf_ + (index * multiplier) + b + (white <= b),
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
&this->effect_data_[index],
&this->correction_};
}
@@ -237,12 +271,46 @@ void ESP32RMTLEDStripLightOutput::dump_config() {
" Pin: %u",
this->pin_);
ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_);
char channel_colors[5];
const char *rgb_order;
switch (this->rgb_order_) {
case ORDER_RGB:
rgb_order = "RGB";
break;
case ORDER_RBG:
rgb_order = "RBG";
break;
case ORDER_GRB:
rgb_order = "GRB";
break;
case ORDER_GBR:
rgb_order = "GBR";
break;
case ORDER_BGR:
rgb_order = "BGR";
break;
case ORDER_BRG:
rgb_order = "BRG";
break;
default:
rgb_order = "UNKNOWN";
break;
}
if (this->is_rgbw_ || this->is_wrgb_) {
char rgbw_order[5];
uint8_t white = this->is_wrgb_ ? 0 : this->white_index_;
uint8_t rgb_index = 0;
for (uint8_t i = 0; i < 4; i++) {
rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++];
}
rgbw_order[4] = '\0';
ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order);
} else {
ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order);
}
ESP_LOGCONFIG(TAG,
" Channel colors: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -3,7 +3,6 @@
#ifdef USE_ESP32
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/components/light/light_output.h"
#include "esphome/core/color.h"
#include "esphome/core/component.h"
@@ -16,6 +15,15 @@
namespace esphome::esp32_rmt_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
ORDER_RBG,
ORDER_GRB,
ORDER_GBR,
ORDER_BGR,
ORDER_BRG,
};
struct LedParams {
rmt_symbol_word_t bit0;
rmt_symbol_word_t bit1;
@@ -31,7 +39,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
if (this->channel_colors_.has_white()) {
if (this->is_rgbw_ || this->is_wrgb_) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
@@ -42,7 +50,13 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_inverted(bool inverted) { this->invert_out_ = inverted; }
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
void set_rgbw_order(uint8_t white_index) {
this->is_rgbw_ = true;
this->is_wrgb_ = false;
this->white_index_ = white_index;
}
void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; }
void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; }
@@ -52,6 +66,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low,
uint32_t reset_time_high, uint32_t reset_time_low);
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; }
void clear_effect_data() override {
@@ -64,7 +79,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); }
uint8_t *buf_{nullptr};
uint8_t *effect_data_{nullptr};
@@ -79,11 +94,15 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
uint32_t rmt_symbols_{48};
uint8_t pin_;
uint16_t num_leds_;
bool is_rgbw_{false};
bool is_wrgb_{false};
// An index after the RGB channels makes offset adjustment a no-op for three-channel strips.
uint8_t white_index_{3};
bool use_dma_{false};
bool use_psram_{false};
bool invert_out_{false};
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
RGBOrder rgb_order_{ORDER_RGB};
uint32_t last_refresh_{0};
optional<uint32_t> max_refresh_rate_{};
+52 -13
View File
@@ -1,9 +1,10 @@
from dataclasses import dataclass
import logging
from esphome import pins
import esphome.codegen as cg
from esphome.components import esp32, esp32_rmt, light
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM
from esphome.components.const import CONF_USE_PSRAM
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import (
@@ -21,6 +22,8 @@ from esphome.const import (
)
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["esp32"]
@@ -29,6 +32,17 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_(
"ESP32RMTLEDStripLightOutput", light.AddressableLight
)
RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder")
RGB_ORDERS = {
"RGB": RGBOrder.ORDER_RGB,
"RBG": RGBOrder.ORDER_RBG,
"GRB": RGBOrder.ORDER_GRB,
"GBR": RGBOrder.ORDER_GBR,
"BGR": RGBOrder.ORDER_BGR,
"BRG": RGBOrder.ORDER_BRG,
}
@dataclass
class LEDStripTimings:
@@ -48,6 +62,8 @@ CHIPSETS = {
"SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0),
}
CONF_IS_WRGB = "is_wrgb"
CONF_RGBW_ORDER = "rgbw_order"
CONF_BIT0_HIGH = "bit0_high"
CONF_BIT0_LOW = "bit0_low"
CONF_BIT1_HIGH = "bit1_high"
@@ -56,6 +72,26 @@ CONF_RESET_HIGH = "reset_high"
CONF_RESET_LOW = "reset_low"
def _validate_rgbw_order(value: str) -> str:
value = cv.string(value).upper()
if len(value) != 4 or set(value) != set("RGBW"):
raise cv.Invalid("RGBW order must be a permutation of RGBW")
return value
def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]:
return rgbw_order.replace("W", ""), rgbw_order.index("W")
def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType:
if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]):
raise cv.Invalid(
f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or "
f"'{CONF_IS_WRGB}'"
)
return config
CONFIG_SCHEMA = cv.All(
esp32.only_on_variant(
unsupported=list(esp32_rmt.VARIANTS_NO_RMT),
@@ -66,11 +102,8 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
cv.Optional(CONF_IS_RGBW): cv.boolean,
cv.Optional(CONF_IS_WRGB): cv.boolean,
cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order,
cv.SplitDefault(
CONF_RMT_SYMBOLS,
esp32=192,
@@ -84,6 +117,8 @@ CONFIG_SCHEMA = cv.All(
): cv.int_range(min=2),
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
cv.Optional(CONF_IS_WRGB, default=False): cv.boolean,
cv.Optional(CONF_USE_DMA): cv.All(
esp32.only_on_variant(
supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3]
@@ -118,13 +153,12 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
light.migrate_channel_colors(
removed_in="2027.3.0", component="esp32_rmt_led_strip"
),
cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER),
_validate_rgbw_order_exclusivity,
)
async def to_code(config: ConfigType) -> None:
async def to_code(config):
# Re-enable ESP-IDF's RMT driver (excluded by default to save compile time)
include_builtin_idf_component("esp_driver_rmt")
@@ -164,9 +198,14 @@ async def to_code(config: ConfigType) -> None:
)
)
cg.add(
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
)
if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None:
rgb_order, white_index = _split_rgbw_order(rgbw_order)
cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order]))
cg.add(var.set_rgbw_order(white_index))
else:
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
cg.add(var.set_use_psram(config[CONF_USE_PSRAM]))
cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS]))
if CONF_USE_DMA in config:
+1 -2
View File
@@ -23,7 +23,6 @@ from esphome.components.image import (
get_image_type_enum,
get_transparency_enum,
is_svg_file,
validate_byte_order,
validate_settings,
validate_transparency,
validate_type,
@@ -201,7 +200,7 @@ OPTIONS_SCHEMA = {
"NONE", "FLOYDSTEINBERG", upper=True
),
cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean,
cv.Optional(CONF_BYTE_ORDER): validate_byte_order,
cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True),
cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
}
@@ -1,22 +0,0 @@
from esphome import pins
import esphome.config_validation as cv
from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED
from esphome.types import ConfigType
def validate_interrupt_pin(value: ConfigType) -> ConfigType:
# The expander components own INT polarity (active-low, hardcoded falling-edge ISR)
# and install a single ISR per GPIO, so neither inversion nor sharing is supported.
value = pins.internal_gpio_input_pin_schema(value)
if value.get(CONF_INVERTED):
raise cv.Invalid(
f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
"the expander INT line is fixed active-low"
)
if value.get(CONF_ALLOW_OTHER_USES):
raise cv.Invalid(
f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
"sharing the interrupt pin between multiple components is not implemented. "
f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling."
)
return value
+16 -136
View File
@@ -10,14 +10,7 @@ from PIL import Image, UnidentifiedImageError
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA
import esphome.config_validation as cv
from esphome.const import (
CONF_DEFAULTS,
CONF_FILE,
CONF_FILES,
CONF_ID,
CONF_PLATFORM,
CONF_TYPE,
)
from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE
from esphome.core import CORE
from esphome.types import ConfigType
@@ -55,9 +48,6 @@ TRANSPARENCY_TYPES = (
CONF_ALPHA_CHANNEL,
)
# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`.
validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True)
def get_image_type_enum(type):
return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}")
@@ -414,120 +404,6 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None:
return get_all_image_metadata().get(image_id)
# ---------------------------------------------------------------------------
# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:`
# into every `files:` entry; the platform's CONFIG_SCHEMA validates each.
# Permanent, unlike the legacy migration below.
# ---------------------------------------------------------------------------
def _drop_incompatible_byte_order(
merged: dict, explicit: dict, *, index: int | None = None
) -> dict:
"""Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`.
With `index`, inherited values are validated before being dropped (the legacy flattener always drops).
"""
if CONF_BYTE_ORDER in explicit:
return merged
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
if (
CONF_BYTE_ORDER in merged
and isinstance(type_class, type)
and issubclass(type_class, ImageEncoder)
and not type_class.is_endian()
):
if index is not None:
try:
validate_byte_order(merged[CONF_BYTE_ORDER])
except cv.Invalid as exc:
exc.prepend([index])
raise
del merged[CONF_BYTE_ORDER]
return merged
def _expand_platform_entry(index: int, entry: dict) -> list[dict]:
if CONF_FILES not in entry:
if CONF_DEFAULTS in entry:
raise cv.Invalid(
f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'",
path=[index],
)
return [entry]
extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES}
if extra_keys:
raise cv.Invalid(
f"'{CONF_FILES}' cannot be combined with "
f"{', '.join(sorted(extra_keys))} on the same entry",
path=[index],
)
files = entry[CONF_FILES]
if files is None:
raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index])
if not isinstance(files, list):
raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index])
if not files:
raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index])
defaults = entry.get(CONF_DEFAULTS, {})
if defaults is None:
defaults = {}
if not isinstance(defaults, dict):
raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index])
# Neither `id:` nor `platform:` makes sense inside `defaults:`.
for disallowed in (CONF_ID, CONF_PLATFORM):
if disallowed in defaults:
raise cv.Invalid(
f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'",
path=[index],
)
from esphome import yaml_util
platform = entry[CONF_PLATFORM]
result: list[dict] = []
for file_entry in files:
if not isinstance(file_entry, dict):
raise cv.Invalid(
f"each entry in '{CONF_FILES}' must be a mapping", path=[index]
)
# The platform is chosen by the entry's own `platform:` key, not per file.
if CONF_PLATFORM in file_entry:
raise cv.Invalid(
f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'",
path=[index],
)
# Keep the `files:` item's source range so whole-entry errors anchor there;
# `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts.
source = (
file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None
)
merged = yaml_util.make_data_base(
{CONF_PLATFORM: platform, **defaults, **file_entry}, source
)
result.append(_drop_incompatible_byte_order(merged, file_entry, index=index))
return result
def expand_platform_config(config: list) -> list:
"""Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result."""
result = []
for i, entry in enumerate(config):
if isinstance(entry, dict) and CONF_PLATFORM in entry:
result.extend(_expand_platform_entry(i, entry))
else:
result.append(entry)
return result
EXPAND_PLATFORM_CONFIG = expand_platform_config
# --------------------- end defaults/files expansion -------------------------
# ---------------------------------------------------------------------------
# Legacy top-level component -> `image:` platform deprecation helpers
# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims.
@@ -620,17 +496,11 @@ def _is_legacy_image_format(config: object) -> bool:
proper error instead of the migration silently dropping the input.
"""
if isinstance(config, list):
# Exclude `files:` entries -- the list branch would otherwise silently
# migrate them to `platform: file` instead of raising the missing-platform error.
# A bare list of (not-yet-platform-tagged) image dicts.
return bool(config) and all(
isinstance(entry, dict)
and CONF_PLATFORM not in entry
and CONF_FILES not in entry
for entry in config
isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config
)
if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config:
# `platform:`/`files:` dicts are new-format (left for list-wrapping +
# expansion); the legacy flattener has no `files:` branch and would drop them.
if not isinstance(config, dict):
return False
# A single image dict, or the grouped `defaults:`/`images:`/type-key form.
return (
@@ -662,8 +532,18 @@ def _flatten_legacy_image_config(config: object) -> list[dict]:
def _add(entry: dict, extra: dict) -> None:
merged = {**defaults, **extra, **entry}
# Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`.
result.append(_drop_incompatible_byte_order(merged, {}))
# The legacy `defaults:`/type-grouped forms only applied `byte_order` to
# types that support it. Replicate that so an endian default merged into
# e.g. a binary image stays valid.
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
if (
CONF_BYTE_ORDER in merged
and isinstance(type_class, type)
and issubclass(type_class, ImageEncoder)
and not type_class.is_endian()
):
del merged[CONF_BYTE_ORDER]
result.append(merged)
def _add_entries(entries: object, extra: dict) -> None:
# `entries` may be a single image dict or a list of them; non-dict
-106
View File
@@ -1,12 +1,9 @@
from collections.abc import Callable
from dataclasses import dataclass, field
import enum
import logging
import esphome.automation as auto
import esphome.codegen as cg
from esphome.components import mqtt, power_supply, web_server
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
import esphome.config_validation as cv
from esphome.const import (
CONF_BLUE,
@@ -26,7 +23,6 @@ from esphome.const import (
CONF_ICON,
CONF_ID,
CONF_INITIAL_STATE,
CONF_IS_RGBW,
CONF_MQTT_ID,
CONF_NAME,
CONF_ON_STATE,
@@ -36,7 +32,6 @@ from esphome.const import (
CONF_POWER_SUPPLY,
CONF_RED,
CONF_RESTORE_MODE,
CONF_RGB_ORDER,
CONF_STATE,
CONF_TRIGGER_ID,
CONF_WARM_WHITE,
@@ -66,7 +61,6 @@ from .effects import (
from .types import ( # noqa: F401
AddressableLight,
AddressableLightState,
ChannelColors,
ColorMode,
LightOutput,
LightState,
@@ -77,8 +71,6 @@ from .types import ( # noqa: F401
light_ns,
)
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@esphome/core"]
IS_PLATFORM_COMPONENT = True
@@ -173,104 +165,6 @@ def available_effects_str(effects: list) -> str:
return ", ".join(f"'{name}'" for name in available) if available else "none"
# Accepted values of the deprecated `rgb_order` key.
RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG")
_RGB_CHANNELS = frozenset("RGB")
_RGBW_CHANNELS = frozenset("RGBW")
def validate_channel_colors(value: str) -> str:
"""Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB"."""
value = cv.string_strict(value).upper()
channels = frozenset(value)
if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS):
raise cv.Invalid(
f"'{value}' is not a valid channel order. List each of R, G and B exactly "
"once, optionally with a single W, in the order the strip expects them "
"(for example GRB, GRBW or WRGB)"
)
return value
def channel_colors_struct(value: str) -> cg.StructInitializer:
"""Build the C++ `light::ChannelColors` for a validated channel order string."""
return cg.StructInitializer(
ChannelColors,
("r", value.index("R")),
("g", value.index("G")),
("b", value.index("B")),
(
"w",
value.index("W")
if "W" in value
else cg.RawExpression(f"{ChannelColors}::NO_WHITE"),
),
)
def _quote_and_join(keys: list[str]) -> str:
"""Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'"."""
quoted = [f"'{key}'" for key in keys]
if len(quoted) == 1:
return quoted[0]
return f"{', '.join(quoted[:-1])} and {quoted[-1]}"
def migrate_channel_colors(
*, removed_in: str, component: str
) -> Callable[[ConfigType], ConfigType]:
"""Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`.
This also enforces that `channel_colors` is set, which the schema cannot do on its
own while the deprecated keys are still accepted. After this runs, `to_code` only
ever sees `channel_colors`.
"""
def validator(config: ConfigType) -> ConfigType:
config = config.copy()
deprecated = [
key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config
]
if CONF_CHANNEL_COLORS in config:
if deprecated:
raise cv.Invalid(
f"'{CONF_CHANNEL_COLORS}' cannot be combined with "
f"{_quote_and_join(deprecated)}"
)
return config
if CONF_RGB_ORDER not in config:
raise cv.Invalid(
f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS]
)
rgb_order = config.pop(CONF_RGB_ORDER)
is_rgbw = config.pop(CONF_IS_RGBW, False)
is_wrgb = config.pop(CONF_IS_WRGB, False)
if is_rgbw and is_wrgb:
raise cv.Invalid(
f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled"
)
if is_wrgb:
channel_colors = f"W{rgb_order}"
elif is_rgbw:
channel_colors = f"{rgb_order}W"
else:
channel_colors = rgb_order
_LOGGER.warning(
"[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s",
component,
_quote_and_join(deprecated),
"are" if len(deprecated) > 1 else "is",
CONF_CHANNEL_COLORS,
channel_colors,
removed_in,
)
config[CONF_CHANNEL_COLORS] = channel_colors
return config
return validator
def _final_validate(config: ConfigType) -> None:
"""Validate all recorded effect name references against their target lights.
-41
View File
@@ -1,41 +0,0 @@
#pragma once
#include <cstdint>
namespace esphome::light {
/// Which byte of an addressable LED's data carries each colour.
///
/// Built from a configuration string such as "GRB" or "WRGB": every field holds the
/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when
/// the strip has no separate white channel.
struct ChannelColors {
/// Value of `w` for a strip that only has red, green and blue channels.
static constexpr uint8_t NO_WHITE = 0xFF;
uint8_t r;
uint8_t g;
uint8_t b;
uint8_t w;
bool has_white() const { return this->w != NO_WHITE; }
uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; }
/// Write the order back out as text, e.g. "GRBW".
///
/// `buf` must have room for at least 5 characters. Returns `buf` so the result can be
/// passed straight to a log call.
const char *to_string(char *buf) const {
buf[this->r] = 'R';
buf[this->g] = 'G';
buf[this->b] = 'B';
if (this->has_white()) {
buf[this->w] = 'W';
}
buf[this->bytes_per_led()] = '\0';
return buf;
}
};
} // namespace esphome::light
-3
View File
@@ -16,9 +16,6 @@ LightColorValues = light_ns.class_("LightColorValues")
LightStateRTCState = light_ns.struct("LightStateRTCState")
LightCall = light_ns.class_("LightCall")
# Addressable strips
ChannelColors = light_ns.struct("ChannelColors")
# Color modes
ColorMode = light_ns.enum("ColorMode", is_class=True)
COLOR_MODES = {
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander, i2c
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -25,7 +25,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(MCP23016),
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
+20 -2
View File
@@ -1,8 +1,8 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander
import esphome.config_validation as cv
from esphome.const import (
CONF_ALLOW_OTHER_USES,
CONF_ID,
CONF_INPUT,
CONF_INTERRUPT,
@@ -32,10 +32,28 @@ MCP23XXX_INTERRUPT_MODES = {
}
def _validate_interrupt_pin(value):
# The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR)
# and installs a single ISR per GPIO, so neither inversion nor sharing is supported.
value = pins.internal_gpio_input_pin_schema(value)
if value.get(CONF_INVERTED):
raise cv.Invalid(
f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
"the MCP23xxx INT line is fixed active-low"
)
if value.get(CONF_ALLOW_OTHER_USES):
raise cv.Invalid(
f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
"sharing the interrupt pin between multiple MCP23xxx (or other components) "
"is not implemented. Remove the interrupt_pin to fall back to polling."
)
return value
MCP23XXX_CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin,
}
).extend(cv.COMPONENT_SCHEMA)
+11 -22
View File
@@ -219,25 +219,14 @@ void ModbusServerHub::parse_modbus_frames() {
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
}
uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const {
// Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values)
// could be any length - we have to rely on the CRC to determine completeness.
uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const {
// Custom functions could be any length - we have to rely on the CRC to determine completeness.
// If a CRC match is never found, the buffer will eventually overflow and be cleared.
const uint8_t *raw = &this->rx_buffer_[0];
const size_t size = this->rx_buffer_.size();
const auto max_len = static_cast<uint16_t>(std::min(size, size_t(MAX_FRAME_SIZE)));
if (min_length > max_len)
return 0;
// The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value,
// so we seed once over the first min_length bytes and extend one byte at a time instead of
// recomputing the whole prefix for every candidate length.
uint16_t crc = crc16(raw, min_length);
if (crc == 0)
return min_length;
for (uint16_t len = min_length; len < max_len; len++) {
crc = crc16(&raw[len], 1, crc);
if (crc == 0)
return len + 1;
for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) {
if (crc16(raw, len) == 0)
return len;
}
return 0;
}
@@ -252,11 +241,11 @@ bool Modbus::parse_modbus_server_frame_() {
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_unknown_length(function_code)) {
frame_length = this->find_frame_end_by_crc_(frame_length);
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "Unknown-length function %02X found", function_code);
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
@@ -283,11 +272,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_unknown_length(function_code)) {
frame_length = this->find_frame_end_by_crc_(frame_length);
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "Unknown-length function %02X found", function_code);
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
+1 -1
View File
@@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component {
bool send_frame_(const ModbusFrame &frame);
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t find_frame_end_by_crc_(uint16_t min_length) const;
uint16_t find_custom_frame_end_(uint16_t min_length) const;
uint32_t last_modbus_byte_{0};
uint32_t last_receive_check_{0};
@@ -55,38 +55,6 @@ inline bool is_function_code_custom(uint8_t function_code) {
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END);
}
/// True for any function code whose frame length the parsers cannot predict - everything the
/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list
/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined
/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes
/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value.
/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code -
/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what
/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary
/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec
/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one
/// pays (recovery by timeout instead of an immediate CRC failure).
inline bool is_function_code_unknown_length(uint8_t function_code) {
switch (static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK)) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS:
case FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
case FunctionCode::READ_FILE_RECORD:
case FunctionCode::WRITE_FILE_RECORD:
case FunctionCode::MASK_WRITE_REGISTER:
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
case FunctionCode::READ_FIFO_QUEUE:
return false;
default:
return true;
}
}
// Returns the expected length of a server response PDU based on the function code.
// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the
// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander, i2c
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -29,7 +29,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent),
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander, i2c
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -30,7 +30,7 @@ CONFIG_SCHEMA = (
{
cv.Required(CONF_ID): cv.declare_id(PCA9554Component),
cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16),
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander, i2c
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
{
cv.Required(CONF_ID): cv.declare_id(PCF8574Component),
cv.Optional(CONF_PCF8575, default=False): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander, i2c
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -34,7 +34,7 @@ CONFIG_SCHEMA = (
{
cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component),
cv.Optional(CONF_RESET, default=True): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() {
pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO
dma_channel_configure(this->dma_chan_, &this->dma_config_,
&this->pio_->txf[this->sm_], // write to the state machine's TX FIFO
this->buf_, // read from memory
this->get_buffer_size_(), // number of bytes to transfer
false // don't start yet
&this->pio_->txf[this->sm_], // write to the state machine's TX FIFO
this->buf_, // read from memory
this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer
false // don't start yet
);
// Initialize the semaphore for this DMA channel
@@ -142,25 +142,58 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) {
}
light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const {
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
int32_t r = 0, g = 0, b = 0;
switch (this->rgb_order_) {
case ORDER_RGB:
r = 0;
g = 1;
b = 2;
break;
case ORDER_RBG:
r = 0;
g = 2;
b = 1;
break;
case ORDER_GRB:
r = 1;
g = 0;
b = 2;
break;
case ORDER_GBR:
r = 2;
g = 0;
b = 1;
break;
case ORDER_BGR:
r = 2;
g = 1;
b = 0;
break;
case ORDER_BRG:
r = 1;
g = 2;
b = 0;
break;
}
uint8_t multiplier = this->is_rgbw_ ? 4 : 3;
return {this->buf_ + (index * multiplier) + r,
this->buf_ + (index * multiplier) + g,
this->buf_ + (index * multiplier) + b,
this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr,
&this->effect_data_[index],
&this->correction_};
}
void RP2040PIOLEDStripLightOutput::dump_config() {
char channel_colors[5];
ESP_LOGCONFIG(TAG,
"RP2040 PIO LED Strip Light Output:\n"
" Pin: GPIO%d\n"
" Number of LEDs: %d\n"
" Channel colors: %s\n"
" RGBW: %s\n"
" RGB Order: %s\n"
" Max Refresh Rate: %f Hz",
this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_);
this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_),
this->max_refresh_rate_);
}
float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -7,7 +7,6 @@
#include "esphome/core/helpers.h"
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/components/light/light_output.h"
#include <hardware/dma.h>
@@ -19,6 +18,15 @@
namespace esphome::rp2040_pio_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
ORDER_RBG,
ORDER_GRB,
ORDER_GBR,
ORDER_BGR,
ORDER_BRG,
};
enum Chipset : uint8_t {
CHIPSET_WS2812,
CHIPSET_WS2812B,
@@ -28,6 +36,25 @@ enum Chipset : uint8_t {
CHIPSET_CUSTOM = 0xFF,
};
inline const char *rgb_order_to_string(RGBOrder order) {
switch (order) {
case ORDER_RGB:
return "RGB";
case ORDER_RBG:
return "RBG";
case ORDER_GRB:
return "GRB";
case ORDER_GBR:
return "GBR";
case ORDER_BGR:
return "BGR";
case ORDER_BRG:
return "BRG";
default:
return "UNKNOWN";
}
}
using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq);
class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
@@ -39,14 +66,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
this->channel_colors_.has_white()
? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE})
: traits.set_supported_color_modes({light::ColorMode::RGB});
this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE})
: traits.set_supported_color_modes({light::ColorMode::RGB});
return traits;
}
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; }
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; }
@@ -55,6 +81,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
void set_init_function(init_fn init) { this->init_ = init; }
void set_chipset(Chipset chipset) { this->chipset_ = chipset; };
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
void clear_effect_data() override {
for (int i = 0; i < this->size(); i++) {
this->effect_data_[i] = 0;
@@ -66,7 +93,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); }
static void dma_write_complete_handler();
@@ -75,13 +102,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
uint8_t pin_;
uint32_t num_leds_;
bool is_rgbw_;
pio_hw_t *pio_;
uint sm_;
uint dma_chan_;
dma_channel_config dma_config_;
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
RGBOrder rgb_order_{ORDER_RGB};
Chipset chipset_{CHIPSET_CUSTOM};
uint32_t last_refresh_{0};
@@ -3,7 +3,6 @@ from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import light, rp2
from esphome.components.const import CONF_CHANNEL_COLORS
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
@@ -14,7 +13,6 @@ from esphome.const import (
CONF_PIN,
CONF_RGB_ORDER,
)
from esphome.types import ConfigType
from esphome.util import _LOGGER
@@ -39,7 +37,7 @@ def get_nops(timing):
return nops
def generate_assembly_code(id, t0h, t0l, t1h, t1l):
def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l):
"""
Generate assembly code with the given timing values.
"""
@@ -141,6 +139,8 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_(
"RP2040PIOLEDStripLightOutput", light.AddressableLight
)
RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder")
Chipset = rp2040_pio_led_strip_ns.enum("Chipset")
CHIPSETS = {
@@ -159,6 +159,15 @@ class LEDStripTimings:
T1L: int
RGB_ORDERS = {
"RGB": RGBOrder.ORDER_RGB,
"RBG": RGBOrder.ORDER_RBG,
"GRB": RGBOrder.ORDER_GRB,
"GBR": RGBOrder.ORDER_GBR,
"BGR": RGBOrder.ORDER_BGR,
"BRG": RGBOrder.ORDER_BRG,
}
CHIPSET_TIMINGS = {
"WS2812": LEDStripTimings(20, 40, 46, 34),
"WS2812B": LEDStripTimings(23, 49, 46, 26),
@@ -190,12 +199,10 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
cv.Optional(CONF_IS_RGBW): cv.boolean,
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Required(CONF_PIO): cv.one_of(0, 1, int=True),
cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
cv.Inclusive(
CONF_BIT0_HIGH,
"custom",
@@ -215,13 +222,10 @@ CONFIG_SCHEMA = cv.All(
}
),
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
light.migrate_channel_colors(
removed_in="2027.3.0", component="rp2040_pio_led_strip"
),
)
async def to_code(config: ConfigType) -> None:
async def to_code(config):
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
id = config[CONF_ID].id
await light.register_light(var, config)
@@ -230,9 +234,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_num_leds(config[CONF_NUM_LEDS]))
cg.add(var.set_pin(config[CONF_PIN]))
cg.add(
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
)
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
cg.add(var.set_pio(config[CONF_PIO]))
cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program")))
@@ -252,6 +255,7 @@ async def to_code(config: ConfigType) -> None:
key,
generate_assembly_code(
id,
config[CONF_IS_RGBW],
CHIPSET_TIMINGS[chipset].T0H,
CHIPSET_TIMINGS[chipset].T0L,
CHIPSET_TIMINGS[chipset].T1H,
@@ -266,6 +270,7 @@ async def to_code(config: ConfigType) -> None:
key,
generate_assembly_code(
id,
config[CONF_IS_RGBW],
time_to_cycles(config[CONF_BIT0_HIGH]),
time_to_cycles(config[CONF_BIT0_LOW]),
time_to_cycles(config[CONF_BIT1_HIGH]),
+3 -2
View File
@@ -5,7 +5,6 @@ from esphome.components.const import CONF_BYTE_ORDER
from esphome.components.image import (
IMAGE_TYPE,
Image_,
validate_byte_order,
validate_settings,
validate_transparency,
validate_type,
@@ -129,7 +128,9 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True),
cv.Optional(CONF_RESIZE): cv.dimensions,
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
cv.Optional(CONF_BYTE_ORDER): validate_byte_order,
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"BIG_ENDIAN", "LITTLE_ENDIAN", upper=True
),
cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(),
cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_),
}
@@ -89,12 +89,12 @@ void SerialProxy::dump_config() {
this->dtr_pin_ != nullptr ? "configured" : "not configured");
}
void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size) {
SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control,
uint8_t parity, uint8_t stop_bits, uint8_t data_size) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
ESP_LOGD(TAG,
@@ -105,25 +105,29 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat
auto *uart_comp = this->parent_;
if (uart_comp == nullptr) {
ESP_LOGE(TAG, "UART component not available");
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR;
}
// Validate all parameters before applying any (values come from a remote client)
if (baudrate == 0) {
ESP_LOGW(TAG, "Invalid baud rate: 0");
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (stop_bits < 1 || stop_bits > 2) {
ESP_LOGW(TAG, "Invalid stop bits: %u (must be 1 or 2)", stop_bits);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (data_size < 5 || data_size > 8) {
ESP_LOGW(TAG, "Invalid data bits: %u (must be 5-8)", data_size);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (parity > 2) {
ESP_LOGW(TAG, "Invalid parity: %u (must be 0-2)", parity);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (flow_control) {
ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported");
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
// Apply validated parameters
@@ -143,10 +147,7 @@ void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrat
#if defined(USE_ESP8266) || defined(USE_ESP32)
uart_comp->load_settings(true);
#endif
if (flow_control) {
ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported");
}
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {
@@ -163,13 +164,20 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui
this->write_array(data, len);
}
void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
// Asserting a pin that is not configured must fail so the client learns the signal never
// reached the wire; deasserting an absent pin is harmless and stays allowed. Clients can
// avoid this by masking against SerialProxyInfo.configured_line_states.
if ((line_states & ~this->get_configured_modem_pins()) != 0) {
ESP_LOGW(TAG, "Requested modem pin not configured on serial proxy [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
const bool rts = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_RTS) != 0;
const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0;
ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr));
@@ -182,6 +190,7 @@ void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t li
this->dtr_state_ = dtr;
this->dtr_pin_->digital_write(dtr);
}
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
uint32_t SerialProxy::get_modem_pins() const {
@@ -189,9 +198,26 @@ uint32_t SerialProxy::get_modem_pins() const {
(this->dtr_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
}
uart::UARTFlushResult SerialProxy::flush_port() {
SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
#ifdef USE_API
// Flushing stalls the port, so it gets the same ownership check as writes
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
ESP_LOGV(TAG, "Flushing serial proxy [%" PRIu32 "]", this->instance_index_);
return this->flush();
switch (this->flush()) {
case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
return SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS;
case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
return SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT;
case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR;
}
return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR; // Unreachable; all enum values handled above
}
#ifdef USE_API
@@ -200,12 +226,13 @@ bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) con
this->api_connection_->is_connection_setup();
}
void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) {
SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection,
api::enums::SerialProxyRequestType type) {
switch (type) {
case api::enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
if (this->api_connection_ == api_connection) {
ESP_LOGV(TAG, "API connection is already subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
if (this->api_connection_ != nullptr) {
// A living subscriber keeps exclusive access. Its connection may be dead without
@@ -213,26 +240,27 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::
// 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;
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
}
this->api_connection_ = api_connection;
this->enable_loop();
ESP_LOGV(TAG, "API connection subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
case api::enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
// Unsubscribe is idempotent: not being subscribed is not an error
if (this->api_connection_ != api_connection) {
ESP_LOGV(TAG, "API connection is not subscribed to serial proxy [%" PRIu32 "]", this->instance_index_);
return;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
this->api_connection_ = nullptr;
this->disable_loop();
ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_);
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
default:
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(type));
break;
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
}
#endif
+23 -5
View File
@@ -38,6 +38,17 @@ enum SerialProxyLineStateFlag : uint32_t {
SERIAL_PROXY_LINE_STATE_FLAG_DTR = 1 << 1, ///< DTR (Data Terminal Ready)
};
/// Result of a client-initiated operation; mapped to api::enums::SerialProxyStatus by the API layer
enum class SerialProxyResult : uint8_t {
SERIAL_PROXY_RESULT_OK, ///< Operation completed or request accepted
SERIAL_PROXY_RESULT_ASSUMED_SUCCESS, ///< Platform cannot confirm TX drain; success assumed
SERIAL_PROXY_RESULT_PORT_IN_USE, ///< Denied: another live client holds the port
SERIAL_PROXY_RESULT_INVALID_ARGUMENT, ///< A parameter value is out of range
SERIAL_PROXY_RESULT_ERROR, ///< Driver or hardware error
SERIAL_PROXY_RESULT_TIMEOUT, ///< Timed out before TX completed
SERIAL_PROXY_RESULT_NOT_SUPPORTED, ///< Requested feature is not available on this instance
};
/// Maximum bytes to read from UART in a single loop iteration
inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256;
@@ -73,14 +84,14 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// @param parity Parity setting (0=none, 1=even, 2=odd)
/// @param stop_bits Number of stop bits (1 or 2)
/// @param data_size Number of data bits (5-8)
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size);
SerialProxyResult configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size);
/// Get the currently subscribed API connection (nullptr if none)
api::APIConnection *get_api_connection() { return this->api_connection_; }
/// Handle a subscribe/unsubscribe request from an API client
void serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type);
SerialProxyResult serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type);
/// Write data received from an API client to the serial device
/// @param api_connection The API connection sending the data
@@ -89,13 +100,20 @@ class SerialProxy final : public uart::UARTDevice, public Component {
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len);
/// Set modem pin states from a bitmask of SerialProxyLineStateFlag values
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states);
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states);
/// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values
uint32_t get_modem_pins() const;
/// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values
uint32_t get_configured_modem_pins() const {
return (this->rts_pin_ != nullptr ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) |
(this->dtr_pin_ != nullptr ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
}
/// Flush the serial port (block until all TX data is sent)
uart::UARTFlushResult flush_port();
/// @param api_connection The API connection requesting the flush
SerialProxyResult flush_port(api::APIConnection *api_connection);
/// Set the RTS GPIO pin (from YAML configuration)
void set_rts_pin(GPIOPin *pin) { this->rts_pin_ = pin; }
+10 -30
View File
@@ -45,11 +45,6 @@ namespace esphome::socket {
static const char *const TAG = "socket";
#ifdef USE_ESP8266
// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot.
static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000;
#endif
// set to 1 to enable verbose lwip logging
#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if)
#define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__)
@@ -540,14 +535,6 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) {
}
ssize_t LWIPRawImpl::read(void *buf, size_t len) {
#ifdef USE_ESP8266
// Would block: yield to SYS so queued WiFi RX reaches lwip and this read
// may succeed. Without this, inbound segments can sit unprocessed for
// seconds while the main loop polls (CONT/SYS are cooperative on ESP8266).
if (this->waiting_for_data_()) {
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
}
#endif
// See waiting_for_data_() for safety of unlocked reads.
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
this->wait_for_data_();
@@ -558,8 +545,6 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) {
}
ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) {
// No ESP8266 SYS yield here: only read() needs it today. If a consumer
// switches to scatter-gather reads, mirror the yield from read().
// See waiting_for_data_() for safety of unlocked reads.
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
this->wait_for_data_();
@@ -624,24 +609,19 @@ int LWIPRawImpl::internal_output_() {
}
LWIP_LOG("tcp_output(%p)", this->pcb_);
err_t err = tcp_output(this->pcb_);
if (err == ERR_ABRT) {
// sometimes lwip returns ERR_ABRT for no apparent reason
// the connection works fine afterwards, and back with ESPAsyncTCP we
// indirectly also ignored this error
// FIXME: figure out where this is returned and what it means in this context
LWIP_LOG(" -> err ERR_ABRT");
return 0;
}
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
// ERR_ABRT: sometimes lwip returns it for no apparent reason; the
// connection works fine afterwards, and back with ESPAsyncTCP we
// indirectly also ignored this error, so treat it as success for
// flush purposes too.
// FIXME: figure out where this is returned and what it means in this context
if (err != ERR_ABRT) {
errno = ECONNRESET;
return -1;
}
errno = ECONNRESET;
return -1;
}
#ifdef USE_ESP8266
// Flushed: yield to SYS so the queued segments reach the WiFi driver
// instead of waiting seconds for an unrelated SYS slot. Callers only get
// here after a successful tcp_write, so idle paths never yield.
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
#endif
return 0;
}
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander, i2c
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(TCA9555Component),
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -1,6 +1,5 @@
from esphome.components.esp32 import add_idf_sdkconfig_option
import esphome.config_validation as cv
from esphome.core import CORE
CODEOWNERS = ["@dentra"]
@@ -13,11 +12,3 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
# Increase the maximum supported size of headers section in HTTP request packet to be processed by the server
add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024)
def FILTER_SOURCE_FILES() -> list[str]:
# multipart.cpp is fully #ifdef'd on USE_WEBSERVER_OTA (set by the
# web_server OTA platform); skip it when OTA uploads are not configured.
if not any(define.name == "USE_WEBSERVER_OTA" for define in CORE.defines):
return ["multipart.cpp"]
return []
@@ -194,12 +194,13 @@ void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) {
}
}
void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) {
api::enums::ZWaveProxyStatus 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_ == api_connection) {
ESP_LOGV(TAG, "API connection is already subscribed");
return;
return api::enums::ZWAVE_PROXY_STATUS_OK;
}
if (this->api_connection_ != nullptr) {
// A living subscriber keeps exclusive access. Its connection may be dead without
@@ -207,25 +208,26 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en
// 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;
return api::enums::ZWAVE_PROXY_STATUS_IN_USE;
}
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
}
this->api_connection_ = api_connection;
ESP_LOGV(TAG, "API connection is now subscribed");
break;
return api::enums::ZWAVE_PROXY_STATUS_OK;
case api::enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
// Unsubscribe is idempotent: not being subscribed is not an error
if (this->api_connection_ != api_connection) {
ESP_LOGV(TAG, "API connection is not subscribed");
return;
return api::enums::ZWAVE_PROXY_STATUS_OK;
}
this->api_connection_ = nullptr;
break;
return api::enums::ZWAVE_PROXY_STATUS_OK;
default:
ESP_LOGW(TAG, "Unknown request type: %" PRIu32, static_cast<uint32_t>(type));
break;
return api::enums::ZWAVE_PROXY_STATUS_NOT_SUPPORTED;
}
}
+2 -1
View File
@@ -60,7 +60,8 @@ class ZWaveProxy final : public uart::UARTDevice, public Component {
bool can_proceed() override;
void api_connection_authenticated(api::APIConnection *conn);
void zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type);
api::enums::ZWaveProxyStatus zwave_proxy_request(api::APIConnection *api_connection,
api::enums::ZWaveProxyRequestType type);
api::APIConnection *get_api_connection() { return this->api_connection_; }
uint32_t get_feature_flags() const { return ZWaveProxyFeature::FEATURE_ZWAVE_PROXY_ENABLED; }
-17
View File
@@ -620,23 +620,6 @@ class LoadValidationStep(ConfigValidationStep):
elif not isinstance(self.conf, list):
result[self.domain] = self.conf = [self.conf]
# Permanent expansion hook: a platform-tagged entry may expand into
# several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only.
if (expand := component.expand_platform_config) is not None and all(
isinstance(entry, dict) and CONF_PLATFORM in entry
for entry in self.conf
):
with result.catch_error(path):
expanded = expand(self.conf)
if not isinstance(expanded, list):
# A non-list return is a component bug (not a user error):
# raise explicitly (survives -O/-OO) so it escapes catch_error.
raise TypeError(
f"{self.domain}: EXPAND_PLATFORM_CONFIG must "
f"return a list, got {type(expanded).__name__}"
)
result[self.domain] = self.conf = expanded
# Process AUTO_LOAD
_process_auto_load(result, component, path)
-8
View File
@@ -164,14 +164,6 @@ class ComponentManifest:
"""
return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None)
@property
def expand_platform_config(
self,
) -> Callable[[list[ConfigType]], list[ConfigType]] | None:
"""Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged
entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors)."""
return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None)
@property
def resources(self) -> list[FileResource]:
"""Return a list of all file resources defined in the package of this component.
+4 -5
View File
@@ -1,4 +1,5 @@
import os
import shutil
# pylint: disable=E0602
Import("env") # noqa
@@ -8,17 +9,15 @@ Import("env") # noqa
# esphome/platformio/toolchain.py); this script only supplies the SCons-level
# mechanism.
#
# The binary comes pre-resolved in ESPHOME_CCACHE_PATH; _ccache_env() has
# already stripped the Windows \\?\ prefix that cmd.exe cannot run.
#
# This is a "pre" script, so the platform's builder (which sets CC/CXX and
# clones the construction environment for framework and library builds) runs
# after it. Replacing CC/CXX here would be overwritten, and replacing them in
# a "post" script would miss the already-cloned library environments. Wrapping
# SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler
# invocation from every environment funnels through it at execution time.
if os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" and (
ccache_path := os.environ.get("ESPHOME_CCACHE_PATH")
if (
os.environ.get("ESPHOME_CCACHE_ENABLE") == "1"
and (ccache_path := shutil.which("ccache")) is not None
):
original_spawn = env["SPAWN"]
+17 -46
View File
@@ -60,9 +60,6 @@ def _strip_win_long_path_prefix(path: str) -> str:
"The system cannot find the path specified." Stripping the prefix early
keeps the path shell-quotable.
Also applied to the ccache path exported by ``_ccache_env()``, which
``shutil.which`` can return with the same prefix.
No-op on non-Windows platforms.
"""
if sys.platform != "win32":
@@ -238,8 +235,8 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
_write_pio_stamp_python(stamp_file, current)
def _ccache_runs(ccache: str) -> bool:
"""Return True when the ``ccache`` found on PATH actually runs.
def _ccache_usable() -> bool:
"""Return True when the ``ccache`` on PATH actually runs.
``shutil.which`` proves existence, not runnability: on Windows it also
matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose
@@ -247,6 +244,9 @@ def _ccache_runs(ccache: str) -> bool:
step with an opaque OS error, so probe once and fall back to compiling
without ccache when the probe fails.
"""
ccache = shutil.which("ccache")
if ccache is None:
return False
try:
subprocess.run(
[ccache, "--version"],
@@ -265,29 +265,14 @@ def _ccache_runs(ccache: str) -> bool:
def _ccache_env() -> dict[str, str]:
r"""Return ccache settings for PlatformIO builds.
"""Return ccache settings for PlatformIO builds.
Enabled by default whenever the ``ccache`` binary is on PATH; set
``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to
force it on without the runnability probe; a binary is still needed).
The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` and the
binary's location into ``ESPHOME_CCACHE_PATH`` so platform build scripts
(the shared ``ccache.py`` extra script, which wraps compiler invocations
inside SCons) only have to check for ``"1"`` and use the path as given
instead of re-implementing the policy.
The path is exported rather than looked up again inside SCons because
``shutil.which`` can return a Windows extended-length ``\\?\`` path
(ESPHome Desktop puts its bundled ccache on PATH that way). Such a path
runs fine through ``CreateProcess``, which is how ESP-IDF invokes it,
but SCons runs every compile through ``cmd.exe``, which fails on it with
"The system cannot find the path specified." (#18399), so the prefix is
stripped here with ``_strip_win_long_path_prefix()`` before the
runnability probe, which therefore validates the exact string the build
will execute.
``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the
script only honours it together with ``ESPHOME_CCACHE_ENABLE=1``, and this
function always sets both or neither.
force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE``
so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script,
which wraps compiler invocations inside SCons) only have to check for
``"1"`` instead of re-implementing the policy.
The returned values are merged into the environment of the PlatformIO
subprocess only, never into ``os.environ``: a long-running process
@@ -308,27 +293,13 @@ def _ccache_env() -> dict[str, str]:
build dir. The other ``CCACHE_*`` values the user already set in the
environment are respected.
"""
explicit = "ESPHOME_CCACHE_ENABLE" in os.environ
if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"):
return {"ESPHOME_CCACHE_ENABLE": "0"}
ccache_path = shutil.which("ccache")
if ccache_path is None:
if explicit:
_LOGGER.warning(
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
"compiling without ccache"
)
return {"ESPHOME_CCACHE_ENABLE": "0"}
# Strip before probing so the probe validates (and the failure warning
# names) the exact string the build will execute through cmd.exe.
ccache_path = _strip_win_long_path_prefix(ccache_path)
# An explicit opt-in skips the runnability probe.
if not explicit and not _ccache_runs(ccache_path):
return {"ESPHOME_CCACHE_ENABLE": "0"}
env = {
"ESPHOME_CCACHE_ENABLE": "1",
"ESPHOME_CCACHE_PATH": ccache_path,
}
if "ESPHOME_CCACHE_ENABLE" in os.environ:
enabled = get_bool_env("ESPHOME_CCACHE_ENABLE")
else:
enabled = _ccache_usable()
env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"}
if not enabled:
return env
# build_path is set during preload for every config-loading command, so it
# being unset means a caller built the environment too early; fail loudly
# rather than with an opaque TypeError from Path(None).
+2 -18
View File
@@ -3,14 +3,12 @@ from __future__ import annotations
from io import StringIO
import json
from pathlib import Path
import sys
import traceback
from typing import Any
from esphome.config import Config, _format_vol_invalid, validate_config
import esphome.config_validation as cv
from esphome.const import __version__ as ESPHOME_VERSION
from esphome.core import CORE, DocumentRange, EsphomeError
from esphome.core import CORE, DocumentRange
from esphome.yaml_util import parse_yaml
@@ -99,16 +97,6 @@ def _ace_loader(fname: Path) -> dict[str, Any]:
return parse_yaml(fname, raw_yaml_stream)
def _format_unexpected_error(err: Exception) -> str:
"""Describe a crash inside validation with the frame it came from."""
message = f"Unexpected error while validating: {type(err).__name__}: {err}"
frames = traceback.extract_tb(err.__traceback__)
if not frames:
return message
frame = frames[-1]
return f"{message} ({frame.filename}:{frame.lineno} in {frame.name})"
def _print_version():
"""Print ESPHome version."""
print(
@@ -146,12 +134,8 @@ def read_config(args):
try:
config = loader(file_name)
res = validate_config(config, command_line_substitutions)
except (EsphomeError, cv.Invalid) as err:
vs.add_yaml_error(str(err))
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
# stdout carries the JSON protocol; the full chain goes to stderr.
traceback.print_exc(file=sys.stderr)
vs.add_yaml_error(_format_unexpected_error(err))
vs.add_yaml_error(str(err))
else:
for err in res.errors:
try:
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.21 ; api
esphome/noise-c@0.1.18 ; api
improv/Improv@1.2.6 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.21 ; api
esphome/noise-c@0.1.18 ; api
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.21 ; used by api
esphome/noise-c@0.1.18 ; used by api
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+1 -1
View File
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools==84.0.0", "wheel>=0.43,<0.49"]
requires = ["setuptools==84.0.0", "wheel>=0.43,<0.48"]
build-backend = "setuptools.build_meta"
[project]
+3 -3
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==45.12.0
aioesphomeapi==45.10.3
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.150.0
puremagic==2.2.0
@@ -20,14 +20,14 @@ ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
pillow==12.3.0
resvg-py==0.4.0
resvg-py==0.3.4
freetype-py==2.5.1
jinja2==3.1.6
bleak==3.0.2
smpclient==7.2.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.11.3 # native esp-idf toolchain global cache dir
platformdirs==4.11.2 # native esp-idf toolchain global cache dir
filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
-10
View File
@@ -250,16 +250,6 @@ def add_pin_validators():
"modes": ["input"],
}
from esphome.components import gpio_expander
# Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep
# treating the config var as a pin
pin_validators[repr(gpio_expander.validate_interrupt_pin)] = {
"schema": True,
"internal": True,
"modes": ["input"],
}
def add_module_registries(domain, module):
for attr_name in dir(module):
+2 -3
View File
@@ -558,9 +558,8 @@ def _get_changed_files_github_actions() -> list[str] | None:
try:
return _get_changed_files_from_command(cmd)
except Exception as e:
# If it fails due to a diff limit (300 files or 20000 lines),
# use the API method which only returns filenames
if "diff exceeded the maximum" in str(e):
# If it fails due to the 300 file limit, use the API method
if "maximum" in str(e) and "files" in str(e):
cmd = [
"gh",
"api",
@@ -13,17 +13,18 @@ namespace api {
class APIConnection;
} // namespace api
namespace uart {
enum class UARTFlushResult : uint8_t {
UART_FLUSH_RESULT_SUCCESS,
UART_FLUSH_RESULT_ASSUMED_SUCCESS,
UART_FLUSH_RESULT_TIMEOUT,
UART_FLUSH_RESULT_FAILED,
};
} // namespace uart
namespace serial_proxy {
enum class SerialProxyResult : uint8_t {
SERIAL_PROXY_RESULT_OK,
SERIAL_PROXY_RESULT_ASSUMED_SUCCESS,
SERIAL_PROXY_RESULT_PORT_IN_USE,
SERIAL_PROXY_RESULT_INVALID_ARGUMENT,
SERIAL_PROXY_RESULT_ERROR,
SERIAL_PROXY_RESULT_TIMEOUT,
SERIAL_PROXY_RESULT_NOT_SUPPORTED,
};
class SerialProxy {
public:
void set_instance_index(uint32_t index) { this->instance_index_ = index; }
@@ -31,13 +32,20 @@ class SerialProxy {
const char *get_name() const { return ""; }
api::enums::SerialProxyPortType get_port_type() const { return {}; }
api::APIConnection *get_api_connection() { return nullptr; }
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint32_t stop_bits, uint32_t data_size) {}
SerialProxyResult serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
SerialProxyResult configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {}
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
uint32_t get_modem_pins() const { return 0; }
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
uint32_t get_configured_modem_pins() const { return 0; }
SerialProxyResult flush_port(api::APIConnection *api_connection) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; }
protected:
uint32_t instance_index_{0};
@@ -15,7 +15,9 @@ namespace zwave_proxy {
class ZWaveProxy {
public:
api::APIConnection *get_api_connection() { return nullptr; }
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
api::enums::ZWaveProxyStatus zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {
return api::enums::ZWAVE_PROXY_STATUS_OK;
}
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; }
@@ -1,61 +0,0 @@
"""Tests for the shared io expander interrupt_pin validator."""
from __future__ import annotations
import importlib
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.components.gpio_expander import validate_interrupt_pin
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
@pytest.fixture
def stage_esp32(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
def test_plain_pin_accepted(stage_esp32: None) -> None:
value = validate_interrupt_pin(
{"number": 16, "mode": {"input": True, "pullup": True}}
)
assert value["number"] == 16
def test_inverted_rejected(stage_esp32: None) -> None:
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
validate_interrupt_pin({"number": 16, "inverted": True})
def test_allow_other_uses_rejected(stage_esp32: None) -> None:
with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"):
validate_interrupt_pin({"number": 16, "allow_other_uses": True})
# mcp23017 covers the shared mcp23xxx_base schema
@pytest.mark.parametrize(
"component",
[
"pcf8574",
"pca9554",
"tca9555",
"pca6416a",
"pi4ioe5v6408",
"mcp23016",
"mcp23017",
],
)
def test_component_schemas_route_through_validator(
stage_esp32: None, component: str
) -> None:
module = importlib.import_module(f"esphome.components.{component}")
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
module.CONFIG_SCHEMA(
{"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}}
)
+3 -325
View File
@@ -21,20 +21,16 @@ from esphome.components.image import (
CONF_OPAQUE,
CONF_TRANSPARENCY,
PLATFORM_FILE,
_expand_platform_entry,
_flatten_legacy_image_config,
_is_legacy_image_format,
_is_new_image_format,
_migrate_legacy_image_config,
expand_platform_config,
get_all_image_metadata,
get_image_metadata,
)
from esphome.const import (
CONF_DEFAULTS,
CONF_DITHER,
CONF_FILE,
CONF_FILES,
CONF_ID,
CONF_PLATFORM,
CONF_RAW_DATA_ID,
@@ -263,15 +259,6 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None:
assert out[0][CONF_BYTE_ORDER] == "little_endian"
def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None:
"""The legacy flattener drops an incompatible byte_order even when written directly on the entry."""
out = _flatten_legacy_image_config(
{"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
assert CONF_BYTE_ORDER not in out[0]
def test_flatten_skips_meta_and_unknown_keys() -> None:
out = _flatten_legacy_image_config(
{
@@ -355,42 +342,6 @@ def test_migrate_legacy_warns_and_prepends_platform(
),
pytest.param({"foo": 1}, False, id="dict_unknown_keys"),
pytest.param("a string", False, id="scalar"),
# A `platform:`-tagged dict is the new format written without list brackets.
pytest.param(
{CONF_PLATFORM: "file", "id": "a", "file": "x.png"},
False,
id="platform_tagged_flat_dict",
),
pytest.param(
{
CONF_PLATFORM: "file",
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
},
False,
id="platform_tagged_defaults_files_dict",
),
# `files:` without `platform:` is not legacy either -- the flattener has no branch for it.
pytest.param(
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
},
False,
id="defaults_files_dict_without_platform",
),
# Same as above in a list -- without this exclusion it would be silently
# migrated to a hard-coded `platform: file` instead of raising the error.
pytest.param(
[
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
}
],
False,
id="defaults_files_list_entry_without_platform",
),
],
)
def test_is_legacy_image_format(config: object, expected: bool) -> None:
@@ -408,290 +359,17 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None:
def test_migrate_returns_none_for_invalid_legacy_shapes(
config: object, caplog: pytest.LogCaptureFixture
) -> None:
"""Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them."""
"""Unrecognised shapes are not migrated (and emit no warning) so normal
platform validation surfaces a proper error instead of silently dropping
the offending input."""
with caplog.at_level(logging.WARNING):
assert _migrate_legacy_image_config(config) is None
assert "deprecated" not in caplog.text
def test_migrate_returns_none_for_mapping_form_defaults_files() -> None:
"""A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator."""
config = {
CONF_PLATFORM: "file",
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
assert _migrate_legacy_image_config(config) is None
def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None:
"""`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has
no `files:` branch and would silently return `[]`."""
config = {
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
assert _migrate_legacy_image_config(config) is None
def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None:
"""Same, in a list -- previously the list branch migrated it to a hard-coded
`platform: file` instead of raising a missing-platform error."""
config = [
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
]
assert _migrate_legacy_image_config(config) is None
# --------------------------- end legacy migration --------------------------
def test_expand_platform_entry_passes_through_plain_entry() -> None:
entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}
assert _expand_platform_entry(0, entry) == [entry]
def test_expand_platform_entry_expands_files_with_defaults() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"},
CONF_FILES: [
{"id": "img1", "file": "foo.png"},
{"id": "img2", "file": "bar.png", "type": "GRAYSCALE"},
],
}
assert _expand_platform_entry(0, entry) == [
{
CONF_PLATFORM: "file",
"id": "img1",
"file": "foo.png",
"type": "RGB565",
"transparency": "opaque",
},
{
CONF_PLATFORM: "file",
"id": "img2",
"file": "bar.png",
"type": "GRAYSCALE",
"transparency": "opaque",
},
]
def test_expand_platform_entry_files_without_defaults() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
}
assert _expand_platform_entry(0, entry) == [
{CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
]
def test_expand_platform_entry_preserves_source_range() -> None:
"""A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there."""
from esphome import yaml_util
file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"})
file_entry._esp_range = "sentinel-range"
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [file_entry],
}
[out] = _expand_platform_entry(0, entry)
assert isinstance(out, yaml_util.ESPHomeDataBase)
assert out.esp_range == "sentinel-range"
def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None:
"""Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally."""
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
}
[out] = _expand_platform_entry(0, entry)
assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
def test_expand_platform_entry_per_file_overrides_win() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}],
}
[out] = _expand_platform_entry(0, entry)
assert out["type"] == "BINARY"
def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None:
"""A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"},
CONF_FILES: [
{"id": "a", "file": "x.png"},
{"id": "b", "file": "y.png", "type": "binary"},
],
}
out = _expand_platform_entry(0, entry)
assert out[0]["byte_order"] == "little_endian"
assert "byte_order" not in out[1]
def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None:
"""A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"},
CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}],
}
with pytest.raises(cv.Invalid, match="did you mean") as excinfo:
_expand_platform_entry(0, entry)
assert excinfo.value.path == [0]
def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"},
CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}],
}
[out] = _expand_platform_entry(0, entry)
assert out["byte_order"] == "big_endian"
def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None:
"""A `byte_order` written directly on the entry is kept so validate_settings raises the normal error."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565"},
CONF_FILES: [
{
"id": "a",
"file": "x.png",
"type": "binary",
"byte_order": "little_endian",
}
],
}
[out] = _expand_platform_entry(0, entry)
assert out["byte_order"] == "little_endian"
def test_expand_platform_entry_defaults_without_files_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}}
with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo:
_expand_platform_entry(0, entry)
assert excinfo.value.path == [0]
def test_expand_platform_entry_null_files_raises_not_empty() -> None:
"""A `files:` key with no value parses to `None` and must be reported clearly."""
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None}
with pytest.raises(cv.Invalid, match="must not be empty"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None:
"""An explicit `files: []` must not silently drop the whole platform entry."""
entry = {CONF_PLATFORM: "file", CONF_FILES: []}
with pytest.raises(cv.Invalid, match="must not be empty"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_files_with_stray_key_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "a", "file": "x.png"}],
"extra": 1,
}
with pytest.raises(cv.Invalid, match="cannot be combined with"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_id_in_defaults_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {CONF_ID: "a"},
CONF_FILES: [{"file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_platform_in_defaults_raises() -> None:
"""`platform:` inside `defaults:` would silently reassign every file's platform."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {CONF_PLATFORM: "animation"},
CONF_FILES: [{"id": "a", "file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_platform_in_file_entry_raises() -> None:
"""`platform:` on a `files:` item must not silently override the entry's platform."""
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_files_not_list_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"}
with pytest.raises(cv.Invalid, match="must be a list"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_defaults_not_mapping_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: "not-a-mapping",
CONF_FILES: [{"id": "a", "file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="must be a mapping"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_file_item_not_mapping_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]}
with pytest.raises(cv.Invalid, match="must be a mapping"):
_expand_platform_entry(0, entry)
def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None:
config = [
{
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [
{"id": "img1", "file": "foo.png"},
{"id": "img2", "file": "bar.png"},
],
},
{CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"},
]
out = expand_platform_config(config)
assert [entry["id"] for entry in out] == ["img1", "img2", "plain"]
def test_expand_platform_config_ignores_non_platform_entries() -> None:
# Not expanded here -- legacy_config_migrate runs before this hook and is
# responsible for tagging/flattening pre-platform shapes.
config = ["not-a-platform-entry"]
assert expand_platform_config(config) == config
# --------------------- end defaults/files expansion -------------------------
def test_validate_image_final_defaults_to_little_endian() -> None:
config = {CONF_FILE: "x.png"}
validate_image_final(config)
@@ -3,7 +3,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
channel_colors: GRB
rgb_order: GRB
num_leds: 256
pin: ${pin}
@@ -3,7 +3,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
channel_colors: GRB
rgb_order: GRB
num_leds: 256
pin: ${pin}
@@ -1,21 +0,0 @@
# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion.
display:
- platform: sdl
id: animation_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- platform: animation
defaults:
type: rgb565
transparency: opaque
resize: 50x50
files:
- id: platform_defaults_animation
file: $component_dir/anim.gif
- id: platform_defaults_animation_rgb
file: $component_dir/anim.apng
type: rgb
@@ -1,6 +1,6 @@
light:
- platform: beken_spi_led_strip
channel_colors: GRB
rgb_order: GRB
pin: P16
num_leds: 30
chipset: ws2812
@@ -1,10 +0,0 @@
# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0.
# Config-only, and only one strip because P16 is the sole supported pin.
light:
- platform: beken_spi_led_strip
name: Legacy RGBW
pin: P16
num_leds: 30
chipset: sk6812
rgb_order: GRB
is_rgbw: true # -> GRBW
+1 -1
View File
@@ -5,7 +5,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
channel_colors: GRB
rgb_order: GRB
num_leds: 256
pin: ${pin}
effects:
+1 -1
View File
@@ -5,7 +5,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
channel_colors: GRB
rgb_order: GRB
num_leds: 256
pin: ${pin}
effects:
+1 -1
View File
@@ -6,7 +6,7 @@ light:
pin: 2
pio: 0
num_leds: 256
channel_colors: GRB
rgb_order: GRB
chipset: WS2812
effects:
- e131:
@@ -3,13 +3,13 @@ light:
id: led_strip1
pin: ${pin1}
num_leds: 60
channel_colors: GRB
rgb_order: GRB
chipset: ws2812
- platform: esp32_rmt_led_strip
id: led_strip2
pin: ${pin2}
num_leds: 60
channel_colors: RWGB
rgbw_order: RWGB
bit0_high: 100us
bit0_low: 100us
bit1_high: 100us
@@ -8,14 +8,14 @@ light:
id: led_strip1
pin: ${pin1}
num_leds: 60
channel_colors: GRB
rgb_order: GRB
chipset: ws2812
use_dma: "true"
- platform: esp32_rmt_led_strip
id: led_strip2
pin: ${pin2}
num_leds: 60
channel_colors: RGB
rgb_order: RGB
bit0_high: 100us
bit0_low: 100us
bit1_high: 100us
@@ -1,23 +0,0 @@
# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0.
# Config-only: each strip below must migrate to the channel_colors shown in the comment.
light:
- platform: esp32_rmt_led_strip
id: legacy_rgb
pin: GPIO13
num_leds: 60
chipset: ws2812
rgb_order: GRB # -> GRB
- platform: esp32_rmt_led_strip
id: legacy_rgbw
pin: GPIO14
num_leds: 60
chipset: sk6812
rgb_order: GRB
is_rgbw: true # -> GRBW
- platform: esp32_rmt_led_strip
id: legacy_wrgb
pin: GPIO15
num_leds: 60
chipset: sk6812
rgb_order: GRB
is_wrgb: true # -> WGRB
@@ -1,24 +0,0 @@
# `platform: file` entry using the `defaults:`/`files:` shape, including the
# per-type byte_order drop when an entry overrides to a non-endian type.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- platform: file
defaults:
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
files:
- id: platform_defaults_image
file: ../../pnglogo.png
- id: platform_defaults_binary
file: ../../pnglogo.png
type: binary
-36
View File
@@ -1,10 +1,7 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
#include "esphome/components/uart/uart_component.h"
#include "esphome/core/helpers.h"
namespace esphome::modbus::testing {
@@ -33,37 +30,4 @@ class RecordingUART : public NullUART {
std::vector<uint8_t> written;
};
// A UART the test can inject received bytes into, so frames travel the full receive path
// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded.
class InjectableUART : public RecordingUART {
public:
bool peek_byte(uint8_t *data) override {
if (this->rx_.empty())
return false;
*data = this->rx_.front();
return true;
}
bool read_array(uint8_t *data, size_t len) override {
if (len > this->rx_.size())
return false;
memcpy(data, this->rx_.data(), len);
this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len);
return true;
}
size_t available() override { return this->rx_.size(); }
// Queues a complete wire frame: address + PDU + CRC16 (low byte first).
void inject_frame(uint8_t address, std::span<const uint8_t> pdu) {
size_t start = this->rx_.size();
this->rx_.push_back(address);
this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end());
uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start);
this->rx_.push_back(crc & 0xFF);
this->rx_.push_back(crc >> 8);
}
private:
std::vector<uint8_t> rx_;
};
} // namespace esphome::modbus::testing
@@ -1,141 +0,0 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <span>
#include <vector>
#include "common.h"
#include "esphome/components/modbus/modbus.h"
namespace esphome::modbus::testing {
namespace {
// Records custom-response dispatches so tests can assert an unknown-length frame reached the device.
class CustomRecordingDevice : public ModbusClientDevice {
public:
using ModbusClientDevice::ModbusClientDevice;
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) override {
this->requests.emplace_back(request_pdu.begin(), request_pdu.end());
this->responses.emplace_back(response_pdu.begin(), response_pdu.end());
this->statuses.push_back(status);
}
std::vector<std::vector<uint8_t>> requests;
std::vector<std::vector<uint8_t>> responses;
std::vector<ResponseStatus> statuses;
};
// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test.
class SilentServerDevice : public ModbusServerDevice {};
// Drives full client frames through the server hub's receive path (same shape as the broadcast tests).
class TestServerHub : public ModbusServerHub {
public:
bool tx_blocked() override { return false; }
// Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser.
// Returns true once the buffer has fully drained.
bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span<const uint8_t> data) {
this->rx_buffer_.clear();
this->rx_buffer_.reserve(data.size() + 4);
this->rx_buffer_.push_back(address);
this->rx_buffer_.push_back(function_code);
this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end());
uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size());
this->rx_buffer_.push_back(crc & 0xFF);
this->rx_buffer_.push_back(crc >> 8);
this->parse_modbus_frames();
return this->rx_buffer_.empty();
}
};
} // namespace
// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the
// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes -
// must classify as unknown length. The exception flag masks off first.
TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) {
EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
}
for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) {
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
}
// Exception replies classify by their base code.
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83));
EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87));
// Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa.
for (int fc = 0; fc <= 0xFF; fc++) {
if (helpers::is_function_code_custom(fc))
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc;
}
EXPECT_FALSE(helpers::is_function_code_custom(0x49));
// Derived contract check: the helper must say "unknown" exactly when both length parsers fall
// through to default. With a zero-filled max-size PDU every explicit case returns at least 2
// (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The
// loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length()
// switches on the unmasked byte and server_pdu_length() early-returns the exception length.
for (int fc = 0; fc <= 0x7F; fc++) {
const uint8_t pdu[MAX_PDU_SIZE] = {static_cast<uint8_t>(fc)}; // zero header fields
EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
<< "client_pdu_length disagrees for fc 0x" << std::hex << fc;
EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
<< "server_pdu_length disagrees for fc 0x" << std::hex << fc;
}
}
// A response with a function code outside the user-defined ranges (0x49) has no length case in
// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already
// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the
// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device.
TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) {
InjectableUART uart;
ModbusClientHub hub;
hub.set_uart_parent(&uart);
hub.setup(); // computes frame timing from the baud rate
CustomRecordingDevice device(&hub, 0x02);
const uint8_t request[] = {0x49, 0x01};
ASSERT_TRUE(device.queue_pdu(request));
hub.loop(); // transmit
ASSERT_FALSE(uart.written.empty());
const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB};
uart.inject_frame(0x02, response_pdu);
hub.loop(); // receive + parse + match + dispatch
ASSERT_EQ(device.responses.size(), 1u);
EXPECT_EQ(device.requests[0], std::vector<uint8_t>(request, request + sizeof(request)));
EXPECT_EQ(device.responses[0], std::vector<uint8_t>(response_pdu, response_pdu + sizeof(response_pdu)));
EXPECT_FALSE(device.statuses[0].has_value());
}
// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC
// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails
// to parse and the client gets silence instead of the exception.
TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) {
TestServerHub hub;
RecordingUART uart;
hub.set_uart_parent(&uart);
SilentServerDevice device;
device.set_address(0x02);
hub.register_device(&device);
const uint8_t data[] = {0x02, 0xAA, 0xBB};
ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data));
// Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC.
std::vector<uint8_t> expected = {0x02, 0xC9, 0x01};
uint16_t crc = crc16(expected.data(), expected.size());
expected.push_back(crc & 0xFF);
expected.push_back(crc >> 8);
EXPECT_EQ(uart.written, expected);
}
} // namespace esphome::modbus::testing
+1 -1
View File
@@ -4,7 +4,7 @@ light:
default_transition_length: 500ms
chipset: ws2812
num_leds: 256
channel_colors: GRB
rgb_order: GRB
pin: ${pin}
- platform: partition
name: Partition Light
+1 -1
View File
@@ -4,7 +4,7 @@ light:
default_transition_length: 500ms
chipset: ws2812
num_leds: 256
channel_colors: GRB
rgb_order: GRB
pin: ${pin}
- platform: partition
name: Partition Light
@@ -4,14 +4,14 @@ light:
pin: 4
num_leds: 60
pio: 0
channel_colors: GRB
rgb_order: GRB
chipset: WS2812
- platform: rp2040_pio_led_strip
id: led_strip_custom_timings
pin: 5
num_leds: 60
pio: 1
channel_colors: GRB
rgb_order: GRB
bit0_high: .1us
bit0_low: 1.2us
bit1_high: .69us
@@ -1,18 +0,0 @@
# The deprecated rgb_order / is_rgbw keys, kept working until 2027.3.0.
# Config-only: each strip below must migrate to the channel_colors shown in the comment.
light:
- platform: rp2040_pio_led_strip
id: legacy_rgb
pin: 4
num_leds: 60
pio: 0
chipset: WS2812
rgb_order: GRB # -> GRB
- platform: rp2040_pio_led_strip
id: legacy_rgbw
pin: 5
num_leds: 60
pio: 1
chipset: SK6812
rgb_order: GRB
is_rgbw: true # -> GRBW
+1 -1
View File
@@ -9,7 +9,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
channel_colors: GRB
rgb_order: GRB
num_leds: 256
pin: 2
effects:
+1 -5
View File
@@ -60,11 +60,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
env = os.environ.copy()
env["PLATFORMIO_CORE_DIR"] = str(cache_dir)
env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache")
# libdeps is keyed only by env name (the device name), and fixtures share
# names; two xdist workers first-compiling the same name race pio pkg
# install in the same directory. Keep libdeps per worker.
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker)
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps")
# Prevent cache cleaning during integration tests
env["ESPHOME_SKIP_CLEAN_BUILD"] = "1"
# Compile with THIS tree's esphome sources, not wherever the venv's editable
-38
View File
@@ -244,44 +244,6 @@ def test_get_changed_files_github_actions_pull_request_large_pr(
assert result == expected_files
def test_get_changed_files_github_actions_pull_request_large_diff(
monkeypatch: MonkeyPatch,
) -> None:
"""Test _get_changed_files_github_actions fallback for PRs with >20000 diff lines."""
monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
expected_files = ["file1.py", "file2.cpp"]
with (
patch("helpers._get_pr_number_from_github_env", return_value="17909"),
patch("helpers._get_changed_files_from_command") as mock_get,
):
# First call fails with too many diff lines error, second succeeds with API method
mock_get.side_effect = [
Exception(
"could not find pull request diff: HTTP 406: Sorry, "
"the diff exceeded the maximum number of lines (20000)"
),
expected_files,
]
result = _get_changed_files_github_actions()
assert mock_get.call_count == 2
mock_get.assert_any_call(["gh", "pr", "diff", "17909", "--name-only"])
mock_get.assert_any_call(
[
"gh",
"api",
"repos/esphome/esphome/pulls/17909/files",
"--paginate",
"--jq",
".[].filename",
]
)
assert result == expected_files
def test_get_changed_files_github_actions_pull_request_other_error(
monkeypatch: MonkeyPatch,
) -> None:
@@ -263,6 +263,17 @@ def test_device_capabilities_response_has_id_150() -> None:
)
def test_z_wave_proxy_request_response_has_id_151() -> None:
body = _extract_proto_message(PROTO_TEXT, "ZWaveProxyRequestResponse")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "ZWaveProxyRequestResponse is missing `option (id)`"
assert int(match.group(1)) == 151, (
f"ZWaveProxyRequestResponse has id {match.group(1)}, expected 151. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None:
"""The six superseded fields must not carry `[deprecated = true]` in
api.proto, or the generator drops them and old clients stop receiving
@@ -1,144 +0,0 @@
"""Tests for the shared addressable-strip channel order helpers."""
import logging
import pytest
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
from esphome.components.light import (
channel_colors_struct,
migrate_channel_colors,
validate_channel_colors,
)
import esphome.config_validation as cv
from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER
from esphome.types import ConfigType
NO_WHITE = "light::ChannelColors::NO_WHITE"
@pytest.mark.parametrize(
("value", "expected"),
[
("RGB", "RGB"),
("grb", "GRB"),
("BRG", "BRG"),
("rgbw", "RGBW"),
("WRGB", "WRGB"),
("GWRB", "GWRB"),
],
)
def test_validate_channel_colors(value: str, expected: str) -> None:
assert validate_channel_colors(value) == expected
@pytest.mark.parametrize(
"value",
[
"RG", # missing a channel
"RGBB", # duplicate channel
"RRGB", # duplicate channel, correct length
"RGBWW", # two white channels
"RGBX", # unknown channel
"RGBWX", # unknown channel, correct length
"",
],
)
def test_validate_channel_colors_rejects_invalid(value: str) -> None:
with pytest.raises(cv.Invalid, match="is not a valid channel order"):
validate_channel_colors(value)
@pytest.mark.parametrize(
("value", "expected"),
[
("RGB", (0, 1, 2, NO_WHITE)),
("GRB", (1, 0, 2, NO_WHITE)),
("BRG", (1, 2, 0, NO_WHITE)),
("RGBW", (0, 1, 2, 3)),
("GRBW", (1, 0, 2, 3)),
("WRGB", (1, 2, 3, 0)),
("GWRB", (2, 0, 3, 1)),
],
)
def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None:
struct = channel_colors_struct(value)
assert str(struct.base) == "light::ChannelColors"
assert tuple(str(arg) for arg in struct.args.values()) == tuple(
str(field) for field in expected
)
def _migrate(config: ConfigType) -> ConfigType:
return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config)
def test_migrate_passes_through_channel_colors() -> None:
config = {CONF_CHANNEL_COLORS: "GRBW"}
assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"}
@pytest.mark.parametrize(
("deprecated", "expected", "named"),
[
({}, "GRB", "'rgb_order' is"),
(
{CONF_IS_RGBW: False, CONF_IS_WRGB: False},
"GRB",
"'rgb_order', 'is_rgbw' and 'is_wrgb' are",
),
({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"),
({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"),
],
)
def test_migrate_folds_deprecated_keys(
deprecated: ConfigType,
expected: str,
named: str,
caplog: pytest.LogCaptureFixture,
) -> None:
config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated}
with caplog.at_level(logging.WARNING):
result = _migrate(config)
assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1}
assert f"[test_strip] {named} deprecated" in caplog.text
assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text
assert "2027.3.0" in caplog.text
def test_migrate_does_not_mutate_input() -> None:
config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True}
_migrate(config)
assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True}
@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB])
def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None:
config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"}
with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"):
_migrate(config)
def test_migrate_reports_every_conflicting_key() -> None:
config = {
CONF_CHANNEL_COLORS: "GRBW",
CONF_RGB_ORDER: "GRB",
CONF_IS_RGBW: True,
CONF_IS_WRGB: False,
}
with pytest.raises(
cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'"
):
_migrate(config)
def test_migrate_requires_channel_colors() -> None:
with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"):
_migrate({"num_leds": 1})
def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None:
config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True}
with pytest.raises(cv.Invalid, match="cannot both be enabled"):
_migrate(config)
@@ -0,0 +1,57 @@
import pytest
from esphome.components.esp32_rmt_led_strip.light import (
CONF_IS_WRGB,
CONF_RGBW_ORDER,
_split_rgbw_order,
_validate_rgbw_order,
_validate_rgbw_order_exclusivity,
)
import esphome.config_validation as cv
from esphome.const import CONF_IS_RGBW
def test_validate_rgbw_order() -> None:
assert _validate_rgbw_order("rwgb") == "RWGB"
@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"])
def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None:
with pytest.raises(cv.Invalid, match="permutation of RGBW"):
_validate_rgbw_order(rgbw_order)
@pytest.mark.parametrize(
("rgbw_order", "expected"),
[
("WRGB", ("RGB", 0)),
("RWGB", ("RGB", 1)),
("GWRB", ("GRB", 1)),
("RGBW", ("RGB", 3)),
],
)
def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None:
assert _split_rgbw_order(rgbw_order) == expected
@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB])
def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None:
with pytest.raises(cv.Invalid, match="cannot be used with"):
_validate_rgbw_order_exclusivity(
{
CONF_RGBW_ORDER: "RGBW",
CONF_IS_RGBW: conflict == CONF_IS_RGBW,
CONF_IS_WRGB: conflict == CONF_IS_WRGB,
}
)
@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB])
def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None:
config = {
CONF_RGBW_ORDER: "RGBW",
CONF_IS_RGBW: False,
CONF_IS_WRGB: False,
}
config[legacy_option] = False
assert _validate_rgbw_order_exclusivity(config) is config
+2 -122
View File
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import config, config_validation as cv, yaml_util
from esphome import config, yaml_util
from esphome.core import CORE, AutoLoad
from esphome.types import ConfigType
@@ -127,14 +127,12 @@ def _run_load_step(
domain: str,
conf: object,
migrate: Callable[[ConfigType], list | None] | None,
expand: Callable[[list], list] | None = None,
) -> config.Config:
"""Run a LoadValidationStep for a platform component with given hooks."""
"""Run a LoadValidationStep for a platform component with a given migrate hook."""
component = Mock()
component.is_platform_component = True
component.multi_conf_no_default = False
component.legacy_config_migrate = migrate
component.expand_platform_config = expand
result = config.Config()
with (
@@ -199,124 +197,6 @@ def test_legacy_migrate_skipped_for_autoload() -> None:
assert result["image"] == [auto]
# ---------------------------------------------------------------------------
# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart
# to legacy_config_migrate; runs after legacy migration/list normalization.
# ---------------------------------------------------------------------------
def test_expand_hook_rewrites_conf() -> None:
"""A config the expand hook rewrites is replaced with the expanded list."""
expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}]
expand = Mock(return_value=expanded)
result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand)
expand.assert_called_once_with([{"platform": "file", "id": "a"}])
assert result["image"] == expanded
def test_expand_hook_absent_is_noop() -> None:
"""A platform component without the hook is left as normalized by the
existing list-wrapping logic."""
result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None)
assert result["image"] == [{"platform": "file", "id": "a"}]
def test_expand_hook_runs_after_legacy_migrate() -> None:
"""The expand hook sees the already-migrated list, not the raw legacy conf."""
migrated = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=migrated)
expand = Mock(side_effect=lambda conf: conf)
_run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand)
expand.assert_called_once_with(migrated)
def test_expand_hook_skipped_for_non_dict_entry() -> None:
"""Malformed entries are left alone; the hook only sees `platform:`-tagged dicts."""
expand = Mock(side_effect=lambda conf: conf)
result = _run_load_step("image", ["not-a-dict"], None, expand)
expand.assert_not_called()
assert result["image"] == ["not-a-dict"]
def test_expand_hook_skipped_for_entry_missing_platform_key() -> None:
"""A dict entry missing the `platform:` key is left alone -- the normal
per-entry error reporting further down catches this case instead."""
expand = Mock(side_effect=lambda conf: conf)
result = _run_load_step("image", [{"id": "a"}], None, expand)
expand.assert_not_called()
assert result["image"] == [{"id": "a"}]
def test_expand_hook_skipped_for_autoload() -> None:
"""A non-empty AutoLoad reaching the hook stage is left alone."""
expand = Mock(side_effect=lambda conf: conf)
auto = AutoLoad()
auto["id"] = "a"
result = _run_load_step("image", auto, None, expand)
expand.assert_not_called()
assert result["image"] == [auto]
def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None:
"""The guard does not block the normal, well-formed case."""
expand = Mock(side_effect=lambda conf: conf)
conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}]
result = _run_load_step("image", conf, None, expand)
expand.assert_called_once_with(conf)
assert result["image"] == conf
def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None:
"""A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs."""
expand = Mock(side_effect=cv.Invalid("bad shape"))
pre_expand_conf = [{"platform": "file", "id": "a"}]
result = _run_load_step("image", pre_expand_conf, None, expand)
assert len(result.errors) == 1
assert result.errors[0].path == ["image"]
assert "bad shape" in str(result.errors[0])
assert result["image"] == pre_expand_conf
def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None:
"""`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended)."""
already_resolved_error = cv.FinalExternalInvalid(
"bad shape", path=["image", 3, "files"]
)
expand = Mock(side_effect=already_resolved_error)
pre_expand_conf = [{"platform": "file", "id": "a"}]
result = _run_load_step("image", pre_expand_conf, None, expand)
assert len(result.errors) == 1
assert result.errors[0] is already_resolved_error
assert result.errors[0].path == ["image", 3, "files"]
assert result["image"] == pre_expand_conf
def test_expand_hook_non_list_return_raises_type_error() -> None:
"""A non-list return is a component bug: it escapes as an uncaught TypeError
(explicit raise survives -O/-OO)."""
expand = Mock(return_value={"not": "a list"})
with pytest.raises(TypeError, match="must return a list"):
_run_load_step("image", [{"platform": "file", "id": "a"}], None, expand)
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
"""Create a config where two `<<` includes both define `logger:`.
+5 -235
View File
@@ -2,7 +2,7 @@
# pylint: disable=protected-access
from collections.abc import Callable, Generator
from collections.abc import Generator
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
@@ -437,7 +437,6 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache"
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
assert env["CCACHE_DIR"].endswith("platformio-ccache")
assert env["CCACHE_NOHASHDIR"] == "true"
@@ -447,35 +446,17 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
assert "ESPHOME_CCACHE_ENABLE" not in os.environ
@pytest.mark.parametrize(
("env_vars", "expect_warning"),
[
pytest.param({}, False, id="default"),
pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, True, id="forced-on"),
],
)
def test_ccache_env_disabled_without_binary(
setup_core: Path,
caplog: pytest.LogCaptureFixture,
env_vars: dict[str, str],
expect_warning: bool,
) -> None:
"""Ccache stays off when the binary is not on PATH, even when forced on.
A deliberate opt-in that finds no binary is downgraded with a warning so
the user can tell why it had no effect; the default path stays quiet.
"""
def test_ccache_env_disabled_without_binary(setup_core: Path) -> None:
"""Ccache stays off when the binary is not on PATH."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, env_vars, clear=True),
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value=None),
caplog.at_level("WARNING"),
):
env = toolchain._ccache_env()
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
assert ("no ccache binary is on PATH" in caplog.text) is expect_warning
@pytest.mark.parametrize(
@@ -508,47 +489,14 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None:
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run") as mock_probe,
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
# The binary's location is still handed to the build script.
assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache"
mock_probe.assert_not_called()
def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None:
r"""A ``\\?\`` ccache path from PATH is exported without the prefix.
That is the shape ESPHome Desktop puts on PATH (#18399); see ``_ccache_env``.
"""
CORE.build_path = setup_core / "build" / "test"
prefixed = (
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder"
"\\ccache\\ccache.exe"
)
stripped = (
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder\\ccache\\ccache.exe"
)
with (
patch.dict(os.environ, {}, clear=True),
# shutil.which is patched, so the win32 code path of the real
# implementation (which crashes on a POSIX host) is never reached.
patch("esphome.platformio.toolchain.sys.platform", "win32"),
patch.object(toolchain.shutil, "which", return_value=prefixed),
patch.object(toolchain.subprocess, "run") as mock_probe,
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
assert env["ESPHOME_CCACHE_PATH"] == stripped
# The probe validates the exact string the build will execute.
assert mock_probe.call_args[0][0] == [stripped, "--version"]
def test_ccache_env_opt_out(setup_core: Path) -> None:
"""ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present."""
CORE.build_path = setup_core / "build" / "test"
@@ -568,7 +516,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None:
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.shutil, "which", return_value=None),
):
env = toolchain._ccache_env()
@@ -615,10 +563,8 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only(
env = mock_run_external_process.call_args[1]["env"]
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache"
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
assert "ESPHOME_CCACHE_ENABLE" not in os.environ
assert "ESPHOME_CCACHE_PATH" not in os.environ
assert "CCACHE_BASEDIR" not in os.environ
@@ -667,182 +613,6 @@ def test_copy_ccache_script(setup_core: Path) -> None:
assert dest.read_text() == source.read_text()
class _FakeSConsEnv(dict):
"""Just enough of a SCons construction environment for ccache.py."""
def Replace(self, **kwargs: object) -> None: # noqa: N802
self.update(kwargs)
def _load_ccache_script(
env_vars: dict[str, str], original_spawn: Callable[..., int] | None = None
) -> tuple[_FakeSConsEnv, Callable[..., int]]:
"""Run ccache.py.script against a fake SCons env and return (env, original SPAWN)."""
if original_spawn is None:
original_spawn = Mock(name="original_spawn", return_value=0)
scons_env = _FakeSConsEnv(SPAWN=original_spawn)
source = (Path(toolchain.__file__).parent / "ccache.py.script").read_text()
with patch.dict(os.environ, env_vars, clear=True):
exec( # noqa: S102
compile(source, "ccache.py", "exec"),
{"Import": lambda *_names: None, "env": scons_env},
)
return scons_env, original_spawn
def _scons_win32_escape(x: str) -> str:
"""Copy of ``SCons.Platform.win32.escape``: quote, guarding a trailing backslash."""
if x[-1] == "\\":
x = x + "\\"
return '"' + x + '"'
def test_ccache_script_wraps_compiles_with_exported_path() -> None:
"""The SCons script uses ESPHOME_CCACHE_PATH as given, without a PATH lookup."""
ccache_path = "C:\\Users\\jesse\\ESPHome Device Builder\\ccache\\ccache.exe"
scons_env, original_spawn = _load_ccache_script(
{"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": ccache_path}
)
spawn = scons_env["SPAWN"]
assert spawn is not original_spawn
# A compile step is routed through ccache, with the same path used for
# the program and (escaped) as the first argument.
compile_args = ["xtensa-lx106-elf-g++", "-o", "main.o", "-c", "main.cpp"]
spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", compile_args, {})
original_spawn.assert_called_once_with(
"cmd.exe",
_scons_win32_escape,
ccache_path,
[_scons_win32_escape(ccache_path), *compile_args],
{},
)
# Link steps pass through untouched.
original_spawn.reset_mock()
link_args = ["xtensa-lx106-elf-g++", "-o", "firmware.elf", "main.o"]
spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {})
original_spawn.assert_called_once_with(
"cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {}
)
@pytest.mark.parametrize(
"env_vars",
[
pytest.param({"ESPHOME_CCACHE_ENABLE": "0"}, id="disabled"),
pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, id="enabled-without-path"),
pytest.param({}, id="unset"),
],
)
def test_ccache_script_leaves_spawn_alone_without_path(
env_vars: dict[str, str],
) -> None:
"""Without both the enable flag and a path, SPAWN is not replaced."""
scons_env, original_spawn = _load_ccache_script(env_vars)
assert scons_env["SPAWN"] is original_spawn
def _scons_win32_spawn(
sh: str, escape: Callable[[str], str], cmd: str, args: list[str], env: dict
) -> int:
r"""Mirror of ``SCons.Platform.win32.spawn``: every command runs via ``cmd.exe /C``.
SCons is not importable in the test environment (PlatformIO fetches it at
build time), so the lines that matter are mirrored here. The command line
SCons hands ``os.spawnve`` goes to ``CreateProcess`` via ``subprocess``
instead (identical on Windows, where a string passes through untouched);
``spawnve`` itself crashes inside pytest.
"""
return subprocess.run(
" ".join([sh, "/C", escape(" ".join(args))]), env=env, check=False
).returncode
_MARKER_ENV = "ESPHOME_TEST_CCACHE_MARKER"
# Stands in for a compile: the "ccache" is really the Python interpreter, and
# the compile "flags" make it write a marker file so the test can tell whether
# the wrapped command actually ran to completion.
_FAKE_COMPILE_ARGS = [
"-c",
f"import os, pathlib; pathlib.Path(os.environ['{_MARKER_ENV}']).write_text('compiled')",
]
def _spawn_fake_compile_via_cmd_exe(scons_env: _FakeSConsEnv, marker: Path) -> int:
"""Run one wrapped compile step the way SCons does on Windows."""
child_env = {**os.environ, _MARKER_ENV: str(marker)}
return scons_env["SPAWN"](
os.environ.get("COMSPEC", "cmd.exe"),
_scons_win32_escape,
"xtensa-lx106-elf-gcc",
[_scons_win32_escape(arg) if " " in arg else arg for arg in _FAKE_COMPILE_ARGS],
child_env,
)
_WINDOWS_ONLY = pytest.mark.skipif(
sys.platform != "win32", reason="drives cmd.exe, which SCons uses only on Windows"
)
@_WINDOWS_ONLY
def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None:
r"""With a ``\\?\`` which result, the real probe runs the stripped binary.
The probe therefore validates the exact string the build will execute
through ``cmd.exe``; probing the verbatim path instead would pass even
when the stripped path is unusable (``CreateProcess`` accepts
extended-length paths, ``cmd.exe`` does not).
"""
CORE.build_path = setup_core / "build" / "test"
assert not sys.executable.startswith("\\\\?\\")
with (
patch.dict(os.environ, {}, clear=False),
patch.object(
toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable
),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
assert env["ESPHOME_CCACHE_PATH"] == sys.executable
@_WINDOWS_ONLY
@pytest.mark.parametrize(
("prefix", "expect_ok"),
[
pytest.param("", True, id="stripped-path-compiles"),
pytest.param("\\\\?\\", False, id="verbatim-path-fails"),
],
)
def test_ccache_wrapper_through_cmd_exe(
tmp_path: Path, prefix: str, expect_ok: bool
) -> None:
r"""End to end through ``cmd.exe``: the exported path works, a ``\\?\`` one does not.
The interpreter stands in for ccache; the spawn mirrors SCons on Windows.
The failing case is the mechanism behind #18399 ("The system cannot find
the path specified." on every compile step); should it ever start passing,
``cmd.exe`` learned extended-length paths and the strip is no longer needed.
"""
marker = tmp_path / "compiled.txt"
scons_env, _ = _load_ccache_script(
{"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": prefix + sys.executable},
original_spawn=_scons_win32_spawn,
)
assert scons_env["SPAWN"] is not _scons_win32_spawn
rc = _spawn_fake_compile_via_cmd_exe(scons_env, marker)
assert (rc == 0) is expect_ok
assert marker.exists() is expect_ok
if expect_ok:
assert marker.read_text() == "compiled"
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
-66
View File
@@ -3,8 +3,6 @@ from pathlib import Path
from unittest.mock import Mock, patch
from esphome import vscode
import esphome.config_validation as cv
from esphome.core import EsphomeError
def _run_repl_test(input_data):
@@ -128,67 +126,3 @@ packages:
assert range["start_col"] == 2
assert range["end_line"] == 1
assert range["end_col"] == 7
def _explode(*_args: object, **_kwargs: object) -> None:
raise AttributeError("'NoneType' object has no attribute 'get'")
def test_unexpected_error_reports_origin() -> None:
source_path = str(Path("dir_path", "x.yaml"))
with patch("esphome.vscode.validate_config", _explode):
output_lines = _run_repl_test(
[
_validate(source_path),
_file_response("""esphome:
name: test1
"""),
]
)
result = json.loads(output_lines[-1])
assert result["validation_errors"] == []
(error,) = result["yaml_errors"]
assert error["message"].startswith(
"Unexpected error while validating: AttributeError: "
"'NoneType' object has no attribute 'get' ("
)
assert "test_vscode.py" in error["message"]
assert error["message"].endswith(" in _explode)")
def test_esphome_error_stays_plain() -> None:
source_path = str(Path("dir_path", "x.yaml"))
with patch("esphome.vscode.validate_config", side_effect=EsphomeError("boom")):
output_lines = _run_repl_test(
[
_validate(source_path),
_file_response("""esphome:
name: test1
"""),
]
)
result = json.loads(output_lines[-1])
assert result["yaml_errors"] == [{"message": "boom"}]
def test_invalid_stays_plain() -> None:
source_path = str(Path("dir_path", "x.yaml"))
with patch("esphome.vscode.validate_config", side_effect=cv.Invalid("bad value")):
output_lines = _run_repl_test(
[
_validate(source_path),
_file_response("""esphome:
name: test1
"""),
]
)
result = json.loads(output_lines[-1])
assert result["yaml_errors"] == [{"message": "bad value"}]
def test_format_unexpected_error_without_traceback() -> None:
message = vscode._format_unexpected_error(ValueError("boom"))
assert message == "Unexpected error while validating: ValueError: boom"