Compare commits

..

6 Commits

31 changed files with 1146 additions and 227 deletions
+1
View File
@@ -501,6 +501,7 @@ esphome/components/ssd1331_base/* @kbx81
esphome/components/ssd1331_spi/* @kbx81
esphome/components/ssd1351_base/* @kbx81
esphome/components/ssd1351_spi/* @kbx81
esphome/components/st7123/* @miniskipper
esphome/components/st7567_base/* @latonita
esphome/components/st7567_i2c/* @latonita
esphome/components/st7567_spi/* @latonita
+2 -1
View File
@@ -21,9 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native ESP-IDF install on the persistent cache root, not the
# Keep the native toolchain installs on the persistent cache root, not the
# container's ephemeral user cache dir (re-downloaded on every restart).
export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf"
# If /build is mounted, use that as the build path
# otherwise use path in /config (so that builds aren't lost on container restart)
@@ -15,9 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native ESP-IDF install on the persistent /data volume, not the
# Keep the native toolchain installs on the persistent /data volume, not the
# container's ephemeral user cache dir (wiped on every add-on update/restart).
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf
if bashio::config.true 'leave_front_door_open'; then
export DISABLE_HA_AUTHENTICATION=true
@@ -0,0 +1,146 @@
#include "epaper_waveshare_bwr.h"
#include <algorithm>
namespace esphome::epaper_spi {
enum class BwrState : uint8_t {
BWR_BLACK,
BWR_WHITE,
BWR_RED,
};
static BwrState color_to_bwr(Color color) {
if (color.r > color.g + color.b && color.r > 127) {
return BwrState::BWR_RED;
}
if (color.r + color.g + color.b >= 382) {
return BwrState::BWR_WHITE;
}
return BwrState::BWR_BLACK;
}
// UC8179 3-color display buffer layout:
// - 1 bit per pixel, 8 pixels per byte
// - Buffer first half: Black/White plane (1=black, 0=white)
// - Buffer second half: Red plane (1=red, 0=white)
// - Total: row_width * height * 2 bytes
void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
const uint32_t pos = (x / 8) + (y * this->row_width_);
const uint8_t bit = 0x80 >> (x & 0x07);
const uint32_t red_offset = this->buffer_length_ / 2u;
const auto bwr = color_to_bwr(color);
if (bwr == BwrState::BWR_BLACK) {
this->buffer_[pos] |= bit;
} else {
this->buffer_[pos] &= ~bit;
}
if (bwr == BwrState::BWR_RED) {
this->buffer_[red_offset + pos] |= bit;
} else {
this->buffer_[red_offset + pos] &= ~bit;
}
}
void EPaperWaveshareBWR::fill(Color color) {
const size_t half_buffer = this->buffer_length_ / 2u;
const auto bwr = color_to_bwr(color);
if (bwr == BwrState::BWR_BLACK) {
// Black plane: 0xFF (black), Red plane: 0x00 (no red)
for (size_t i = 0; i < half_buffer; i++)
this->buffer_[i] = 0xFF;
for (size_t i = 0; i < half_buffer; i++)
this->buffer_[half_buffer + i] = 0x00;
} else if (bwr == BwrState::BWR_RED) {
// Black plane: 0x00 (no black), Red plane: 0xFF (red)
for (size_t i = 0; i < half_buffer; i++)
this->buffer_[i] = 0x00;
for (size_t i = 0; i < half_buffer; i++)
this->buffer_[half_buffer + i] = 0xFF;
} else {
// Black plane: 0x00 (no black), Red plane: 0x00 (no red)
this->buffer_.fill(0x00);
}
}
bool HOT EPaperWaveshareBWR::transfer_data() {
const uint32_t start_time = millis();
const size_t buffer_length = this->buffer_length_;
const size_t half_buffer = buffer_length / 2u;
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
// Phase 1: send Black/White plane (first half) via command 0x10 (DTM1)
// UC8179 DTM1 (0x10): inverted to get 0=black, 1=white
if (this->current_data_index_ < half_buffer) {
if (this->current_data_index_ == 0) {
this->command(0x10); // DATA START TRANSMISSION 1 (black channel)
}
this->start_data_();
while (this->current_data_index_ < half_buffer) {
const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_);
for (size_t i = 0; i < bytes_to_copy; i++) {
bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i];
}
this->write_array(bytes_to_send, bytes_to_copy);
this->current_data_index_ += bytes_to_copy;
if (millis() - start_time > MAX_TRANSFER_TIME) {
this->disable();
return false;
}
}
this->disable();
}
// Phase 2: send Red plane (second half) via command 0x13 (DTM2)
// UC8179 DTM2 (0x13): 1=red, 0=white
if (this->current_data_index_ < buffer_length) {
if (this->current_data_index_ == half_buffer) {
this->command(0x13); // DATA START TRANSMISSION 2 (red channel)
}
this->start_data_();
while (this->current_data_index_ < buffer_length) {
const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_);
for (size_t i = 0; i < bytes_to_copy; i++) {
bytes_to_send[i] = this->buffer_[this->current_data_index_ + i];
}
this->write_array(bytes_to_send, bytes_to_copy);
this->current_data_index_ += bytes_to_copy;
if (millis() - start_time > MAX_TRANSFER_TIME) {
this->disable();
return false;
}
}
this->disable();
}
this->current_data_index_ = 0;
return true;
}
void EPaperWaveshareBWR::power_on() {
this->cmd_data(0x01, {0x07, 0x17, 0x3F, 0x3F}); // POWER SETTING
this->command(0x04); // POWER ON
}
void EPaperWaveshareBWR::refresh_screen(bool /*partial*/) {
this->command(0x12); // DISPLAY REFRESH
}
void EPaperWaveshareBWR::power_off() {
this->command(0x02); // POWER OFF
}
void EPaperWaveshareBWR::deep_sleep() {
this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,40 @@
#pragma once
#include "epaper_spi.h"
namespace esphome::epaper_spi {
/**
* Waveshare 3-color e-paper displays (UC8179 controller).
* Supports: 7.5" V2 BWR (EDP_7in5b_V2), 800x480 pixels.
*
* Color scheme: Black, White, Red (BWR)
* Buffer layout: 1 bit per pixel, separate planes
* - Buffer first half: Black/White plane (1=black, 0=white)
* - Buffer second half: Red plane (1=red, 0=no red)
* - Total buffer: width * height / 4 bytes (2 * width * height / 8)
*
* The init sequence (INITIALISE state) sends panel configuration only.
* Power-on (0x01 + 0x04) is sent in the POWER_ON state after data transfer;
* the state machine then busy-waits before triggering REFRESH_SCREEN (0x12).
*/
class EPaperWaveshareBWR : public EPaperBase {
public:
EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {
this->buffer_length_ = this->row_width_ * height * 2;
}
void fill(Color color) override;
protected:
void draw_pixel_at(int x, int y, Color color) override;
bool transfer_data() override;
void refresh_screen(bool partial) override;
void power_on() override;
void power_off() override;
void deep_sleep() override;
};
} // namespace esphome::epaper_spi
@@ -0,0 +1,56 @@
"""Waveshare Black/White/Red e-paper displays using UC8179 controller.
Supported models:
- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EDP_7in5b_V2)
These displays use the UC8179 controller. Panel configuration is sent during
the INITIALISE state. Power-on is handled in the POWER_ON state, after data
transfer, so the state machine's built-in busy wait covers the power-on delay.
"""
from . import EpaperModel
class WaveshareBWR(EpaperModel):
"""EpaperModel class for Waveshare Black/White/Red displays using UC8179 controller."""
def __init__(self, name, **defaults):
super().__init__(name, "EPaperWaveshareBWR", **defaults)
def get_init_sequence(self, config):
"""Generate initialization sequence for UC8179 BWR displays.
Panel configuration only — power-on is handled separately in power_on()
after data transfer, with the state machine busy-waiting before refresh.
"""
width, height = self.get_dimensions(config)
return (
# PANEL SETTING (KWR mode)
(0x00, 0x0F),
# RESOLUTION SETTING (width x height)
(
0x61,
(width >> 8) & 0xFF,
width & 0xFF,
(height >> 8) & 0xFF,
height & 0xFF,
),
# DUAL SPI MODE (disabled)
(0x15, 0x00),
# VCOM AND DATA INTERVAL SETTING
(0x50, 0x11, 0x07),
# TCON SETTING
(0x60, 0x22),
# RESOLUTION GATE SETTING
(0x65, 0x00, 0x00, 0x00, 0x00),
)
# Model: Waveshare 7.5" V2 BWR (EDP_7in5b_V2) — 800x480, UC8179 controller
WaveshareBWR(
"waveshare-7.5in-bv2-bwr",
width=800,
height=480,
data_rate="10MHz",
minimum_update_interval="30s",
)
-16
View File
@@ -24,7 +24,6 @@ from esphome.const import (
CONF_IGNORE_EFUSE_CUSTOM_MAC,
CONF_IGNORE_EFUSE_MAC_CRC,
CONF_LOG_LEVEL,
CONF_LOGGER,
CONF_NAME,
CONF_OTA,
CONF_PATH,
@@ -2215,21 +2214,6 @@ async def to_code(config):
# This saves ~250 bytes of RAM (tag cache) and associated code
add_idf_sdkconfig_option("CONFIG_LOG_TAG_LEVEL_IMPL_NONE", True)
# ESP-IDF Log V2 centralizes formatting inside esp_log(), removing the
# per-site macro expansions of V1 (saves ~4KB flash, ~180B RAM). Only enable
# on IDF >= 6.1, where CONFIG_LOG_API_CONSTRAINED_ENV_SAFE=n lets
# ESP_DRAM_LOGx / ESP_EARLY_LOGx expand directly to esp_rom_printf and drops
# the ~1.2KB esp_rom_vprintf that would otherwise sit in IRAM. Below 6.1 the
# option does not exist, so we stay on V1 unchanged. Also requires the
# logger component: the esp_log_format override in core/log.cpp integrates
# V2 with ESPHome's logger hook, and without the logger there is no hook to
# integrate with, so such builds stay on V1 as well.
if idf_version() >= cv.Version(6, 1, 0) and CONF_LOGGER in CORE.config:
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_1", False)
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_2", True)
add_idf_sdkconfig_option("CONFIG_LOG_API_CONSTRAINED_ENV_SAFE", False)
cg.add_define("USE_ESP32_LOG_V2")
# Reduce PHY TX power in the event of a brownout
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
+42 -4
View File
@@ -58,7 +58,7 @@ from esphome.framework_helpers import (
get_project_link_flags,
run_command_ok,
)
from esphome.helpers import write_file_if_changed
from esphome.helpers import rmtree, write_file_if_changed
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
@@ -411,6 +411,17 @@ async def _dfu_to_code(dfu_config):
def copy_files() -> None:
"""Copy files to the build directory."""
# Library conversion to Zephyr modules is wired into the sdk-nrf
# CMakeLists only; the PlatformIO toolchain's forked platform package
# cannot compile external libraries at all, so the build would fail at
# link time anyway. Fail fast with a clear message instead.
if CORE.using_toolchain_platformio and CORE.platformio_libraries:
raise EsphomeError(
f"Libraries ({', '.join(sorted(CORE.platformio_libraries))}) are "
"not supported on the nRF52 'platformio' toolchain; use toolchain "
"'sdk-nrf' to build them as Zephyr modules."
)
if CORE.using_toolchain_platformio and (
zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT
or zephyr_data()[KEY_BOARD] == "xiao_ble"
@@ -697,15 +708,31 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
return False
def _generate_cmake_lists() -> None:
def _generate_cmake_lists() -> bool:
"""Write the project CMakeLists.txt, returning True if it changed."""
compile_flags = get_project_compile_flags()
link_flags = get_project_link_flags()
# Convert any PlatformIO libraries added via cg.add_library() into Zephyr
# modules and discover them through EXTRA_ZEPHYR_MODULES (a CMake list, set
# before find_package(Zephyr) so the modules are picked up). Only
# framework-agnostic libraries actually compile under Zephyr.
from esphome.components.zephyr.library import generate_zephyr_modules
module_dirs = generate_zephyr_modules(list(CORE.platformio_libraries.values()))
lines = [
"cmake_minimum_required(VERSION 3.20.0)",
"",
'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")',
"",
]
if module_dirs:
modules = ";".join(str(d).replace("\\", "/") for d in module_dirs)
lines += [f'set(EXTRA_ZEPHYR_MODULES "{modules}")', ""]
lines += [
"find_package(Zephyr REQUIRED)",
"",
f"project({CORE.name})",
@@ -732,7 +759,7 @@ def _generate_cmake_lists() -> None:
")",
]
write_file_if_changed(
return write_file_if_changed(
CORE.relative_build_path("zephyr", "CMakeLists.txt"),
"\n".join(lines) + "\n",
)
@@ -751,12 +778,23 @@ def run_compile(args, config: ConfigType) -> bool:
paths = get_build_paths()
env = get_build_env()
_generate_cmake_lists()
cmake_lists_changed = _generate_cmake_lists()
board = zephyr_data()[KEY_BOARD]
build_dir = CORE.relative_pioenvs_path(CORE.name)
source_dir = CORE.relative_build_path("zephyr")
# A missing CMake cache (dropped by zephyr's copy_files() on config
# change) or a changed CMakeLists.txt requires a pristine build: Zephyr
# caches Kconfig/devicetree state that survives a plain cmake re-run.
# West can't do the wipe — its pristine modes only recognize a build dir
# by reading ZEPHYR_BASE from the very cache that was dropped.
if (
cmake_lists_changed or not (build_dir / "CMakeCache.txt").is_file()
) and build_dir.is_dir():
_LOGGER.info("Build inputs changed, cleaning %s", build_dir)
rmtree(build_dir)
west_cmd = [
str(paths["python_executable"]),
"-m",
+16 -5
View File
@@ -4,6 +4,8 @@ from pathlib import Path
import platform
import tempfile
import platformdirs
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import (
@@ -15,6 +17,7 @@ from esphome.framework_helpers import (
run_command_ok,
str_to_lst_of_str,
)
from esphome.helpers import get_str_env
_LOGGER = logging.getLogger(__name__)
@@ -38,20 +41,28 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str(
)
def _get_tools_path() -> Path:
return CORE.data_dir / "sdk-nrf"
def get_sdk_nrf_tools_path() -> Path:
# A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("")
# resolves to the CWD, which clean-all would then delete.
if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip():
path = Path(prefix).expanduser()
else:
# Machine-global (OS user cache dir) so all projects share one install;
# see espidf.framework.get_idf_tools_path for the location rationale.
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
return path.resolve()
def _get_python_env_path(version: str) -> Path:
return _get_tools_path() / "penvs" / version
return get_sdk_nrf_tools_path() / "penvs" / version
def _get_framework_path(version: str) -> Path:
return _get_tools_path() / "frameworks" / version
return get_sdk_nrf_tools_path() / "frameworks" / version
def _get_toolchain_path(version: str) -> Path:
return _get_tools_path() / "toolchains" / version
return get_sdk_nrf_tools_path() / "toolchains" / version
_SITECUSTOMIZE = """\
+6
View File
@@ -0,0 +1,6 @@
import esphome.codegen as cg
CODEOWNERS = ["@miniskipper"]
DEPENDENCIES = ["i2c"]
st7123_ns = cg.esphome_ns.namespace("st7123")
@@ -0,0 +1,32 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from .. import st7123_ns
ST7123Touchscreen = st7123_ns.class_(
"ST7123Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(ST7123Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
).extend(i2c.i2c_device_schema(0x55))
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
if reset_pin := config.get(CONF_RESET_PIN):
cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin)))
@@ -0,0 +1,108 @@
#include "st7123_touchscreen.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::st7123 {
static const char *const TAG = "st7123.touchscreen";
void ST7123Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false); // TP_RESX is active low, assert for at least tRSTW (2ms)
delay(5);
this->reset_pin_->digital_write(true);
// The controller needs up to 20ms to initialize after reset before it can be accessed.
this->setup_time_ = millis() + 30;
}
}
void ST7123Touchscreen::update() {
// check if setup is complete
if (this->setup_time_ != 0) {
if (this->setup_time_ > millis())
return;
uint8_t status;
if (this->read_register16(ST7123_REG_STATUS, &status, 1) != i2c::ERROR_OK) {
this->mark_failed(LOG_STR("Failed to read status register")); // will stop updates
return;
}
if ((status & 0x0F) == ST7123_STATUS_INIT) {
ESP_LOGD(TAG, "Controller still initializing");
return;
}
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
// INT is held high when idle and pulses low when touch data is ready.
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
ESP_LOGD(TAG, "Status is %X", status);
uint8_t data;
if (this->read_register16(ST7123_REG_MAX_TOUCHES, &data, 1) == i2c::ERROR_OK && data != 0 &&
data <= ST7123_MAX_TOUCHES) {
this->max_touches_ = data;
}
// If no calibration was supplied, read the native coordinate resolution from the controller.
if (this->x_raw_max_ == this->x_raw_min_ || this->y_raw_max_ == this->y_raw_min_) {
uint8_t res[4];
if (this->read_register16(ST7123_REG_MAX_X, res, sizeof(res)) == i2c::ERROR_OK) {
this->x_raw_max_ = encode_uint16(res[0] & ST7123_COORD_HIGH_MASK, res[1]);
this->y_raw_max_ = encode_uint16(res[2] & ST7123_COORD_HIGH_MASK, res[3]);
if (this->swap_x_y_)
std::swap(this->x_raw_max_, this->y_raw_max_);
} else {
this->mark_failed(LOG_STR("Failed to read calibration"));
return;
}
ESP_LOGD(TAG, "Read dimensions %d/%d", this->x_raw_max_, this->y_raw_max_);
}
this->setup_time_ = 0; // flag setup complete
}
Touchscreen::update();
}
void ST7123Touchscreen::update_touches() {
// Read the reporting table from the advanced touch info register through the last touch point.
// Reading from this register also clears the INT pin so the controller can report the next frame.
uint8_t data[(ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + ST7123_MAX_TOUCHES * ST7123_TOUCH_STRIDE];
const size_t len = (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + this->max_touches_ * ST7123_TOUCH_STRIDE;
if (this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) != i2c::ERROR_OK) {
this->skip_update_ = true;
this->status_set_warning();
return;
}
this->status_clear_warning();
const uint8_t *points = data + (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO);
for (uint8_t i = 0; i != this->max_touches_; i++) {
const uint8_t *p = points + i * ST7123_TOUCH_STRIDE;
if ((p[0] & ST7123_TOUCH_VALID) == 0)
continue;
uint16_t x = encode_uint16(p[0] & ST7123_COORD_HIGH_MASK, p[1]);
uint16_t y = encode_uint16(p[2] & ST7123_COORD_HIGH_MASK, p[3]);
uint8_t intensity = p[5];
ESP_LOGV(TAG, "Touch %u: x=%u, y=%u, intensity=%u", i, x, y, intensity);
this->add_raw_touch_position_(i, x, y, intensity);
}
}
void ST7123Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG,
"ST7123 Touchscreen:\n"
" Max touches: %u\n"
" X Raw Min: %d, X Raw Max: %d\n"
" Y Raw Min: %d, Y Raw Max: %d",
this->max_touches_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_);
LOG_I2C_DEVICE(this);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
}
} // namespace esphome::st7123
@@ -0,0 +1,48 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
namespace esphome::st7123 {
// Sitronix ST7123 capacitive touch controller.
// Registers are addressed with a 16-bit big-endian address (sent MSB first).
static constexpr uint16_t ST7123_REG_STATUS = 0x0001; // [7:4] error code, [3:0] device status
static constexpr uint16_t ST7123_REG_MAX_X = 0x0005; // 0x0005..0x0006 X resolution, 0x0007..0x0008 Y resolution
static constexpr uint16_t ST7123_REG_MAX_TOUCHES = 0x0009;
static constexpr uint16_t ST7123_REG_ADV_TOUCH_INFO = 0x0010; // start of the reporting table
static constexpr uint16_t ST7123_REG_TOUCH_DATA = 0x0014; // first touch point
// Device status field of the status register.
static constexpr uint8_t ST7123_STATUS_INIT = 0x1;
// Each touch point occupies 7 bytes: X high, X low, Y high, Y low, area, intensity, reserved.
static constexpr uint8_t ST7123_TOUCH_STRIDE = 7;
// Bit 7 of the X high byte indicates a valid touch point.
static constexpr uint8_t ST7123_TOUCH_VALID = 0x80;
// The X and Y high bytes only use the low 6 bits.
static constexpr uint8_t ST7123_COORD_HIGH_MASK = 0x3F;
// The ST7123 can report at most 10 touch points.
static constexpr uint8_t ST7123_MAX_TOUCHES = 10;
class ST7123Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
void update_touches() override;
InternalGPIOPin *interrupt_pin_{nullptr};
GPIOPin *reset_pin_{nullptr};
uint8_t max_touches_{ST7123_MAX_TOUCHES};
uint32_t setup_time_{1};
};
} // namespace esphome::st7123
+45 -25
View File
@@ -46,42 +46,59 @@ DEFAULT_BAUD_RATE = 9600
class Type:
def __init__(self, name, vid, pid, cls, max_channels=1, baud_rate_required=True):
def __init__(
self,
name,
vid,
pid,
cls,
max_channels=1,
baud_rate_required=True,
max_baud=1_000_000,
):
self.name = name
cls = cls or name
self.vid = vid
self.pid = pid
self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent)
self.max_channels = max_channels
self._max_channels = max_channels
self.baud_rate_required = baud_rate_required
self.max_baud = max_baud
@property
def max_channels(self) -> int:
return (
3
if (
CORE.is_esp32
and get_esp32_variant() != VARIANT_ESP32P4
and self._max_channels > 3
)
else self._max_channels
)
uart_types = (
Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False),
Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4),
Type("CH340", 0x1A86, 0x7523, "CH34X", 1),
Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3),
Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000),
Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000),
Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000),
Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False),
Type("FT232", 0x0403, 0x6001, "FT23XX", 1),
Type("FT2232", 0x0403, 0x6010, "FT23XX", 2),
Type("FT4232", 0x0403, 0x6011, "FT23XX", 4),
Type("PL2303", 0x067B, 0x2303, "PL2303", 1),
Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1),
Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1),
Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1),
Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1),
Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1),
Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1),
Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000),
Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000),
Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000),
Type("PL2303", 0x067B, 0x2303, "PL2303", 1, max_baud=6_000_000),
Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1, max_baud=6_000_000),
Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1, max_baud=6_000_000),
Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1, max_baud=6_000_000),
Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000),
Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000),
Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000),
Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False),
)
def channel_schema(channels, baud_rate_required):
# For now S3 is restricted to 3 channels since each needs 2 endpoints, plus the control endpoint, and
# there are only a total of 8 endpoints available.
# This will need updating when the 8 channel devices that multiplex over an endpoint are added.
if CORE.is_esp32 and get_esp32_variant() != VARIANT_ESP32P4 and channels > 3:
channels = 3
def channel_schema(type_: "Type") -> cv.Schema:
return cv.Schema(
{
cv.Required(CONF_CHANNELS): cv.All(
@@ -94,11 +111,11 @@ def channel_schema(channels, baud_rate_required):
),
(
cv.Required(CONF_BAUD_RATE)
if baud_rate_required
if type_.baud_rate_required
else cv.Optional(
CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE
)
): cv.int_range(min=300, max=1000000),
): cv.int_range(min=300, max=type_.max_baud),
cv.Optional(CONF_STOP_BITS, default="1"): cv.enum(
UART_STOP_BITS_OPTIONS, upper=True
),
@@ -117,7 +134,10 @@ def channel_schema(channels, baud_rate_required):
}
)
),
cv.Length(max=channels),
cv.Length(
max=type_.max_channels,
msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels",
),
)
}
)
@@ -127,7 +147,7 @@ CONFIG_SCHEMA = cv.ensure_list(
cv.typed_schema(
{
it.name: usb_device_schema(it.cls, it.vid, it.pid).extend(
channel_schema(it.max_channels, it.baud_rate_required)
channel_schema(it)
)
for it in uart_types
},
+32 -9
View File
@@ -8,6 +8,7 @@ from esphome.const import CONF_BOARD, KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.helpers import copy_file_if_changed, write_file_if_changed
from esphome.types import ConfigType
from esphome.writer import clean_cmake_cache
from .const import (
CONF_CDC_ACM,
@@ -203,7 +204,20 @@ def zephyr_add_user(key, value):
user[key] += [value]
def copy_files():
def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool:
"""Write content to path, or remove a stale file when content is empty.
Returns True if the file changed on disk.
"""
if content:
return write_file_if_changed(path, content)
if path.is_file():
path.unlink()
return True
return False
def copy_files() -> None:
user = zephyr_data()[KEY_USER]
if user:
entries = " ".join(
@@ -219,6 +233,8 @@ def copy_files():
"""
)
changed = False
for image, want_opts in zephyr_data()[KEY_PRJ_CONF].items():
prj_conf = (
"\n".join(
@@ -233,26 +249,25 @@ def copy_files():
else:
path = CORE.relative_build_path("zephyr/prj.conf")
write_file_if_changed(CORE.relative_build_path(path), prj_conf)
changed |= write_file_if_changed(path, prj_conf)
for image, content in zephyr_data()[KEY_OVERLAY].items():
if image:
path = CORE.relative_build_path(f"sysbuild/{image}.overlay")
else:
path = CORE.relative_build_path("zephyr/app.overlay")
write_file_if_changed(path, content)
changed |= write_file_if_changed(path, content)
for filename, path in zephyr_data()[KEY_EXTRA_BUILD_FILES].items():
copy_file_if_changed(
changed |= copy_file_if_changed(
path,
CORE.relative_build_path(filename),
)
pm_static = "\n".join(str(item) for item in zephyr_data()[KEY_PM_STATIC])
if pm_static:
write_file_if_changed(
CORE.relative_build_path("zephyr/pm_static.yml"), pm_static
)
changed |= _write_file_if_changed_or_remove_when_empty(
CORE.relative_build_path("zephyr/pm_static.yml"), pm_static
)
kconfig = zephyr_data()[KEY_KCONFIG]
if kconfig:
@@ -267,4 +282,12 @@ def copy_files():
+ "\n"
+ kconfig
)
write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig)
changed |= _write_file_if_changed_or_remove_when_empty(
CORE.relative_build_path("zephyr/Kconfig"), kconfig
)
if changed:
# A configure-time input changed; drop the CMake cache so the build
# can't reuse stale configure results (the native sdk-nrf toolchain
# rebuilds pristine when the cache is missing).
clean_cmake_cache()
+180
View File
@@ -0,0 +1,180 @@
"""Zephyr backend for the shared PlatformIO library converter.
For each PlatformIO library added via ``cg.add_library()``, emit a Zephyr
external module (``zephyr/module.yml`` + ``zephyr/CMakeLists.txt`` built with the
``zephyr_library*`` API) into the shared ``pio_components`` cache. The caller
wires the resulting module directories into the build via
``EXTRA_ZEPHYR_MODULES``; Zephyr then compiles each module and links it into the
final image.
Only framework-agnostic libraries (plain C/C++ that doesn't depend on the Arduino
API) will actually compile under Zephyr — this converter shares the
fetch/parse/cache plumbing, not API compatibility.
"""
from pathlib import Path
from esphome import yaml_util
from esphome.core import EsphomeError, Library
from esphome.helpers import write_file_if_changed
from esphome.platformio.library import (
DEFAULT_BUILD_FLAGS,
DEFAULT_BUILD_INCLUDE_DIR,
DEFAULT_BUILD_SRC_FILTER,
SRC_FILE_EXTENSIONS,
ConvertedLibrary,
LibraryBackend,
PathType,
collect_filtered_files,
convert_libraries,
ensure_list,
split_list_by_condition,
)
# Zephyr libraries declare frameworks rarely and the PIO ``platforms`` token for
# nRF is seldom present, so the platform check is disabled (None) and only the
# framework mismatch warning fires.
ZEPHYR_FRAMEWORK = "zephyr"
def _escape(p: PathType) -> str:
# In CMakeLists.txt, backslashes need to be escaped (mirrors the ESP-IDF
# backend's escape_entry). Doubling -- rather than rewriting '\' -> '/' --
# preserves content, so it's safe for arbitrary build flags (e.g. a -D value
# containing a backslash) as well as Windows paths.
return f'"{str(p)}"'.replace("\\", "\\\\")
def generate_module_yml(component: ConvertedLibrary) -> str:
"""Render the ``zephyr/module.yml`` manifest for a converted library."""
return yaml_util.dump(
{
"name": component.get_require_name(),
"build": {"cmake": "zephyr"},
}
)
def generate_cmakelists_txt(component: ConvertedLibrary) -> str:
"""Render the ``zephyr/CMakeLists.txt`` that builds a converted library.
Sources/includes are emitted as absolute paths since the CMakeLists lives in
the library's ``zephyr/`` subdir while its sources sit alongside it. Include
dirs are published globally so the app (and sibling libraries) can include the
library's headers, mirroring ESP-IDF's public ``INCLUDE_DIRS``.
"""
build = component.data.get("build", {})
build_src_dir = build.get("srcDir")
if not build_src_dir:
for d in ["src", "Src", "."]:
if (component.path / Path(d)).is_dir():
build_src_dir = d
break
build_include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR)
build_src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS))
src_files = collect_filtered_files(
component.path / Path(build_src_dir), build_src_filter
)
src_files = sorted(
str(Path(p).resolve())
for p in src_files
if Path(p).suffix in SRC_FILE_EXTENSIONS
)
include_dir_flags, build_flags = split_list_by_condition(
build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None
)
link_directories, build_flags = split_list_by_condition(
build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None
)
link_libraries, build_flags = split_list_by_condition(
build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None
)
include_dirs = [build_include_dir, build_src_dir, *include_dir_flags]
include_dirs = [
str((component.path / Path(d)).resolve())
for d in include_dirs
if (component.path / Path(d)).is_dir()
]
lines = [f"zephyr_library_named({component.get_require_name()})"]
if src_files:
lines += [
"zephyr_library_sources(",
*[f" {_escape(p)}" for p in src_files],
")",
]
if include_dirs:
lines += [
"zephyr_include_directories(",
*[f" {_escape(p)}" for p in include_dirs],
")",
]
if build_flags:
lines += [
"zephyr_library_compile_options(",
*[f" {_escape(f)}" for f in build_flags],
")",
]
# Best-effort link wiring; most Zephyr-portable libraries don't need it.
link_flags = [f"-L{d}" for d in link_directories] + [
f"-l{lib}" for lib in link_libraries
]
if link_flags:
lines += [
"zephyr_link_libraries(",
*[f" {_escape(f)}" for f in link_flags],
")",
]
return "\n".join(lines) + "\n"
def _emit_zephyr_module(component: ConvertedLibrary) -> None:
zephyr_dir = component.path / "zephyr"
write_file_if_changed(zephyr_dir / "module.yml", generate_module_yml(component))
write_file_if_changed(
zephyr_dir / "CMakeLists.txt", generate_cmakelists_txt(component)
)
def generate_zephyr_modules(libraries: list[Library]) -> list[Path]:
"""Convert ``libraries`` to Zephyr modules and return all module directories.
The returned list includes transitive dependencies (each converted library is
its own module). Every directory should be added to ``EXTRA_ZEPHYR_MODULES``;
Zephyr links all module libraries into the image, so cross-library symbols
resolve without explicit dependency declarations.
Raises ``EsphomeError`` if two libraries resolve to the same Zephyr module
name -- each module's CMakeLists calls ``zephyr_library_named(<name>)``, so a
duplicate would otherwise fail the build with a CMake "target already exists".
The converter already warns when a library is referenced under inconsistent
specs (bare ``name`` vs ``owner/name``, git vs registry); this turns that into
an actionable error at the Zephyr boundary where it is fatal.
"""
module_dirs: list[Path] = []
by_name: dict[str, Path] = {}
def emit(component: ConvertedLibrary) -> None:
name = component.get_require_name()
if name in by_name:
raise EsphomeError(
f"Two libraries resolve to the same Zephyr module '{name}' "
f"({by_name[name]} and {component.path}). Reference the library "
f"consistently (e.g. always as 'owner/name') so it resolves once."
)
by_name[name] = component.path
_emit_zephyr_module(component)
module_dirs.append(component.path)
backend = LibraryBackend(
platform=None, framework=ZEPHYR_FRAMEWORK, emit=emit, cache_key="zephyr"
)
convert_libraries(libraries, backend)
return module_dirs
-1
View File
@@ -246,7 +246,6 @@
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
#define USE_CAPTIVE_PORTAL
#define USE_ESP32_LOG_V2
#define USE_ESP32_BLE
#define USE_ESP32_BLE_MAX_CONNECTIONS 3
#define USE_ESP32_BLE_CLIENT
-106
View File
@@ -86,109 +86,3 @@ int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT
#endif
} // namespace esphome
// Only compiled when the logger component is present: the override exists to
// integrate V2 with ESPHome's logger hook. Without the logger no hook is ever
// installed, so liblog's stock esp_log_format links in and console output
// stays bone-stock ESP-IDF (V2's flash savings still apply).
#if defined(USE_ESP32_LOG_V2) && defined(USE_LOGGER) && !defined(BOOTLOADER_BUILD)
// Override esp_log_format to prevent V2's 3-call vprintf fragmentation.
// Without this, Log V2 calls the vprintf hook 3 times per message (header,
// body, newline) which creates 3 separate log entries in ESPHome's logger.
// This strong definition overrides the archive symbol from ESP-IDF's liblog,
// affecting all callers including precompiled blobs (e.g. wifi).
#include <esp_private/log_message.h>
#include <esp_log_write.h>
// Format an ESP-IDF log message directly to the console, bypassing the
// ESPHome logger hook. Used when the hook isn't installed (early boot) or
// can't be used safely (constrained env: PHY init, efuse reads -- fwrite
// locks crash on USB JTAG devices).
// Formats in ESPHome style with ANSI colors into a 512-byte stack buffer,
// then outputs atomically via esp_rom_printf.
// This path is cold on 99.9% of builds -- it only runs during early boot
// and at DEBUG framework log level (default is ERROR).
static void __attribute__((noinline)) esp_log_format_direct_(esp_log_msg_t *message) { // NOLINT
// ESP-IDF levels: NONE=0 ERROR=1 WARN=2 INFO=3 DEBUG=4 VERBOSE=5
// Color digits: E=1(red) W=3(yellow) I=2(green) D=6(cyan) V=7(gray)
static const char COLOR_DIGIT[] = {'\0', '1', '3', '2', '6', '7'};
static const char LVL[] = {'\0', 'E', 'W', 'I', 'D', 'V'};
// Format into stack buffer and output atomically via esp_rom_printf.
// Can't use fwrite (locks crash during early boot and PHY init).
char buf[512];
int pos = 0;
uint8_t level = message->config.opts.log_level;
if (level > 0 && level < sizeof(LVL)) {
pos = snprintf(buf, sizeof(buf), "\033[0;3%cm[%c][%s]: ", COLOR_DIGIT[level], LVL[level],
message->tag ? message->tag : "idf");
// Clamp: snprintf returns the untruncated length (or negative on error),
// so an oversized tag or an encoding error would otherwise leave pos
// outside the buffer and break every bound check below.
if (pos < 0) {
pos = 0;
} else if (pos >= (int) sizeof(buf)) {
pos = sizeof(buf) - 1;
}
}
if (pos < (int) sizeof(buf) - 2) {
int body = vsnprintf(buf + pos, sizeof(buf) - pos, message->format, message->args);
if (body > 0)
pos += (body < (int) sizeof(buf) - pos) ? body : (int) sizeof(buf) - pos - 1;
}
if (level > 0 && level < sizeof(LVL) && pos < (int) sizeof(buf) - 6) {
pos += snprintf(buf + pos, sizeof(buf) - pos, "\033[0m");
}
if (pos < (int) sizeof(buf) - 1) {
buf[pos++] = '\n';
}
buf[pos] = '\0';
esp_rom_printf("%s", buf);
}
extern "C" {
// Override esp_log_format from liblog.a to prevent V2's 3-call vprintf
// fragmentation. Deliberately NOT IRAM_ATTR: every caller that must work with
// flash cache disabled (ESP_DRAM_LOGx, ESP_EARLY_LOGx) bypasses esp_log()
// entirely under CONFIG_LOG_API_CONSTRAINED_ENV_SAFE=n, and both paths below
// immediately call flash-resident code anyway, so IRAM placement would only
// spend the IRAM this change exists to save.
void esp_log_format(esp_log_msg_t *message) {
extern vprintf_like_t esp_log_vprint_func;
extern int vprintf(const char *, __gnuc_va_list); // NOLINT
if (esp_log_vprint_func == &vprintf || message->config.opts.constrained_env) [[unlikely]] {
// Early boot or constrained env (PHY init, efuse reads, scheduler not
// running). Can't use the ESPHome hook -- fwrite locks crash during PHY
// init on USB JTAG devices and newlib isn't initialized during early boot.
// Format to stack buffer with vsnprintf + esp_rom_printf instead.
//
// Note: if called from an ISR with flash cache disabled, this will crash
// because the format string and tag are in flash. This is the same as V1
// where ESP_EARLY_LOGx from ISR also used flash-resident format strings
// via esp_rom_printf. No ESP-IDF code is known to log from ISR with
// cache disabled.
esp_log_format_direct_(message);
return;
}
// After hook installed, normal environment. Never call the esp_log_vprintf
// inline here: it would pull in esp_rom_vprintf (1.2KB IRAM).
if (esp_log_vprint_func == &esphome::esp_idf_log_vprintf_ && esphome::logger::global_logger != nullptr) {
// The hook is ESPHome's own (the common case). V2 keeps the component tag
// (wifi, phy_init, ...) and per-message severity separate from the format
// string, so call the logger directly with both: lines render with the
// real tag and level (e.g. "[E][wifi]: ...") including color and per-tag
// filtering, details the (format, args) hook signature cannot carry.
// IDF levels: NONE=0 E=1 W=2 I=3 D=4 V=5; ESPHome inserts CONFIG at 4.
static const uint8_t LEVEL_MAP[] = {ESPHOME_LOG_LEVEL_NONE, ESPHOME_LOG_LEVEL_ERROR, ESPHOME_LOG_LEVEL_WARN,
ESPHOME_LOG_LEVEL_INFO, ESPHOME_LOG_LEVEL_DEBUG, ESPHOME_LOG_LEVEL_VERBOSE};
uint8_t idf_level = message->config.opts.log_level;
uint8_t level = idf_level < sizeof(LEVEL_MAP) ? LEVEL_MAP[idf_level] : ESPHOME_LOG_LEVEL_VERBOSE;
esphome::logger::global_logger->log_vprintf_(level, message->tag ? message->tag : "esp-idf", 0, message->format,
message->args);
return;
}
// A custom hook was installed via esp_log_set_vprintf: honor it and forward
// the message body as-is.
esp_log_vprint_func(message->format, message->args);
}
} // extern "C"
#endif
+1
View File
@@ -264,5 +264,6 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
platform=ESP32_PLATFORM,
framework=_idf_framework(),
emit=_emit_idf_component,
cache_key="idf",
)
return convert_libraries(libraries, backend)
+8 -8
View File
@@ -75,7 +75,7 @@ ESP_IDF_CONSTRAINTS_MIRRORS = str_to_lst_of_str(
)
def _get_idf_tools_path() -> Path:
def get_idf_tools_path() -> Path:
"""
Get the path to the ESP-IDF tools directory.
@@ -141,7 +141,7 @@ def _check_windows_path_length() -> None:
"""
if platform.system() != "Windows" or _windows_long_paths_enabled():
return
tools_path = str(_get_idf_tools_path())
tools_path = str(get_idf_tools_path())
projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN
if projected <= _WINDOWS_MAX_PATH:
return
@@ -180,7 +180,7 @@ def _get_framework_path(version: str) -> Path:
Returns:
Path object pointing to the framework directory
"""
return _get_idf_tools_path() / "frameworks" / f"{version}"
return get_idf_tools_path() / "frameworks" / f"{version}"
def _get_python_env_path(version: str) -> Path:
@@ -193,7 +193,7 @@ def _get_python_env_path(version: str) -> Path:
Returns:
Path object pointing to the Python environment directory
"""
return _get_idf_tools_path() / "penvs" / f"{version}"
return get_idf_tools_path() / "penvs" / f"{version}"
def _check_stamp(file: PathType, data: dict[str, str]) -> bool:
@@ -707,7 +707,7 @@ def _check_esp_idf_python_env_install(
esp_idf_version = _get_idf_version(framework_path, env=env)
constraint_file_path = (
_get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt"
get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt"
)
_LOGGER.debug("ESP-IDF version %s", esp_idf_version)
@@ -798,7 +798,7 @@ def check_esp_idf_install(
_check_windows_path_length()
env = {}
env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path())
env["IDF_TOOLS_PATH"] = str(get_idf_tools_path())
env["IDF_PATH"] = ""
targets = targets or ESPHOME_IDF_DEFAULT_TARGETS
@@ -867,7 +867,7 @@ def _ccache_env() -> dict[str, str]:
defaults = {
"IDF_CCACHE_ENABLE": "1",
"CCACHE_DIR": str(_get_idf_tools_path() / "ccache"),
"CCACHE_DIR": str(get_idf_tools_path() / "ccache"),
"CCACHE_NOHASHDIR": "true",
"CCACHE_DEPEND": "1",
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
@@ -894,7 +894,7 @@ def get_framework_env(
"""
# 1. Initialize base environment with extra ESP-IDF environment variables
env = env.copy() if env else {}
env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path())
env["IDF_TOOLS_PATH"] = str(get_idf_tools_path())
env["IDF_PATH"] = ""
# 2. Get existing PATH from env or os.environ
+33 -12
View File
@@ -68,7 +68,9 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
class Source:
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = ""
) -> Path:
raise NotImplementedError
@@ -76,8 +78,14 @@ class URLSource(Source):
def __init__(self, url: str):
self.url = url
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = ""
) -> Path:
# Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so
# the build files each backend writes into the library dir can't collide.
base_dir = Path(CORE.data_dir) / DOMAIN
if namespace:
base_dir = base_dir / namespace
h = hashlib.new("sha256")
h.update(self.url.encode())
if salt:
@@ -113,12 +121,19 @@ class GitSource(Source):
self.url = url
self.ref = ref
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
def download(
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = ""
) -> Path:
domain = DOMAIN
if namespace:
domain = f"{domain}/{namespace}"
if salt:
domain = f"{domain}/{salt}"
path, _ = git.clone_or_update(
url=self.url,
ref=self.ref,
refresh=git.NEVER_REFRESH if not force else None,
domain=f"{DOMAIN}/{salt}" if salt else DOMAIN,
domain=domain,
submodules=[],
subpath=Path(dir_suffix),
)
@@ -167,16 +182,16 @@ class ConvertedLibrary:
def get_require_name(self):
return self.get_sanitized_name().replace("/", "__")
def download(self, force: bool = False, salt: str = ""):
def download(self, force: bool = False, salt: str = "", namespace: str = ""):
"""Fetch the library into the shared cache and record its ``path``.
The cache directory is named after the sanitized library name; backends
rely on that name to identify the unit they build (e.g. ESP-IDF uses the
directory name as the component name, replacing ``/`` with ``__`` via
``get_require_name``).
``get_require_name``). ``namespace`` keeps each backend's cache separate.
"""
self.path = self.source.download(
self.get_sanitized_name(), force=force, salt=salt
self.get_sanitized_name(), force=force, salt=salt, namespace=namespace
)
@@ -188,11 +203,15 @@ class LibraryBackend:
``emit`` writes the toolchain-specific build files into a resolved library's
``path`` (e.g. the ESP-IDF ``CMakeLists.txt`` + ``idf_component.yml``, or a
Zephyr ``module.yml`` + ``CMakeLists.txt``).
``cache_key`` namespaces the download cache (``pio_components/<cache_key>/``)
so the differing build files two backends emit into a library dir never
collide when the same config dir hosts both an ESP-IDF and a Zephyr build.
"""
platform: str
platform: str | None
framework: str
emit: Callable[["ConvertedLibrary"], None]
cache_key: str
def ensure_list[T](obj: T | list[T]) -> list[T]:
@@ -306,7 +325,7 @@ def split_list_by_condition(
return matched, non_matched
def check_library_data(data: dict, platform: str, framework: str):
def check_library_data(data: dict, platform: str | None, framework: str):
"""
Check whether a library manifest is compatible with the target toolchain.
@@ -319,7 +338,9 @@ def check_library_data(data: dict, platform: str, framework: str):
Args:
data: PIO library manifest dict being processed.
platform: The PlatformIO platform token the build targets (e.g.
``espressif32``).
``espressif32``). ``None`` skips the platform check entirely — useful
for targets (e.g. Zephyr) where PIO manifests rarely declare the
platform yet portable libraries still build.
framework: The active framework name (e.g. ``espidf``, ``arduino``,
``zephyr``) the manifest is expected to declare.
@@ -332,7 +353,7 @@ def check_library_data(data: dict, platform: str, framework: str):
platforms = ensure_list(platforms)
# Check if library supports the target platform
valid_platforms = "*" in platforms or platform in platforms
valid_platforms = platform is None or "*" in platforms or platform in platforms
if not valid_platforms:
raise InvalidLibrary(f"Unsupported library platforms: {platforms}")
@@ -613,7 +634,7 @@ def convert_libraries(
component = ConvertedLibrary(
_owner_pkgname_to_name(owner, name), version, URLSource(url)
)
component.download(salt=salt)
component.download(salt=salt, namespace=backend.cache_key)
library_json_path = component.path / "library.json"
library_properties_path = component.path / "library.properties"
+14 -7
View File
@@ -653,14 +653,21 @@ def clean_all(configuration: list[str]):
elif item.is_dir() and item.name != "storage":
rmtree(item)
# The native ESP-IDF install lives in a machine-global cache dir, outside
# any .esphome data dir, so the per-config loop above won't reach it.
from esphome.espidf.framework import _get_idf_tools_path
# The native toolchain installs live in a machine-global cache dir that
# the per-config loop above can't reach. Wipe the default cache root
# (also catches leftovers from older install layouts), then the resolved
# install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI)
# that live outside it.
import platformdirs
idf_install_path = _get_idf_tools_path()
if idf_install_path.is_dir():
_LOGGER.info("Deleting %s", idf_install_path)
rmtree(idf_install_path)
from esphome.components.nrf52.framework import get_sdk_nrf_tools_path
from esphome.espidf.framework import get_idf_tools_path
cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve()
for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()):
if install_path.is_dir():
_LOGGER.info("Deleting %s", install_path)
rmtree(install_path)
# Clean PlatformIO project files
try:
@@ -203,3 +203,24 @@ display:
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2)
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-7.5in-bv2-bwr
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0));
+18
View File
@@ -0,0 +1,18 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: st7123_ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
- id: st7123_page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
touchscreen:
- platform: st7123
i2c_id: i2c_bus
id: st7123_touchscreen
display: st7123_ssd1306_i2c_display
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
@@ -0,0 +1,9 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
+27 -11
View File
@@ -481,7 +481,7 @@ def test_generate_idf_components_dedupes_shared_dependency(
"esphome/C": {"name": "C"},
}
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -543,7 +543,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
download_salts: list[str] = []
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
download_salts.append(salt)
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
@@ -597,7 +597,7 @@ def test_generate_idf_components_handles_dependency_cycle(
},
}
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -654,7 +654,7 @@ def test_generate_idf_components_git_overrides_registry_warns(
"esphome/shared": {"name": "shared"},
}
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -691,7 +691,7 @@ def test_generate_idf_components_missing_manifest_raises(
) -> None:
# A library with neither library.json nor library.properties is invalid;
# fail loudly rather than silently generating build files for it.
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
# no library.json / library.properties written
@@ -733,7 +733,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
"owner/shared": {"name": "shared"},
}
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -766,7 +766,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
) -> None:
# A top-level library that isn't ESP-IDF/esp32 compatible must fail fast,
# not be silently dropped.
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "library.json").write_text(
@@ -804,7 +804,7 @@ def test_generate_idf_components_incompatible_dependency_skipped(
"esphome/B": {"name": "B", "platforms": ["espressif8266"]},
}
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
@@ -847,6 +847,13 @@ def test_url_source_salt_changes_cache_path(
assert source.download("lib") == expected[""]
assert source.download("lib", salt="abcd1234") == expected["abcd1234"]
# A backend namespace adds a pio_components/<namespace>/ subdir.
digest = hashlib.sha256(url.encode()).hexdigest()[:8]
ns_expected = base / "idf" / digest / "lib"
ns_expected.mkdir(parents=True)
(ns_expected / ".esphome_extracted").touch()
assert source.download("lib", namespace="idf") == ns_expected
def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
"""The salt becomes a subdirectory of the git clone domain."""
@@ -863,7 +870,14 @@ def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
source = GitSource("https://github.com/esphome/noise-c.git", "v1.0")
source.download("noise-c")
source.download("noise-c", salt="abcd1234")
assert domains == ["pio_components", "pio_components/abcd1234"]
source.download("noise-c", namespace="idf")
source.download("noise-c", namespace="zephyr", salt="abcd1234")
assert domains == [
"pio_components",
"pio_components/abcd1234",
"pio_components/idf",
"pio_components/zephyr/abcd1234",
]
def test_idf_component_download_passes_salt() -> None:
@@ -873,7 +887,9 @@ def test_idf_component_download_passes_salt() -> None:
source.download.return_value = Path("/converted/owner/name")
c = IDFComponent("owner/name", "1.0", source=source)
c.download(force=True, salt="abcd1234")
c.download(force=True, salt="abcd1234", namespace="idf")
source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234")
source.download.assert_called_once_with(
"owner/name", force=True, salt="abcd1234", namespace="idf"
)
assert c.path == Path("/converted/owner/name")
+14 -14
View File
@@ -21,7 +21,6 @@ from esphome.espidf.framework import (
_clone_idf_with_submodules,
_get_framework_path,
_get_idf_tool_paths,
_get_idf_tools_path,
_get_idf_version,
_get_python_env_path,
_get_python_version,
@@ -32,6 +31,7 @@ from esphome.espidf.framework import (
_write_stamp,
check_esp_idf_install,
get_framework_env,
get_idf_tools_path,
)
from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path
@@ -639,7 +639,7 @@ def test_write_stamp_writes_json(tmp_path: Path) -> None:
def test_get_framework_env_with_python_env(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"),
@@ -664,7 +664,7 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None:
def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"),
@@ -687,7 +687,7 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
return (
patch("esphome.espidf.framework.shutil.which", return_value=which),
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=tmp_path / "tools",
),
patch(
@@ -761,7 +761,7 @@ def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None:
# ---------------------------------------------------------------------------
# _check_stamp / _write_idf_version_txt / _get_idf_tools_path
# _check_stamp / _write_idf_version_txt / get_idf_tools_path
# ---------------------------------------------------------------------------
@@ -798,14 +798,14 @@ def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None:
assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n"
def test_get_idf_tools_path_env_override(tmp_path: Path) -> None:
def testget_idf_tools_path_env_override(tmp_path: Path) -> None:
override = str(tmp_path / "custom-idf")
with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}):
assert _get_idf_tools_path() == Path(override)
assert get_idf_tools_path() == Path(override)
@pytest.mark.parametrize("value", ["", " "])
def test_get_idf_tools_path_blank_env_falls_back_to_default(
def testget_idf_tools_path_blank_env_falls_back_to_default(
value: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD.
@@ -819,10 +819,10 @@ def test_get_idf_tools_path_blank_env_falls_back_to_default(
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
).resolve()
assert _get_idf_tools_path() == expected
assert get_idf_tools_path() == expected
def test_get_idf_tools_path_default_uses_user_cache(
def testget_idf_tools_path_default_uses_user_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Without the env override the install root is the machine-global OS user
@@ -833,7 +833,7 @@ def test_get_idf_tools_path_default_uses_user_cache(
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
).resolve()
assert _get_idf_tools_path() == expected
assert get_idf_tools_path() == expected
def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None:
@@ -908,7 +908,7 @@ def test_check_windows_path_length_noop_when_long_paths_enabled(
patch(
"esphome.espidf.framework._windows_long_paths_enabled", return_value=True
),
patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock,
patch("esphome.espidf.framework.get_idf_tools_path") as get_path_mock,
caplog.at_level(logging.WARNING),
):
_check_windows_path_length()
@@ -925,7 +925,7 @@ def test_check_windows_path_length_short_path_silent(
"esphome.espidf.framework._windows_long_paths_enabled", return_value=False
),
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=_SHORT_IDF_PATH,
),
caplog.at_level(logging.WARNING),
@@ -943,7 +943,7 @@ def test_check_windows_path_length_long_path_warns(
"esphome.espidf.framework._windows_long_paths_enabled", return_value=False
),
patch(
"esphome.espidf.framework._get_idf_tools_path",
"esphome.espidf.framework.get_idf_tools_path",
return_value=_LONG_IDF_PATH,
),
caplog.at_level(logging.WARNING),
+60 -1
View File
@@ -10,12 +10,28 @@ from esphome.components.nrf52.framework import (
_TOOLCHAIN_VERSION,
_get_toolchain_platform_info,
check_and_install,
get_sdk_nrf_tools_path,
)
from esphome.config_validation import Version
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def _isolate_sdk_nrf_install_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Pin the sdk-nrf install root to a tmp dir for every test.
The default location is the OS user cache dir, so without this any test
that builds framework paths or pre-creates the install dir would touch
the real ``~/.cache/esphome`` on the developer's machine. Tests that need
to exercise the override or default-resolution logic clear/override the
env themselves.
"""
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(tmp_path / "sdk_nrf_install"))
@pytest.mark.parametrize(
("system", "machine", "expected"),
[
@@ -52,7 +68,7 @@ _TEST_SDK_VERSION = "2.9.0"
def nrf52_dirs(setup_core: Path) -> SimpleNamespace:
"""Populate CORE and pre-create SDK directories so sentinel.touch() succeeds."""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)}
tools = CORE.data_dir / "sdk-nrf"
tools = get_sdk_nrf_tools_path()
python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}"
framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}"
toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION
@@ -226,3 +242,46 @@ class TestCheckAndInstall:
assert substitutions["sysname"] == "linux"
assert substitutions["machine"] == "x86_64"
assert substitutions["extension"] == "tar.xz"
# ---------------------------------------------------------------------------
# get_sdk_nrf_tools_path tests
# ---------------------------------------------------------------------------
def testget_tools_path_env_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
override = tmp_path / "custom" / "sdk-nrf"
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(override))
assert get_sdk_nrf_tools_path() == override.resolve()
@pytest.mark.parametrize("value", ["", " "])
def testget_tools_path_blank_env_falls_back_to_default(
value: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A blank ESPHOME_SDK_NRF_PREFIX is treated as unset, not as CWD.
Path("") would resolve to the working directory, which clean-all could
then delete by accident.
"""
import platformdirs
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value)
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
).resolve()
assert get_sdk_nrf_tools_path() == expected
def testget_tools_path_default_is_global_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import platformdirs
monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False)
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
).resolve()
assert get_sdk_nrf_tools_path() == expected
+4 -2
View File
@@ -26,7 +26,9 @@ from esphome.platformio.library import (
def _backend(emit=lambda component: None) -> LibraryBackend:
return LibraryBackend(platform="espressif32", framework="espidf", emit=emit)
return LibraryBackend(
platform="espressif32", framework="espidf", emit=emit, cache_key="idf"
)
def test_check_library_data_accepts_wildcards():
@@ -134,7 +136,7 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch):
def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()):
"""Fake ConvertedLibrary.download to materialize canned manifests on disk."""
def fake_download(self, force=False, salt=""):
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
self.path.mkdir(parents=True, exist_ok=True)
if self.name in properties:
+64 -4
View File
@@ -68,12 +68,16 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
test_clean_all_partial_exists) install their own inner patch which
stacks on top of this one and wins for the duration of their block.
Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the
same reason: ``clean_all`` removes the now machine-global ESP-IDF
install, which otherwise defaults to the real ``~/.cache/esphome``.
Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to
nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the
same reason: ``clean_all`` removes the machine-global toolchain installs
and their default cache root, which otherwise resolve to the real
``~/.cache/esphome``.
"""
pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent"
idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent"
sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent"
cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent"
mock_cfg = MagicMock()
mock_cfg.get.side_effect = lambda section, option: (
str(pio_root / option) if section == "platformio" else ""
@@ -83,7 +87,14 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
"platformio.project.config.ProjectConfig.get_instance",
return_value=mock_cfg,
),
patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}),
patch.dict(
"os.environ",
{
"ESPHOME_ESP_IDF_PREFIX": str(idf_root),
"ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root),
},
),
patch("platformdirs.user_cache_dir", return_value=str(cache_root)),
):
yield
@@ -1022,6 +1033,55 @@ def test_clean_all_removes_global_idf_install(
assert str(idf_install.resolve()) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_removes_global_sdk_nrf_install(
mock_core: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""clean_all removes the machine-global native sdk-nrf install dir."""
sdk_nrf_install = tmp_path / "sdk_nrf_install"
(sdk_nrf_install / "frameworks").mkdir(parents=True)
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(sdk_nrf_install))
config_dir = tmp_path / "config"
config_dir.mkdir()
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(config_dir)])
assert not sdk_nrf_install.exists()
assert str(sdk_nrf_install.resolve()) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_removes_default_cache_root(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""clean_all removes the default cache root (stale/orphaned installs)."""
cache_root = tmp_path / "cache_root"
(cache_root / "some-old-toolchain").mkdir(parents=True)
config_dir = tmp_path / "config"
config_dir.mkdir()
from esphome.writer import clean_all
with (
patch("platformdirs.user_cache_dir", return_value=str(cache_root)),
caplog.at_level("INFO"),
):
clean_all([str(config_dir)])
assert not cache_root.exists()
assert str(cache_root.resolve()) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_with_yaml_build_path(
mock_core: MagicMock,
+117
View File
@@ -0,0 +1,117 @@
"""Tests for the Zephyr backend of the shared PlatformIO library converter."""
from pathlib import Path
import pytest
import esphome.components.zephyr.library as zlib
from esphome.components.zephyr.library import (
generate_cmakelists_txt,
generate_module_yml,
generate_zephyr_modules,
)
from esphome.core import EsphomeError, Library
from esphome.platformio.library import ConvertedLibrary, URLSource
def _make_component(path: Path, name: str = "mylib") -> ConvertedLibrary:
c = ConvertedLibrary(name, "1.0", source=URLSource("http://dummy"))
c.path = path
return c
def test_generate_module_yml_uses_sanitized_name():
c = ConvertedLibrary("owner/My Lib", "1.0", source=URLSource("http://dummy"))
out = generate_module_yml(c)
# "/" -> "__" and " " -> "_" so it's a valid Zephyr module name.
assert "name: owner__My_Lib" in out
assert "cmake: zephyr" in out
def test_generate_cmakelists_txt_basic(tmp_path):
c = _make_component(tmp_path)
src = tmp_path / "src"
src.mkdir()
(src / "main.c").write_text("int main() {}")
c.data = {}
out = generate_cmakelists_txt(c)
assert "zephyr_library_named(mylib)" in out
assert "zephyr_library_sources(" in out
# Sources are emitted as absolute paths (CMakeLists lives in zephyr/ subdir),
# backslash-escaped for CMake (matching the output on Windows).
assert str((src / "main.c").resolve()).replace("\\", "\\\\") in out
def test_generate_cmakelists_txt_flags_and_includes(tmp_path):
c = _make_component(tmp_path)
(tmp_path / "src").mkdir()
(tmp_path / "src" / "a.c").write_text("")
(tmp_path / "include").mkdir()
c.data = {"build": {"flags": ["-Iinclude", "-DFOO", "-Wall", "-Llibdir", "-lm"]}}
out = generate_cmakelists_txt(c)
assert "zephyr_include_directories(" in out
assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out
assert "zephyr_library_compile_options(" in out
assert "-DFOO" in out
assert "-Wall" in out
assert "zephyr_link_libraries(" in out
assert "-Llibdir" in out
assert "-lm" in out
def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch):
# Two converted libraries: one top-level, one transitive dependency. The
# converter calls backend.emit for both; generate_zephyr_modules must return
# *all* module dirs (not just top-level) so every module is discoverable.
top = _make_component(tmp_path / "top", "top")
(top.path / "src").mkdir(parents=True)
(top.path / "src" / "t.c").write_text("")
dep = _make_component(tmp_path / "dep", "dep")
(dep.path / "src").mkdir(parents=True)
(dep.path / "src" / "d.c").write_text("")
captured = {}
def fake_convert(libraries, backend):
captured["platform"] = backend.platform
captured["framework"] = backend.framework
backend.emit(top)
backend.emit(dep)
return [top]
monkeypatch.setattr(zlib, "convert_libraries", fake_convert)
dirs = generate_zephyr_modules([Library("top", "1.0", None)])
assert dirs == [top.path, dep.path]
# Platform check disabled for Zephyr; framework declared as zephyr.
assert captured["platform"] is None
assert captured["framework"] == "zephyr"
for comp in (top, dep):
assert (comp.path / "zephyr" / "module.yml").is_file()
assert (comp.path / "zephyr" / "CMakeLists.txt").is_file()
def test_generate_zephyr_modules_errors_on_duplicate_module_name(tmp_path, monkeypatch):
# The same library referenced under inconsistent specs (e.g. bare vs
# owner-qualified, or git vs registry) resolves to two components with the
# same Zephyr module name, which would collide in zephyr_library_named().
a = _make_component(tmp_path / "a", "esphome/noise-c")
a.path.mkdir(parents=True)
b = _make_component(tmp_path / "b", "esphome/noise-c")
b.path.mkdir(parents=True)
assert a.get_require_name() == b.get_require_name()
def fake_convert(libraries, backend):
backend.emit(a)
backend.emit(b)
return [a]
monkeypatch.setattr(zlib, "convert_libraries", fake_convert)
with pytest.raises(EsphomeError, match="same Zephyr module"):
generate_zephyr_modules([Library("esphome/noise-c", "1.0", None)])