Compare commits

..
125 changed files with 3274 additions and 1187 deletions
+13 -2
View File
@@ -374,8 +374,9 @@ jobs:
- name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly;
# libsdl2-dev is unused here and carried only for cache-key parity.
# path. Packages and version must match seed-apt-cache exactly.
# libsdl2-dev is needed by the headless display tests, which capture
# screenshots.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
@@ -438,6 +439,16 @@ jobs:
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \
--junitxml=junit-integration.xml "${test_files[@]}"
- name: Upload test artifacts
# Tests that compare rendered output write the image they actually got here, so a
# failure can be looked at without reproducing the whole build locally.
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: integration-test-artifacts-${{ matrix.bucket.name }}
path: test_artifacts/
if-no-files-found: ignore
retention-days: 7
- name: Upload junit timings
# Consumed by sync-integration-durations.yml through
# script/update_integration_test_durations.py; only full matrix dev
+2
View File
@@ -137,6 +137,8 @@ config/
!tests/component_tests/**/config/
tests/build/
tests/.esphome/
# Output kept by failing tests for inspection; uploaded by CI
test_artifacts/
/.temp-clang-tidy.cpp
/.temp/
.pio/
+1
View File
@@ -496,6 +496,7 @@ esphome/components/sm2335/* @Cossid
esphome/components/sml/* @alengwenus
esphome/components/smt100/* @piechade
esphome/components/sn74hc165/* @jesserockz
esphome/components/snapshot/* @clydebarrow
esphome/components/socket/* @esphome/core
esphome/components/sonoff_d1/* @anatoly-savchenkov
esphome/components/sound_level/* @kahrendt
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.9.0-dev
PROJECT_NUMBER = 2026.9.0b1
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
-1
View File
@@ -100,7 +100,6 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma
void CM1106Component::dump_config() {
ESP_LOGCONFIG(TAG, "CM1106:");
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
this->check_uart_settings(9600);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
+8
View File
@@ -46,6 +46,14 @@ CONFIG_SCHEMA = (
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"cm1106",
baud_rate=9600,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
"""Code generation entry point."""
-1
View File
@@ -58,7 +58,6 @@ void CSE7761Component::dump_config() {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
LOG_UPDATE_INTERVAL(this);
this->check_uart_settings(38400, 1, uart::UART_CONFIG_PARITY_EVEN, 8);
}
void CSE7761Component::update() {
+7 -1
View File
@@ -68,7 +68,13 @@ CONFIG_SCHEMA = (
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"cse7761", baud_rate=38400, require_rx=True, require_tx=True
"cse7761",
baud_rate=38400,
require_rx=True,
require_tx=True,
data_bits=8,
parity="EVEN",
stop_bits=1,
)
-1
View File
@@ -255,7 +255,6 @@ void CSE7766Component::dump_config() {
LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_);
LOG_SENSOR(" ", "Reactive Power", this->reactive_power_sensor_);
LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_);
this->check_uart_settings(4800, 1, uart::UART_CONFIG_PARITY_EVEN);
}
} // namespace esphome::cse7766
+6 -1
View File
@@ -84,7 +84,12 @@ CONFIG_SCHEMA = (
.extend(cv.COMPONENT_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"cse7766", baud_rate=4800, parity="EVEN", require_rx=True
"cse7766",
baud_rate=4800,
require_rx=True,
data_bits=8,
parity="EVEN",
stop_bits=1,
)
+8
View File
@@ -26,6 +26,14 @@ CONFIG_SCHEMA = (
.extend(cv.polling_component_schema("30s"))
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"daly_bms",
baud_rate=9600,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
+1 -4
View File
@@ -22,10 +22,7 @@ static const uint8_t DALY_REQUEST_TEMPERATURE = 0x96;
void DalyBmsComponent::setup() { this->next_request_ = 1; }
void DalyBmsComponent::dump_config() {
ESP_LOGCONFIG(TAG, "Daly BMS:");
this->check_uart_settings(9600);
}
void DalyBmsComponent::dump_config() { ESP_LOGCONFIG(TAG, "Daly BMS:"); }
void DalyBmsComponent::update() {
this->trigger_next_ = true;
+6 -1
View File
@@ -60,7 +60,12 @@ CONFIG_SCHEMA = cv.All(
).extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"dfplayer", baud_rate=9600, require_tx=True
"dfplayer",
baud_rate=9600,
require_tx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
+1 -4
View File
@@ -277,9 +277,6 @@ void DFPlayer::loop() {
}
}
}
void DFPlayer::dump_config() {
ESP_LOGCONFIG(TAG, "DFPlayer:");
this->check_uart_settings(9600);
}
void DFPlayer::dump_config() { ESP_LOGCONFIG(TAG, "DFPlayer:"); }
} // namespace esphome::dfplayer
-1
View File
@@ -96,7 +96,6 @@ void HC8Component::dump_config() {
" Warmup time: %" PRIu32 " s",
this->warmup_seconds_);
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
this->check_uart_settings(9600);
}
} // namespace esphome::hc8
+3
View File
@@ -47,6 +47,9 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
baud_rate=9600,
require_rx=True,
require_tx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
-1
View File
@@ -38,7 +38,6 @@ CoverTraits HE60rCover::get_traits() {
void HE60rCover::dump_config() {
LOG_COVER("", "HE60R Cover", this);
this->check_uart_settings(1200, 1, uart::UART_CONFIG_PARITY_EVEN, 8);
ESP_LOGCONFIG(TAG,
" Open Duration: %.1fs\n"
" Close Duration: %.1fs",
@@ -68,8 +68,6 @@ void HrxlMaxsonarWrComponent::check_buffer_() {
void HrxlMaxsonarWrComponent::dump_config() {
ESP_LOGCONFIG(TAG, "HRXL MaxSonar WR Sensor:");
LOG_SENSOR(" ", "Distance", this);
// As specified in the sensor's data sheet
this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8);
}
} // namespace esphome::hrxl_maxsonar_wr
@@ -23,6 +23,14 @@ CONFIG_SCHEMA = sensor.sensor_schema(
state_class=STATE_CLASS_MEASUREMENT,
).extend(uart.UART_DEVICE_SCHEMA)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"hrxl_maxsonar_wr",
baud_rate=9600,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
@@ -11,7 +11,6 @@ static const char *const PROTOCOL_NAMES[] = {HYDREON_RGXX_PROTOCOL_LIST(, HYDREO
static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_RGXX_COMMA)};
void HydreonRGxxComponent::dump_config() {
this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8);
ESP_LOGCONFIG(TAG, "hydreon_rgxx:");
if (this->is_failed()) {
ESP_LOGE(TAG, "Connection with hydreon_rgxx failed!");
@@ -130,6 +130,14 @@ CONFIG_SCHEMA = cv.All(
_validate,
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"hydreon_rgxx",
baud_rate=9600,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -26,8 +26,6 @@ void KamstrupKMPComponent::dump_config() {
LOG_SENSOR(" ", "Custom Sensor", this->custom_sensors_[i]);
ESP_LOGCONFIG(TAG, " Command: 0x%04X", this->custom_commands_[i]);
}
this->check_uart_settings(1200, 2, uart::UART_CONFIG_PARITY_NONE, 8);
}
void KamstrupKMPComponent::update() {
+7 -1
View File
@@ -102,7 +102,13 @@ CONFIG_SCHEMA = (
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"kamstrup_kmp", baud_rate=1200, require_rx=True, require_tx=True
"kamstrup_kmp",
baud_rate=1200,
require_rx=True,
require_tx=True,
data_bits=8,
parity="NONE",
stop_bits=2,
)
-2
View File
@@ -143,8 +143,6 @@ void MHZ19Component::dump_config() {
ESP_LOGCONFIG(TAG, "MH-Z19:");
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
this->check_uart_settings(9600);
if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) {
ESP_LOGCONFIG(TAG, " Automatic baseline calibration enabled on boot");
} else if (this->abc_boot_logic_ == MHZ19_ABC_DISABLED) {
+8
View File
@@ -80,6 +80,14 @@ CONFIG_SCHEMA = (
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"mhz19",
baud_rate=9600,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -163,10 +163,7 @@ void Mk2PVRouter::publish_value_(const char *tag, const char *val) {
#endif
}
void Mk2PVRouter::dump_config() {
ESP_LOGCONFIG(TAG, "Mk2PVRouter:");
this->check_uart_settings(BAUD_RATE, 1, uart::UART_CONFIG_PARITY_EVEN, 7);
}
void Mk2PVRouter::dump_config() { ESP_LOGCONFIG(TAG, "Mk2PVRouter:"); }
#ifdef MK2PVROUTER_LISTENER_COUNT
void Mk2PVRouter::register_mk2pvrouter_listener(Mk2PVRouterListener *listener) {
@@ -43,7 +43,6 @@ class Mk2PVRouter final : public Component, public uart::UARTDevice {
protected:
static constexpr size_t CRC_SUFFIX_LEN = 1;
static constexpr uint32_t BAUD_RATE = 9600;
enum class State : uint8_t {
WAITING_FOR_START,
-1
View File
@@ -16,7 +16,6 @@ void PM1006Component::dump_config() {
ESP_LOGCONFIG(TAG, "PM1006:");
LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_);
LOG_UPDATE_INTERVAL(this);
this->check_uart_settings(9600);
}
void PM1006Component::update() {
+3
View File
@@ -48,6 +48,9 @@ def validate_interval_uart(config: ConfigType) -> None:
baud_rate=9600,
require_rx=True,
require_tx=interval.total_milliseconds != SCHEDULER_DONT_RUN,
data_bits=8,
parity="NONE",
stop_bits=1,
)(config)
-2
View File
@@ -46,8 +46,6 @@ void PMSX003Component::dump_config() {
} else {
ESP_LOGCONFIG(TAG, " Mode: passive with sleep/wake cycles");
}
this->check_uart_settings(9600);
}
void PMSX003Component::loop() {
+7 -1
View File
@@ -302,7 +302,13 @@ CONFIG_SCHEMA = cv.All(
def final_validate(config: ConfigType) -> None:
require_tx = config[CONF_UPDATE_INTERVAL] > cv.time_period("0s")
schema = uart.final_validate_device_schema(
"pmsx003", baud_rate=9600, require_rx=True, require_tx=require_tx
"pmsx003",
baud_rate=9600,
require_rx=True,
require_tx=require_tx,
data_bits=8,
parity="NONE",
stop_bits=1,
)
schema(config)
+8
View File
@@ -41,6 +41,14 @@ CONFIG_SCHEMA = cv.All(
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"pylontech",
baud_rate=115200,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -33,7 +33,6 @@ static const uint8_t ASCII_LF = 0x0A;
PylontechComponent::PylontechComponent() {}
void PylontechComponent::dump_config() {
this->check_uart_settings(115200, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8);
ESP_LOGCONFIG(TAG, "pylontech:");
if (this->is_failed()) {
ESP_LOGE(TAG, "Connection with pylontech failed!");
+253
View File
@@ -1 +1,254 @@
import esphome.codegen as cg
CODEOWNERS = ["@clydebarrow"]
SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode")
SDL_KEYS = (
"SDLK_UNKNOWN",
"SDLK_RETURN",
"SDLK_ESCAPE",
"SDLK_BACKSPACE",
"SDLK_TAB",
"SDLK_SPACE",
"SDLK_EXCLAIM",
"SDLK_QUOTEDBL",
"SDLK_HASH",
"SDLK_PERCENT",
"SDLK_DOLLAR",
"SDLK_AMPERSAND",
"SDLK_QUOTE",
"SDLK_LEFTPAREN",
"SDLK_RIGHTPAREN",
"SDLK_ASTERISK",
"SDLK_PLUS",
"SDLK_COMMA",
"SDLK_MINUS",
"SDLK_PERIOD",
"SDLK_SLASH",
"SDLK_0",
"SDLK_1",
"SDLK_2",
"SDLK_3",
"SDLK_4",
"SDLK_5",
"SDLK_6",
"SDLK_7",
"SDLK_8",
"SDLK_9",
"SDLK_COLON",
"SDLK_SEMICOLON",
"SDLK_LESS",
"SDLK_EQUALS",
"SDLK_GREATER",
"SDLK_QUESTION",
"SDLK_AT",
"SDLK_LEFTBRACKET",
"SDLK_BACKSLASH",
"SDLK_RIGHTBRACKET",
"SDLK_CARET",
"SDLK_UNDERSCORE",
"SDLK_BACKQUOTE",
"SDLK_a",
"SDLK_b",
"SDLK_c",
"SDLK_d",
"SDLK_e",
"SDLK_f",
"SDLK_g",
"SDLK_h",
"SDLK_i",
"SDLK_j",
"SDLK_k",
"SDLK_l",
"SDLK_m",
"SDLK_n",
"SDLK_o",
"SDLK_p",
"SDLK_q",
"SDLK_r",
"SDLK_s",
"SDLK_t",
"SDLK_u",
"SDLK_v",
"SDLK_w",
"SDLK_x",
"SDLK_y",
"SDLK_z",
"SDLK_CAPSLOCK",
"SDLK_F1",
"SDLK_F2",
"SDLK_F3",
"SDLK_F4",
"SDLK_F5",
"SDLK_F6",
"SDLK_F7",
"SDLK_F8",
"SDLK_F9",
"SDLK_F10",
"SDLK_F11",
"SDLK_F12",
"SDLK_PRINTSCREEN",
"SDLK_SCROLLLOCK",
"SDLK_PAUSE",
"SDLK_INSERT",
"SDLK_HOME",
"SDLK_PAGEUP",
"SDLK_DELETE",
"SDLK_END",
"SDLK_PAGEDOWN",
"SDLK_RIGHT",
"SDLK_LEFT",
"SDLK_DOWN",
"SDLK_UP",
"SDLK_NUMLOCKCLEAR",
"SDLK_KP_DIVIDE",
"SDLK_KP_MULTIPLY",
"SDLK_KP_MINUS",
"SDLK_KP_PLUS",
"SDLK_KP_ENTER",
"SDLK_KP_1",
"SDLK_KP_2",
"SDLK_KP_3",
"SDLK_KP_4",
"SDLK_KP_5",
"SDLK_KP_6",
"SDLK_KP_7",
"SDLK_KP_8",
"SDLK_KP_9",
"SDLK_KP_0",
"SDLK_KP_PERIOD",
"SDLK_APPLICATION",
"SDLK_POWER",
"SDLK_KP_EQUALS",
"SDLK_F13",
"SDLK_F14",
"SDLK_F15",
"SDLK_F16",
"SDLK_F17",
"SDLK_F18",
"SDLK_F19",
"SDLK_F20",
"SDLK_F21",
"SDLK_F22",
"SDLK_F23",
"SDLK_F24",
"SDLK_EXECUTE",
"SDLK_HELP",
"SDLK_MENU",
"SDLK_SELECT",
"SDLK_STOP",
"SDLK_AGAIN",
"SDLK_UNDO",
"SDLK_CUT",
"SDLK_COPY",
"SDLK_PASTE",
"SDLK_FIND",
"SDLK_MUTE",
"SDLK_VOLUMEUP",
"SDLK_VOLUMEDOWN",
"SDLK_KP_COMMA",
"SDLK_KP_EQUALSAS400",
"SDLK_ALTERASE",
"SDLK_SYSREQ",
"SDLK_CANCEL",
"SDLK_CLEAR",
"SDLK_PRIOR",
"SDLK_RETURN2",
"SDLK_SEPARATOR",
"SDLK_OUT",
"SDLK_OPER",
"SDLK_CLEARAGAIN",
"SDLK_CRSEL",
"SDLK_EXSEL",
"SDLK_KP_00",
"SDLK_KP_000",
"SDLK_THOUSANDSSEPARATOR",
"SDLK_DECIMALSEPARATOR",
"SDLK_CURRENCYUNIT",
"SDLK_CURRENCYSUBUNIT",
"SDLK_KP_LEFTPAREN",
"SDLK_KP_RIGHTPAREN",
"SDLK_KP_LEFTBRACE",
"SDLK_KP_RIGHTBRACE",
"SDLK_KP_TAB",
"SDLK_KP_BACKSPACE",
"SDLK_KP_A",
"SDLK_KP_B",
"SDLK_KP_C",
"SDLK_KP_D",
"SDLK_KP_E",
"SDLK_KP_F",
"SDLK_KP_XOR",
"SDLK_KP_POWER",
"SDLK_KP_PERCENT",
"SDLK_KP_LESS",
"SDLK_KP_GREATER",
"SDLK_KP_AMPERSAND",
"SDLK_KP_DBLAMPERSAND",
"SDLK_KP_VERTICALBAR",
"SDLK_KP_DBLVERTICALBAR",
"SDLK_KP_COLON",
"SDLK_KP_HASH",
"SDLK_KP_SPACE",
"SDLK_KP_AT",
"SDLK_KP_EXCLAM",
"SDLK_KP_MEMSTORE",
"SDLK_KP_MEMRECALL",
"SDLK_KP_MEMCLEAR",
"SDLK_KP_MEMADD",
"SDLK_KP_MEMSUBTRACT",
"SDLK_KP_MEMMULTIPLY",
"SDLK_KP_MEMDIVIDE",
"SDLK_KP_PLUSMINUS",
"SDLK_KP_CLEAR",
"SDLK_KP_CLEARENTRY",
"SDLK_KP_BINARY",
"SDLK_KP_OCTAL",
"SDLK_KP_DECIMAL",
"SDLK_KP_HEXADECIMAL",
"SDLK_LCTRL",
"SDLK_LSHIFT",
"SDLK_LALT",
"SDLK_LGUI",
"SDLK_RCTRL",
"SDLK_RSHIFT",
"SDLK_RALT",
"SDLK_RGUI",
"SDLK_MODE",
"SDLK_AUDIONEXT",
"SDLK_AUDIOPREV",
"SDLK_AUDIOSTOP",
"SDLK_AUDIOPLAY",
"SDLK_AUDIOMUTE",
"SDLK_MEDIASELECT",
"SDLK_WWW",
"SDLK_MAIL",
"SDLK_CALCULATOR",
"SDLK_COMPUTER",
"SDLK_AC_SEARCH",
"SDLK_AC_HOME",
"SDLK_AC_BACK",
"SDLK_AC_FORWARD",
"SDLK_AC_STOP",
"SDLK_AC_REFRESH",
"SDLK_AC_BOOKMARKS",
"SDLK_BRIGHTNESSDOWN",
"SDLK_BRIGHTNESSUP",
"SDLK_DISPLAYSWITCH",
"SDLK_KBDILLUMTOGGLE",
"SDLK_KBDILLUMDOWN",
"SDLK_KBDILLUMUP",
"SDLK_EJECT",
"SDLK_SLEEP",
"SDLK_APP1",
"SDLK_APP2",
"SDLK_AUDIOREWIND",
"SDLK_AUDIOFASTFORWARD",
"SDLK_SOFTLEFT",
"SDLK_SOFTRIGHT",
"SDLK_CALL",
"SDLK_ENDCALL",
)
SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS}
+3 -250
View File
@@ -7,262 +7,15 @@ from esphome.core import Lambda
from esphome.cpp_generator import ExpressionStatement, RawExpression
from esphome.types import ConfigType
from .display import CONF_SDL_ID, Sdl
from . import SDL_KEYMAP
from .display import CONF_SDL_ID, Sdl, headless_final_validate
CODEOWNERS = ["@bdm310"]
STATE_ARG = "state"
SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode")
FINAL_VALIDATE_SCHEMA = headless_final_validate("binary_sensor")
SDL_KEYS = (
"SDLK_UNKNOWN",
"SDLK_RETURN",
"SDLK_ESCAPE",
"SDLK_BACKSPACE",
"SDLK_TAB",
"SDLK_SPACE",
"SDLK_EXCLAIM",
"SDLK_QUOTEDBL",
"SDLK_HASH",
"SDLK_PERCENT",
"SDLK_DOLLAR",
"SDLK_AMPERSAND",
"SDLK_QUOTE",
"SDLK_LEFTPAREN",
"SDLK_RIGHTPAREN",
"SDLK_ASTERISK",
"SDLK_PLUS",
"SDLK_COMMA",
"SDLK_MINUS",
"SDLK_PERIOD",
"SDLK_SLASH",
"SDLK_0",
"SDLK_1",
"SDLK_2",
"SDLK_3",
"SDLK_4",
"SDLK_5",
"SDLK_6",
"SDLK_7",
"SDLK_8",
"SDLK_9",
"SDLK_COLON",
"SDLK_SEMICOLON",
"SDLK_LESS",
"SDLK_EQUALS",
"SDLK_GREATER",
"SDLK_QUESTION",
"SDLK_AT",
"SDLK_LEFTBRACKET",
"SDLK_BACKSLASH",
"SDLK_RIGHTBRACKET",
"SDLK_CARET",
"SDLK_UNDERSCORE",
"SDLK_BACKQUOTE",
"SDLK_a",
"SDLK_b",
"SDLK_c",
"SDLK_d",
"SDLK_e",
"SDLK_f",
"SDLK_g",
"SDLK_h",
"SDLK_i",
"SDLK_j",
"SDLK_k",
"SDLK_l",
"SDLK_m",
"SDLK_n",
"SDLK_o",
"SDLK_p",
"SDLK_q",
"SDLK_r",
"SDLK_s",
"SDLK_t",
"SDLK_u",
"SDLK_v",
"SDLK_w",
"SDLK_x",
"SDLK_y",
"SDLK_z",
"SDLK_CAPSLOCK",
"SDLK_F1",
"SDLK_F2",
"SDLK_F3",
"SDLK_F4",
"SDLK_F5",
"SDLK_F6",
"SDLK_F7",
"SDLK_F8",
"SDLK_F9",
"SDLK_F10",
"SDLK_F11",
"SDLK_F12",
"SDLK_PRINTSCREEN",
"SDLK_SCROLLLOCK",
"SDLK_PAUSE",
"SDLK_INSERT",
"SDLK_HOME",
"SDLK_PAGEUP",
"SDLK_DELETE",
"SDLK_END",
"SDLK_PAGEDOWN",
"SDLK_RIGHT",
"SDLK_LEFT",
"SDLK_DOWN",
"SDLK_UP",
"SDLK_NUMLOCKCLEAR",
"SDLK_KP_DIVIDE",
"SDLK_KP_MULTIPLY",
"SDLK_KP_MINUS",
"SDLK_KP_PLUS",
"SDLK_KP_ENTER",
"SDLK_KP_1",
"SDLK_KP_2",
"SDLK_KP_3",
"SDLK_KP_4",
"SDLK_KP_5",
"SDLK_KP_6",
"SDLK_KP_7",
"SDLK_KP_8",
"SDLK_KP_9",
"SDLK_KP_0",
"SDLK_KP_PERIOD",
"SDLK_APPLICATION",
"SDLK_POWER",
"SDLK_KP_EQUALS",
"SDLK_F13",
"SDLK_F14",
"SDLK_F15",
"SDLK_F16",
"SDLK_F17",
"SDLK_F18",
"SDLK_F19",
"SDLK_F20",
"SDLK_F21",
"SDLK_F22",
"SDLK_F23",
"SDLK_F24",
"SDLK_EXECUTE",
"SDLK_HELP",
"SDLK_MENU",
"SDLK_SELECT",
"SDLK_STOP",
"SDLK_AGAIN",
"SDLK_UNDO",
"SDLK_CUT",
"SDLK_COPY",
"SDLK_PASTE",
"SDLK_FIND",
"SDLK_MUTE",
"SDLK_VOLUMEUP",
"SDLK_VOLUMEDOWN",
"SDLK_KP_COMMA",
"SDLK_KP_EQUALSAS400",
"SDLK_ALTERASE",
"SDLK_SYSREQ",
"SDLK_CANCEL",
"SDLK_CLEAR",
"SDLK_PRIOR",
"SDLK_RETURN2",
"SDLK_SEPARATOR",
"SDLK_OUT",
"SDLK_OPER",
"SDLK_CLEARAGAIN",
"SDLK_CRSEL",
"SDLK_EXSEL",
"SDLK_KP_00",
"SDLK_KP_000",
"SDLK_THOUSANDSSEPARATOR",
"SDLK_DECIMALSEPARATOR",
"SDLK_CURRENCYUNIT",
"SDLK_CURRENCYSUBUNIT",
"SDLK_KP_LEFTPAREN",
"SDLK_KP_RIGHTPAREN",
"SDLK_KP_LEFTBRACE",
"SDLK_KP_RIGHTBRACE",
"SDLK_KP_TAB",
"SDLK_KP_BACKSPACE",
"SDLK_KP_A",
"SDLK_KP_B",
"SDLK_KP_C",
"SDLK_KP_D",
"SDLK_KP_E",
"SDLK_KP_F",
"SDLK_KP_XOR",
"SDLK_KP_POWER",
"SDLK_KP_PERCENT",
"SDLK_KP_LESS",
"SDLK_KP_GREATER",
"SDLK_KP_AMPERSAND",
"SDLK_KP_DBLAMPERSAND",
"SDLK_KP_VERTICALBAR",
"SDLK_KP_DBLVERTICALBAR",
"SDLK_KP_COLON",
"SDLK_KP_HASH",
"SDLK_KP_SPACE",
"SDLK_KP_AT",
"SDLK_KP_EXCLAM",
"SDLK_KP_MEMSTORE",
"SDLK_KP_MEMRECALL",
"SDLK_KP_MEMCLEAR",
"SDLK_KP_MEMADD",
"SDLK_KP_MEMSUBTRACT",
"SDLK_KP_MEMMULTIPLY",
"SDLK_KP_MEMDIVIDE",
"SDLK_KP_PLUSMINUS",
"SDLK_KP_CLEAR",
"SDLK_KP_CLEARENTRY",
"SDLK_KP_BINARY",
"SDLK_KP_OCTAL",
"SDLK_KP_DECIMAL",
"SDLK_KP_HEXADECIMAL",
"SDLK_LCTRL",
"SDLK_LSHIFT",
"SDLK_LALT",
"SDLK_LGUI",
"SDLK_RCTRL",
"SDLK_RSHIFT",
"SDLK_RALT",
"SDLK_RGUI",
"SDLK_MODE",
"SDLK_AUDIONEXT",
"SDLK_AUDIOPREV",
"SDLK_AUDIOSTOP",
"SDLK_AUDIOPLAY",
"SDLK_AUDIOMUTE",
"SDLK_MEDIASELECT",
"SDLK_WWW",
"SDLK_MAIL",
"SDLK_CALCULATOR",
"SDLK_COMPUTER",
"SDLK_AC_SEARCH",
"SDLK_AC_HOME",
"SDLK_AC_BACK",
"SDLK_AC_FORWARD",
"SDLK_AC_STOP",
"SDLK_AC_REFRESH",
"SDLK_AC_BOOKMARKS",
"SDLK_BRIGHTNESSDOWN",
"SDLK_BRIGHTNESSUP",
"SDLK_DISPLAYSWITCH",
"SDLK_KBDILLUMTOGGLE",
"SDLK_KBDILLUMDOWN",
"SDLK_KBDILLUMUP",
"SDLK_EJECT",
"SDLK_SLEEP",
"SDLK_APP1",
"SDLK_APP2",
"SDLK_AUDIOREWIND",
"SDLK_AUDIOFASTFORWARD",
"SDLK_SOFTLEFT",
"SDLK_SOFTRIGHT",
"SDLK_CALL",
"SDLK_ENDCALL",
)
SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS}
CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(BinarySensor)
+52 -1
View File
@@ -4,6 +4,7 @@ from typing import Any
import esphome.codegen as cg
from esphome.components import display
from esphome.components.snapshot import Snapshot, register_snapshot
import esphome.config_validation as cv
from esphome.const import (
CONF_DIMENSIONS,
@@ -16,14 +17,21 @@ from esphome.const import (
CONF_Y,
PLATFORM_HOST,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import SDL_KEYMAP
AUTO_LOAD = ["snapshot"]
sdl_ns = cg.esphome_ns.namespace("sdl")
Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component)
Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component, Snapshot)
sdl_window_flags = cg.global_ns.enum("SDL_WindowFlags")
CONF_CENTERED_ON_DISPLAY = "centered_on_display"
CONF_HEADLESS = "headless"
CONF_SNAPSHOT_KEY = "snapshot_key"
CONF_SDL_OPTIONS = "sdl_options"
CONF_SDL_ID = "sdl_id"
CONF_WINDOW_OPTIONS = "window_options"
@@ -67,12 +75,29 @@ def _validate_position(config: dict) -> dict:
raise cv.Invalid("Must specify either 'x' and 'y' or 'centered_on_display'")
def _validate_headless(config: ConfigType) -> ConfigType:
if not config[CONF_HEADLESS]:
return config
if CONF_WINDOW_OPTIONS in config:
raise cv.Invalid(
f"'{CONF_WINDOW_OPTIONS}' has no effect when '{CONF_HEADLESS}' is set - there is no window"
)
if CONF_SNAPSHOT_KEY in config:
raise cv.Invalid(
f"'{CONF_SNAPSHOT_KEY}' cannot be used when '{CONF_HEADLESS}' is set - "
f"there is no keyboard. Use the 'snapshot.take' action instead"
)
return config
CONFIG_SCHEMA = cv.All(
display.FULL_DISPLAY_SCHEMA.extend(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Sdl),
cv.Optional(CONF_SDL_OPTIONS, default=""): get_sdl_options,
cv.Optional(CONF_HEADLESS, default=False): cv.boolean,
cv.Optional(CONF_SNAPSHOT_KEY): cv.enum(SDL_KEYMAP),
cv.Required(CONF_DIMENSIONS): cv.Any(
cv.dimensions,
cv.Schema(
@@ -99,16 +124,42 @@ CONFIG_SCHEMA = cv.All(
}
)
),
_validate_headless,
cv.only_on(PLATFORM_HOST),
)
def headless_final_validate(platform: str) -> cv.Schema:
"""Build a FINAL_VALIDATE_SCHEMA rejecting a platform whose sdl display is headless.
Mouse and keyboard platforms are driven by window events, so under a headless display they
would never report anything.
"""
def validate_display(display_config: ConfigType) -> ConfigType:
if display_config.get(CONF_HEADLESS):
raise cv.Invalid(
f"The sdl {platform} platform needs a window, but its display has "
f"'{CONF_HEADLESS}' set"
)
return display_config
return cv.Schema(
{cv.Required(CONF_SDL_ID): fv.id_declaration_match_schema(validate_display)},
extra=cv.ALLOW_EXTRA,
)
async def to_code(config: ConfigType) -> None:
for option in config[CONF_SDL_OPTIONS].split():
cg.add_build_flag(option)
cg.add_build_flag("-DSDL_BYTEORDER=4321")
var = cg.new_Pvariable(config[CONF_ID])
await display.register_display(var, config)
await register_snapshot(var, config)
cg.add(var.set_headless(config[CONF_HEADLESS]))
if (key := config.get(CONF_SNAPSHOT_KEY)) is not None:
cg.add(var.set_snapshot_key(key))
dimensions = config[CONF_DIMENSIONS]
if isinstance(dimensions, dict):
+228 -46
View File
@@ -2,8 +2,17 @@
#include "sdl_esphome.h"
#include "esphome/components/display/display_color_utils.h"
#include <cstdlib>
namespace esphome::sdl {
namespace {
// Key under which each window keeps a pointer back to its Sdl instance.
constexpr const char *const WINDOW_DATA_KEY = "esphome_sdl";
} // namespace
int Sdl::get_width() {
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
@@ -28,17 +37,96 @@ int Sdl::get_height() {
}
}
void Sdl::setup() {
SDL_Init(SDL_INIT_VIDEO);
this->window_ = SDL_CreateWindow(App.get_name().c_str(), this->pos_x_, this->pos_y_, this->width_, this->height_,
this->window_options_);
this->renderer_ = SDL_CreateRenderer(this->window_, -1, SDL_RENDERER_SOFTWARE);
SDL_RenderSetLogicalSize(this->renderer_, this->width_, this->height_);
void Sdl::destroy_renderer_() {
// Reverse order of creation: the renderer refers to the window or surface it was made from.
if (this->shot_target_ != nullptr) {
SDL_DestroyTexture(this->shot_target_);
this->shot_target_ = nullptr;
}
if (this->texture_ != nullptr) {
SDL_DestroyTexture(this->texture_);
this->texture_ = nullptr;
}
if (this->renderer_ != nullptr) {
SDL_DestroyRenderer(this->renderer_);
this->renderer_ = nullptr;
}
if (this->window_ != nullptr) {
SDL_DestroyWindow(this->window_);
this->window_ = nullptr;
}
if (this->surface_ != nullptr) {
SDL_FreeSurface(this->surface_);
this->surface_ = nullptr;
}
}
bool Sdl::setup_failed_(const char *what) {
ESP_LOGE(TAG, "%s: %s", what, SDL_GetError());
// Give back whatever was created before the failure. Without this a half set up display leaves an
// empty window on screen for the life of the process, still registered as an event target.
this->destroy_renderer_();
return false;
}
bool Sdl::setup_renderer_() {
SDL_SetMainReady();
if (this->headless_) {
// SDL_INIT_VIDEO is deliberately not requested: a software renderer bound to a surface needs no
// video device, so this works on a machine with no display server at all.
if (SDL_Init(0) != 0)
return this->setup_failed_("SDL_Init failed");
this->surface_ = SDL_CreateRGBSurfaceWithFormat(0, this->width_, this->height_, 16, SDL_PIXELFORMAT_RGB565);
if (this->surface_ == nullptr)
return this->setup_failed_("Could not create offscreen surface");
this->renderer_ = SDL_CreateSoftwareRenderer(this->surface_);
} else {
if (SDL_Init(SDL_INIT_VIDEO) != 0)
return this->setup_failed_("SDL_Init failed");
this->window_ = SDL_CreateWindow(App.get_name().c_str(), this->pos_x_, this->pos_y_, this->width_, this->height_,
this->window_options_);
if (this->window_ == nullptr)
return this->setup_failed_("Could not create window");
// Lets loop() find the display an event belongs to, so one display does not act on another's
// input when several windows are open.
SDL_SetWindowData(this->window_, WINDOW_DATA_KEY, this);
this->renderer_ = SDL_CreateRenderer(this->window_, -1, SDL_RENDERER_SOFTWARE);
}
if (this->renderer_ == nullptr)
return this->setup_failed_("Could not create renderer");
if (SDL_RenderSetLogicalSize(this->renderer_, this->width_, this->height_) != 0)
return this->setup_failed_("Could not set renderer logical size");
this->texture_ =
SDL_CreateTexture(this->renderer_, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STATIC, this->width_, this->height_);
SDL_SetTextureBlendMode(this->texture_, SDL_BLENDMODE_BLEND);
if (this->texture_ == nullptr)
return this->setup_failed_("Could not create texture");
// The texture has no alpha channel, so blending is pointless. Headless it would also force a
// different software blit path onto the 16 bit target surface.
if (SDL_SetTextureBlendMode(this->texture_, this->headless_ ? SDL_BLENDMODE_NONE : SDL_BLENDMODE_BLEND) != 0)
return this->setup_failed_("Could not set texture blend mode");
return true;
}
void Sdl::setup() {
if (!this->setup_renderer_()) {
this->mark_failed();
return;
}
if (this->headless_) {
// Nothing generates events, so there is nothing for loop() to do.
this->disable_loop();
} else if (this->snapshot_key_ != 0) {
this->add_key_listener(this->snapshot_key_, [this](bool down) {
if (down && !this->take_snapshot(nullptr)) {
ESP_LOGW(TAG, "snapshot key did not write a file");
}
});
}
}
void Sdl::update() {
if (this->texture_ == nullptr)
return;
this->do_update_();
if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_))
return;
@@ -51,12 +139,19 @@ void Sdl::update() {
}
void Sdl::redraw_(SDL_Rect &rect) {
// Nothing to present when headless - a snapshot blits the whole texture when it needs it, so
// doing it here as well would just burn CPU. draw_pixels_at() calls this on every partial
// update, so it is worth skipping.
if (this->headless_)
return;
SDL_RenderCopy(this->renderer_, this->texture_, &rect, &rect);
SDL_RenderPresent(this->renderer_);
}
void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
if (this->texture_ == nullptr)
return;
SDL_Rect rect{x_start, y_start, w, h};
if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || big_endian) {
Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
@@ -69,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *
}
void Sdl::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
if (this->texture_ == nullptr || !this->get_clipping().inside(x, y))
return;
if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) {
@@ -104,61 +199,148 @@ void Sdl::process_key(uint32_t keycode, bool down) {
callback->second(down);
}
Sdl *Sdl::instance_for_window_(uint32_t window_id) {
SDL_Window *window = SDL_GetWindowFromID(window_id);
if (window == nullptr)
return nullptr;
return static_cast<Sdl *>(SDL_GetWindowData(window, WINDOW_DATA_KEY));
}
void Sdl::handle_event_(const SDL_Event &event) {
switch (event.type) {
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP:
if (event.button.button == 1) {
this->mouse_x = event.button.x;
this->mouse_y = event.button.y;
this->mouse_down = event.button.state != 0;
}
break;
case SDL_MOUSEMOTION:
if (event.motion.state & 1) {
this->mouse_x = event.motion.x;
this->mouse_y = event.motion.y;
this->mouse_down = true;
} else {
this->mouse_down = false;
}
break;
case SDL_KEYDOWN:
// Ignore auto-repeat, otherwise holding a key floods the listeners.
if (event.key.repeat != 0)
break;
ESP_LOGD(TAG, "keydown %d", event.key.keysym.sym);
this->process_key(event.key.keysym.sym, true);
break;
case SDL_KEYUP:
ESP_LOGD(TAG, "keyup %d", event.key.keysym.sym);
this->process_key(event.key.keysym.sym, false);
break;
case SDL_WINDOWEVENT:
switch (event.window.event) {
case SDL_WINDOWEVENT_SIZE_CHANGED:
case SDL_WINDOWEVENT_EXPOSED:
case SDL_WINDOWEVENT_RESIZED: {
SDL_Rect rect{0, 0, this->width_, this->height_};
this->redraw_(rect);
break;
}
default:
break;
}
break;
default:
break;
}
}
void Sdl::loop() {
SDL_Event e;
if (SDL_PollEvent(&e)) {
switch (e.type) {
case SDL_QUIT:
exit(0);
// Take everything that is waiting, not one event per loop. A touch drag produces a burst of
// motion events, and consuming them one at a time lets the queue grow without bound, so the
// pointer ends up acting on input from further and further in the past. Draining collapses a
// burst to the position it ended at, which is the one the user is asking for anyway.
while (SDL_PollEvent(&e)) {
if (e.type == SDL_QUIT)
exit(0);
// Events carry the window they happened in, so send each one to the display that owns it.
uint32_t window_id;
switch (e.type) {
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP:
if (e.button.button == 1) {
this->mouse_x = e.button.x;
this->mouse_y = e.button.y;
this->mouse_down = e.button.state != 0;
}
window_id = e.button.windowID;
break;
case SDL_MOUSEMOTION:
if (e.motion.state & 1) {
this->mouse_x = e.button.x;
this->mouse_y = e.button.y;
this->mouse_down = true;
} else {
this->mouse_down = false;
}
window_id = e.motion.windowID;
break;
case SDL_KEYDOWN:
ESP_LOGD(TAG, "keydown %d", e.key.keysym.sym);
this->process_key(e.key.keysym.sym, true);
break;
case SDL_KEYUP:
ESP_LOGD(TAG, "keyup %d", e.key.keysym.sym);
this->process_key(e.key.keysym.sym, false);
window_id = e.key.windowID;
break;
case SDL_WINDOWEVENT:
switch (e.window.event) {
case SDL_WINDOWEVENT_SIZE_CHANGED:
case SDL_WINDOWEVENT_EXPOSED:
case SDL_WINDOWEVENT_RESIZED: {
SDL_Rect rect{0, 0, this->width_, this->height_};
this->redraw_(rect);
break;
}
default:
break;
}
window_id = e.window.windowID;
break;
default:
// Anything else, including the touch events SDL reports alongside the mouse events it
// synthesises from them, is not used here.
ESP_LOGV(TAG, "Event %d", e.type);
break;
continue;
}
Sdl *target = instance_for_window_(window_id);
if (target == nullptr) {
// Nothing to route this to: the window has gone, or it is not one of ours. Say so, otherwise
// input that stops working leaves no trace at all.
ESP_LOGV(TAG, "Event %d for unknown window %u", e.type, window_id);
continue;
}
target->handle_event_(e);
}
}
bool Sdl::capture_bgr(uint8_t *dest, size_t row_stride) {
if (this->texture_ == nullptr || this->renderer_ == nullptr) {
ESP_LOGE(TAG, "Snapshot requested but SDL is not set up");
return false;
}
if (this->shot_target_ == nullptr) {
this->shot_target_ = SDL_CreateTexture(this->renderer_, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_TARGET,
this->width_, this->height_);
if (this->shot_target_ == nullptr) {
ESP_LOGE(TAG, "Could not create capture texture: %s", SDL_GetError());
return false;
}
SDL_SetTextureBlendMode(this->shot_target_, SDL_BLENDMODE_NONE);
}
// Render into an offscreen target first. SDL_RenderReadPixels works in physical output pixels and
// ignores the logical size, so reading straight off a resizable window would read more pixels than
// there is room for.
// Every step is checked: a failed clear or copy would otherwise be read back as a blank or stale
// picture, written out, and reported as a snapshot that worked.
bool ok = false;
if (SDL_SetRenderTarget(this->renderer_, this->shot_target_) == 0) {
ok = SDL_SetRenderDrawColor(this->renderer_, 0, 0, 0, SDL_ALPHA_OPAQUE) == 0 &&
SDL_RenderClear(this->renderer_) == 0 &&
SDL_RenderCopy(this->renderer_, this->texture_, nullptr, nullptr) == 0 &&
SDL_RenderReadPixels(this->renderer_, nullptr, SDL_PIXELFORMAT_BGR24, dest, static_cast<int>(row_stride)) == 0;
if (SDL_SetRenderTarget(this->renderer_, nullptr) != 0) {
// Stuck rendering into shot_target_ from here on, so there's no point continuing.
ESP_LOGE(TAG, "Could not restore the render target: %s", SDL_GetError());
this->mark_failed();
return false;
}
}
if (!ok) {
ESP_LOGE(TAG, "Could not capture the screen: %s", SDL_GetError());
}
return ok;
}
} // namespace esphome::sdl
+30 -5
View File
@@ -1,10 +1,12 @@
#pragma once
#ifdef USE_HOST
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/components/display/display.h"
#include "esphome/components/snapshot/snapshot.h"
#define SDL_MAIN_HANDLED
#include "SDL.h"
#include <map>
@@ -13,7 +15,7 @@ namespace esphome::sdl {
constexpr static const char *const TAG = "sdl";
class Sdl final : public display::Display {
class Sdl final : public display::Display, public snapshot::Snapshot {
public:
display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; }
void update() override;
@@ -32,6 +34,9 @@ class Sdl final : public display::Display {
this->pos_x_ = pos_x;
this->pos_y_ = pos_y;
}
void set_headless(bool headless) { this->headless_ = headless; }
void set_snapshot_key(int32_t keycode) { this->snapshot_key_ = keycode; }
int get_width() override;
int get_height() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
@@ -51,20 +56,40 @@ class Sdl final : public display::Display {
int get_width_internal() override { return this->width_; }
int get_height_internal() override { return this->height_; }
void redraw_(SDL_Rect &rect);
bool setup_renderer_();
/// Release the window, surface, renderer and textures, and forget them.
void destroy_renderer_();
/// Log an SDL failure during setup, release anything already created, and return false.
bool setup_failed_(const char *what);
int snapshot_width() override { return this->width_; }
int snapshot_height() override { return this->height_; }
bool capture_bgr(uint8_t *dest, size_t row_stride) override;
void handle_event_(const SDL_Event &event);
/// The display owning the given window, or nullptr if it is not one of ours.
static Sdl *instance_for_window_(uint32_t window_id);
SDL_Renderer *renderer_{};
SDL_Window *window_{};
SDL_Texture *texture_{};
// Offscreen render target used when headless. SDL_CreateSoftwareRenderer only borrows the
// surface, and the renderer goes back to using it as its output whenever the capture target is
// released, so it has to stay alive as long as the renderer does.
SDL_Surface *surface_{};
// Capture target, created on first snapshot.
SDL_Texture *shot_target_{};
std::map<int32_t, CallbackManager<void(bool)>> key_callbacks_{};
int width_{};
int height_{};
uint32_t window_options_{0};
int32_t pos_x_{SDL_WINDOWPOS_UNDEFINED};
int32_t pos_y_{SDL_WINDOWPOS_UNDEFINED};
SDL_Renderer *renderer_{};
SDL_Window *window_{};
SDL_Texture *texture_{};
int32_t snapshot_key_{0};
uint16_t x_low_{0};
uint16_t y_low_{0};
uint16_t x_high_{0};
uint16_t y_high_{0};
std::map<int32_t, CallbackManager<void(bool)>> key_callbacks_{};
bool headless_{false};
};
} // namespace esphome::sdl
#endif
@@ -4,10 +4,12 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from ..display import CONF_SDL_ID, Sdl, sdl_ns
from ..display import CONF_SDL_ID, Sdl, headless_final_validate, sdl_ns
SdlTouchscreen = sdl_ns.class_("SdlTouchscreen", touchscreen.Touchscreen)
FINAL_VALIDATE_SCHEMA = headless_final_validate("touchscreen")
CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend(
{
@@ -31,6 +31,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
require_tx=True,
require_rx=True,
baud_rate=115200,
data_bits=8,
parity="NONE",
stop_bits=1,
)
@@ -33,8 +33,6 @@ void MR60FDA2Component::dump_config() {
// Initialisation functions
void MR60FDA2Component::setup() {
this->check_uart_settings(115200);
this->current_frame_locate_ = LOCATE_FRAME_HEADER;
this->current_frame_id_ = 0;
this->current_frame_len_ = 0;
@@ -130,17 +130,26 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
// Apply validated parameters
uart_comp->set_baud_rate(baudrate);
uart_comp->set_stop_bits(stop_bits);
uart_comp->set_data_bits(data_size);
// Map parity value to UARTParityOptions
// Skip a no-op reconfigure. Clients routinely re-send identical settings on every
// port open, and on a USB UART each apply is a CDC SET_LINE_CODING control transfer.
// Some bridges watch line-coding changes as a signalling channel (a magic baud
// sequence to enter a bootloader, say), so redundant applies are not harmless.
static const uart::UARTParityOptions PARITY_MAP[] = {
uart::UART_CONFIG_PARITY_NONE,
uart::UART_CONFIG_PARITY_EVEN,
uart::UART_CONFIG_PARITY_ODD,
};
if (uart_comp->get_baud_rate() == baudrate && uart_comp->get_stop_bits() == stop_bits &&
uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity]) {
ESP_LOGV(TAG, "Settings unchanged, skipping reconfigure [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
// Apply validated parameters
uart_comp->set_baud_rate(baudrate);
uart_comp->set_stop_bits(stop_bits);
uart_comp->set_data_bits(data_size);
uart_comp->set_parity(PARITY_MAP[parity]);
// load_settings() is available on ESP8266 and ESP32 platforms
+7 -1
View File
@@ -68,7 +68,13 @@ CONFIG_SCHEMA = (
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"smt100", baud_rate=9600, require_rx=True, require_tx=True
"smt100",
baud_rate=9600,
require_rx=True,
require_tx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
-1
View File
@@ -65,7 +65,6 @@ void SMT100Component::dump_config() {
LOG_SENSOR(TAG, "Temperature", this->temperature_sensor_);
LOG_SENSOR(TAG, "Moisture", this->moisture_sensor_);
LOG_UPDATE_INTERVAL(this);
this->check_uart_settings(9600);
}
int SMT100Component::readline_(int readch, char *buffer, int len) {
+76
View File
@@ -0,0 +1,76 @@
"""Shared support for writing what a display is showing out to an image file.
The component itself has no configuration. It provides the ``snapshot.take`` action and the C++
base class behind it, so any display that can hand over its pixels - the in memory display in this
component, or an SDL window - saves files the same way, under the same directory, with the same
rules about names.
"""
from dataclasses import dataclass
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@clydebarrow"]
DOMAIN = "snapshot"
CONF_FILENAME = "filename"
snapshot_ns = cg.esphome_ns.namespace("snapshot")
Snapshot = snapshot_ns.class_("Snapshot")
SnapshotAction = snapshot_ns.class_("SnapshotAction", automation.Action)
@automation.register_action(
"snapshot.take",
SnapshotAction,
automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Snapshot),
cv.Optional(CONF_FILENAME): cv.templatable(cv.string),
}
),
synchronous=True,
)
async def snapshot_take_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
if (filename := config.get(CONF_FILENAME)) is not None:
cg.add(var.set_filename(await cg.templatable(filename, args, cg.std_string)))
return var
@dataclass
class SnapshotData:
directory_defined: bool = False
def _get_data() -> SnapshotData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = SnapshotData()
return CORE.data[DOMAIN]
async def register_snapshot(var: MockObj, config: ConfigType) -> None:
"""Set up a component so that the snapshot action can write its picture to a file."""
data = _get_data()
# Only once, however many displays there are: two defines that say the same thing do not
# compare equal, so asking for this per display repeats the line in defines.h.
if not data.directory_defined:
data.directory_defined = True
cg.add_define(
"ESPHOME_SNAPSHOT_DIR",
(CORE.data_dir / "snapshots" / CORE.name).as_posix(),
)
cg.add(var.set_snapshot_prefix(str(config[CONF_ID])))
@@ -0,0 +1,61 @@
import esphome.codegen as cg
from esphome.components import display
import esphome.config_validation as cv
from esphome.const import (
CONF_DIMENSIONS,
CONF_HEIGHT,
CONF_ID,
CONF_LAMBDA,
CONF_WIDTH,
PLATFORM_HOST,
)
from esphome.types import ConfigType
from .. import Snapshot, register_snapshot, snapshot_ns
# The base class and the file writing live in the parent component, which nothing else in a
# configuration using only this platform would pull in.
AUTO_LOAD = ["snapshot"]
SnapshotDisplay = snapshot_ns.class_(
"SnapshotDisplay", display.DisplayBuffer, cg.Component, Snapshot
)
CONFIG_SCHEMA = cv.All(
display.FULL_DISPLAY_SCHEMA.extend(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(SnapshotDisplay),
cv.Required(CONF_DIMENSIONS): cv.Any(
cv.dimensions,
cv.Schema(
{
cv.Required(CONF_WIDTH): cv.positive_not_null_int,
cv.Required(CONF_HEIGHT): cv.positive_not_null_int,
}
),
),
}
)
),
cv.only_on(PLATFORM_HOST),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await display.register_display(var, config)
await register_snapshot(var, config)
dimensions = config[CONF_DIMENSIONS]
if isinstance(dimensions, dict):
cg.add(var.set_dimensions(dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT]))
else:
(width, height) = dimensions
cg.add(var.set_dimensions(width, height))
if lamb := config.get(CONF_LAMBDA):
lambda_ = await cg.process_lambda(
lamb, [(display.DisplayRef, "it")], return_type=cg.void
)
cg.add(var.set_writer(lambda_))
@@ -0,0 +1,80 @@
#ifdef USE_HOST
#include "snapshot_display.h"
#include "esphome/components/display/display_color_utils.h"
#include "esphome/core/log.h"
#include <cstring>
namespace esphome::snapshot {
static const char *const TAG = "snapshot.display";
namespace {
/// Spread a channel that only goes up to `max` over the whole 0 to 255 range, so that the
/// brightest value stays the brightest. This is the same arithmetic SDL uses, which is what makes
/// a picture taken here come out identical to the same picture taken from an SDL window.
constexpr uint8_t expand_channel(uint16_t value, uint16_t max) { return static_cast<uint8_t>(value * 255 / max); }
constexpr uint16_t RED_MAX = 0x1F;
constexpr uint16_t GREEN_MAX = 0x3F;
constexpr uint16_t BLUE_MAX = 0x1F;
} // namespace
void SnapshotDisplay::setup() {
this->init_internal_(static_cast<uint32_t>(this->width_) * this->height_ * 2);
if (this->buffer_ == nullptr) {
this->mark_failed(LOG_STR("Could not allocate display buffer"));
}
}
void SnapshotDisplay::dump_config() { LOG_DISPLAY("", "Snapshot", this); }
void SnapshotDisplay::draw_absolute_pixel_internal(int x, int y, Color color) {
if (this->buffer_ == nullptr || x < 0 || x >= this->width_ || y < 0 || y >= this->height_)
return;
this->pixels_()[y * this->width_ + x] = display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB);
}
void SnapshotDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr,
display::ColorOrder order, display::ColorBitness bitness, bool big_endian,
int x_offset, int y_offset, int x_pad) {
if (this->buffer_ == nullptr)
return;
// Anything that is not already laid out the way the buffer is, or that would reach outside it,
// goes through the base class, which turns it into one call per pixel with the bounds checked.
const bool copyable = this->rotation_ == display::DISPLAY_ROTATION_0_DEGREES &&
bitness == display::COLOR_BITNESS_565 && !big_endian && x_start >= 0 && y_start >= 0 &&
x_start + w <= this->width_ && y_start + h <= this->height_;
if (!copyable) {
DisplayBuffer::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
return;
}
const size_t stride = static_cast<size_t>(x_offset) + w + x_pad;
const uint8_t *src = ptr + (stride * y_offset + x_offset) * 2;
for (int y = 0; y != h; y++) {
memcpy(&this->pixels_()[(y_start + y) * this->width_ + x_start], src + y * stride * 2, w * 2);
}
}
bool SnapshotDisplay::capture_bgr(uint8_t *dest, size_t row_stride) {
if (this->buffer_ == nullptr) {
ESP_LOGE(TAG, "Snapshot requested but there is no buffer to read");
return false;
}
const uint16_t *src = this->pixels_();
for (int y = 0; y != this->height_; y++) {
uint8_t *out = dest + y * row_stride;
for (int x = 0; x != this->width_; x++) {
const uint16_t pixel = *src++;
*out++ = expand_channel(pixel & BLUE_MAX, BLUE_MAX);
*out++ = expand_channel((pixel >> 5) & GREEN_MAX, GREEN_MAX);
*out++ = expand_channel(pixel >> 11, RED_MAX);
}
}
return true;
}
} // namespace esphome::snapshot
#endif
@@ -0,0 +1,48 @@
#pragma once
#ifdef USE_HOST
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/snapshot/snapshot.h"
#include "esphome/core/component.h"
namespace esphome::snapshot {
/// A display with nowhere to show anything: it keeps the picture in memory, where the snapshot
/// action can pick it up. That makes it a way to see what a configuration draws on a machine with
/// no screen, and to check the result in a test.
class SnapshotDisplay final : public display::DisplayBuffer, public Snapshot {
public:
void setup() override;
void update() override { this->do_update_(); }
void dump_config() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; }
void set_dimensions(uint16_t width, uint16_t height) {
this->width_ = width;
this->height_ = height;
}
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override;
int get_width_internal() override { return this->width_; }
int get_height_internal() override { return this->height_; }
int snapshot_width() override { return this->width_; }
int snapshot_height() override { return this->height_; }
bool capture_bgr(uint8_t *dest, size_t row_stride) override;
/// The picture, one 16 bit RGB565 value per pixel, topmost row first. Owned by DisplayBuffer as
/// a byte pointer; this is the same memory seen as what is actually stored in it.
uint16_t *pixels_() { return reinterpret_cast<uint16_t *>(this->buffer_); }
int width_{};
int height_{};
};
} // namespace esphome::snapshot
#endif
+248
View File
@@ -0,0 +1,248 @@
#ifdef USE_HOST
#include "snapshot.h"
#include "esphome/core/log.h"
#include <fcntl.h>
#include <strings.h>
#include <unistd.h>
#include <cctype>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <memory>
namespace esphome::snapshot {
namespace {
constexpr const char *const TAG = "snapshot";
// Longest name we will build a path from. NAME_MAX is 255 and we may append a collision suffix.
constexpr size_t MAX_NAME_LENGTH = 200;
// Give up rather than spin forever if every candidate name is taken.
constexpr unsigned MAX_NAME_ATTEMPTS = 1000;
// A BMP file header followed by a BITMAPINFOHEADER, which is where the pixels start.
constexpr size_t BMP_HEADER_SIZE = 54;
constexpr size_t BMP_INFO_HEADER_SIZE = 40;
constexpr int BMP_BITS_PER_PIXEL = 24;
/// True if the name already ends in ".bmp". The comparison ignores case, so "shot.BMP" is left
/// alone rather than turned into "shot.BMP.bmp".
bool has_bmp_suffix(const std::string &name) {
return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, ".bmp") == 0;
}
/// Reduce a user supplied name to a single safe path component. Everything outside the allowed set
/// is replaced, so "..", "/" and absolute paths cannot escape the snapshot directory.
/// Returns an empty string if nothing usable is left.
std::string sanitise_filename(const char *const name, bool *name_changed) {
std::string result;
bool all_dots = true;
bool changed = false;
for (const char *p = name; *p != '\0'; p++) {
if (result.size() >= MAX_NAME_LENGTH) {
changed = true;
break;
}
char c = *p;
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '.' || c == '_' || c == '-')) {
c = '_';
changed = true;
}
if (c != '.')
all_dots = false;
result.push_back(c);
}
if (all_dots) {
*name_changed = true;
return "";
}
if (!has_bmp_suffix(result))
result += ".bmp";
*name_changed = changed;
return result;
}
/// Insert "-<attempt>" before the file extension, e.g. "shot.bmp" -> "shot-1.bmp".
std::string add_suffix(const std::string &name, unsigned attempt) {
char suffix[12];
snprintf(suffix, sizeof(suffix), "-%u", attempt);
auto dot = name.rfind('.');
if (dot == std::string::npos)
return name + suffix;
return name.substr(0, dot) + suffix + name.substr(dot);
}
/// Directory snapshots are written to. The environment variable lets a test redirect output
/// without rebuilding, matching how the host platform handles ESPHOME_PREFDIR.
const char *snapshot_dir() {
const char *dir = getenv("ESPHOME_SNAPSHOT_DIR"); // NOLINT(concurrency-mt-unsafe)
return dir != nullptr && dir[0] != '\0' ? dir : ESPHOME_SNAPSHOT_DIR;
}
/// Store a value in as many bytes, least significant first, and step the pointer past it.
/// BMP is a little endian format whatever the machine writing it uses.
void put_le(uint8_t *&dest, uint32_t value, size_t bytes) {
for (size_t i = 0; i != bytes; i++)
*dest++ = static_cast<uint8_t>(value >> (8 * i));
}
/// The number of bytes one row of `width` pixels takes up in the file. Rows are padded out to a
/// multiple of four bytes.
size_t bmp_row_size(int width) { return (static_cast<size_t>(width) * 3 + 3) & ~size_t{3}; }
/// Write pixels out as a 24 bit BMP. The rows given start with the topmost and are `row_stride`
/// bytes apart, which must leave room for a whole padded row; a BMP holds its rows the other way
/// up, so they go out last first.
bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t row_stride) {
const size_t row_size = bmp_row_size(width);
const size_t pixel_bytes = row_size * height;
uint8_t header[BMP_HEADER_SIZE];
uint8_t *pos = header;
*pos++ = 'B';
*pos++ = 'M';
put_le(pos, static_cast<uint32_t>(BMP_HEADER_SIZE + pixel_bytes), 4);
put_le(pos, 0, 4); // reserved
put_le(pos, BMP_HEADER_SIZE, 4);
put_le(pos, BMP_INFO_HEADER_SIZE, 4);
put_le(pos, static_cast<uint32_t>(width), 4);
put_le(pos, static_cast<uint32_t>(height), 4);
put_le(pos, 1, 2); // one plane
put_le(pos, BMP_BITS_PER_PIXEL, 2);
put_le(pos, 0, 4); // not compressed
put_le(pos, static_cast<uint32_t>(pixel_bytes), 4);
put_le(pos, 0, 4); // pixels per metre across, unspecified
put_le(pos, 0, 4); // pixels per metre down, unspecified
put_le(pos, 0, 4); // no palette
put_le(pos, 0, 4); // so no palette entry matters more than another
if (fwrite(header, 1, sizeof(header), file) != sizeof(header))
return false;
for (int y = height - 1; y >= 0; y--) {
if (fwrite(pixels + static_cast<size_t>(y) * row_stride, 1, row_size, file) != row_size)
return false;
}
return true;
}
/// Reserve a name in the snapshot directory and write the picture to it.
/// With `exact` set the given name is the only one tried; otherwise a number is added on
/// collision. Returns true if a file was written.
bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name,
bool exact) {
const std::string dir = snapshot_dir();
std::error_code ec;
std::filesystem::create_directories(dir, ec);
if (ec) {
ESP_LOGE(TAG, "Could not create snapshot directory %s: %s", dir.c_str(), ec.message().c_str());
return false;
}
// O_EXCL guarantees we never write over a file that is already there.
std::string path;
int fd = -1;
for (unsigned attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
path = dir + "/" + (attempt == 0 ? name : add_suffix(name, attempt));
fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0644);
if (fd >= 0)
break;
if (errno != EEXIST) {
ESP_LOGE(TAG, "Could not create %s: %s", path.c_str(), strerror(errno));
return false;
}
if (exact) {
// The caller asked for this exact name, so silently writing somewhere else would be worse
// than failing - a test asserting on the path would pick up a stale file.
ESP_LOGE(TAG, "Snapshot %s already exists, not overwriting", path.c_str());
return false;
}
}
if (fd < 0) {
ESP_LOGE(TAG, "Could not find an unused name for %s in %s", name.c_str(), dir.c_str());
return false;
}
FILE *file = fdopen(fd, "wb");
if (file == nullptr) {
ESP_LOGE(TAG, "Could not open %s: %s", path.c_str(), strerror(errno));
::close(fd);
::unlink(path.c_str());
return false;
}
bool ok = write_bmp(file, pixels, width, height, row_stride);
int saved_errno = ok ? 0 : errno;
// Closing can fail in its own right - the last of the data is still on its way out.
if (fclose(file) != 0) {
if (ok)
saved_errno = errno;
ok = false;
}
if (!ok) {
ESP_LOGE(TAG, "Could not write %s: %s", path.c_str(), strerror(saved_errno));
// Leave no truncated file behind - it would block a retry under the same name.
::unlink(path.c_str());
return false;
}
ESP_LOGI(TAG, "Snapshot written to %s", path.c_str());
return true;
}
} // namespace
// helper function since ESP_LOGW is disallowed in a header file
void Snapshot::log_action_failed() { ESP_LOGW(TAG, "snapshot.take did not write a file"); }
bool Snapshot::take_snapshot(const char *filename) {
const int width = this->snapshot_width();
const int height = this->snapshot_height();
if (width <= 0 || height <= 0) {
ESP_LOGE(TAG, "Snapshot requested but the display is %dx%d", width, height);
return false;
}
std::string name;
bool exact = false;
if (filename != nullptr) {
bool name_changed = false;
name = sanitise_filename(filename, &name_changed);
exact = !name.empty();
if (name_changed) {
ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename,
name.empty() ? "a name made from the time" : name.c_str());
}
}
if (name.empty()) {
struct timespec now {};
if (clock_gettime(CLOCK_REALTIME, &now) != 0)
now = {};
struct tm tm_buf {};
if (localtime_r(&now.tv_sec, &tm_buf) == nullptr)
tm_buf = {};
char stamp[32]{};
// ::strftime to be sure of the one from <ctime>; display has an unrelated member of that name
if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0)
snprintf(stamp, sizeof(stamp), "unknown-time");
char buffer[MAX_NAME_LENGTH];
int written =
snprintf(buffer, sizeof(buffer), "%s-%s-%03ld.bmp", this->snapshot_prefix_, stamp, now.tv_nsec / 1000000);
if (written < 0 || static_cast<size_t>(written) >= sizeof(buffer)) {
ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback");
snprintf(buffer, sizeof(buffer), "snapshot.bmp");
}
name = buffer;
}
// Rows are padded out to a multiple of four bytes, as the file wants them, so each one can be
// written straight from the buffer. Zeroed on allocation, which is what the padding must be.
const size_t row_stride = bmp_row_size(width);
auto pixels = std::make_unique<uint8_t[]>(row_stride * height);
if (!this->capture_bgr(pixels.get(), row_stride))
return false;
return write_snapshot_file(pixels.get(), width, height, row_stride, name, exact);
}
} // namespace esphome::snapshot
#endif
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#ifdef USE_HOST
#include "esphome/core/automation.h"
#include <cstddef>
#include <cstdint>
#include <string>
// Directory snapshots are written to. Normally set by codegen to a folder under .esphome; the
// fallback keeps the component compiling for static analysis, where no defines.h is generated.
#ifndef ESPHOME_SNAPSHOT_DIR
#define ESPHOME_SNAPSHOT_DIR "."
#endif
namespace esphome::snapshot {
/// Base for anything that can hand over the picture it is showing so it can be written to a file.
///
/// A subclass says how big the picture is and fills in the pixels. Everything else - picking a
/// name, staying inside the snapshot directory, not writing over anything, and encoding the file -
/// is done here, so every component that can take a snapshot behaves the same way.
class Snapshot {
public:
virtual ~Snapshot() = default;
/// Set the word generated names start with. Codegen passes the component id, so with more than
/// one display in a device it is clear which one a file came from.
void set_snapshot_prefix(const char *prefix) { this->snapshot_prefix_ = prefix; }
/// Write the current picture to a BMP file in the snapshot directory.
///
/// Pass nullptr to have a name made up from the prefix and the current time. A file that is
/// already there is never written over. Returns true if a file was written.
bool take_snapshot(const char *filename);
/// Log that an action-triggered snapshot did not write a file.
static void log_action_failed();
protected:
/// Width of the picture in pixels.
virtual int snapshot_width() = 0;
/// Height of the picture in pixels.
virtual int snapshot_height() = 0;
/// Fill in the picture: three bytes per pixel in blue, green, red order, topmost row first, with
/// `row_stride` bytes from the start of one row to the start of the next. Returns false, having
/// logged why, if the picture could not be read.
virtual bool capture_bgr(uint8_t *dest, size_t row_stride) = 0;
const char *snapshot_prefix_{"snapshot"};
};
template<typename... Ts> class SnapshotAction final : public Action<Ts...>, public Parented<Snapshot> {
public:
TEMPLATABLE_VALUE(std::string, filename)
protected:
void play(const Ts &...x) override {
bool ok;
if (this->filename_.has_value()) {
ok = this->parent_->take_snapshot(this->filename_.value(x...).c_str());
} else {
ok = this->parent_->take_snapshot(nullptr);
}
if (!ok)
this->parent_->log_action_failed();
}
};
} // namespace esphome::snapshot
#endif
+7 -1
View File
@@ -33,7 +33,13 @@ CONFIG_SCHEMA = (
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"t6615", baud_rate=19200, require_rx=True, require_tx=True
"t6615",
baud_rate=19200,
require_rx=True,
require_tx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
-1
View File
@@ -88,7 +88,6 @@ void T6615Component::query_ppm_() {
void T6615Component::dump_config() {
ESP_LOGCONFIG(TAG, "T6615:");
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
this->check_uart_settings(19200);
}
} // namespace esphome::t6615
+16
View File
@@ -35,6 +35,22 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
# Historical mode runs at 1200 baud, standard mode at 9600 baud.
baud_rate = 1200 if config[CONF_HISTORICAL_MODE] else 9600
uart.final_validate_device_schema(
"teleinfo",
baud_rate=baud_rate,
data_bits=7,
parity="EVEN",
stop_bits=1,
)(config)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE])
await cg.register_component(var, config)
+1 -6
View File
@@ -184,10 +184,7 @@ void TeleInfo::publish_value_(const std::string &tag, const std::string &val) {
element->publish_val(val);
}
}
void TeleInfo::dump_config() {
ESP_LOGCONFIG(TAG, "TeleInfo:");
this->check_uart_settings(baud_rate_, 1, uart::UART_CONFIG_PARITY_EVEN, 7);
}
void TeleInfo::dump_config() { ESP_LOGCONFIG(TAG, "TeleInfo:"); }
TeleInfo::TeleInfo(bool historical_mode) {
if (historical_mode) {
/*
@@ -195,11 +192,9 @@ TeleInfo::TeleInfo(bool historical_mode) {
*/
checksum_area_end_ = 2;
separator_ = 0x20;
baud_rate_ = 1200;
} else {
checksum_area_end_ = 1;
separator_ = 0x9;
baud_rate_ = 9600;
}
}
void TeleInfo::register_teleinfo_listener(TeleInfoListener *listener) { teleinfo_listeners_.push_back(listener); }
-1
View File
@@ -31,7 +31,6 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice {
std::vector<TeleInfoListener *> teleinfo_listeners_{};
protected:
uint32_t baud_rate_;
int checksum_area_end_;
int separator_;
char buf_[MAX_BUF_SIZE];
@@ -36,8 +36,6 @@ cover::CoverTraits Tormatic::get_traits() {
void Tormatic::dump_config() {
LOG_COVER("", "Tormatic Cover", this);
this->check_uart_settings(9600, 1, uart::UART_CONFIG_PARITY_NONE, 8);
ESP_LOGCONFIG(TAG,
" Open Duration: %.1fs\n"
" Close Duration: %.1fs",
+2
View File
@@ -3,6 +3,7 @@
#include <vector>
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "uart_component.h"
@@ -66,6 +67,7 @@ class UARTDevice {
}
/// Check that the configuration of the UART bus matches the provided values and otherwise print a warning
ESPDEPRECATED("Use uart.final_validate_device_schema() in Python instead. Removed in 2027.3.0", "2026.9.0")
void check_uart_settings(uint32_t baud_rate, uint8_t stop_bits = 1,
UARTParityOptions parity = UART_CONFIG_PARITY_NONE, uint8_t data_bits = 8);
+1
View File
@@ -30,6 +30,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
require_tx=True,
require_rx=True,
baud_rate=2400,
data_bits=8,
parity="EVEN",
stop_bits=1,
)
-1
View File
@@ -213,7 +213,6 @@ void UFM01Component::dump_config() {
LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_);
#endif
this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8);
}
void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) {
@@ -50,7 +50,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
require_tx=True,
require_rx=True,
data_bits=8,
parity=None,
parity="NONE",
stop_bits=1,
)
@@ -29,8 +29,6 @@ void UponorSmatrixComponent::dump_config() {
}
#endif
this->check_uart_settings(19200);
if (!this->unknown_devices_.empty()) {
ESP_LOGCONFIG(TAG, " Detected unknown device addresses:");
for (auto device_address : this->unknown_devices_) {
+8
View File
@@ -29,6 +29,14 @@ CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend(
}
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"vbus",
baud_rate=9600,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
+1 -4
View File
@@ -11,10 +11,7 @@ static const char *const TAG = "vbus";
// Maximum bytes to log in verbose hex output (16 frames * 4 bytes = 64 bytes typical)
static constexpr size_t VBUS_MAX_LOG_BYTES = 64;
void VBus::dump_config() {
ESP_LOGCONFIG(TAG, "VBus:");
check_uart_settings(9600);
}
void VBus::dump_config() { ESP_LOGCONFIG(TAG, "VBus:"); }
static void septet_spread(uint8_t *data, int start, int count, uint8_t septet) {
for (int i = 0; i < count; i++, septet >>= 1) {
+8
View File
@@ -21,6 +21,14 @@ CONFIG_SCHEMA = (
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"wl_134",
baud_rate=9600,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
-2
View File
@@ -110,7 +110,5 @@ uint64_t Wl134Component::hex_lsb_ascii_to_uint64_(const uint8_t *text, uint8_t t
void Wl134Component::dump_config() {
ESP_LOGCONFIG(TAG, "WL-134 Sensor:");
LOG_TEXT_SENSOR("", "Tag", this);
// As specified in the sensor's data sheet
this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8);
}
} // namespace esphome::wl_134
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.9.0-dev"
__version__ = "2026.9.0b1"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+1
View File
@@ -13,6 +13,7 @@
#define ESPHOME_PROJECT_VERSION "v2"
#define ESPHOME_PROJECT_VERSION_30 "v2"
#define ESPHOME_VARIANT "ESP32"
#define ESPHOME_SNAPSHOT_DIR "."
#define ESPHOME_NAME_ADD_MAC_SUFFIX
#define ESPHOME_DEBUG_SCHEDULER
#define ESPHOME_DEBUG_API
-17
View File
@@ -1104,10 +1104,6 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
# Any usage form (decorator, pytestmark assignment or list element); only
# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit
_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml")
_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)")
@cache
@@ -1127,19 +1123,6 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
for func in _TEST_FUNC_RE.findall(content):
base_name = func.replace("test_", "").partition("[")[0]
result.setdefault(base_name, set()).add(rel_path)
# Shared fixtures are named by marker, not by a test function; each
# decorator must carry a string literal or its fixture would silently
# map to no tests
for use in _SHARED_YAML_USE_RE.finditer(content):
arg = _SHARED_YAML_ARG_RE.match(content, use.end())
if arg is None:
line = content.count("\n", 0, use.start()) + 1
raise ValueError(
f"{rel_path}:{line}: shared_yaml marker must take a "
"single-line string literal so CI test selection can map "
"its fixture"
)
result.setdefault(arg.group(1), set()).add(rel_path)
return {k: frozenset(v) for k, v in result.items()}
+101
View File
@@ -0,0 +1,101 @@
"""Tests for the sdl display schema, in particular the headless option."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.sdl.display import (
CONF_SDL_ID,
CONFIG_SCHEMA,
headless_final_validate,
)
from esphome.config import Config
from esphome.const import PlatformFramework
from esphome.core import ID
from esphome.final_validate import full_config
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
@pytest.fixture(autouse=True)
def _host_platform(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.HOST_NATIVE)
def _config(**extra: object) -> ConfigType:
config: ConfigType = {
"dimensions": {"width": 320, "height": 240},
# sdl2-config is not necessarily installed in the test environment
"sdl_options": "-lSDL2",
}
config.update(extra)
return config
def test_defaults_to_windowed() -> None:
"""A display without the option is not headless."""
assert CONFIG_SCHEMA(_config())["headless"] is False
def test_headless_accepted() -> None:
"""A headless display needs nothing beyond the dimensions."""
assert CONFIG_SCHEMA(_config(headless=True))["headless"] is True
def test_headless_rejects_window_options() -> None:
"""Window options are meaningless without a window."""
with pytest.raises(cv.Invalid, match="has no effect"):
CONFIG_SCHEMA(
_config(headless=True, window_options={"position": {"x": 0, "y": 0}})
)
def test_headless_rejects_snapshot_key() -> None:
"""A headless display has no keyboard, so the action is the only way in."""
with pytest.raises(cv.Invalid, match="snapshot.take"):
CONFIG_SCHEMA(_config(headless=True, snapshot_key="SDLK_F12"))
def test_snapshot_key_accepted_when_windowed() -> None:
"""The key is only valid alongside a window."""
config = CONFIG_SCHEMA(_config(snapshot_key="SDLK_F12"))
assert str(config["snapshot_key"]) == "SDLK_F12"
def _declare_sdl_display(headless: bool) -> ID:
"""Register a full_config with a single sdl display declaration and return a reference to it.
Mirrors what the real config pipeline leaves behind: a "display" domain entry plus a
declare_ids record id_declaration_match_schema uses to find it again.
"""
declared_id = ID("my_sdl", is_declaration=True)
fc = Config()
fc["display"] = [
{
"platform": "sdl",
"id": declared_id,
"headless": headless,
"dimensions": {"width": 320, "height": 240},
}
]
fc.declare_ids.append((declared_id, ["display", 0, "id"]))
full_config.set(fc)
return ID("my_sdl")
@pytest.mark.parametrize("platform", ["binary_sensor", "touchscreen"])
def test_headless_final_validate_rejects_headless_display(platform: str) -> None:
"""binary_sensor and touchscreen both need a window, so a headless display is rejected."""
sdl_ref = _declare_sdl_display(headless=True)
schema = headless_final_validate(platform)
with pytest.raises(cv.Invalid, match="needs a window"):
schema({CONF_SDL_ID: sdl_ref})
@pytest.mark.parametrize("platform", ["binary_sensor", "touchscreen"])
def test_headless_final_validate_accepts_windowed_display(platform: str) -> None:
"""The same platforms are accepted once the display has a window."""
sdl_ref = _declare_sdl_display(headless=False)
schema = headless_final_validate(platform)
schema({CONF_SDL_ID: sdl_ref}) # Should not raise.
+1 -1
View File
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO14
packages:
uart_38400: !include ../../test_build_components/common/uart_38400/esp32-idf.yaml
uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO3
packages:
uart_38400: !include ../../test_build_components/common/uart_38400/esp8266-ard.yaml
uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp8266-ard.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO5
packages:
uart_38400: !include ../../test_build_components/common/uart_38400/rp2040-ard.yaml
uart_38400_even: !include ../../test_build_components/common/uart_38400_even/rp2040-ard.yaml
<<: !include common.yaml
@@ -1,4 +1,4 @@
packages:
uart_1200: !include ../../test_build_components/common/uart_1200/esp32-idf.yaml
uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
uart_rx_pin: GPIO3
packages:
uart_1200: !include ../../test_build_components/common/uart_1200/esp8266-ard.yaml
uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO5
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO2
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO5
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
<<: !include common.yaml
+27
View File
@@ -14,6 +14,15 @@ display:
position:
x: 100
y: 100
snapshot_key: SDLK_F12
- platform: sdl
id: headless_display
headless: true
show_test_card: true
dimensions:
width: 320
height: 240
- platform: sdl
id: second_display
@@ -46,3 +55,21 @@ binary_sensor:
sdl_id: sdl_sdl_display
id: key_enter
key: SDLK_RETURN
esphome:
# A name of your own is only good for one snapshot - a second one under the same name fails
# rather than writing over the first - so these run once rather than on a repeating interval.
on_boot:
- delay: 2s
- snapshot.take:
id: headless_display
filename: test_card.bmp
- snapshot.take:
id: headless_display
filename: !lambda 'return "shot.bmp";'
interval:
# A generated name has the time in it, so this one can repeat.
- interval: 10s
then:
- snapshot.take: sdl_sdl_display
+29
View File
@@ -0,0 +1,29 @@
# Config-only test for the headless and screenshot options. The combinations that must be
# rejected are covered by tests/component_tests/sdl/test_sdl.py; this file checks that the
# accepted forms validate together.
host:
mac_address: "62:23:45:AF:B3:DD"
display:
- platform: sdl
id: headless_display
headless: true
dimensions: 320x240
- platform: sdl
id: windowed_display
dimensions: 320x240
snapshot_key: SDLK_F12
binary_sensor:
- platform: sdl
sdl_id: windowed_display
id: key_up
key: SDLK_UP
interval:
- interval: 10s
then:
- snapshot.take:
id: headless_display
filename: periodic.bmp
+34
View File
@@ -0,0 +1,34 @@
display:
- platform: snapshot
id: snapshot_display
update_interval: 1s
show_test_card: true
# An odd width exercises the row padding in the BMP writer
dimensions:
width: 101
height: 64
- platform: snapshot
id: snapshot_rotated
rotation: 90
dimensions: 320x240
lambda: |-
it.filled_rectangle(0, 0, 40, 20, Color(0xFF, 0x80, 0x00));
esphome:
# A name of your own is only good for one snapshot - a second one under the same name fails
# rather than writing over the first - so these run once rather than on a repeating interval.
on_boot:
- delay: 2s
- snapshot.take:
id: snapshot_display
filename: test_card.bmp
- snapshot.take:
id: snapshot_rotated
filename: !lambda 'return "rotated.bmp";'
interval:
# A generated name has the time in it, so this one can repeat.
- interval: 10s
then:
- snapshot.take: snapshot_display
+5
View File
@@ -0,0 +1,5 @@
host:
mac_address: "62:23:45:AF:B3:DE"
packages:
snapshot: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO5
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/esp32-idf.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO2
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/esp8266-ard.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO5
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/rp2040-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,14 @@
packages:
uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml
teleinfo:
id: test_teleinfo_standard
historical_mode: false
update_interval: 60s
sensor:
- platform: teleinfo
name: sinsts
tag_name: SINSTS
teleinfo_id: test_teleinfo_standard
unit_of_measurement: VA
-7
View File
@@ -21,13 +21,6 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t
- The fixture file must exist or the test will fail with a clear error message
- The fixture automatically injects a dynamic port number into the API configuration
Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead
of the test-named file and compile it in a shared, hash-keyed build directory, so
the whole group pays one full compile and each test only a relink. The marker
argument must be a single-line string literal (CI test selection maps fixtures to
test files by scanning for it), and marked tests must hand the `yaml_config`
content to `run_compiled` unmodified.
### Key Fixtures
- `run_compiled` - Combines write, compile, and run operations into a single context manager
+26
View File
@@ -0,0 +1,26 @@
"""Shared utilities for ESPHome integration tests - keeping output from failing tests."""
from __future__ import annotations
from pathlib import Path
#: Where a failing test leaves output for someone to look at afterwards. pytest's own
#: temporary folder is no use on a CI runner, which throws the whole workspace away when
#: the job ends; the workflow uploads this folder instead when a job fails.
ARTIFACT_DIR = Path(__file__).resolve().parents[2] / "test_artifacts"
def keep_artifact(name: str, data: bytes) -> Path:
"""Write ``data`` where it can still be read after the run, and return the path.
Args:
name: File name to write under the artifact folder.
data: Contents to write.
Returns:
The full path written.
"""
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
path = ARTIFACT_DIR / name
path.write_bytes(data)
return path
+161
View File
@@ -0,0 +1,161 @@
"""Shared utilities for ESPHome integration tests - reading BMP snapshots."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
import struct
# Size of the smallest BMP header pair (file header plus BITMAPINFOHEADER).
_MIN_HEADER_SIZE = 54
# How long capture_when_drawn() keeps asking for a picture with something on it.
DRAW_TIMEOUT = 15.0
@dataclass(frozen=True)
class Bmp:
"""A decoded BMP image."""
width: int
height: int
bits: int
#: Pixel data with the per row padding stripped, so it depends only on the image itself.
pixels: bytes
class NotABmpError(Exception):
"""The data is not a BMP at all, as opposed to a BMP that is still being written."""
def parse_bmp(data: bytes) -> Bmp | None:
"""Decode a BMP, or return None if the data is not a complete image yet.
Raises:
NotABmpError: If the data cannot become a valid BMP however much more is appended.
"""
# Writes go to the file in order, so a short read is always a prefix of what will be there.
# Anything wrong in a prefix we have already read is wrong for good, and worth saying now
# rather than reporting as a timeout later.
if len(data) >= 2 and data[:2] != b"BM":
raise NotABmpError(f"expected a BMP, got {data[:2]!r}")
if len(data) < _MIN_HEADER_SIZE:
return None
file_size = struct.unpack_from("<I", data, 2)[0]
offset = struct.unpack_from("<I", data, 10)[0]
width, height = struct.unpack_from("<ii", data, 18)
bits = struct.unpack_from("<H", data, 28)[0]
rows = abs(height)
row_size = ((width * bits + 31) // 32) * 4
if width <= 0 or rows == 0 or bits == 0 or offset < _MIN_HEADER_SIZE:
raise NotABmpError(
f"BMP header makes no sense: {width}x{height}, {bits} bits, "
f"pixels at offset {offset}"
)
if file_size < offset + row_size * rows:
raise NotABmpError(
f"BMP header claims {file_size} bytes, too few for {width}x{rows} "
f"at {bits} bits"
)
if len(data) < file_size:
return None
used = width * bits // 8
pixels = b"".join(
data[offset + row * row_size : offset + row * row_size + used]
for row in range(rows)
)
return Bmp(width=width, height=rows, bits=bits, pixels=pixels)
async def wait_for_bmp(path: Path, timeout: float = 5.0) -> Bmp:
"""Wait for a complete BMP file to appear at ``path`` and return it.
The file is created before any of its contents are written, so waiting for it to exist is
not enough - a read that wins the race sees a truncated image. Keep reading until the
headers say the whole image is there.
Args:
path: The file to wait for.
timeout: Maximum time to wait in seconds.
Returns:
The decoded image.
Raises:
AssertionError: If no complete image is readable within ``timeout``.
NotABmpError: If what was written is not a BMP. This is reported as soon as it is
seen, so a device that writes the wrong thing is named for what it did rather
than waiting out the timeout.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while True:
try:
data = path.read_bytes()
except FileNotFoundError:
data = b""
if (image := parse_bmp(data)) is not None:
return image
if loop.time() >= deadline:
break
await asyncio.sleep(0.05)
if not data:
raise AssertionError(f"no snapshot appeared at {path} within {timeout}s")
raise AssertionError(
f"{path} was still incomplete after {timeout}s ({len(data)} bytes)"
)
def is_blank(image: Bmp) -> bool:
"""True if every pixel of the image is the same colour.
Whole pixels are counted rather than byte values: a plain background is usually made of more
than one distinct byte, so counting bytes would find several of them in a blank screen.
"""
return len({image.pixels[i : i + 3] for i in range(0, len(image.pixels), 3)}) <= 1
async def capture_when_drawn(
take: Callable[[str], Awaitable[None]],
directory: Path,
prefix: str = "drawn",
timeout: float = DRAW_TIMEOUT,
) -> tuple[Bmp, Path]:
"""Ask for snapshots until one has something drawn on it, and return it and where it went.
A display holds one flat colour until it first draws, which is one update interval after it
starts - long enough that a test connecting over the API can easily get in first. Capturing
once and hoping would compare a blank screen against whatever the test expects, reporting a
drawing fault where the real trouble was timing.
Args:
take: Asks the device for a snapshot under the name it is given.
directory: Where the device writes them.
prefix: Start of the names asked for. Each attempt needs its own, because a snapshot never
writes over a file that is already there.
timeout: How long to keep asking.
Returns:
The first image that is not one flat colour, and the path it was read from.
Raises:
AssertionError: If nothing had been drawn within ``timeout``.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
attempt = 0
while True:
attempt += 1
path = directory / f"{prefix}-{attempt}.bmp"
await take(path.name)
image = await wait_for_bmp(path)
if not is_blank(image):
return image, path
if loop.time() >= deadline:
raise AssertionError(
f"the screen was still a single flat colour after {timeout}s and "
f"{attempt} captures - nothing was drawn"
)
await asyncio.sleep(0.5)
+74 -335
View File
@@ -4,22 +4,17 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
from contextlib import AbstractAsyncContextManager, asynccontextmanager
import fcntl
from functools import cache
import hashlib
import logging
import os
from pathlib import Path
import platform
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
from typing import TextIO
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
@@ -28,13 +23,7 @@ import pytest_asyncio
import esphome.config
from esphome.core import CORE
from esphome.helpers import (
get_usable_cpu_count,
read_file,
rmtree,
write_file,
write_file_if_changed,
)
from esphome.helpers import get_usable_cpu_count
from esphome.platformio.toolchain import get_idedata
from .const import (
@@ -67,21 +56,6 @@ import pty # not available on Windows
pytest.register_assert_rewrite("tests.integration.entity_utils")
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
"shared_yaml(name): load fixtures/<name>.yaml and compile it in a shared, "
"hash-keyed incremental build directory",
)
FIXTURES_DIR = Path(__file__).parent / "fixtures"
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
"""Get environment variables for PlatformIO with shared cache."""
env = os.environ.copy()
@@ -104,7 +78,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
)
# Compile with THIS tree's esphome sources, not wherever the venv's editable
# install points (which may be a different git worktree or checkout).
repo_root = str(REPO_ROOT)
repo_root = str(Path(__file__).resolve().parent.parent.parent)
existing = env.get("PYTHONPATH")
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
return env
@@ -114,7 +88,8 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
def shared_platformio_cache() -> Generator[Path]:
"""Initialize a shared PlatformIO cache for all integration tests."""
# Use a dedicated directory for integration tests to avoid conflicts.
test_cache_dir = INTEGRATION_TESTS_ROOT
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
test_cache_dir = Path.home() / ".esphome-integration-tests"
cache_dir = test_cache_dir / "platformio"
# Use a lock file in the home directory to ensure only one process initializes the cache
@@ -137,9 +112,7 @@ def shared_platformio_cache() -> Generator[Path]:
init_dir = Path(tmpdir)
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
config_path = init_dir / "cache_init.yaml"
config_path.write_text(
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
)
config_path.write_text(fixture_path.read_text())
# Run compilation to populate the cache
# We must succeed here to avoid race conditions where multiple
@@ -208,29 +181,21 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
return reserved_tcp_port[0]
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Give every test its own host prefs dir; prefs are keyed only by device
name, which tests sharing a fixture also share."""
prefdir = tmp_path / "prefs"
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
return prefdir
@pytest_asyncio.fixture
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
"""Load YAML configuration based on test name."""
shared_name = _shared_yaml_name(request)
# Base test name: test_ prefix and any parametrization stripped
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0]
# Get the test function name
test_name: str = request.node.name
# Extract the base test name (remove test_ prefix and any parametrization)
base_name = test_name.replace("test_", "").partition("[")[0]
# Load the fixture file
fixture_path = FIXTURES_DIR / f"{base_name}.yaml"
fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
if not fixture_path.exists():
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
loop = asyncio.get_running_loop()
content = await loop.run_in_executor(None, read_file, fixture_path)
content = await loop.run_in_executor(None, fixture_path.read_text)
# Replace the port in the config if it contains api section
if "api:" in content:
@@ -254,13 +219,11 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
# Replace external component path placeholder if present
if "EXTERNAL_COMPONENT_PATH" in content:
external_components_path = str(FIXTURES_DIR / "external_components")
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
if shared_name is not None:
# _compile verifies the marked test compiles this content unmodified
request.node._shared_yaml_content = content
return content
@@ -270,218 +233,24 @@ async def write_yaml_config(
) -> AsyncGenerator[ConfigWriter]:
"""Write YAML configuration to a file."""
# Get the test name for default filename
base_name = request.node.name.replace("test_", "").partition("[")[0]
test_name = request.node.name
base_name = test_name.replace("test_", "").split("[")[0]
async def _write_config(content: str, filename: str | None = None) -> Path:
if filename is None:
filename = f"{base_name}.yaml"
config_path = integration_test_dir / filename
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, write_file, config_path, content)
await loop.run_in_executor(None, config_path.write_text, content)
return config_path
yield _write_config
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
# dirs for a fixture are pruned when its content hash changes.
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
# In the dir name (not just the hash) so pruning stays inside this checkout
_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8]
# Give a contended shared build lock time for a full cold compile ahead of us
_SHARED_LOCK_TIMEOUT_S = 900
_SHARED_LOCK_POLL_S = 0.1
_SHARED_LOCK_REPORT_S = 30
# Reclaims dirs orphaned by fixture renames or deleted checkouts
_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600
# ELF path per shared build dir; constant once compiled, so resolve it only once
_shared_elf_paths: dict[Path, Path] = {}
# Dirs this process already swept; pruning is session-scoped work
_pruned_dirs: set[Path] = set()
def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None:
"""Name passed to the shared_yaml marker, or None when unmarked."""
marker = request.node.get_closest_marker("shared_yaml")
if marker is None:
return None
# Exactly one \w+ positional arg: the name doubles as a build dir
# component, and CI test selection (script/helpers.py) parses the same shape
if (
len(marker.args) != 1
or marker.kwargs
or not re.fullmatch(r"\w+", str(marker.args[0]))
):
raise ValueError(
"shared_yaml marker requires exactly one \\w+ fixture name literal"
)
return marker.args[0]
def _shared_build_prefix(name: str) -> str:
return f"{name}-{_REPO_KEY}-"
@cache
def _shared_build_dir(name: str) -> Path:
"""Dir keyed by checkout and fixture source, before per-test injections."""
key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16]
return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key)
def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None:
"""ELF path recorded by the last completed compile, or None."""
try:
text = stamp.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return None
except OSError as err:
print(f"Cannot read {stamp}: {err}")
return None
if not text:
print(f"Ignoring empty stamp {stamp}")
return None
built = Path(text)
# Never trust a stamp pointing outside its own build dir as an unlink target
if shared_dir.resolve() in built.resolve().parents:
return built
print(f"Ignoring stamp {stamp} pointing outside {shared_dir}")
return None
def _unused_since(stale: Path, cutoff: float) -> bool:
"""Whether a build dir looks untouched since cutoff; unknown counts as used."""
# Newest of the .built stamp (rewritten by every completed compile) and the
# dir itself (freshened by a worker claiming the dir before locking)
newest: float | None = None
for probe in (stale / ".built", stale):
try:
mtime = probe.stat().st_mtime
except FileNotFoundError:
continue
except NotADirectoryError:
return True # a stray file where a dir should be; reclaimable
except OSError as err:
print(f"Cannot age-probe {stale}: {err}")
return False # unknown never authorizes deletion
newest = mtime if newest is None else max(newest, mtime)
return newest is not None and newest < cutoff
def _prune_stale_builds(name: str, keep: Path) -> None:
"""Remove outdated build dirs (blocking, run in executor): this checkout's
other dirs for the fixture, plus anything untouched for 30 days. Tolerates
other workers pruning the same dirs concurrently."""
cutoff = time.time() - _STALE_BUILD_MAX_AGE_S
prefix = _shared_build_prefix(name)
for stale in SHARED_BUILDS_ROOT.iterdir():
if stale == keep:
continue
same_fixture = stale.name.startswith(prefix)
if not same_fixture and not _unused_since(stale, cutoff):
continue
# Creating .lock bumps the dir mtime, so remember whether the re-probe
# under the lock can trust it
lock_preexisting = (stale / ".lock").exists()
try:
lock_file = (stale / ".lock").open("w")
except FileNotFoundError:
continue # pruned by another worker meanwhile
except NotADirectoryError:
print(f"Removing stray file {stale}")
stale.unlink(missing_ok=True)
continue
except OSError as err:
print(f"Cannot prune {stale}: {err}")
continue
with lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
continue # still in use by another run
# Re-probe under the lock: a worker freshens its dir before
# locking, so a just-claimed dir no longer looks unused. A dir
# whose .lock we just created cannot be held by anyone, and our
# own open bumped its mtime, so its pre-open probe stands
if (
lock_preexisting
and not same_fixture
and not _unused_since(stale, cutoff)
):
continue
# rmtree tolerates races; a leftover partial tree only costs a
# rebuild, since the ELF is deleted before every compile
try:
rmtree(stale)
except OSError as err:
print(f"Failed to prune {stale}: {err}")
async def _run_esphome_compile(
config_path: Path, cwd: Path, env: dict[str, str]
) -> None:
"""Run `esphome compile`, retrying up to 3 times on a segfault."""
max_retries = 3
for attempt in range(max_retries):
# Compile using subprocess, inheriting stdout/stderr to show progress
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"esphome",
"compile",
str(config_path),
cwd=cwd,
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=asyncio.subprocess.DEVNULL,
# Start in a new process group to isolate signal handling
start_new_session=True,
env=env,
close_fds=False,
)
await proc.wait()
if proc.returncode == 0:
break
if proc.returncode == -11 and attempt < max_retries - 1:
# Segfault (-11 = SIGSEGV), retry
print(
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
)
await asyncio.sleep(1) # Brief pause before retry
continue
raise RuntimeError(
f"Failed to compile {config_path}, return code: {proc.returncode}. "
f"Run with 'pytest -s' to see compilation output."
)
def _resolve_compiled_binary(config_path: Path) -> Path:
"""Load the config to learn the compiled ELF path (blocking, run in executor)."""
CORE.reset() # Reset CORE state between test runs
CORE.config_path = config_path
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
idedata = get_idedata(config)
binary_path = Path(idedata.firmware_elf_path)
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
@pytest_asyncio.fixture
async def compile_esphome(
integration_test_dir: Path,
shared_platformio_cache: Path,
request: pytest.FixtureRequest,
) -> AsyncGenerator[CompileFunction]:
"""Compile an ESPHome configuration and return the binary path."""
@@ -489,96 +258,66 @@ async def compile_esphome(
# Use the shared PlatformIO cache for faster compilation
# This avoids re-downloading dependencies for each test
env = _get_platformio_env(shared_platformio_cache)
# Retry compilation up to 3 times if we get a segfault
max_retries = 3
for attempt in range(max_retries):
# Compile using subprocess, inheriting stdout/stderr to show progress
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"esphome",
"compile",
str(config_path),
cwd=integration_test_dir,
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=asyncio.subprocess.DEVNULL,
# Start in a new process group to isolate signal handling
start_new_session=True,
env=env,
close_fds=False,
)
await proc.wait()
if proc.returncode == 0:
# Success!
break
if proc.returncode == -11 and attempt < max_retries - 1:
# Segfault (-11 = SIGSEGV), retry
print(
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
)
await asyncio.sleep(1) # Brief pause before retry
continue
# Other error or final retry
raise RuntimeError(
f"Failed to compile {config_path}, return code: {proc.returncode}. "
f"Run with 'pytest -s' to see compilation output."
)
# Load the config to get idedata (blocking call, must use executor)
loop = asyncio.get_running_loop()
name = _shared_yaml_name(request)
if name is None:
await _run_esphome_compile(config_path, integration_test_dir, env)
return await loop.run_in_executor(
None, _resolve_compiled_binary, config_path
def _read_config_and_get_binary():
CORE.reset() # Reset CORE state between test runs
CORE.config_path = config_path
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}
)
if config is None:
raise RuntimeError(f"Failed to read config from {config_path}")
# Shared fixture: build in a hash-keyed dir so tests sharing a config
# pay one full compile and later only a main.cpp (port) rebuild + relink
shared_dir = _shared_build_dir(name)
shared_dir.mkdir(parents=True, exist_ok=True)
# Freshen the dir before locking so a concurrent age sweep, which
# re-probes under the lock, never reaps a dir a worker just claimed;
# if a peer reaped it already, the guarded lock open recreates it
with suppress(FileNotFoundError):
os.utime(shared_dir)
if shared_dir not in _pruned_dirs:
_pruned_dirs.add(shared_dir)
await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir)
shared_config = shared_dir / f"{name}.yaml"
private_binary = integration_test_dir / f"{name}.elf"
content = await loop.run_in_executor(None, read_file, config_path)
if content != getattr(request.node, "_shared_yaml_content", None):
# The dir is keyed by the fixture source; a mutated config would be
# cached under a hash that does not describe it
raise RuntimeError(
"shared_yaml tests must compile the yaml_config content unmodified"
)
# flock serializes concurrent xdist workers; closing the fd releases it.
# Hand-rolled rather than filelock.FileLock: non-blocking retries keep
# the wait cancellable, while a blocking acquire in an executor thread
# would survive test cancellation holding the fd
try:
lock_file = (shared_dir / ".lock").open("w")
except FileNotFoundError:
# A peer run pruning divergent hashes reaped the dir between our
# mkdir and this open; recreate it and pay a full rebuild
shared_dir.mkdir(parents=True, exist_ok=True)
lock_file = (shared_dir / ".lock").open("w")
with lock_file:
start = time.monotonic()
last_report = start
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
now = time.monotonic()
if now - start > _SHARED_LOCK_TIMEOUT_S:
raise RuntimeError(
f"Timed out waiting for the {shared_dir} lock"
) from None
if now - last_report >= _SHARED_LOCK_REPORT_S:
last_report = now
print(
f"Waited {now - start:.0f}s for another worker's "
f"build of {shared_dir.name}"
)
await asyncio.sleep(_SHARED_LOCK_POLL_S)
# .built carries the ELF path of the last completed compile, so
# later workers skip the config re-read in _resolve_compiled_binary
stamp = shared_dir / ".built"
if (built := _shared_elf_paths.get(shared_dir)) is None:
built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir)
# Delete the ELF before compiling: whatever exists afterwards is
# this compile's output, so no staleness check is ever needed.
# With no usable stamp, sweep any leftover at the known layout
if built is not None:
built.unlink(missing_ok=True)
else:
# Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree
for leftover in shared_dir.rglob("program"):
if leftover.is_file():
leftover.unlink()
await loop.run_in_executor(
None, write_file_if_changed, shared_config, content
)
await _run_esphome_compile(shared_config, shared_dir, env)
if built is None or not built.exists():
built = await loop.run_in_executor(
None, _resolve_compiled_binary, shared_config
)
_shared_elf_paths[shared_dir] = built
await loop.run_in_executor(None, write_file, stamp, str(built))
# Copy out before unlocking: another worker may relink firmware.elf
# while this test is still running its private copy
await loop.run_in_executor(None, shutil.copy2, built, private_binary)
return private_binary
# Get the compiled binary path
idedata = get_idedata(config)
return Path(idedata.firmware_elf_path)
binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
if not binary_path.exists():
raise RuntimeError(f"Compiled binary not found at {binary_path}")
return binary_path
yield _compile
@@ -0,0 +1,53 @@
esphome:
name: lvgl-headless-render-test
host:
api:
actions:
# The name comes from the test so it can capture more than once: a snapshot never writes over
# a file that is already there, so a fixed name could only ever be captured once.
- action: take_screenshot
variables:
name: string
then:
- snapshot.take:
id: lvgl_display
filename: !lambda return name;
logger:
level: DEBUG
display:
# A display with no screen, so what LVGL draws depends on LVGL alone - nothing about the machine
# running the test, and no graphics library outside this repository, can move the result.
- platform: snapshot
id: lvgl_display
auto_clear_enabled: false
dimensions:
width: 300
height: 300
# The widgets are spelled out here rather than left to the built in "Hello World" screen, which
# LVGL builds when nothing is configured: that screen contains a spinner, and an animation cannot
# produce the same picture twice.
#
# Everything that affects the rendered pixels is set explicitly, so the expected hash in the test
# depends only on the drawing code and the built in font. In particular the background comes from a
# full screen object rather than from the theme, so adjusting a theme default does not break this.
lvgl:
displays: lvgl_display
default_font: montserrat_14
widgets:
- obj:
width: 100%
height: 100%
bg_color: 0x000080
bg_opa: cover
border_width: 0
radius: 0
pad_all: 0
widgets:
- label:
align: center
text: "Hello World!"
text_color: 0xFFFFFF
@@ -0,0 +1,29 @@
esphome:
name: sdl-headless-screenshot-test
host:
api:
actions:
# The name comes from the test so it can capture more than once while it waits for the first
# frame: a snapshot never writes over a file that is already there.
- action: take_screenshot
variables:
name: string
then:
- snapshot.take:
id: sdl_display
filename: !lambda return name;
logger:
level: DEBUG
display:
- platform: sdl
id: sdl_display
headless: true
show_test_card: true
update_interval: 100ms
# An odd width exercises the row padding in the BMP writer
dimensions:
width: 101
height: 64
@@ -0,0 +1,58 @@
esphome:
name: test-batch-window-filters
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template sensor that we'll use to publish values
sensor:
- platform: template
name: "Source Sensor"
id: source_sensor
accuracy_decimals: 2
# Batch window filters (window_size == send_every) - use streaming filters
- platform: copy
source_id: source_sensor
name: "Min Sensor"
id: min_sensor
filters:
- min:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Max Sensor"
id: max_sensor
filters:
- max:
window_size: 5
send_every: 5
send_first_at: 1
- platform: copy
source_id: source_sensor
name: "Moving Avg Sensor"
id: moving_avg_sensor
filters:
- sliding_window_moving_average:
window_size: 5
send_every: 5
send_first_at: 1
# Button to trigger publishing test values
button:
- platform: template
name: "Publish Values Button"
id: publish_button
on_press:
- lambda: |-
// Publish 10 values: 1.0, 2.0, ..., 10.0
for (int i = 1; i <= 10; i++) {
id(source_sensor).publish_state(float(i));
}
@@ -0,0 +1,28 @@
esphome:
name: snapshot-display-test
host:
api:
actions:
# The name comes from the test so it can ask for several in a row and check what each one
# does with it.
- action: take_snapshot
variables:
name: string
then:
- snapshot.take:
id: snapshot_display
filename: !lambda return name;
logger:
level: DEBUG
display:
- platform: snapshot
id: snapshot_display
show_test_card: true
update_interval: 100ms
# An odd width exercises the row padding in the BMP writer
dimensions:
width: 101
height: 64
@@ -0,0 +1,111 @@
esphome:
name: uart-mock-modbus-cli-rw
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Two virtual buses looped back to each other: the client's transmissions reach the server and the
# server's replies reach the client. auto_start so forwarding is active before the button fires.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_client
data: !lambda return data;
- id: virtual_uart_client
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: stored_1
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_client
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
# Writable + readable register: the read publishes what it returns, so the test can confirm the
# write half of the 0x17 ran before the read half (Modbus 6.17).
- address: 0x01
value_type: U_WORD
read_lambda: |-
id(srv_read_1).publish_state(id(stored_1));
return id(stored_1);
write_lambda: |-
id(stored_1) = x;
id(srv_write_1).publish_state(x);
return true;
# Read-only register, returned together with 0x01 by the 2-register read half.
- address: 0x02
value_type: U_WORD
read_lambda: return 0x00AA;
sensor:
# Server-side observations.
- platform: template
name: "srv_write_1"
id: srv_write_1
- platform: template
name: "srv_read_1"
id: srv_read_1
# Client-side read-back: the values the client's on_response received.
- platform: template
name: "client_read_0"
id: client_read_0
- platform: template
name: "client_read_1"
id: client_read_1
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
- modbus_client.read_write_multiple_registers:
address: 0x01
read_address: 0x0001
read_count: 2
write_address: 0x0001
values: [0x1234]
on_response:
then:
- lambda: |-
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
if (values.size() >= 2) {
id(client_read_0).publish_state(values[0]);
id(client_read_1).publish_state(values[1]);
}
@@ -0,0 +1,88 @@
esphome:
name: uart-mock-modbus-custom-pdu
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 259;
sensor:
# Plain read to confirm the controller <-> server link is up.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "plain_read"
address: 0x01
register_type: holding
value_type: U_WORD
# Custom PDU: read holding register 0x0001, count 1. The PDU is
# {function code, address hi, address lo, count hi, count lo}; the device
# address and CRC are added by the hub. The lambda parses the response payload
# (the register value, big-endian).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,106 @@
esphome:
name: uart-mock-modbus-dep-buffer
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: |-
id(reg10) = x;
return true;
# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw
# frame as words: device address + function code + data) instead of the new item->write_* API. The write
# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per
# entity no matter how many writes happen.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x10
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value.
payload.push_back(0x0106);
payload.push_back(0x0010);
payload.push_back((uint16_t) x);
return {};
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
sensor:
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg10);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# The test drives the writes via number_command; the mock is autostart.
@@ -0,0 +1,95 @@
esphome:
name: uart-mock-modbus-lambda-invert
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg40
type: uint16_t
initial_value: "5"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x40
value_type: U_WORD
read_lambda: return id(reg40);
write_lambda: id(reg40) = x; return true;
# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still
# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes
# only from write_state() - turning ON writes 0x0000 yet the switch shows ON.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "invert_switch"
register_type: holding
address: 0x40
assumed_state: true
write_lambda: |-
return !x;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_40"
address: 0x40
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,97 @@
esphome:
name: uart-mock-modbus-lambda-write
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg30
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -1,233 +0,0 @@
esphome:
name: uart-mock-modbus-loopback
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Shared loopback fixture (see the shared_yaml markers in the test file);
# register spaces are disjoint so each test only observes its own entities.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
- id: reg30
type: uint16_t
initial_value: "0"
- id: reg40
type: uint16_t
initial_value: "5"
- id: reg50
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return 259;
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
- address: 0x40
value_type: U_WORD
read_lambda: return id(reg40);
write_lambda: id(reg40) = x; return true;
- address: 0x50
value_type: U_WORD
read_lambda: return id(reg50);
write_lambda: id(reg50) = x; return true;
# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a
# register count, hitting 0x12). assumed_state keeps the switch write-only.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16)
# would draw ILLEGAL_DATA_ADDRESS and never publish.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
# Coil switch whose write_lambda dispatches a holding-register write via `item`;
# returning an empty optional suppresses the default coil write.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
# Active-low: the write_lambda inverts the wire value but the entity must still
# report the requested state (assumed_state keeps the register unpolled).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "invert_switch"
register_type: holding
address: 0x40
assumed_state: true
write_lambda: |-
return !x;
# Uses the deprecated buffer parameter (legacy raw frame as words); the write
# must land and the deprecation warning must fire only once per entity.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x50
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value.
payload.push_back(0x0106);
payload.push_back(0x0050);
payload.push_back((uint16_t) x);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "plain_read"
address: 0x01
register_type: holding
value_type: U_WORD
# Custom PDU: read holding register 0x0001; device address and CRC are added
# by the hub. The lambda parses the big-endian register value.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_40"
address: 0x40
register_type: holding
value_type: U_WORD
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg50);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# Nothing to start (mock is autostart); tests drive entities directly
@@ -0,0 +1,138 @@
esphome:
name: uart-mock-modbus-reg-offset
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
write_lambda: id(reg13) = x; return true;
# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target
# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register
# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)

Some files were not shown because too many files have changed in this diff Show More