mirror of
https://github.com/esphome/esphome.git
synced 2026-09-04 12:06:01 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c29fd9e0 | ||
|
|
567a981078 | ||
|
|
81ecb87253 | ||
|
|
f0e2eb96bd | ||
|
|
567f7f9196 | ||
|
|
ecbde8ddf4 |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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!");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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_) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user