Merge remote-tracking branch 'origin/dev' into binary-sensor-lazy-integration-imports

This commit is contained in:
J. Nick Koston
2026-10-14 10:39:26 -10:00
92 changed files with 5160 additions and 285 deletions
+5 -5
View File
@@ -680,7 +680,7 @@ jobs:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP8266
options: --environment esp8266-arduino-tidy --grep USE_ESP8266
options: --environment esp8266-arduino-tidy --grep USE_ESP8266 --grep USE_SOCKET_IMPL_LWIP_TCP --grep USE_SHD_FIRMWARE_DATA
pio_cache_key: tidyesp8266
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino
@@ -693,7 +693,7 @@ jobs:
ignore_errors: false
- id: clang-tidy
name: Run script/clang-tidy for RP2
options: --environment rp2-tidy --grep USE_RP2
options: --environment rp2-tidy --grep USE_RP2 --grep USE_BLE_SCAN_RESPONSE_MERGER
pio_cache_key: tidyrp2
- id: clang-tidy
name: Run script/clang-tidy for host
@@ -702,7 +702,7 @@ jobs:
- id: clang-tidy
name: Run script/clang-tidy for LibreTiny
environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X --grep USE_LN882H_BLE --grep USE_SOCKET_IMPL_LWIP_SOCKETS
pio_cache_key: tidylibretiny
steps:
@@ -1003,14 +1003,14 @@ jobs:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 S3
# yamllint disable-line rule:line-length
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM
tidy_environment: esp32s3-idf-tidy
- id: clang-tidy
name: Run script/clang-tidy for ESP32 P4
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
# so their code paths differ -- lint them under the P4 build too.
# yamllint disable-line rule:line-length
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep CONFIG_IDF_TARGET_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
tidy_environment: esp32p4-idf-tidy
- id: clang-tidy
name: Run script/clang-tidy for ESP32 C6
+2
View File
@@ -219,6 +219,7 @@ esphome/components/friedrich/* @rwrozelle
esphome/components/fs3000/* @kahrendt
esphome/components/ft5x06/* @clydebarrow
esphome/components/ft63x6/* @gpambrozio
esphome/components/fusb302b/* @remcom
esphome/components/gcja5/* @gcormier
esphome/components/gdk101/* @Szewcson
esphome/components/gl_r01_i2c/* @pkejval
@@ -386,6 +387,7 @@ esphome/components/microphone/* @jesserockz @kahrendt
esphome/components/mics_4514/* @jesserockz
esphome/components/midea/* @dudanov
esphome/components/midea_ir/* @dudanov
esphome/components/midea_portasplit/* @Fexiven
esphome/components/mipi_dsi/* @clydebarrow
esphome/components/mipi_rgb/* @clydebarrow
esphome/components/mipi_spi/* @clydebarrow
+3 -2
View File
@@ -100,7 +100,8 @@ void BM8563::get_date_(ESPTime &time) {
this->read_register(DATE_FIRST_REG, buf, sizeof(buf));
time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f);
time.day_of_week = this->bcd2_to_byte_(buf[1] & 0x07);
// The chip counts weekdays 0-6 from Sunday (not BCD), ESPTime counts 1-7
time.day_of_week = (buf[1] & 0x07) + 1;
time.month = this->bcd2_to_byte_(buf[2] & 0x1f);
uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff);
@@ -115,7 +116,7 @@ void BM8563::get_date_(ESPTime &time) {
void BM8563::set_date_(const ESPTime &time) {
uint8_t buf[4] = {
this->byte_to_bcd2_(time.day_of_month),
this->byte_to_bcd2_(time.day_of_week),
static_cast<uint8_t>(time.day_of_week - 1),
this->byte_to_bcd2_(time.month),
this->byte_to_bcd2_(time.year % 100),
};
+22 -4
View File
@@ -8,6 +8,9 @@ namespace esphome::cse7766 {
ESPHOME_LOG_TAG(TAG, "cse7766");
// The chip only sends the time since the last pulse once a cycle is longer than 1 s (in us)
static constexpr uint32_t MIN_HELD_POWER_CYCLE = 1000000;
void CSE7766Component::loop() {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_transmission_ >= 500) {
@@ -154,18 +157,33 @@ void CSE7766Component::parse_data_() {
if (power_cycle_exceeds_range) {
// Datasheet: power cycle exceeding range means active power is 0
have_power = true;
this->last_power_cycle_ = 0;
if (this->power_sensor_ != nullptr) {
this->power_sensor_->publish_state(0.0f);
}
} else if (have_power) {
power = power_coeff / float(power_cycle);
this->last_power_cycle_ = power_cycle;
if (this->power_sensor_ != nullptr) {
this->power_sensor_->publish_state(power);
}
} else if (this->power_sensor_ != nullptr) {
// No valid power measurement from chip - publish 0W to avoid stale readings
// This typically happens when current is below the measurable threshold (~50mA)
this->power_sensor_->publish_state(0.0f);
} else if (this->last_power_cycle_ > MIN_HELD_POWER_CYCLE && power_cycle <= 2 * this->last_power_cycle_) {
// No new measurement in this frame. With cycles over 1 s the register holds the last
// cycle or the time elapsed since the last pulse. While that time is within twice the
// last cycle, the next pulse is not overdue, so the last measurement still applies.
// Shorter cycles never get here in normal operation and keep the previous behaviour.
have_power = true;
power = power_coeff / float(this->last_power_cycle_);
if (this->power_sensor_ != nullptr) {
this->power_sensor_->publish_state(power);
}
} else {
// The next pulse is overdue: the load has dropped, report 0 W. A load that drops to less
// than half also reads 0 W here until its first new pulse arrives.
this->last_power_cycle_ = 0;
if (this->power_sensor_ != nullptr) {
this->power_sensor_->publish_state(0.0f);
}
}
float current = 0.0f;
+3 -1
View File
@@ -36,6 +36,7 @@ class CSE7766Component final : public Component, public uart::UARTDevice {
uint8_t raw_data_[CSE7766_RAW_DATA_SIZE];
uint8_t raw_data_index_{0};
uint16_t cf_pulses_last_{0};
uint32_t last_transmission_{0};
sensor::Sensor *voltage_sensor_{nullptr};
sensor::Sensor *current_sensor_{nullptr};
@@ -45,7 +46,8 @@ class CSE7766Component final : public Component, public uart::UARTDevice {
sensor::Sensor *reactive_power_sensor_{nullptr};
sensor::Sensor *power_factor_sensor_{nullptr};
uint32_t cf_pulses_total_{0};
uint16_t cf_pulses_last_{0};
// Power cycle of the last frame that carried a new power measurement, 0 if none
uint32_t last_power_cycle_{0};
};
} // namespace esphome::cse7766
@@ -42,6 +42,7 @@ extern "C" {
}
#include "esp_now_hosted_rpc.h"
#include "esphome/core/log.h"
namespace {
@@ -52,6 +53,8 @@ ESPHOME_LOG_TAG(TAG, "esp_now_hosted");
// single esp-hosted RPC RX thread. Serializing requests keeps the shared
// response slot race-free; a sequence number stops a late/stale response from
// being mistaken for ours.
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables): the shim is
// C-ABI state shared between the main loop and the esp-hosted RX thread.
SemaphoreHandle_t g_req_mutex = nullptr;
SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands
bool g_setup_done = false; // set only after setup fully succeeds
@@ -85,6 +88,7 @@ constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20;
uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6];
size_t g_peer_count = 0;
portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED;
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
// Caller must hold g_peer_lock.
int peer_cache_find_locked(const uint8_t *mac) {
@@ -231,12 +235,14 @@ esp_err_t ensure_setup() {
g_resp_sem = xSemaphoreCreateBinary();
if (g_req_mutex == nullptr || g_resp_sem == nullptr)
return ESP_ERR_NO_MEM;
esp_err_t err;
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK)
esp_err_t err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr);
if (err != ESP_OK)
return err;
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK)
err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr);
if (err != ESP_OK)
return err;
if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK)
err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr);
if (err != ESP_OK)
return err;
g_setup_done = true;
return ESP_OK;
@@ -422,10 +428,11 @@ esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len
uint8_t hdr[sizeof(esp_now_hosted_send_req_t)];
auto *s = reinterpret_cast<esp_now_hosted_send_req_t *>(hdr);
s->has_addr = peer_addr != nullptr ? 1 : 0;
if (peer_addr != nullptr)
if (peer_addr != nullptr) {
memcpy(s->peer_addr, peer_addr, 6);
else
} else {
memset(s->peer_addr, 0, 6);
}
s->data_len = static_cast<uint16_t>(len);
// Fire-and-forget (wait=false): native esp_now_send returns once the frame is
// queued, with the real TX result delivered later through the send callback.
+11
View File
@@ -53,10 +53,14 @@ from .const import (
KEY_SERIAL1_REQUIRED,
KEY_SERIAL_REQUIRED,
KEY_WAVEFORM_REQUIRED,
PREFERENCES_RTC_EBOOT_WORDS,
PREFERENCES_RTC_NORMAL_WORDS,
THROW_STUBS_HEADER,
check_preferences_fit,
enable_serial,
enable_serial1,
esp8266_ns,
preferences_flash_words,
)
from .gpio import PinInitialState, add_pin_initial_states_array
@@ -467,6 +471,12 @@ async def to_code(config: ConfigType) -> None:
# Other projects like Tasmota & ESPEasy also use this
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
cg.add_define("ESP8266_PREFERENCES_RTC_NORMAL_WORDS", PREFERENCES_RTC_NORMAL_WORDS)
cg.add_define("ESP8266_PREFERENCES_RTC_EBOOT_WORDS", PREFERENCES_RTC_EBOOT_WORDS)
cg.add_define(
"ESP8266_PREFERENCES_FLASH_WORDS",
preferences_flash_words(config[CONF_RESTORE_FROM_FLASH]),
)
if config[CONF_RESTORE_FROM_FLASH]:
cg.add_define("USE_ESP8266_PREFERENCES_FLASH")
@@ -528,6 +538,7 @@ async def to_code(config: ConfigType) -> None:
CORE.add_job(add_pin_initial_states_array)
CORE.add_job(finalize_waveform_config)
CORE.add_job(finalize_serial_config)
CORE.add_job(check_preferences_fit)
@coroutine_with_priority(CoroPriority.WORKAROUNDS)
+86 -1
View File
@@ -1,9 +1,13 @@
import logging
import esphome.codegen as cg
# Re-exported from the shared definition; here it indexes the BOARDS
# metadata dicts, whose entries in boards.py spell the literal.
from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import
from esphome.core import CORE
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
_LOGGER = logging.getLogger(__name__)
KEY_ESP8266 = "esp8266"
KEY_BOARD = "board"
@@ -24,6 +28,87 @@ KEY_LDSCRIPT = "ldscript"
# Crash-decode tools, resolved once per run by __init__
KEY_DECODE_TOOLS = "decode_tools"
# Saved-settings space in 4-byte words; preferences.cpp gets these as defines. A value cannot span the two
# RTC regions, and the flash store is smaller when only explicit flash preferences use it.
PREFERENCES_RTC_NORMAL_WORDS = 78
PREFERENCES_RTC_EBOOT_WORDS = 32
PREFERENCES_RTC_WORDS = PREFERENCES_RTC_NORMAL_WORDS + PREFERENCES_RTC_EBOOT_WORDS
PREFERENCES_FLASH_WORDS = 128
PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE = 64
KEY_PREFERENCES = "preferences"
def preferences_flash_words(restore_from_flash: bool) -> int:
"""Size of the flash store for this configuration."""
return (
PREFERENCES_FLASH_WORDS
if restore_from_flash
else PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE
)
def preference_words(value_bytes: int) -> int:
"""Words a saved value takes, checksum word included (rtc_pref_bytes_to_words plus one)."""
return (value_bytes + 3) // 4 + 1
def reserve_preferences(
count: int, value_bytes: int, in_flash: bool | None = None
) -> None:
"""Record saved values a component creates; check_preferences_fit() adds them all up.
in_flash is the value's own storage, as passed to make_preference(); None is the two-argument
form, which follows restore_from_flash.
"""
if in_flash is None:
in_flash = CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH]
reservations = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_PREFERENCES, [])
reservations.extend([(preference_words(value_bytes), in_flash)] * count)
def _rtc_values_can_split(values: list[int]) -> bool:
"""Whether some order fills the two RTC regions without a value spanning them."""
total = sum(values)
if total <= PREFERENCES_RTC_NORMAL_WORDS:
return True
# Bit i of reachable is set when some subset of the values sums to i
reachable = 1
for words in values:
reachable |= reachable << words
lowest = max(0, total - PREFERENCES_RTC_EBOOT_WORDS)
highest = min(total, PREFERENCES_RTC_NORMAL_WORDS)
return any(reachable >> normal & 1 for normal in range(lowest, highest + 1))
@coroutine_with_priority(CoroPriority.FINAL)
async def check_preferences_fit() -> None:
"""Fail the build when the reserved saved values cannot fit the space preferences.cpp has."""
flash_store = preferences_flash_words(
CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH]
)
reservations = CORE.data[KEY_ESP8266].get(KEY_PREFERENCES, [])
flash_words = sum(w for w, in_flash in reservations if in_flash)
rtc_values = [w for w, in_flash in reservations if not in_flash]
if flash_words > flash_store:
raise EsphomeError(
f"Saved values need {flash_words} words but the ESP8266 flash store has {flash_store}; "
"use fewer or smaller saved values"
)
if not _rtc_values_can_split(rtc_values):
raise EsphomeError(
f"Saved values need {sum(rtc_values)} words but ESP8266 RTC memory has {PREFERENCES_RTC_WORDS} in "
f"regions of {PREFERENCES_RTC_NORMAL_WORDS} and {PREFERENCES_RTC_EBOOT_WORDS} that a value cannot "
"span; use fewer or smaller saved values, or store them in flash"
)
if sum(rtc_values) > PREFERENCES_RTC_NORMAL_WORDS:
_LOGGER.warning(
"Saved values reserve %d of %d words of ESP8266 RTC memory; the fit depends on setup order, and "
"saved settings that do not reserve are not counted",
sum(rtc_values),
PREFERENCES_RTC_WORDS,
)
# esp8266 namespace is already defined by arduino, manually prefix esphome
esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266")
+5 -7
View File
@@ -4,6 +4,7 @@
#ifdef USE_ESP8266_CRASH_HANDLER
#include "crash_handler.h"
#include "rtc_layout.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -46,11 +47,8 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t val) {
// Recover the actual code address from a windowed-ABI return address on the stack.
static inline uint32_t IRAM_ATTR recover_code_addr(uint32_t val) { return (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; }
// RTC user memory layout for crash backtrace data.
// User-accessible RTC memory: blocks 64-191 (each block = 4 bytes).
// We use blocks 174-191 (last 18 blocks, 72 bytes) to minimize conflicts.
// Crash data lives in the last esphome::esp8266::RTC_CRASH_WORDS of user RTC memory (rtc_layout.h).
// Store 16 raw candidates, filter to real return addresses at log time.
static constexpr uint8_t RTC_CRASH_BASE = 174;
static constexpr size_t MAX_BACKTRACE = 16;
// Magic word packs sentinel, version, and count into one uint32_t:
@@ -78,7 +76,7 @@ struct RtcCrashData {
uint32_t backtrace[MAX_BACKTRACE];
uint32_t epc1; // Fault PC, used to filter duplicates
};
static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC blocks");
static_assert(sizeof(RtcCrashData) == esphome::esp8266::RTC_CRASH_WORDS * 4, "RtcCrashData must fill its RTC words");
namespace esphome::esp8266 {
@@ -129,7 +127,7 @@ static const LogString *get_reset_reason(uint32_t reason) {
// Returns the number of valid backtrace entries (0 if no data found).
static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) {
RtcCrashData rtc_data;
if (!system_rtc_mem_read(RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data)))
if (!system_rtc_mem_read(esphome::esp8266::RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data)))
return 0;
uint32_t magic = rtc_data.magic;
if ((magic & CRASH_SENTINEL_MASK) != CRASH_SENTINEL || (magic & CRASH_VERSION_MASK) != CRASH_VERSION)
@@ -233,7 +231,7 @@ extern "C" void IRAM_ATTR custom_crash_callback(struct rst_info *rst_info, uint3
data.epc1 = epc1;
data.magic = CRASH_SENTINEL | CRASH_VERSION | count;
system_rtc_mem_write(RTC_CRASH_BASE, &data, sizeof(data));
system_rtc_mem_write(esphome::esp8266::RTC_CRASH_BASE, &data, sizeof(data));
}
#endif // USE_ESP8266_CRASH_HANDLER
+13 -17
View File
@@ -6,6 +6,7 @@ extern "C" {
}
#include "preferences.h"
#include "rtc_layout.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences_rtc.h"
@@ -17,31 +18,26 @@ namespace esphome::esp8266 {
ESPHOME_LOG_TAG(TAG, "preferences");
static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200;
static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128;
static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = RTC_USER_MEM_WORDS;
static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4;
// RTC memory layout:
// RTC memory layout (sizes come from const.py through codegen):
// - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 78-109)
// - Normal region: RTC words 32-109 (mapped from preference offset 0-77)
// - Crash handler: RTC words 110-127 (reserved for crash_handler.cpp backtrace data)
static constexpr uint32_t RTC_EBOOT_REGION_WORDS = 32; // Words 0-31 reserved for eboot
static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 78; // Words 32-109 for normal prefs
static constexpr uint32_t RTC_EBOOT_REGION_WORDS = ESP8266_PREFERENCES_RTC_EBOOT_WORDS;
static constexpr uint32_t RTC_NORMAL_REGION_WORDS = ESP8266_PREFERENCES_RTC_NORMAL_WORDS;
static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 110
static_assert(PREF_TOTAL_WORDS <= RTC_CRASH_BASE - RTC_SYSTEM_USER_BASE,
"preference regions overlap the crash handler's RTC words");
// Maximum preference size in words (limited by uint8_t length_words field)
static constexpr uint32_t MAX_PREFERENCE_WORDS = 255;
#define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START)
// Flash storage size depends on esp8266 -> restore_from_flash YAML option (default: false).
// When enabled (USE_ESP8266_PREFERENCES_FLASH), all preferences default to flash and need
// 128 words (512 bytes). When disabled, only explicit flash prefs use this storage so
// 64 words (256 bytes) suffices since most preferences go to RTC memory instead.
#ifdef USE_ESP8266_PREFERENCES_FLASH
static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128;
#else
static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64;
#endif
// Smaller without restore_from_flash, when only explicit flash preferences live here
static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = ESP8266_PREFERENCES_FLASH_WORDS;
static uint32_t
s_flash_storage[ESP8266_FLASH_STORAGE_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -60,7 +56,7 @@ static inline bool esp_rtc_user_mem_write(uint32_t index, uint32_t value) {
if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) {
return false;
}
if (index < 32 && s_prevent_write) {
if (index < RTC_EBOOT_REGION_WORDS && s_prevent_write) {
return false;
}
@@ -123,7 +119,7 @@ static bool load_from_rtc(size_t offset, uint32_t *data, size_t len) {
// Maximum buffer for any single preference - bounded by storage sizes.
// Flash prefs: bounded by ESP8266_FLASH_STORAGE_SIZE (128 or 64 words).
// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (96) - a single pref can't span both RTC regions.
// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (78) - a single pref can't span both RTC regions.
static constexpr size_t PREF_MAX_BUFFER_WORDS =
ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS;
@@ -180,8 +176,8 @@ ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t
} else {
uint32_t start = this->current_offset;
bool in_normal = start < RTC_NORMAL_REGION_WORDS;
// Normal: offset 0-95 maps to RTC offset 32-127
// Eboot: offset 96-127 maps to RTC offset 0-31
// Normal: offset 0-77 maps to RTC offset 32-109
// Eboot: offset 78-109 maps to RTC offset 0-31
if (in_normal && start + total_words > RTC_NORMAL_REGION_WORDS) {
// start is in normal but end is not -> switch to Eboot
this->current_offset = start = RTC_NORMAL_REGION_WORDS;
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#ifdef USE_ESP8266
#include <cstdint>
namespace esphome::esp8266 {
// User RTC memory is system blocks 64-191, one 4-byte word each. preferences.cpp owns the first
// words, crash_handler.cpp the last; const.py hands preferences.cpp its region sizes.
static constexpr uint32_t RTC_SYSTEM_USER_BASE = 64;
static constexpr uint32_t RTC_USER_MEM_WORDS = 128;
static constexpr uint32_t RTC_CRASH_WORDS = 18;
static constexpr uint32_t RTC_CRASH_BASE = RTC_SYSTEM_USER_BASE + RTC_USER_MEM_WORDS - RTC_CRASH_WORDS;
} // namespace esphome::esp8266
#endif // USE_ESP8266
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@remcom"]
DOMAIN = "fusb302b"
+330
View File
@@ -0,0 +1,330 @@
#include "pd.h"
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::fusb302b {
ESPHOME_LOG_TAG(TAG, "fusb302b.pd");
static constexpr uint32_t AMS_TIMEOUT_MS = 2000;
// tSinkRequest: delay before repeating a request that the source answered with Wait
static constexpr uint32_t SINK_REQUEST_MS = 100;
static constexpr uint8_t MAX_WAIT_RETRIES = 10;
// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object flags; only the first (5 V) object carries them
static constexpr uint32_t SINK_PDO_HIGHER_CAPABILITY = 1u << 28;
static constexpr uint32_t SINK_PDO_USB_COMMS_CAPABLE = 1u << 26;
// Operational current advertised for every sink object: 3 A (300 x 10 mA)
static constexpr uint16_t SINK_PDO_CURRENT = 300;
// USB PD 6.4.1.2.3 Source Fixed Supply Power Data Object; other PDO types only report their type
static PdContract parse_pdo(uint32_t pdo) {
PdContract info{};
info.type = static_cast<PdPdoType>(pdo >> 30);
if (info.type == PD_PDO_TYPE_FIXED_SUPPLY) {
info.max_v = (pdo >> 10) & 0x3FF;
info.max_i = pdo & 0x3FF;
}
return info;
}
// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object; voltage in 50 mV units, current in 10 mA units
static constexpr uint32_t sink_fixed_pdo(uint16_t voltage, uint16_t current) {
return (static_cast<uint32_t>(PD_PDO_TYPE_FIXED_SUPPLY) << 30) | (static_cast<uint32_t>(voltage & 0x3FF) << 10) |
(current & 0x3FF);
}
void PdMsg::set_header(uint16_t header) {
this->type = header & 0x1F;
this->spec_rev = static_cast<PdSpecRevision>((header >> 6) & 0x3);
this->id = (header >> 9) & 0x7;
this->num_of_obj = (header >> 12) & 0x7;
this->extended = (header >> 15) & 0x1;
}
uint16_t PdMsg::get_coded_header() const {
// Data role UFP (bit 5 = 0), power role sink (bit 8 = 0)
return static_cast<uint16_t>(this->type & 0x1F) | (static_cast<uint16_t>(this->spec_rev) << 6) |
(static_cast<uint16_t>(this->id & 0x7) << 9) | (static_cast<uint16_t>(this->num_of_obj & 0x7) << 12) |
(static_cast<uint16_t>(this->extended) << 15);
}
PdMsg PowerDelivery::make_control_msg_(PdControlMsgType type) const {
PdMsg msg;
msg.type = type;
msg.id = this->msg_counter_ % 8;
return msg;
}
PdMsg PowerDelivery::make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const {
PdMsg msg;
msg.type = type;
msg.id = this->msg_counter_ % 8;
msg.num_of_obj = len;
memcpy(msg.data_objects, objects, len * sizeof(uint32_t));
return msg;
}
void PowerDelivery::reset_protocol_() {
this->last_received_msg_id_ = 255;
this->msg_counter_ = 0;
this->active_ams_ = false;
this->ps_rdy_expected_ = false;
this->retry_request_pending_ = false;
this->wait_retries_ = 0;
}
void PowerDelivery::set_state_(PdState state) {
if (this->state_.exchange(state) != state)
this->on_state_changed();
}
void PowerDelivery::set_contract_(const PdContract &contract) {
this->accepted_contract_ = contract;
const uint32_t packed = PackedContract::from(contract).value;
if (this->contract_.exchange(packed) != packed)
this->on_state_changed();
}
void PowerDelivery::clear_contract_() {
this->accepted_contract_ = {};
if (this->contract_.exchange(0) != 0)
this->on_state_changed();
}
void PowerDelivery::set_state_and_contract_(PdState state, const PdContract &contract) {
this->accepted_contract_ = contract;
const uint32_t packed = PackedContract::from(contract).value;
// The contract is stored first, so a reader that sees the new state also sees the new contract
const bool contract_changed = this->contract_.exchange(packed) != packed;
const bool state_changed = this->state_.exchange(state) != state;
if (contract_changed || state_changed)
this->on_state_changed();
}
void PowerDelivery::set_ams_(bool active) {
this->active_ams_ = active;
// A new sequence has no accepted request yet, and an ended one can no longer complete
this->ps_rdy_expected_ = false;
if (active)
this->ams_start_ = millis();
}
bool PowerDelivery::check_ams_() {
if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS)
this->set_ams_(false);
return this->active_ams_;
}
void PowerDelivery::check_request_retry_() {
if (!this->retry_request_pending_ || millis() - this->wait_received_ms_ < SINK_REQUEST_MS)
return;
if (this->send_request_()) {
this->retry_request_pending_ = false;
} else if (this->wait_retries_ < MAX_WAIT_RETRIES) {
// Try again after another tSinkRequest; failed attempts count against the same limit as Wait replies
this->wait_retries_++;
this->wait_received_ms_ = millis();
} else {
ESP_LOGW(TAG, "Giving up repeating the request");
this->retry_request_pending_ = false;
this->request_failed_();
}
}
bool PowerDelivery::send_request_() {
this->set_ams_(true);
if (this->send_message(this->make_data_msg_(PD_DATA_REQUEST, &this->last_rdo_, 1)))
return true;
ESP_LOGW(TAG, "Sending the request failed");
this->set_ams_(false);
return false;
}
void PowerDelivery::request_failed_() {
// A contract already in place stays valid. Without one, report that negotiation is over at default power.
if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT)
this->set_state_(PdState::PD_STATE_PD_TIMEOUT);
}
void PowerDelivery::check_transition_timeout_() {
// The AMS that sent the request only ends on PS_RDY, so an ended AMS while in transition means a timeout
if (this->state_ != PdState::PD_STATE_TRANSITION || this->check_ams_())
return;
ESP_LOGW(TAG, "Source did not report the new supply ready, falling back to default power");
this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT);
this->on_transition_timeout();
}
void PowerDelivery::handle_transmit_failed_() {
// USB PD 6.12.2.2: the counter also moves on after a failed transmission. The source may have received the
// message and only its GoodCRC was lost, so reusing the MessageID would make it drop the next one as a repeat.
ESP_LOGD(TAG, "Message %u not acknowledged", this->msg_counter_ % 8);
this->msg_counter_++;
}
void PowerDelivery::handle_message_(const PdMsg &msg) {
if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_GOODCRC) {
// A GoodCRC carries the MessageID it acknowledges; a duplicate or stray one must not advance the counter
if (msg.id != this->msg_counter_ % 8) {
ESP_LOGD(TAG, "Ignoring GoodCRC for MessageID %u, expected %u", msg.id, this->msg_counter_ % 8);
return;
}
// Our last message was received, so the next one gets a new MessageID
this->msg_counter_++;
return;
}
// Soft_Reset always has MessageID 0, so it must not be dropped as a retransmission of the last message
if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_SOFT_RESET) {
this->handle_soft_reset_(msg);
return;
}
// Retransmissions repeat the MessageID of a message that was already handled
if (msg.id == this->last_received_msg_id_)
return;
this->last_received_msg_id_ = msg.id;
if (msg.num_of_obj == 0) {
this->handle_control_message_(msg);
} else {
this->handle_data_message_(msg);
}
}
void PowerDelivery::handle_data_message_(const PdMsg &msg) {
if (msg.extended || msg.type != PD_DATA_SOURCE_CAP) {
// BIST and Vendor_Defined messages must not be answered with Reject, so unsupported data messages are ignored
ESP_LOGV(TAG, "Ignoring data message type %u%s", msg.type,
msg.extended ? LOG_STR_LITERAL(" (extended)") : LOG_STR_LITERAL(""));
return;
}
this->on_source_caps_received();
this->respond_to_source_caps_(msg);
}
void PowerDelivery::handle_control_message_(const PdMsg &msg) {
switch (msg.type) {
case PD_CNTRL_ACCEPT:
if (!this->check_ams_()) {
ESP_LOGD(TAG, "Ignoring Accept outside a request");
break;
}
this->ps_rdy_expected_ = true;
if (this->requested_contract_ != this->accepted_contract_)
this->set_state_and_contract_(PdState::PD_STATE_TRANSITION, this->requested_contract_);
break;
case PD_CNTRL_PS_RDY:
// check_ams_() first, so a PS_RDY that arrives after the sequence timed out is ignored too
if (!this->check_ams_() || !this->ps_rdy_expected_) {
ESP_LOGD(TAG, "Ignoring PS_RDY without an accepted request");
break;
}
this->set_ams_(false);
this->set_state_(PdState::PD_STATE_EXPLICIT_CONTRACT);
break;
case PD_CNTRL_REJECT:
if (!this->check_ams_()) {
ESP_LOGD(TAG, "Ignoring Reject outside a request");
break;
}
ESP_LOGW(TAG, "Source rejected the request");
this->set_ams_(false);
this->request_failed_();
break;
case PD_CNTRL_WAIT:
if (!this->check_ams_()) {
ESP_LOGD(TAG, "Ignoring Wait outside a request");
break;
}
this->set_ams_(false);
if (this->wait_retries_ < MAX_WAIT_RETRIES) {
ESP_LOGD(TAG, "Source asked to wait, repeating the request");
this->wait_retries_++;
this->retry_request_pending_ = true;
this->wait_received_ms_ = millis();
} else {
ESP_LOGW(TAG, "Source kept answering Wait, giving up");
this->request_failed_();
}
break;
case PD_CNTRL_PING:
case PD_CNTRL_NOT_SUPPORTED:
break;
case PD_CNTRL_GET_SINK_CAP: {
// 5 V first, then the requested voltage. Higher Capability tells the source that 5 V is not enough for
// full operation.
const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units
uint32_t pdos[2];
uint8_t count = 0;
pdos[count++] = sink_fixed_pdo(100, SINK_PDO_CURRENT) | SINK_PDO_USB_COMMS_CAPABLE |
(request_v > 100 ? SINK_PDO_HIGHER_CAPABILITY : 0);
if (request_v > 100)
pdos[count++] = sink_fixed_pdo(request_v, SINK_PDO_CURRENT);
this->send_message(this->make_data_msg_(PD_DATA_SINK_CAP, pdos, count));
break;
}
default:
// Messages are sent as PD 2.0, which has no Not_Supported message
this->send_message(this->make_control_msg_(PD_CNTRL_REJECT));
break;
}
}
void PowerDelivery::handle_soft_reset_(const PdMsg &msg) {
this->reset_protocol_();
this->last_received_msg_id_ = msg.id;
// If this fails the source escalates to a hard reset. The hook still runs, so the PHY starts waiting for source
// capabilities and recovers on its own if neither follows.
if (!this->send_message(this->make_control_msg_(PD_CNTRL_ACCEPT))) {
ESP_LOGW(TAG, "Accepting the soft reset failed");
}
// A soft reset keeps the power level, but the outcome of a transition in progress is unknown
if (this->state_ == PdState::PD_STATE_TRANSITION)
this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT);
this->on_soft_reset_received();
}
void PowerDelivery::respond_to_source_caps_(const PdMsg &msg) {
// Pick the highest fixed supply PDO that does not exceed the requested voltage.
// The first PDO is always the 5 V fixed supply.
const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units
PdContract selected{};
uint8_t position = 0; // 1-based object position, 0 = none
for (uint8_t idx = 0; idx < msg.num_of_obj; idx++) {
PdContract info = parse_pdo(msg.data_objects[idx]);
if (info.type != PD_PDO_TYPE_FIXED_SUPPLY)
continue;
if (position == 0 || (info.max_v <= request_v && info.max_v > selected.max_v)) {
selected = info;
position = idx + 1;
}
}
// USB PD 6.4.2 Request Data Object: no USB suspend (bit 24), USB communications capable (bit 25)
uint32_t rdo = (1u << 24) | (1u << 25);
if (position == 0) {
ESP_LOGW(TAG, "No usable PDO in source capabilities, requesting 5 V");
// Object 1 (5 V fixed supply), 300 mA maximum, 100 mA operating current
selected = PdContract{PD_PDO_TYPE_FIXED_SUPPLY, 100, 30};
position = 1;
rdo |= (30u << 0) | (10u << 10);
} else {
// Request the full current the source offers
const uint32_t current = selected.max_i;
rdo |= (current << 0) | (current << 10);
}
rdo |= static_cast<uint32_t>(position) << 28;
ESP_LOGD(TAG, "Requesting PDO %u: %.2f V", position, selected.max_v * 0.05f);
this->requested_contract_ = selected;
this->last_rdo_ = rdo;
this->retry_request_pending_ = false;
this->wait_retries_ = 0;
// A repeat would come too late: the source resets if it gets no Request within tSenderResponse (24-30 ms)
if (!this->send_request_())
this->request_failed_();
}
} // namespace esphome::fusb302b
+178
View File
@@ -0,0 +1,178 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <cstddef>
#include <cstdint>
namespace esphome::fusb302b {
static constexpr uint8_t PD_MAX_NUM_DATA_OBJECTS = 7;
// Standard Power Range fixed supplies, which are the only ones this sink negotiates
static constexpr uint8_t MIN_REQUEST_VOLTAGE = 5;
static constexpr uint8_t MAX_REQUEST_VOLTAGE = 20;
enum PdSpecRevision : uint8_t {
PD_SPEC_REV_1 = 0,
PD_SPEC_REV_2 = 1,
PD_SPEC_REV_3 = 2,
};
enum PdDataMsgType : uint8_t {
PD_DATA_SOURCE_CAP = 0x01,
PD_DATA_REQUEST = 0x02,
PD_DATA_SINK_CAP = 0x04,
};
enum PdControlMsgType : uint8_t {
PD_CNTRL_GOODCRC = 0x01,
PD_CNTRL_ACCEPT = 0x03,
PD_CNTRL_REJECT = 0x04,
PD_CNTRL_PING = 0x05,
PD_CNTRL_PS_RDY = 0x06,
PD_CNTRL_GET_SINK_CAP = 0x08,
PD_CNTRL_WAIT = 0x0C,
PD_CNTRL_SOFT_RESET = 0x0D,
PD_CNTRL_NOT_SUPPORTED = 0x10,
};
enum PdPdoType : uint8_t {
PD_PDO_TYPE_FIXED_SUPPLY = 0,
PD_PDO_TYPE_BATTERY = 1,
PD_PDO_TYPE_VARIABLE_SUPPLY = 2,
PD_PDO_TYPE_AUGMENTED = 3,
};
enum class PdState : uint8_t {
PD_STATE_DISCONNECTED,
/// Negotiation is over without a PD contract (no capabilities, or the request was not accepted); the source
/// supplies Type-C default power.
PD_STATE_PD_TIMEOUT,
PD_STATE_DEFAULT_CONTRACT,
PD_STATE_TRANSITION,
PD_STATE_EXPLICIT_CONTRACT,
PD_STATE_ERROR,
};
/// Returns true for the states in which a source is attached and supplying power.
inline bool is_connected_state(PdState state) {
return state == PdState::PD_STATE_DEFAULT_CONTRACT || state == PdState::PD_STATE_TRANSITION ||
state == PdState::PD_STATE_EXPLICIT_CONTRACT || state == PdState::PD_STATE_PD_TIMEOUT;
}
struct PdContract {
PdPdoType type{PD_PDO_TYPE_FIXED_SUPPLY};
uint16_t max_v{0}; // 50 mV units
uint16_t max_i{0}; // 10 mA units
bool operator==(const PdContract &other) const {
return this->max_v == other.max_v && this->max_i == other.max_i && this->type == other.type;
}
bool operator!=(const PdContract &other) const { return !(*this == other); }
};
// Type-C default power: 5 V (100 x 50 mV) at 500 mA (50 x 10 mA)
static constexpr PdContract DEFAULT_CONTRACT{PD_PDO_TYPE_FIXED_SUPPLY, 100, 50};
/// A contract packed into one word so it can be shared between tasks as an atomic.
/// 0 means there is no contract (nothing attached).
struct PackedContract {
uint32_t value{0};
static PackedContract from(const PdContract &contract) {
return {(static_cast<uint32_t>(contract.max_v) << 16) | contract.max_i};
}
bool is_set() const { return this->value != 0; }
float voltage() const { return (this->value >> 16) * 0.05f; }
float current() const { return (this->value & 0xFFFF) * 0.01f; }
};
struct PdMsg {
uint8_t type{0};
PdSpecRevision spec_rev{PD_SPEC_REV_2};
uint8_t id{0};
uint8_t num_of_obj{0};
bool extended{false};
uint32_t data_objects[PD_MAX_NUM_DATA_OBJECTS]{};
void set_header(uint16_t header);
uint16_t get_coded_header() const;
};
/// USB-PD sink protocol logic, independent of the PHY chip.
/// Everything except the getters and set_request_voltage() runs in the PHY task, which must also call
/// check_transition_timeout_() and check_request_retry_() regularly; the protocol has no timers of its own.
class PowerDelivery {
public:
void set_request_voltage(uint8_t voltage) {
this->request_voltage_ = std::clamp(voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE);
}
uint8_t get_request_voltage() const { return this->request_voltage_; }
PdState get_state() const { return this->state_; }
bool is_connected() const { return is_connected_state(this->state_); }
protected:
/// Returns false when the message could not be handed to the chip; the PHY logs the failure.
virtual bool send_message(const PdMsg &msg) = 0;
/// Called after state_ or contract_ changed, to let the main loop publish it.
virtual void on_state_changed() = 0;
/// Called after the source soft reset the protocol; it sends new source capabilities next.
virtual void on_soft_reset_received() = 0;
/// Called when source capabilities arrive, before the request for them is sent.
virtual void on_source_caps_received() = 0;
/// Called after an accepted request was not followed by PS_RDY in time and the state fell back to default power;
/// the PHY sends a soft reset so the source offers its capabilities again.
virtual void on_transition_timeout() = 0;
void handle_message_(const PdMsg &msg);
/// Called by the PHY when the chip gave up on a message after its retries without receiving a GoodCRC.
void handle_transmit_failed_();
void reset_protocol_();
void set_state_(PdState state);
void set_contract_(const PdContract &contract);
void clear_contract_();
/// Stores both before calling on_state_changed() once, so the main loop never sees the new state with the old
/// contract.
void set_state_and_contract_(PdState state, const PdContract &contract);
void set_ams_(bool active);
/// Returns true while an atomic message sequence is in progress; ends it after a timeout.
bool check_ams_();
/// Falls back to default power when the source accepted a request but did not report the new supply ready in time.
void check_transition_timeout_();
/// Sends the last request again once tSinkRequest has passed after the source answered Wait.
void check_request_retry_();
PdMsg make_control_msg_(PdControlMsgType type) const;
PdMsg make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const;
// Shared with the main loop
std::atomic<PdState> state_{PdState::PD_STATE_DISCONNECTED};
std::atomic<uint32_t> contract_{0};
std::atomic<uint8_t> request_voltage_{5};
// PHY task only
uint32_t ams_start_{0};
uint32_t wait_received_ms_{0};
bool retry_request_pending_{false};
private:
void handle_data_message_(const PdMsg &msg);
void handle_control_message_(const PdMsg &msg);
void handle_soft_reset_(const PdMsg &msg);
void respond_to_source_caps_(const PdMsg &msg);
bool send_request_();
void request_failed_();
PdContract requested_contract_{};
PdContract accepted_contract_{};
uint32_t last_rdo_{0};
bool active_ams_{false};
// Set by Accept; PS_RDY is only valid after the source accepted the request of the current sequence
bool ps_rdy_expected_{false};
uint8_t last_received_msg_id_{255};
uint8_t msg_counter_{0};
uint8_t wait_retries_{0};
};
} // namespace esphome::fusb302b
+11
View File
@@ -1,6 +1,7 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import uart
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE
from esphome.types import ConfigType
@@ -16,6 +17,8 @@ LD2450Component = ld2450_ns.class_("LD2450Component", cg.Component, uart.UARTDev
CONF_LD2450_ID = "ld2450_id"
CONF_POLYGON_ZONE_ID = "polygon_zone_id"
CONF_POLYGON_ZONES = "polygon_zones"
CONFIG_SCHEMA = cv.All(
cv.Schema(
@@ -50,6 +53,14 @@ _CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"),
)
# Polygon zone sources build only when zones are configured
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{
"polygon.cpp": "LD2450_POLYGON_ZONE_COUNT",
"polygon_zone.cpp": "LD2450_POLYGON_ZONE_COUNT",
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
+35 -1
View File
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_FILTERS,
CONF_HAS_MOVING_TARGET,
CONF_HAS_STILL_TARGET,
CONF_HAS_TARGET,
@@ -11,7 +12,14 @@ from esphome.const import (
)
from esphome.types import ConfigType
from . import CONF_LD2450_ID, LD2450Component
from . import ( # noqa: F401 pylint: disable=unused-import
CONF_LD2450_ID,
CONF_POLYGON_ZONE_ID,
CONF_POLYGON_ZONES,
FILTER_SOURCE_FILES,
LD2450Component,
)
from .text import PolygonZone
DEPENDENCIES = ["ld2450"]
@@ -19,6 +27,18 @@ ICON_MEDITATION = "mdi:meditation"
ICON_SHIELD_ACCOUNT = "mdi:shield-account"
ICON_TARGET_ACCOUNT = "mdi:target-account"
def _reject_filters(config: ConfigType) -> ConfigType:
# Clearing a polygon makes presence unknown without going through filters, so they would get out of step
if CONF_FILTERS in config:
raise cv.Invalid(
"Polygon zone presence binary sensors can't have filters; use the ld2450 number platform's "
"'presence_timeout' to keep presence on after targets leave",
path=[CONF_FILTERS],
)
return config
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component),
@@ -37,6 +57,16 @@ CONFIG_SCHEMA = {
filters=[{"settle": cv.TimePeriod(milliseconds=1000)}],
icon=ICON_MEDITATION,
),
cv.Optional(CONF_POLYGON_ZONES): cv.ensure_list(
binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_OCCUPANCY,
).extend(
{
cv.Required(CONF_POLYGON_ZONE_ID): cv.use_id(PolygonZone),
}
),
_reject_filters,
),
}
@@ -46,3 +76,7 @@ async def to_code(config: ConfigType) -> None:
await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor)
await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor)
await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor)
for presence_conf in config.get(CONF_POLYGON_ZONES, []):
presence = await binary_sensor.new_binary_sensor(presence_conf)
zone = await cg.get_variable(presence_conf[CONF_POLYGON_ZONE_ID])
cg.add(zone.set_presence_binary_sensor(presence))
+31 -3
View File
@@ -179,6 +179,11 @@ void LD2450Component::setup() {
this->pref_ = this->presence_timeout_number_->make_entity_preference<float>();
this->set_presence_timeout();
}
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
for (PolygonZone *zone : this->polygon_zones_) {
zone->setup();
}
#endif
this->restart_and_read_all_info();
}
@@ -265,6 +270,12 @@ void LD2450Component::dump_config() {
LOG_BUTTON(" ", "FactoryReset", this->factory_reset_button_);
LOG_BUTTON(" ", "Restart", this->restart_button_);
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
ESP_LOGCONFIG(TAG, "Polygon Zones:");
for (PolygonZone *zone : this->polygon_zones_) {
zone->dump_config();
}
#endif
}
void LD2450Component::loop() {
@@ -465,15 +476,15 @@ void LD2450Component::handle_periodic_data_() {
#if defined(USE_BINARY_SENSOR) || defined(USE_SENSOR) || defined(USE_TEXT_SENSOR)
// Loop thru targets
for (index = 0; index < MAX_TARGETS; index++) {
#ifdef USE_SENSOR
// X
start = TARGET_X + index * 8;
is_moving = false;
// tx is used for further calculations, so always needs to be populated
// tx and ty are used for further calculations, so always need to be populated
tx = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]);
// Y
start = TARGET_Y + index * 8;
ty = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]);
#ifdef USE_SENSOR
// RESOLUTION
start = TARGET_RESOLUTION + index * 8;
res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start];
@@ -542,6 +553,23 @@ void LD2450Component::handle_periodic_data_() {
still_target_count = target_count - moving_target_count;
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
const uint32_t now = App.get_loop_component_start_time();
for (PolygonZone *zone : this->polygon_zones_) {
if (!zone->has_polygon())
continue;
bool target_inside = false;
for (const Target &target : this->target_info_) {
// Untracked targets are stored as (0, 0)
if ((target.x != 0 || target.y != 0) && zone->contains(target.x, target.y)) {
target_inside = true;
break;
}
}
zone->update(target_inside, now, this->get_timeout_status_(zone->get_last_inside_ms()));
}
#endif
#ifdef USE_SENSOR
// Loop thru zones
uint8_t zone_still_targets = 0;
@@ -602,7 +630,7 @@ void LD2450Component::handle_periodic_data_() {
}
}
#endif
#ifdef USE_SENSOR
#ifdef USE_BINARY_SENSOR
// For presence timeout check
if (target_count > 0) {
this->presence_millis_ = App.get_loop_component_start_time();
+12 -1
View File
@@ -24,6 +24,10 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
#include "polygon_zone.h"
#endif
#include "esphome/components/ld24xx/ld24xx.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/helpers.h"
@@ -137,6 +141,9 @@ class LD2450Component : public Component, public uart::UARTDevice {
void set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s);
void set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s);
void set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s);
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
void register_polygon_zone(PolygonZone *zone) { this->polygon_zones_.push_back(zone); }
#endif
void reset_radar_zone();
void set_radar_zone(int32_t zone_type, int32_t zone1_x1, int32_t zone1_y1, int32_t zone1_x2, int32_t zone1_y2,
@@ -167,7 +174,7 @@ class LD2450Component : public Component, public uart::UARTDevice {
uint32_t presence_millis_ = 0;
uint32_t still_presence_millis_ = 0;
uint32_t moving_presence_millis_ = 0;
uint32_t timeout_ = 5;
uint32_t timeout_ = DEFAULT_PRESENCE_TIMEOUT * 1000; // presence_timeout, in ms
uint8_t buffer_data_[MAX_LINE_LENGTH];
uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
@@ -200,6 +207,10 @@ class LD2450Component : public Component, public uart::UARTDevice {
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
StaticVector<PolygonZone *, LD2450_POLYGON_ZONE_COUNT> polygon_zones_;
#endif
LazyCallbackManager<void()> data_callback_;
};
+123
View File
@@ -0,0 +1,123 @@
#include "polygon.h"
#include "esphome/core/helpers.h"
#include <cstdio>
namespace esphome::ld2450 {
namespace {
class Scanner {
public:
Scanner(const char *str, size_t len) : pos_(str), end_(str + len) {}
bool at_end() const { return this->pos_ == this->end_; }
void skip_spaces() {
while (this->pos_ != this->end_ && *this->pos_ == ' ')
this->pos_++;
}
bool consume(char c) {
this->skip_spaces();
if (this->pos_ != this->end_ && *this->pos_ == c) {
this->pos_++;
return true;
}
return false;
}
/// Read an integer in [min, max]; returns false if there is none or it is out of range.
bool read_int(int16_t min, int16_t max, int16_t &out) {
bool negative = this->consume('-');
int32_t value = 0;
uint8_t digits = 0;
while (this->pos_ != this->end_ && *this->pos_ >= '0' && *this->pos_ <= '9') {
// Six digits is already beyond any valid coordinate; stop before value can overflow
if (++digits > 6)
return false;
value = value * 10 + (*this->pos_ - '0');
this->pos_++;
}
if (digits == 0)
return false;
if (negative)
value = -value;
if (value < min || value > max)
return false;
out = static_cast<int16_t>(value);
return true;
}
protected:
const char *pos_;
const char *end_;
};
} // namespace
bool Polygon::is_valid() const {
if (this->count == 0)
return true;
if (this->count < MIN_POLYGON_POINTS || this->count > MAX_POLYGON_POINTS)
return false;
for (uint8_t i = 0; i < this->count; i++) {
const Point &p = this->points[i];
if (p.x < POLYGON_MIN_X || p.x > POLYGON_MAX_X || p.y < POLYGON_MIN_Y || p.y > POLYGON_MAX_Y)
return false;
}
return true;
}
bool Polygon::contains(int16_t x, int16_t y) const {
bool inside = false;
for (uint8_t i = 0, j = this->count - 1; i < this->count; j = i++) {
const Point &a = this->points[i];
const Point &b = this->points[j];
if ((a.y > y) == (b.y > y))
continue;
// Does the edge a-b cross the ray going from (x, y) towards +x? Compare
// x - a.x < (y - a.y) * (b.x - a.x) / (b.y - a.y) without division; values fit in 32 bits.
const int32_t dy = static_cast<int32_t>(b.y) - a.y;
const int32_t lhs = (static_cast<int32_t>(x) - a.x) * dy;
const int32_t rhs = (static_cast<int32_t>(y) - a.y) * (static_cast<int32_t>(b.x) - a.x);
if (dy > 0 ? lhs < rhs : lhs > rhs)
inside = !inside;
}
return inside;
}
bool Polygon::parse(const char *str, size_t len) {
Polygon result;
Scanner scanner(str, len);
scanner.skip_spaces();
while (!scanner.at_end()) {
if (result.count == MAX_POLYGON_POINTS)
return false;
Point &p = result.points[result.count];
if (!scanner.read_int(POLYGON_MIN_X, POLYGON_MAX_X, p.x) || !scanner.consume(',') ||
!scanner.read_int(POLYGON_MIN_Y, POLYGON_MAX_Y, p.y))
return false;
result.count++;
bool separator = scanner.consume(';');
scanner.skip_spaces();
if (!separator && !scanner.at_end())
return false;
}
if (!result.is_valid())
return false;
*this = result;
return true;
}
void Polygon::format(char *buf) const {
size_t pos = 0;
buf[0] = '\0';
for (uint8_t i = 0; i < this->count; i++) {
pos = buf_append_printf(buf, POLYGON_STR_SIZE, pos, "%s%d,%d", i == 0 ? "" : ";", this->points[i].x,
this->points[i].y);
}
}
} // namespace esphome::ld2450
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
namespace esphome::ld2450 {
static constexpr uint8_t MIN_POLYGON_POINTS = 3;
// The most points whose text always fits Home Assistant's 255-character limit: 23 x "-4860,7560" + 22 x ';' = 252
static constexpr uint8_t MAX_POLYGON_POINTS = 23;
// Coordinate limits of the radar detection area, in mm
static constexpr int16_t POLYGON_MIN_X = -4860;
static constexpr int16_t POLYGON_MAX_X = 4860;
static constexpr int16_t POLYGON_MIN_Y = 0;
static constexpr int16_t POLYGON_MAX_Y = 7560;
// Longest point is "-4860,7560;" (11 characters), plus the null terminator
static constexpr size_t POLYGON_STR_SIZE = MAX_POLYGON_POINTS * 11 + 1;
struct Point {
int16_t x;
int16_t y;
};
/// A polygon in radar coordinates. Trivially copyable so it can be saved to preferences as-is.
struct Polygon {
std::array<Point, MAX_POLYGON_POINTS> points{};
uint8_t count{0};
/// True when the polygon has no points, which disables the zone.
bool empty() const { return this->count == 0; }
/// True when the polygon is empty, or has a valid number of points that are all inside the radar area.
bool is_valid() const;
/// Return whether the point is inside the polygon (even-odd rule). Always false for an empty polygon.
bool contains(int16_t x, int16_t y) const;
/** Parse "x,y;x,y;..." (mm) into this polygon.
*
* Spaces around numbers and a trailing ';' are allowed. An empty or all-space string gives an empty polygon.
* @return false if the text is invalid; the polygon is then left unchanged.
*/
bool parse(const char *str, size_t len);
/// Write the polygon as "x,y;x,y;..." into buf, which must hold at least POLYGON_STR_SIZE bytes.
void format(char *buf) const;
};
// text.py counts on this size
static_assert(sizeof(Polygon) == 94, "Polygon layout changed; update POLYGON_SAVED_BYTES in text.py");
} // namespace esphome::ld2450
@@ -0,0 +1,60 @@
#include "polygon_zone.h"
#ifdef LD2450_POLYGON_ZONE_COUNT
#include "esphome/core/log.h"
namespace esphome::ld2450 {
ESPHOME_LOG_TAG(TAG, "ld2450.polygon_zone");
void PolygonZone::setup() {
// Bump the version when Polygon's layout changes
this->pref_ = this->make_entity_preference<Polygon>(1);
Polygon saved;
if (this->pref_.load(&saved)) {
if (saved.is_valid()) {
this->polygon_ = saved;
} else {
ESP_LOGW(TAG, "'%s': saved polygon is invalid; zone disabled", this->get_name().c_str());
}
}
// Presence stays unknown until the first radar frame
this->publish_polygon_();
}
void PolygonZone::dump_config() {
char polygon_s[POLYGON_STR_SIZE];
this->polygon_.format(polygon_s);
LOG_TEXT(" ", "PolygonZone", this);
ESP_LOGCONFIG(TAG, " Polygon: '%s'", polygon_s);
LOG_BINARY_SENSOR(" ", "Presence", this->presence_);
}
void PolygonZone::control(const std::string &value) {
Polygon polygon;
if (!polygon.parse(value.c_str(), value.size())) {
ESP_LOGW(TAG, "'%s': invalid polygon '%s'; expected 'x,y;x,y;...' with %u to %u points", this->get_name().c_str(),
value.c_str(), MIN_POLYGON_POINTS, MAX_POLYGON_POINTS);
} else if (!this->pref_.save(&polygon)) {
// On ESP8266, the space for saved settings is small and can run out with several zones
ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", this->get_name().c_str());
} else {
this->polygon_ = polygon;
// A target seen inside the previous polygon must not keep the new one on
this->last_inside_ms_ = 0;
if (this->polygon_.empty())
this->presence_->invalidate_state();
}
// On error this publishes the previous polygon again, so the frontend shows the value in use
this->publish_polygon_();
}
void PolygonZone::publish_polygon_() {
char polygon_s[POLYGON_STR_SIZE];
this->polygon_.format(polygon_s);
this->publish_state(polygon_s);
}
} // namespace esphome::ld2450
#endif // LD2450_POLYGON_ZONE_COUNT
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef LD2450_POLYGON_ZONE_COUNT
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/text/text.h"
#include "esphome/core/preferences.h"
#include "polygon.h"
namespace esphome::ld2450 {
/** A zone with any polygon shape. The text state holds the polygon, so it can be edited and read back from HA.
*
* The polygon is in the radar's raw coordinates (mm), and targets are checked against it as decoded from each
* radar frame, before any sensor filter. Filters on the target X/Y sensors change only what they publish, so a
* filter that changes their values (multiply, offset, lambda, ...) puts them in a different frame from the polygon.
*
* Like the hub's has_target binary sensors, presence stays on until presence_timeout has passed since a target was
* last inside the zone.
*/
class PolygonZone : public text::Text {
public:
/// Config validation guarantees every zone gets exactly one presence binary sensor.
void set_presence_binary_sensor(binary_sensor::BinarySensor *presence) { this->presence_ = presence; }
/// Load the saved polygon and publish it. Called from the hub's setup().
void setup();
void dump_config();
/** Publish presence for one radar frame. A zone without a polygon keeps presence unknown.
*
* @param target_inside whether any target is inside the zone in this frame.
* @param now the frame's time, in ms.
* @param timed_out whether presence_timeout has passed since get_last_inside_ms().
*/
void update(bool target_inside, uint32_t now, bool timed_out) {
if (this->polygon_.empty())
return;
if (target_inside) {
this->last_inside_ms_ = now;
this->presence_->publish_state(true);
} else if (timed_out) {
this->presence_->publish_state(false);
}
}
/// When a target was last inside the zone, in ms; 0 if never since the polygon was set.
uint32_t get_last_inside_ms() const { return this->last_inside_ms_; }
bool contains(int16_t x, int16_t y) const { return this->polygon_.contains(x, y); }
bool has_polygon() const { return !this->polygon_.empty(); }
protected:
void control(const std::string &value) override;
void publish_polygon_();
binary_sensor::BinarySensor *presence_{nullptr};
Polygon polygon_;
ESPPreferenceObject pref_;
uint32_t last_inside_ms_{0};
};
} // namespace esphome::ld2450
#endif // LD2450_POLYGON_ZONE_COUNT
+1 -1
View File
@@ -17,7 +17,7 @@ from esphome.const import (
)
from esphome.types import ConfigType
from . import CONF_LD2450_ID, LD2450Component
from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import
DEPENDENCIES = ["ld2450"]
+120
View File
@@ -0,0 +1,120 @@
from esphome import preferences
import esphome.codegen as cg
from esphome.components import text
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PLATFORM, ENTITY_CATEGORY_CONFIG
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import ( # noqa: F401 pylint: disable=unused-import
CONF_LD2450_ID,
CONF_POLYGON_ZONE_ID,
CONF_POLYGON_ZONES,
DOMAIN,
FILTER_SOURCE_FILES,
LD2450Component,
ld2450_ns,
)
DEPENDENCIES = ["ld2450"]
ICON_VECTOR_POLYGON = "mdi:vector-polygon"
# Must match MIN_POLYGON_POINTS and MAX_POLYGON_POINTS in polygon.h
MIN_POLYGON_POINTS = 3
MAX_POLYGON_POINTS = 23
# Matches what Polygon::parse() accepts, apart from coordinate ranges, which only the device checks:
# empty, or MIN_POLYGON_POINTS to MAX_POLYGON_POINTS "x,y" points separated by ';'. HA validates input with it.
# No character classes: the HA frontend compiles it with the JS "v" flag, HA core with Python re.
POLYGON_PATTERN = (
r"^ *$|^ *-?\d{1,4} *, *\d{1,4} *"
rf"(; *-?\d{{1,4}} *, *\d{{1,4}} *){{{MIN_POLYGON_POINTS - 1},{MAX_POLYGON_POINTS - 1}}};? *$"
)
# Home Assistant's limit for a state; the longest polygon the device publishes is 252 characters
POLYGON_MAX_LENGTH = 255
# sizeof(Polygon), pinned by a static_assert in polygon.h
POLYGON_SAVED_BYTES = 94
PolygonZone = ld2450_ns.class_("PolygonZone", text.Text)
_request_polygon_zone_slot = cg.slot_counter("LD2450_POLYGON_ZONE_COUNT")
def _require_manual_id(config: ConfigType) -> ConfigType:
if not config[CONF_ID].is_manual:
raise cv.Invalid(
f"'{CONF_ID}' is required, so the zone's presence binary sensor can refer to it",
path=[CONF_ID],
)
return config
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component),
# Polygons are in raw radar mm, checked before sensor filters (polygon_zone.h)
cv.Required(CONF_POLYGON_ZONES): cv.ensure_list(
text.text_schema(
PolygonZone,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_VECTOR_POLYGON,
mode="TEXT",
),
_require_manual_id,
),
}
)
def _final_validate(config: ConfigType) -> None:
"""Each polygon zone must have exactly one presence binary sensor, on the same LD2450."""
full_config = fv.full_config.get()
# Polygon zone id -> the LD2450 id of each presence binary sensor that uses it
references: dict[str, list[str]] = {}
for binary_sensor_conf in full_config.get("binary_sensor", []):
if binary_sensor_conf.get(CONF_PLATFORM) != "ld2450":
continue
hub_id = binary_sensor_conf[CONF_LD2450_ID].id
for presence_conf in binary_sensor_conf.get(CONF_POLYGON_ZONES, []):
zone_id = presence_conf[CONF_POLYGON_ZONE_ID].id
references.setdefault(zone_id, []).append(hub_id)
zone_hub_id = config[CONF_LD2450_ID].id
for index, zone_conf in enumerate(config[CONF_POLYGON_ZONES]):
zone_id = zone_conf[CONF_ID].id
hub_ids = references.get(zone_id, [])
if len(hub_ids) != 1:
problem = (
"has no presence binary sensor"
if not hub_ids
else "is used by more than one presence binary sensor"
)
raise cv.Invalid(
f"Polygon zone '{zone_id}' {problem}; each zone needs exactly one, under "
f"'binary_sensor: - platform: ld2450' -> '{CONF_POLYGON_ZONES}'",
path=[CONF_POLYGON_ZONES, index],
)
if hub_ids[0] != zone_hub_id:
raise cv.Invalid(
f"Polygon zone '{zone_id}' belongs to LD2450 '{zone_hub_id}', but its presence "
f"binary sensor is under LD2450 '{hub_ids[0]}'; use the same '{CONF_LD2450_ID}'",
path=[CONF_POLYGON_ZONES, index],
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2450_ID])
# Each zone saves its polygon; the ESP8266 checks that every component's saved values fit
preferences.reserve_preferences(
len(config[CONF_POLYGON_ZONES]), POLYGON_SAVED_BYTES
)
for zone_conf in config[CONF_POLYGON_ZONES]:
zone = await text.new_text(
zone_conf, max_length=POLYGON_MAX_LENGTH, pattern=POLYGON_PATTERN
)
_request_polygon_zone_slot(str(hub))
cg.add(hub.register_polygon_zone(zone))
+1 -1
View File
@@ -14,7 +14,7 @@ from esphome.const import (
)
from esphome.types import ConfigType
from . import CONF_LD2450_ID, LD2450Component
from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import
DEPENDENCIES = ["ld2450"]
+2 -1
View File
@@ -246,7 +246,7 @@ static void ble_scan_callback(void *param) {
memcpy(slot->data, info->data, slot->data_len);
s_ble->push_scan_report(slot);
}
} // NOLINT(clang-analyzer-unix.Malloc) the slot is a pool entry handed to push_scan_report(), not a leak
BLEScanReport *LN882HBLE::allocate_scan_report() {
BLEScanReport *slot = this->report_pool_.allocate();
@@ -287,6 +287,7 @@ void LN882HBLE::enable() {
return;
this->state_ = BLEComponentState::ENABLING;
// NOLINTNEXTLINE(performance-no-int-to-ptr) - MMIO register at a fixed address
*reinterpret_cast<volatile uint32_t *>(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL;
soc_module_clk_gate_enable(CLK_G_BLE);
+11 -3
View File
@@ -335,6 +335,16 @@ def final_validation(config_list):
widget_type.final_validate(name, conf, widget_conf, path[1:])
def lv_conf_path_flag(lv_conf_h_file: Path) -> str:
"""The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell
lexers (PlatformIO, host, ESP8266) and the quoted CMake arguments
(ESP-IDF, Zephyr) the build flags pass through."""
# forward slashes for Windows, then escape what shlex or a quoted CMake
# argument would not pass through literally
path = re.sub(r"[^\w/.:+=,-]", r"\\\g<0>", Path(lv_conf_h_file).as_posix())
return f'-DLV_CONF_PATH=\\"{path}\\"'
async def to_code(configs):
config_0 = configs[0]
# Global configuration
@@ -563,9 +573,7 @@ async def to_code(configs):
if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()):
clean_build(clear_pio_cache=False)
cg.add_build_flag("-DLV_CONF_H=1")
# handle windows paths in a way that doesn't break the generated C++
lv_conf_h_path = Path(lv_conf_h_file).as_posix()
cg.add_build_flag(f'-DLV_CONF_PATH=\\"{lv_conf_h_path}\\"')
cg.add_build_flag(lv_conf_path_flag(lv_conf_h_file))
cg.add_build_flag("-DLV_KCONFIG_IGNORE")
for prop in df.get_remapped_uses():
@@ -1,9 +1,20 @@
#include "microphone_source.h"
#include <array>
#include <gain.h> // esp-audio-libs
#include <pcm_convert.h> // esp-audio-libs
namespace esphome::microphone {
static const int32_t Q25_MAX_VALUE = (1 << 25) - 1;
static const int32_t Q25_MIN_VALUE = ~Q25_MAX_VALUE;
// Builds the exact LinearGain for an integer factor without the float dB conversion: the factor is
// mantissa / 2^31 * 2^exponent, with the exponent set to the factor's bit width.
static esp_audio_libs::gain::LinearGain factor_to_linear_gain(uint32_t factor) {
esp_audio_libs::gain::LinearGain gain;
gain.exponent = 32 - __builtin_clz(factor);
gain.mantissa_q31 = static_cast<int32_t>(factor << (31 - gain.exponent));
return gain;
}
audio::AudioStreamInfo MicrophoneSource::get_audio_stream_info() {
return audio::AudioStreamInfo(this->bits_per_sample_, this->channels_.count(),
@@ -26,50 +37,26 @@ void MicrophoneSource::stop() {
}
void MicrophoneSource::process_audio_(const std::vector<uint8_t> &data, std::vector<uint8_t> &filtered_data) {
// - Bit depth conversions are obtained by truncating bits or padding with zeros - no dithering is applied.
// - In the comments, Qxx refers to a fixed point number with xx bits of precision for representing fractional values.
// For example, audio with a bit depth of 16 can store a sample in a int16, which can be considered a Q15 number.
// - All samples are converted to Q25 before applying the gain factor - this results in a small precision loss for
// data with 32 bits per sample. Since the maximum gain factor is 64 = (1<<6), this ensures that applying the gain
// will never overflow a 32 bit signed integer. This still retains more bit depth than what is audibly noticeable.
// - Loops for reading/writing data buffers are unrolled, assuming little endian, for a small performance increase.
const audio::AudioStreamInfo source_info = this->mic_->get_audio_stream_info();
const uint32_t total_frames = source_info.bytes_to_frames(data.size());
const uint8_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8;
const size_t source_bytes_per_sample = this->mic_->get_audio_stream_info().samples_to_bytes(1);
const uint32_t source_channels = this->mic_->get_audio_stream_info().get_channels();
const size_t source_bytes_per_frame = this->mic_->get_audio_stream_info().frames_to_bytes(1);
const uint32_t total_frames = this->mic_->get_audio_stream_info().bytes_to_frames(data.size());
const size_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8;
const size_t target_bytes_per_frame = target_bytes_per_sample * this->channels_.count();
filtered_data.resize(target_bytes_per_frame * total_frames);
uint8_t *current_data = filtered_data.data();
for (uint32_t frame_index = 0; frame_index < total_frames; ++frame_index) {
for (uint32_t channel_index = 0; channel_index < source_channels; ++channel_index) {
if (this->channels_.test(channel_index)) {
// Channel's current sample is included in the target mask. Convert bits per sample, if necessary.
const uint32_t sample_index = frame_index * source_bytes_per_frame + channel_index * source_bytes_per_sample;
int32_t sample = audio::unpack_audio_sample_to_q31(&data[sample_index], source_bytes_per_sample); // Q31
sample >>= 6; // Q31 -> Q25
// Apply gain using multiplication
sample *= this->gain_factor_; // Q25
// Clamp ``sample`` in case gain multiplication overflows 25 bits
sample = clamp<int32_t>(sample, Q25_MIN_VALUE, Q25_MAX_VALUE); // Q25
sample *= (1 << 6); // Q25 -> Q31
audio::pack_q31_as_audio_sample(sample, current_data, target_bytes_per_sample);
current_data = current_data + target_bytes_per_sample;
}
// Enabled channels in ascending order, which is their order in each output frame
std::array<uint8_t, 8> channel_map;
uint8_t target_channels = 0;
for (size_t channel = 0; channel < this->channels_.size(); ++channel) {
if (this->channels_.test(channel)) {
channel_map[target_channels++] = static_cast<uint8_t>(channel);
}
}
filtered_data.resize(static_cast<size_t>(total_frames) * target_bytes_per_sample * target_channels);
// Applies the gain at full precision before narrowing; samples saturate at full scale, and narrowing rounds
esp_audio_libs::pcm_convert::copy_frames_with_gain(data.data(), filtered_data.data(), source_info.samples_to_bytes(1),
source_info.get_channels(), target_bytes_per_sample,
channel_map.data(), target_channels,
factor_to_linear_gain(this->gain_factor_), total_frames);
}
} // namespace esphome::microphone
@@ -0,0 +1 @@
DOMAIN = "midea_portasplit"
@@ -0,0 +1,30 @@
import esphome.codegen as cg
from esphome.components import climate, uart
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import DOMAIN
CODEOWNERS = ["@Fexiven"]
DEPENDENCIES = ["uart"]
midea_portasplit_ns = cg.esphome_ns.namespace("midea_portasplit")
PortaSplitClimate = midea_portasplit_ns.class_(
"PortaSplitClimate", climate.Climate, uart.UARTDevice, cg.Component
)
CONFIG_SCHEMA = (
climate.climate_schema(PortaSplitClimate)
.extend(uart.UART_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
DOMAIN, baud_rate=9600, require_tx=True, require_rx=True
)
async def to_code(config: ConfigType) -> None:
var = await climate.new_climate(config)
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
@@ -0,0 +1,337 @@
#include "midea_portasplit.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::midea_portasplit {
ESPHOME_LOG_TAG(TAG, "midea_portasplit");
static constexpr uint32_t FOLLOWUP_TIMEOUT_ID = 0;
static constexpr uint32_t STATUS_INTERVAL_MS = 30000;
static constexpr uint32_t NETWORK_INTERVAL_MS = 10000;
static constexpr uint32_t FOLLOWUP_DELAY_MS = 1500;
// At 9600 baud bytes within a frame arrive about 1 ms apart.
static constexpr uint32_t RX_TIMEOUT_MS = 100;
// Ten header bytes plus CRC8 and checksum.
static constexpr size_t FRAME_OVERHEAD = 12;
static constexpr size_t MAX_TX_PAYLOAD = 24;
static constexpr float MIN_TARGET_TEMP = 16.0f;
static constexpr float MAX_TARGET_TEMP = 30.0f;
// The AC expects its Wi-Fi module to answer the 0x63 keepalive poll and to send periodic 0x0D network status
// frames. These frames use the XOR sync byte (0x1E ^ 0xAC = 0xB2). The AC does not use the IP address; the
// fixed frame below was captured from the original module.
static constexpr uint8_t MODULE_FRAME[31] PROGMEM = {0xAA, 0x1E, 0xAC, 0xB2, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01,
0x01, 0x04, 0xC0, 0xA8, 0xB2, 0x72, 0x00, 0x00, 0x00, 0x01, 0x01,
0x01, 0x28, 0x09, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00};
static constexpr uint8_t STATUS_QUERY[12] PROGMEM = {0x41, 0x21, 0x00, 0xFF, 0x03, 0xFF,
0x00, 0x02, 0x00, 0x00, 0x00, 0x00};
// Same capability queries the original module sends at boot.
static constexpr uint8_t CAPS_0[] PROGMEM = {0xB5, 0x01, 0x00};
static constexpr uint8_t CAPS_1[] PROGMEM = {0xB5, 0x01, 0x01, 0x01};
// Two's complement of the sum of all bytes after the 0xAA start marker.
static uint8_t checksum(const uint8_t *frame, size_t len) { return (uint8_t) -sum_bytes(frame + 1, len - 1); }
static uint8_t climate_mode_to_midea(climate::ClimateMode mode) {
switch (mode) {
case climate::CLIMATE_MODE_COOL:
return 2;
case climate::CLIMATE_MODE_DRY:
return 3;
case climate::CLIMATE_MODE_HEAT:
return 4;
case climate::CLIMATE_MODE_FAN_ONLY:
return 5;
case climate::CLIMATE_MODE_HEAT_COOL:
default:
return 1;
}
}
static climate::ClimateMode midea_mode_to_climate(uint8_t mode) {
switch (mode) {
case 2:
return climate::CLIMATE_MODE_COOL;
case 3:
return climate::CLIMATE_MODE_DRY;
case 4:
return climate::CLIMATE_MODE_HEAT;
case 5:
return climate::CLIMATE_MODE_FAN_ONLY;
case 1:
default:
return climate::CLIMATE_MODE_HEAT_COOL;
}
}
static uint8_t fan_mode_to_midea(climate::ClimateFanMode mode) {
switch (mode) {
case climate::CLIMATE_FAN_QUIET:
return 20;
case climate::CLIMATE_FAN_LOW:
return 40;
case climate::CLIMATE_FAN_MEDIUM:
return 60;
case climate::CLIMATE_FAN_HIGH:
return 80;
default:
return FAN_AUTO;
}
}
// The AC reports its own fan steps (30 for low, 50 for medium), so classify by range.
static climate::ClimateFanMode midea_fan_to_climate(uint8_t fan) {
if (fan > 100)
return climate::CLIMATE_FAN_AUTO;
if (fan <= 30)
return climate::CLIMATE_FAN_QUIET;
if (fan <= 50)
return climate::CLIMATE_FAN_LOW;
if (fan <= 70)
return climate::CLIMATE_FAN_MEDIUM;
return climate::CLIMATE_FAN_HIGH;
}
void PortaSplitClimate::setup() {
// The status interval starts with a random offset of up to 5 s, which also serves as the boot query.
this->set_timeout(10000, [this]() { this->send_capability_queries_(); });
this->set_interval(STATUS_INTERVAL_MS, [this]() { this->send_status_query_(); });
this->set_interval(NETWORK_INTERVAL_MS, [this]() { this->send_module_frame_(0x0D, this->net_counter_++, 0x08); });
}
void PortaSplitClimate::loop() {
const uint32_t now = App.get_loop_component_start_time();
uint8_t buf[32];
size_t n;
while ((n = std::min((size_t) this->available(), sizeof(buf))) != 0 && this->read_array(buf, n)) {
for (size_t i = 0; i < n; i++)
this->rx_feed_(buf[i]);
this->rx_last_byte_ = now;
}
// The line went quiet mid-frame; drop it so its bytes cannot swallow the next one.
if (this->rx_len_ != 0 && now - this->rx_last_byte_ > RX_TIMEOUT_MS)
this->rx_len_ = 0;
}
void PortaSplitClimate::dump_config() {
ESP_LOGCONFIG(TAG, "Midea PortaSplit:");
this->dump_traits_(TAG);
}
climate::ClimateTraits PortaSplitClimate::traits() {
auto traits = climate::ClimateTraits();
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_visual_min_temperature(MIN_TARGET_TEMP);
traits.set_visual_max_temperature(MAX_TARGET_TEMP);
traits.set_visual_temperature_step(0.5f);
traits.set_supported_modes({
climate::CLIMATE_MODE_OFF,
climate::CLIMATE_MODE_HEAT_COOL,
climate::CLIMATE_MODE_COOL,
climate::CLIMATE_MODE_DRY,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_MODE_FAN_ONLY,
});
traits.set_supported_fan_modes({
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_FAN_QUIET,
climate::CLIMATE_FAN_LOW,
climate::CLIMATE_FAN_MEDIUM,
climate::CLIMATE_FAN_HIGH,
});
traits.set_supported_swing_modes({
climate::CLIMATE_SWING_OFF,
climate::CLIMATE_SWING_VERTICAL,
});
traits.set_supported_presets({
climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_BOOST,
climate::CLIMATE_PRESET_ECO,
climate::CLIMATE_PRESET_AWAY, // Frost protection (8 degC heating)
});
return traits;
}
void PortaSplitClimate::control(const climate::ClimateCall &call) {
if (call.get_mode().has_value()) {
const climate::ClimateMode mode = *call.get_mode();
this->st_power_ = mode != climate::CLIMATE_MODE_OFF;
if (this->st_power_)
this->st_mode_ = climate_mode_to_midea(mode);
// Frost protection only runs while heating.
if (mode != climate::CLIMATE_MODE_HEAT)
this->st_frost_ = false;
}
if (call.get_target_temperature().has_value()) {
const float target = clamp(*call.get_target_temperature(), MIN_TARGET_TEMP, MAX_TARGET_TEMP);
this->st_target_half_ = (uint8_t) lroundf(target * 2.0f);
}
if (call.get_fan_mode().has_value())
this->st_fan_ = fan_mode_to_midea(*call.get_fan_mode());
if (call.get_swing_mode().has_value())
this->st_swing_ = *call.get_swing_mode() == climate::CLIMATE_SWING_VERTICAL;
if (call.get_preset().has_value()) {
const climate::ClimatePreset preset = *call.get_preset();
this->st_turbo_ = preset == climate::CLIMATE_PRESET_BOOST;
this->st_sleep_ = preset == climate::CLIMATE_PRESET_SLEEP;
this->st_eco_ = preset == climate::CLIMATE_PRESET_ECO;
this->st_frost_ = preset == climate::CLIMATE_PRESET_AWAY;
if (this->st_frost_) {
this->st_mode_ = climate_mode_to_midea(climate::CLIMATE_MODE_HEAT);
this->st_power_ = true;
}
}
this->send_set_command_();
}
void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len) {
uint8_t f[FRAME_OVERHEAD + MAX_TX_PAYLOAD] = {0};
const size_t total = FRAME_OVERHEAD + len;
f[0] = 0xAA;
f[1] = (uint8_t) (total - 1);
f[2] = 0xAC;
// f[3] is the sync byte and must stay 0x00 on the PortaSplit; f[4..7] are reserved.
f[8] = proto;
f[9] = type;
progmem_memcpy(&f[10], payload, len);
f[10 + len] = crc8(&f[10], (uint8_t) len);
f[11 + len] = checksum(f, 11 + len);
this->write_array(f, total);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(sizeof(f))];
ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total));
#endif
}
void PortaSplitClimate::send_set_command_() {
uint8_t p[MAX_TX_PAYLOAD] = {0};
const uint8_t whole = this->st_target_half_ >> 1;
const bool half = this->st_target_half_ & 1;
p[0] = 0x40;
p[1] = 0x42; // 0x02 is mandatory, 0x40 enables the beeper
if (this->st_power_)
p[1] |= 0x01;
p[2] = (uint8_t) ((this->st_mode_ << 5) | (half ? 0x10 : 0x00) | ((whole - 16) & 0x0F));
p[3] = this->st_fan_;
p[4] = 0x7F;
p[5] = 0x7F;
p[6] = 0xF0;
p[7] = this->st_swing_ ? 0x3C : 0x30;
p[8] = this->st_turbo_ ? 0x20 : 0x00;
p[9] = (uint8_t) ((this->st_ion_ ? 0x20 : 0x00) | (this->st_eco_ ? 0x80 : 0x00));
p[10] = this->st_sleep_ ? 0x01 : 0x00;
p[18] = (uint8_t) ((whole - 12) & 0x1F);
p[19] = 0x80;
p[21] = 0x0A;
p[22] = this->st_frost_ ? 0x80 : 0x00;
p[23] = this->msg_id_++;
this->send_frame_(0x02, 0x02, p, sizeof(p));
// Read back the state the AC actually applied.
this->set_timeout(FOLLOWUP_TIMEOUT_ID, FOLLOWUP_DELAY_MS, [this]() { this->send_status_query_(); });
}
void PortaSplitClimate::send_status_query_() {
uint8_t p[sizeof(STATUS_QUERY)];
progmem_memcpy(p, STATUS_QUERY, sizeof(p));
p[11] = this->msg_id_++;
this->send_frame_(0x00, 0x03, p, sizeof(p));
}
void PortaSplitClimate::send_capability_queries_() {
this->send_frame_(0x03, 0x03, CAPS_0, sizeof(CAPS_0));
this->set_timeout(1000, [this]() { this->send_frame_(0x03, 0x03, CAPS_1, sizeof(CAPS_1)); });
}
void PortaSplitClimate::send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags) {
uint8_t f[sizeof(MODULE_FRAME)];
progmem_memcpy(f, MODULE_FRAME, sizeof(f));
f[6] = counter;
f[9] = type;
f[25] = wifi_flags;
f[30] = checksum(f, 30);
this->write_array(f, sizeof(f));
}
void PortaSplitClimate::rx_feed_(uint8_t b) {
if (this->rx_len_ == 0 && b != 0xAA)
return;
this->rx_[this->rx_len_++] = b;
if (this->rx_len_ < 3)
return;
const size_t total = (size_t) this->rx_[1] + 1;
// Every frame carries the appliance type 0xAC; anything else is a false start.
if (this->rx_len_ == 3 && (total < FRAME_OVERHEAD || total > MAX_FRAME || this->rx_[2] != 0xAC)) {
this->rx_len_ = 0;
return;
}
if (this->rx_len_ < total)
return;
this->rx_len_ = 0;
if (checksum(this->rx_.data(), total - 1) != this->rx_[total - 1]) {
ESP_LOGW(TAG, "RX checksum mismatch, dropping frame");
return;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MAX_FRAME)];
ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total));
#endif
this->handle_frame_(this->rx_.data(), total);
}
void PortaSplitClimate::handle_frame_(const uint8_t *f, size_t len) {
if (f[9] == 0x63) {
this->send_module_frame_(0x63, 0, 0x00);
} else if (f[9] == 0x05 && f[10] == 0xB5) {
// The original module acknowledges telemetry pushes by echoing them back.
this->write_array(f, len);
} else if (f[10] == 0xC0) {
this->handle_c0_(f, len);
}
}
void PortaSplitClimate::handle_c0_(const uint8_t *f, size_t len) {
if (len < 26)
return;
const uint8_t mode = (f[12] >> 5) & 0x07;
const uint8_t target_half = (uint8_t) ((((f[12] & 0x0F) + 16) << 1) | ((f[12] & 0x10) ? 1 : 0));
const climate::ClimateFanMode fan = midea_fan_to_climate(f[13]);
this->st_power_ = f[11] & 0x01;
if (mode >= 1 && mode <= 5)
this->st_mode_ = mode;
this->st_target_half_ = target_half;
this->st_fan_ = fan_mode_to_midea(fan);
this->st_swing_ = (f[17] & 0x0F) == 0x0C;
this->st_turbo_ = f[18] & 0x20;
this->st_ion_ = f[19] & 0x20;
this->st_eco_ = f[19] & 0x10; // Set command uses bit 7, readback uses bit 4.
this->st_sleep_ = f[20] & 0x01;
this->st_frost_ = len > 31 && (f[31] & 0x80);
this->mode = this->st_power_ ? midea_mode_to_climate(this->st_mode_) : climate::CLIMATE_MODE_OFF;
this->target_temperature = target_half / 2.0f;
// 0x00 and 0xFF mean the indoor probe gave no reading.
const uint8_t raw_temp = f[21];
this->current_temperature = (raw_temp == 0x00 || raw_temp == 0xFF) ? NAN : ((float) raw_temp - 50.0f) / 2.0f;
this->fan_mode = fan;
this->swing_mode = this->st_swing_ ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
if (this->st_frost_) {
this->preset = climate::CLIMATE_PRESET_AWAY;
} else if (this->st_turbo_) {
this->preset = climate::CLIMATE_PRESET_BOOST;
} else if (this->st_sleep_) {
this->preset = climate::CLIMATE_PRESET_SLEEP;
} else if (this->st_eco_) {
this->preset = climate::CLIMATE_PRESET_ECO;
} else {
this->preset = climate::CLIMATE_PRESET_NONE;
}
this->publish_state();
}
} // namespace esphome::midea_portasplit
@@ -0,0 +1,63 @@
#pragma once
// Midea PortaSplit climate component.
//
// The PortaSplit uses sync byte 0x00 for climate frames and silently drops the
// XOR sync byte (LEN ^ 0xAC) frames that the `midea` component sends, which is
// why the stock `midea` platform cannot drive this unit.
//
// Frame: AA | LEN | AC | SYNC | 00 00 00 00 | PROTO | TYPE | PAYLOAD | CRC8 | CS
#include "esphome/core/component.h"
#include "esphome/components/uart/uart.h"
#include "esphome/components/climate/climate.h"
#include <array>
namespace esphome::midea_portasplit {
static constexpr uint8_t FAN_AUTO = 102;
class PortaSplitClimate : public climate::Climate, public uart::UARTDevice, public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
climate::ClimateTraits traits() override;
void control(const climate::ClimateCall &call) override;
protected:
static constexpr size_t MAX_FRAME = 128;
// The payload may live in PROGMEM.
void send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len);
void send_set_command_();
void send_status_query_();
void send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags);
void send_capability_queries_();
void rx_feed_(uint8_t b);
void handle_frame_(const uint8_t *f, size_t len);
void handle_c0_(const uint8_t *f, size_t len);
uint32_t rx_last_byte_{0};
// Last known device state, mirrored from C0 so a set command does not clobber it.
uint8_t st_target_half_{48}; // Target temperature in half degrees
uint8_t st_mode_{1};
uint8_t st_fan_{FAN_AUTO};
bool st_power_ : 1 {false};
bool st_swing_ : 1 {false};
bool st_turbo_ : 1 {false};
bool st_ion_ : 1 {false};
bool st_sleep_ : 1 {false};
bool st_eco_ : 1 {false};
bool st_frost_ : 1 {false};
uint8_t msg_id_{1};
uint8_t net_counter_{0};
uint8_t rx_len_{0};
std::array<uint8_t, MAX_FRAME> rx_{};
};
} // namespace esphome::midea_portasplit
+40 -45
View File
@@ -53,7 +53,6 @@ from esphome.const import (
CONF_SUBSCRIBE_QOS,
CONF_TOPIC,
CONF_TOPIC_PREFIX,
CONF_TRIGGER_ID,
CONF_USE_ABBREVIATIONS,
CONF_USERNAME,
CONF_WILL_MESSAGE,
@@ -119,18 +118,6 @@ MQTTMessage = mqtt_ns.struct("MQTTMessage")
MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason")
MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component)
MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action)
MQTTMessageTrigger = mqtt_ns.class_(
"MQTTMessageTrigger", automation.Trigger.template(cg.std_string), cg.Component
)
MQTTJsonMessageTrigger = mqtt_ns.class_(
"MQTTJsonMessageTrigger", automation.Trigger.template(cg.JsonObjectConst)
)
MQTTConnectTrigger = mqtt_ns.class_(
"MQTTConnectTrigger", automation.Trigger.template(cg.bool_)
)
MQTTDisconnectTrigger = mqtt_ns.class_(
"MQTTDisconnectTrigger", automation.Trigger.template(MQTTClientDisconnectReason)
)
MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component)
MQTTAlarmControlPanelComponent = mqtt_ns.class_(
@@ -229,7 +216,6 @@ JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation(
"on_json_message",
"the parsed JSON document",
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger),
cv.Required(CONF_TOPIC): cv.subscribe_topic,
cv.Optional(CONF_QOS, default=0): cv.mqtt_qos,
},
@@ -295,21 +281,10 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(
CONF_REBOOT_TIMEOUT, default="15min"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTConnectTrigger),
}
),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
MQTTDisconnectTrigger
),
}
),
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(),
cv.Optional(CONF_ON_MESSAGE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTMessageTrigger),
cv.Required(CONF_TOPIC): cv.subscribe_topic,
cv.Optional(CONF_QOS, default=0): cv.mqtt_qos,
cv.Optional(CONF_PAYLOAD): cv.string_strict,
@@ -346,6 +321,18 @@ def exp_mqtt_message(config):
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_CONNECT, "set_on_connect", [(cg.bool_, "session_present")]
),
automation.CallbackAutomation(
CONF_ON_DISCONNECT,
"set_on_disconnect",
[(MQTTClientDisconnectReason, "reason")],
),
)
@coroutine_with_priority(CoroPriority.WEB)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -460,29 +447,37 @@ async def to_code(config):
cg.add_define("USE_MQTT_IDF_ENQUEUE")
# end esp-idf
# The client queues subscriptions until it connects, so they can be made at construction.
for conf in config.get(CONF_ON_MESSAGE, []):
trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC])
cg.add(trig.set_qos(conf[CONF_QOS]))
if CONF_PAYLOAD in conf:
cg.add(trig.set_payload(conf[CONF_PAYLOAD]))
await cg.register_component(trig, conf)
await automation.build_automation(trig, [(cg.std_string, "x")], conf)
callback = await automation.build_trigger_callback(
[(cg.std_string, "x")],
conf,
params=[(cg.std_string, "topic"), (cg.std_string, "payload")],
forward=["payload"],
# The length is compared first; the literal stays in flash on ESP8266.
when=automation.ApplyCall(
"StringRef(payload) == {}",
((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),),
),
)
cg.add(
var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS])
)
for conf in config.get(CONF_ON_JSON_MESSAGE, []):
trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC], conf[CONF_QOS])
await automation.build_automation(trig, [(cg.JsonObjectConst, "x")], conf)
for conf in config.get(CONF_ON_CONNECT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(cg.bool_, "session_present")], conf
callback = await automation.build_trigger_callback(
[(cg.JsonObjectConst, "x")],
conf,
params=[(cg.std_string, "topic"), (cg.JsonObject, "root")],
forward=["root"],
)
cg.add(
var.subscribe_json(
cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]
)
)
for conf in config.get(CONF_ON_DISCONNECT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(MQTTClientDisconnectReason, "reason")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
cg.add(var.set_publish_nan_as_none(config[CONF_PUBLISH_NAN_AS_NONE]))
+42 -54
View File
@@ -461,34 +461,18 @@ void MQTTClientComponent::resubscribe_subscriptions_() {
}
}
void MQTTClientComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) {
MQTTSubscription subscription{
.topic = topic,
void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed,
uint8_t qos) {
auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{
.topic = std::move(topic),
.qos = qos,
.callback = std::move(callback),
.subscribed = false,
.boxed = boxed,
.removed = false,
.callback = callback,
.resubscribe_timeout = 0,
};
});
this->resubscribe_subscription_(&subscription);
this->subscriptions_.push_back(subscription);
}
void MQTTClientComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) {
auto f = [callback](const std::string &topic, const std::string &payload) {
json::parse_json(payload, [topic, callback](JsonObject root) -> bool {
callback(topic, root);
return true;
});
};
MQTTSubscription subscription{
.topic = topic,
.qos = qos,
.callback = f,
.subscribed = false,
.resubscribe_timeout = 0,
};
this->resubscribe_subscription_(&subscription);
this->subscriptions_.push_back(subscription);
}
void MQTTClientComponent::unsubscribe(const std::string &topic) {
@@ -502,10 +486,25 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) {
this->status_momentary_warning(1000);
}
auto it = subscriptions_.begin();
while (it != subscriptions_.end()) {
if (it->topic == topic) {
it = subscriptions_.erase(it);
for (auto &subscription : this->subscriptions_) {
if (subscription.topic == topic)
subscription.removed = true;
}
// A callback may be running; the outermost dispatch erases them once it returns.
if (this->dispatching_) {
this->removed_while_dispatching_ = true;
} else {
this->erase_removed_subscriptions_();
}
}
void MQTTClientComponent::erase_removed_subscriptions_() {
auto it = this->subscriptions_.begin();
while (it != this->subscriptions_.end()) {
if (it->removed) {
if (it->boxed)
it->callback.free_boxed();
it = this->subscriptions_.erase(it);
} else {
++it;
}
@@ -660,9 +659,21 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string
// in simple tests but will cause crashes with complex automations.
this->defer([this, topic, payload]() {
#endif
for (auto &subscription : this->subscriptions_) {
if (topic_match(topic.c_str(), subscription.topic.c_str()))
subscription.callback(topic, payload);
// Indexed with the count taken up front: a callback may subscribe, which can reallocate the
// vector, and the new entry waits for the next message.
// Restored rather than cleared, so only the outermost dispatch erases what was unsubscribed meanwhile.
const bool was_dispatching = this->dispatching_;
this->dispatching_ = true;
const size_t count = this->subscriptions_.size();
for (size_t i = 0; i < count; i++) {
const auto &subscription = this->subscriptions_[i];
if (!subscription.removed && topic_match(topic.c_str(), subscription.topic.c_str()))
subscription.callback.call(topic, payload);
}
this->dispatching_ = was_dispatching;
if (!was_dispatching && this->removed_while_dispatching_) {
this->removed_while_dispatching_ = false;
this->erase_removed_subscriptions_();
}
#ifdef USE_ESP8266
});
@@ -762,29 +773,6 @@ void MQTTClientComponent::set_on_disconnect(mqtt_on_disconnect_callback_t &&call
MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// MQTTMessageTrigger
MQTTMessageTrigger::MQTTMessageTrigger(std::string topic) : topic_(std::move(topic)) {}
void MQTTMessageTrigger::setup() {
global_mqtt_client->subscribe(
this->topic_,
[this](const std::string &topic, const std::string &payload) {
if (this->payload_.has_value() && payload != *this->payload_) {
return;
}
this->trigger(payload);
},
this->qos_);
}
void MQTTMessageTrigger::dump_config() {
ESP_LOGCONFIG(TAG,
"MQTT Message Trigger:\n"
" Topic: '%s'\n"
" QoS: %u",
this->topic_.c_str(), this->qos_);
}
float MQTTMessageTrigger::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
} // namespace esphome::mqtt
#endif // USE_MQTT
+43 -47
View File
@@ -31,19 +31,24 @@ namespace esphome::mqtt {
using mqtt_on_connect_callback_t = std::function<MQTTBackend::on_connect_callback_t>;
using mqtt_on_disconnect_callback_t = std::function<MQTTBackend::on_disconnect_callback_t>;
/** Callback for MQTT subscriptions.
*
* First parameter is the topic, the second one is the payload.
*/
using mqtt_callback_t = std::function<void(const std::string &, const std::string &)>;
using mqtt_json_callback_t = std::function<void(const std::string &, JsonObject)>;
/// Stored callback of an MQTT subscription: the topic, then the payload.
using mqtt_subscription_callback_t = Callback<void(const std::string &, const std::string &)>;
// Remove before 2027.5.0
using mqtt_callback_t ESPDEPRECATED("subscribe() takes any callable; pass the lambda directly. Removed in 2027.5.0",
"2026.11.0") = std::function<void(const std::string &, const std::string &)>;
using mqtt_json_callback_t ESPDEPRECATED(
"subscribe_json() takes any callable; pass the lambda directly. Removed in 2027.5.0",
"2026.11.0") = std::function<void(const std::string &, JsonObject)>;
/// internal struct for MQTT subscriptions.
struct MQTTSubscription {
std::string topic;
uint8_t qos;
mqtt_callback_t callback;
bool subscribed;
bool boxed; // made by create_boxed(), freed on unsubscribe
bool removed; // unsubscribed while a callback ran, erased once dispatch returns
mqtt_subscription_callback_t callback;
uint32_t resubscribe_timeout;
};
@@ -165,12 +170,21 @@ class MQTTClientComponent final : public Component {
bool is_log_message_enabled() const;
/** Subscribe to an MQTT topic and call callback when a message is received.
*
* A callback that fits in a pointer (capture at most `this`) is stored inline; a larger one is
* boxed on the heap and freed by unsubscribe().
*
* @param topic The topic. Wildcards are currently not supported.
* @param callback The callback function.
* @param qos The QoS of this subscription.
*/
void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0);
template<typename F> void subscribe(std::string topic, F &&callback, uint8_t qos = 0) {
constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline<F>();
this->add_subscription_(std::move(topic),
boxed ? mqtt_subscription_callback_t::create_boxed(std::forward<F>(callback))
: mqtt_subscription_callback_t::create(std::forward<F>(callback)),
boxed, qos);
}
/** Subscribe to a MQTT topic and automatically parse JSON payload.
*
@@ -181,11 +195,21 @@ class MQTTClientComponent final : public Component {
* received.
* @param qos The QoS of this subscription.
*/
void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0);
template<typename F> void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) {
static_assert(std::is_invocable_v<const std::decay_t<F> &, const std::string &, JsonObject>,
"subscribe_json() needs a callback that does not mutate its captures");
this->subscribe(
std::move(topic),
[cb = std::forward<F>(callback)](const std::string &topic, const std::string &payload) {
call_json(cb, topic, payload);
},
qos);
}
/** Unsubscribe from an MQTT topic.
*
* If multiple existing subscriptions to the same topic exist, all of them will be removed.
* From inside a subscription callback they stop receiving messages at once and are erased once it returns.
*
* @param topic The topic to unsubscribe from.
* Must match the topic in the original subscribe or subscribe_json call exactly.
@@ -285,6 +309,14 @@ class MQTTClientComponent final : public Component {
bool subscribe_(const char *topic, uint8_t qos);
void resubscribe_subscription_(MQTTSubscription *sub);
template<typename F> static void call_json(const F &callback, const std::string &topic, const std::string &payload) {
JsonDocument doc = json::parse_json(payload);
if (doc.overflowed() || doc.isNull())
return;
callback(topic, doc.as<JsonObject>());
}
void erase_removed_subscriptions_();
void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
void resubscribe_subscriptions_();
MQTTCredentials credentials_;
@@ -336,48 +368,12 @@ class MQTTClientComponent final : public Component {
bool publish_nan_as_none_{false};
bool wait_for_connection_{false};
bool dispatching_{false};
bool removed_while_dispatching_{false};
};
extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
class MQTTMessageTrigger final : public Trigger<std::string>, public Component {
public:
explicit MQTTMessageTrigger(std::string topic);
void set_qos(uint8_t qos) { this->qos_ = qos; }
void set_payload(const std::string &payload) { this->payload_ = payload; }
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
protected:
std::string topic_;
uint8_t qos_{0};
optional<std::string> payload_;
};
class MQTTJsonMessageTrigger final : public Trigger<JsonObjectConst> {
public:
explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) {
global_mqtt_client->subscribe_json(
topic, [this](const std::string &topic, JsonObject root) { this->trigger(root); }, qos);
}
};
class MQTTConnectTrigger final : public Trigger<bool> {
public:
explicit MQTTConnectTrigger(MQTTClientComponent *client) {
client->set_on_connect([this](bool session_present) { this->trigger(session_present); });
}
};
class MQTTDisconnectTrigger final : public Trigger<MQTTClientDisconnectReason> {
public:
explicit MQTTDisconnectTrigger(MQTTClientComponent *client) {
client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); });
}
};
template<typename... Ts> class MQTTPublishJsonAction final : public Action<Ts...> {
public:
MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {}
@@ -342,14 +342,6 @@ bool MQTTComponent::is_discovery_enabled() const {
return this->discovery_enabled_ && global_mqtt_client->is_discovery_enabled();
}
void MQTTComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) {
global_mqtt_client->subscribe(topic, std::move(callback), qos);
}
void MQTTComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) {
global_mqtt_client->subscribe_json(topic, callback, qos);
}
MQTTComponent::MQTTComponent() = default;
float MQTTComponent::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
+6 -2
View File
@@ -259,7 +259,9 @@ class MQTTComponent : public Component {
* @param callback The callback that will be called when a message with matching topic is received.
* @param qos The MQTT quality of service. Defaults to 0.
*/
void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0);
template<typename F> void subscribe(std::string topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe(std::move(topic), std::forward<F>(callback), qos);
}
/** Subscribe to a MQTT topic and automatically parse JSON payload.
*
@@ -270,7 +272,9 @@ class MQTTComponent : public Component {
* received.
* @param qos The MQTT quality of service. Defaults to 0.
*/
void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0);
template<typename F> void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) {
global_mqtt_client->subscribe_json(std::move(topic), std::forward<F>(callback), qos);
}
protected:
static constexpr uint32_t SEND_DEFER_ID = 0;
+3 -2
View File
@@ -37,7 +37,8 @@ void PCF85063Component::read_time() {
.second = uint8_t(pcf85063_.reg.second + 10 * pcf85063_.reg.second_10),
.minute = uint8_t(pcf85063_.reg.minute + 10u * pcf85063_.reg.minute_10),
.hour = uint8_t(pcf85063_.reg.hour + 10u * pcf85063_.reg.hour_10),
.day_of_week = uint8_t(pcf85063_.reg.weekday),
// The chip counts weekdays 0-6 from Sunday, ESPTime counts 1-7
.day_of_week = uint8_t(this->pcf85063_.reg.weekday + 1),
.day_of_month = uint8_t(pcf85063_.reg.day + 10u * pcf85063_.reg.day_10),
.month = uint8_t(pcf85063_.reg.month + 10u * pcf85063_.reg.month_10),
.year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000),
@@ -62,7 +63,7 @@ void PCF85063Component::write_time() {
pcf85063_.reg.month_10 = now.month / 10;
pcf85063_.reg.day = now.day_of_month % 10;
pcf85063_.reg.day_10 = now.day_of_month / 10;
pcf85063_.reg.weekday = now.day_of_week;
this->pcf85063_.reg.weekday = now.day_of_week - 1;
pcf85063_.reg.hour = now.hour % 10;
pcf85063_.reg.hour_10 = now.hour / 10;
pcf85063_.reg.minute = now.minute % 10;
@@ -117,7 +117,7 @@ namespace esphome::shelly_dimmer {
namespace {
int flash_addr_to_page_ceil(const stm32_unique_ptr &stm, uint32_t addr) {
if (!(addr >= stm->dev->fl_start && addr <= stm->dev->fl_end))
if (addr < stm->dev->fl_start || addr > stm->dev->fl_end)
return 0;
int page = 0;
@@ -729,7 +729,7 @@ stm32_err_t stm32_write_memory(const stm32_unique_ptr &stm, uint32_t address, co
uint8_t buf[256 + 2];
buf[0] = aligned_len - 1;
for (auto i = 0; i < len; i++) {
for (unsigned int i = 0; i < len; i++) {
cs ^= data[i];
buf[i + 1] = data[i];
}
@@ -81,14 +81,13 @@ bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t
bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip,
uint16_t *port) {
if (!sockaddr_to_lwip(addr, addrlen, ip, port))
return false;
const bool ok = sockaddr_to_lwip(addr, addrlen, ip, port);
#if LWIP_IPV6
// Only promote wildcard binds — a specific address must keep filtering
if (family == AF_INET6 && ip_addr_isany_val(*ip))
if (ok && family == AF_INET6 && ip_addr_isany_val(*ip))
IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY);
#endif
return true;
return ok;
}
int lwip_err_to_errno(err_t err) {
+77
View File
@@ -24,7 +24,9 @@ CONF_CLOCK_FREQUENCY = "clock_frequency"
CONF_DIAG_PIN = "diag_pin"
CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle"
CONF_ENN_PIN = "enn_pin"
CONF_HEND = "hend"
CONF_HOLD_CURRENT = "hold_current"
CONF_HSTRT = "hstrt"
CONF_IHOLD = "ihold"
CONF_IHOLDDELAY = "iholddelay"
CONF_INDEX_PIN = "index_pin"
@@ -34,9 +36,18 @@ CONF_IRUN = "irun"
CONF_MICROSTEPS = "microsteps"
CONF_ON_STATUS = "on_status"
CONF_OTTRIM = "ottrim"
CONF_PWM_AUTOGRAD = "pwm_autograd"
CONF_PWM_AUTOSCALE = "pwm_autoscale"
CONF_PWM_FREQ = "pwm_freq"
CONF_PWM_GRAD = "pwm_grad"
CONF_PWM_LIM = "pwm_lim"
CONF_PWM_OFS = "pwm_ofs"
CONF_PWM_REG = "pwm_reg"
CONF_RSENSE = "rsense"
CONF_RUN_CURRENT = "run_current"
CONF_STANDSTILL_MODE = "standstill_mode"
CONF_TBL = "tbl"
CONF_TOFF = "toff"
CONF_TPOWERDOWN = "tpowerdown"
CONF_TPWM_THRESHOLD = "tpwm_threshold"
CONF_VSENSE = "vsense"
@@ -110,6 +121,29 @@ def _validate_analog_current_scale(config: ConfigType) -> ConfigType:
return config
def _validate_chopconf(config: ConfigType) -> ConfigType:
# Only values given as constants can be checked here, not lambdas
toff, tbl = config.get(CONF_TOFF), config.get(CONF_TBL)
# TBL is never written at setup, so the chip may still hold a blank time below 2
if toff == 1 and (tbl is None or (isinstance(tbl, int) and tbl < 2)):
raise cv.Invalid(
f"'{CONF_TOFF}: 1' needs '{CONF_TBL}' set to 2 or higher in the same "
"action",
path=[CONF_TOFF],
)
hend, hstrt = config.get(CONF_HEND), config.get(CONF_HSTRT)
# Register values are offsets: HEND 0-15 is -3 to 12, HSTRT 0-7 is 1 to 8
if isinstance(hend, int) and isinstance(hstrt, int):
hysteresis = (hend - 3) + (hstrt + 1)
if hysteresis > 16:
raise cv.Invalid(
f"'{CONF_HEND}' and '{CONF_HSTRT}' give a hysteresis of "
f"{hysteresis}, the limit is 16",
path=[CONF_HEND],
)
return config
def final_validate(config: ConfigType) -> ConfigType:
"""Reject two drivers of the family with the same address on one UART bus."""
for other in fv.full_config.get().get("stepper", []):
@@ -225,3 +259,46 @@ def register_actions(domain: str, cls: MockObjClass) -> None:
CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode
),
)
automation.register_apply_action(
f"{domain}.chopconf",
cv.All(
cv.Schema(
{
**id_schema,
cv.Optional(CONF_TOFF): cv.templatable(cv.int_range(1, 15)),
cv.Optional(CONF_TBL): cv.templatable(cv.int_range(0, 3)),
cv.Optional(CONF_HEND): cv.templatable(cv.int_range(0, 15)),
cv.Optional(CONF_HSTRT): cv.templatable(cv.int_range(0, 7)),
}
),
_validate_chopconf,
),
automation.ApplyField(CONF_TOFF, "set_toff", cg.uint8),
automation.ApplyField(CONF_TBL, "set_tbl", cg.uint8),
automation.ApplyField(CONF_HEND, "set_hend", cg.uint8),
automation.ApplyField(CONF_HSTRT, "set_hstrt", cg.uint8),
)
automation.register_apply_action(
f"{domain}.pwmconf",
cv.Schema(
{
**id_schema,
cv.Optional(CONF_PWM_OFS): cv.templatable(cv.uint8_t),
cv.Optional(CONF_PWM_GRAD): cv.templatable(cv.uint8_t),
cv.Optional(CONF_PWM_FREQ): cv.templatable(cv.int_range(0, 3)),
cv.Optional(CONF_PWM_AUTOSCALE): cv.templatable(cv.boolean),
cv.Optional(CONF_PWM_AUTOGRAD): cv.templatable(cv.boolean),
cv.Optional(CONF_PWM_REG): cv.templatable(cv.int_range(1, 15)),
cv.Optional(CONF_PWM_LIM): cv.templatable(cv.int_range(0, 15)),
}
),
automation.ApplyField(CONF_PWM_OFS, "set_pwm_ofs", cg.uint8),
automation.ApplyField(CONF_PWM_GRAD, "set_pwm_grad", cg.uint8),
automation.ApplyField(CONF_PWM_FREQ, "set_pwm_freq", cg.uint8),
automation.ApplyField(CONF_PWM_AUTOSCALE, "set_pwm_autoscale", cg.bool_),
automation.ApplyField(CONF_PWM_AUTOGRAD, "set_pwm_autograd", cg.bool_),
automation.ApplyField(CONF_PWM_REG, "set_pwm_reg", cg.uint8),
automation.ApplyField(CONF_PWM_LIM, "set_pwm_lim", cg.uint8),
)
+7
View File
@@ -362,6 +362,13 @@ void TMC22XXStepper::set_enabled(bool enabled) {
this->enabled_ = enabled;
}
void TMC22XXStepper::set_toff(uint8_t toff) {
this->toff_ = toff;
// Without an ENN pin, TOFF=0 is what keeps the outputs disabled
if (this->enabled_ || this->enn_pin_ != nullptr)
this->write_field(TOFF, toff);
}
void TMC22XXStepper::set_microsteps(uint16_t microsteps) {
for (uint8_t mres = 0; mres <= 8; mres++) {
if ((256u >> mres) == microsteps) {
+24
View File
@@ -57,12 +57,22 @@ static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false};
static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false};
static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true};
static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false};
static constexpr RegisterField HSTRT{REG_CHOPCONF, 4, 3, false};
static constexpr RegisterField HEND{REG_CHOPCONF, 7, 4, false};
static constexpr RegisterField TBL{REG_CHOPCONF, 15, 2, false};
static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false};
static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false};
static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false};
static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false};
static constexpr RegisterField CS_ACTUAL{REG_DRV_STATUS, 16, 5, false};
static constexpr RegisterField PWM_OFS{REG_PWMCONF, 0, 8, false};
static constexpr RegisterField PWM_GRAD{REG_PWMCONF, 8, 8, false};
static constexpr RegisterField PWM_FREQ{REG_PWMCONF, 16, 2, false};
static constexpr RegisterField PWM_AUTOSCALE{REG_PWMCONF, 18, 1, false};
static constexpr RegisterField PWM_AUTOGRAD{REG_PWMCONF, 19, 1, false};
static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false};
static constexpr RegisterField PWM_REG{REG_PWMCONF, 24, 4, false};
static constexpr RegisterField PWM_LIM{REG_PWMCONF, 28, 4, false};
static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false};
static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true};
static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false};
@@ -160,6 +170,20 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); }
void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); }
void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); }
/// Chopper off time (1-15). TOFF=0 is used to disable the outputs, so this is applied on the next enable.
void set_toff(uint8_t toff);
void set_tbl(uint8_t tbl) { this->write_field(TBL, tbl); }
void set_hend(uint8_t hend) { this->write_field(HEND, hend); }
void set_hstrt(uint8_t hstrt) { this->write_field(HSTRT, hstrt); }
void set_pwm_ofs(uint8_t ofs) { this->write_field(PWM_OFS, ofs); }
void set_pwm_grad(uint8_t grad) { this->write_field(PWM_GRAD, grad); }
void set_pwm_freq(uint8_t freq) { this->write_field(PWM_FREQ, freq); }
void set_pwm_autoscale(bool enable) { this->write_field(PWM_AUTOSCALE, enable); }
void set_pwm_autograd(bool enable) { this->write_field(PWM_AUTOGRAD, enable); }
void set_pwm_reg(uint8_t reg) { this->write_field(PWM_REG, reg); }
void set_pwm_lim(uint8_t lim) { this->write_field(PWM_LIM, lim); }
/// Convert a current scale (0-31) to A RMS, without analog current scale.
float scale_to_current(uint8_t scale);
+11 -2
View File
@@ -442,6 +442,16 @@ class ID:
)
# Replaced by write_cpp with a #line back into main.cpp
LINE_DIRECTIVE_RESET = "// ESPHOME_LINE_DIRECTIVE_RESET"
def line_directive(document: str, line: int) -> str:
"""A #line directive naming the 1-based ``line`` of ``document``."""
escaped = document.replace("\\", "\\\\")
return f'#line {line} "{escaped}"'
class DocumentLocation:
def __init__(self, document: str, line: int, column: int):
self.document: str = document
@@ -457,8 +467,7 @@ class DocumentLocation:
@property
def as_line_directive(self):
document_path = str(self.document).replace("\\", "\\\\")
return f'#line {self.line + 1} "{document_path}"'
return line_directive(str(self.document), self.line + 1)
class DocumentRange:
+6 -2
View File
@@ -157,6 +157,7 @@
#define MDNS_SERVICE_COUNT 3
#define USE_MDNS_DYNAMIC_TXT
#define MDNS_DYNAMIC_TXT_COUNT 2
#define LD2450_POLYGON_ZONE_COUNT 2
#define MICRONOVA_LISTENER_COUNT 1
#define USE_MICRONOVA_WRITER
#define MK2PVROUTER_LISTENER_COUNT 1
@@ -526,7 +527,8 @@
#endif
#ifdef USE_ARDUINO
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7)
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 12)
#endif
#define USE_ETHERNET
#define USE_ETHERNET_LAN8720
#define USE_ETHERNET_RTL8201
@@ -557,7 +559,6 @@
#define USE_ETHERNET_DISCONNECT_TRIGGER
#define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2
#define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2
#endif
#ifdef USE_ESP32
#define USE_MICRO_WAKE_WORD
@@ -607,6 +608,9 @@
#define USE_ESP8266_LOGGER_SERIAL
#define USE_ESP8266_LOGGER_SERIAL1
#define USE_ESP8266_PREFERENCES_FLASH
#define ESP8266_PREFERENCES_FLASH_WORDS 128
#define ESP8266_PREFERENCES_RTC_EBOOT_WORDS 32
#define ESP8266_PREFERENCES_RTC_NORMAL_WORDS 78
#define USE_ESP8266_UART_SERIAL
#define USE_ESP8266_UART_SERIAL1
#define USE_HTTP_REQUEST_ESP8266_HTTPS
+33 -2
View File
@@ -1725,7 +1725,8 @@ enum class TemperatureUnit : uint8_t {
/// No null check, no exceptions, no heap allocation for small trivially-copyable callables.
///
/// With C++20 if constexpr, automatically detects [this] lambdas (sizeof <= sizeof(void*),
/// trivially copyable) and stores them inline. Larger callables are heap-allocated.
/// trivially copyable, callable as const) and stores them inline. Larger callables, and mutable
/// ones whose state must survive between calls, are heap-allocated.
template<typename... X> struct Callback;
template<typename... Ts> struct Callback<void(Ts...)> {
@@ -1740,11 +1741,19 @@ template<typename... Ts> struct Callback<void(Ts...)> {
/// Invoke the callback. Only valid on Callbacks created via create(), never on default-constructed instances.
void call(Ts... args) const { this->fn_(this->ctx_, std::forward<Ts>(args)...); }
/// Whether create() stores F inline in the ctx pointer. The inline path invokes a copy, so a
/// callable that mutates its captures (a mutable lambda) is kept whole on the heap instead.
template<typename F> static constexpr bool fits_inline() {
using DecayF = std::decay_t<F>;
return sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v<DecayF> &&
std::is_invocable_v<const DecayF &, Ts...>;
}
/// Create from any callable. Small trivially-copyable callables (like [this] lambdas)
/// are stored inline in the ctx pointer without heap allocation.
template<typename F> static Callback create(F &&callable) {
using DecayF = std::decay_t<F>;
if constexpr (sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v<DecayF>) {
if constexpr (fits_inline<F>()) {
// Small trivial callable (e.g. [this]() { this->method(); }) - store inline in ctx.
// Safe under C++20 (P0593R6): byte copy into aligned storage implicitly
// creates objects of implicit-lifetime types (trivially copyable qualifies).
@@ -1767,6 +1776,28 @@ template<typename... Ts> struct Callback<void(Ts...)> {
return {[](void *c, Ts... args) { (*static_cast<DecayF *>(c))(args...); }, static_cast<void *>(stored)};
}
}
/// Heap home of a callable stored by create_boxed(); the header knows how to free it.
struct Box {
void (*free)(Box *box);
};
/// Store any callable on the heap with a deleter, for an owner that can later free_boxed() it.
template<typename F> static Callback create_boxed(F &&callable) {
struct Boxed : Box {
std::decay_t<F> fn;
};
auto *box = new Boxed{{[](Box *b) { delete static_cast<Boxed *>(b); }}, // NOLINT(cppcoreguidelines-owning-memory)
std::forward<F>(callable)};
return {[](void *c, Ts... args) { static_cast<Boxed *>(static_cast<Box *>(c))->fn(args...); },
static_cast<Box *>(box)};
}
/// Free the callable of a Callback made by create_boxed(); the Callback must not be called afterwards.
void free_boxed() const {
auto *box = static_cast<Box *>(this->ctx_);
box->free(box);
}
};
/// Grow a CallbackManager's backing array to exactly size+1. Defined in helpers.cpp.
+8 -4
View File
@@ -8,6 +8,7 @@ from typing import Any
from esphome.core import (
CORE,
ID,
LINE_DIRECTIVE_RESET,
Define,
EnumValue,
EsphomeError,
@@ -214,9 +215,13 @@ class LambdaExpression(Expression):
if self.return_type is not None:
cpp += f" -> {self.return_type}"
cpp += " {\n"
body = f"{self.content}\n"
if self.source is not None:
cpp += f"{self.source.as_line_directive}\n"
cpp += f"{self.content}\n}}"
body = f"{self.source.as_line_directive}\n{body}"
# A #line in the body, the source's or lvgl's own marks, needs the reset after it
if "#line " in body:
body += f"{LINE_DIRECTIVE_RESET}\n"
cpp += f"{body}}}"
return indent_all_but_first_and_last(cpp)
@property
@@ -946,8 +951,7 @@ async def process_lambda(
parts[i * 3 + 2] = ""
if isinstance(value, ESPHomeDataBase) and value.esp_range is not None:
location = value.esp_range.start_mark
location.line += value.content_offset
location = value.content_start
else:
location = None
return LambdaExpression(parts, parameters, capture, return_type, location)
+3
View File
@@ -85,6 +85,9 @@ dependencies:
version: "1.1.0"
rules:
- if: "idf_version >=6.0.0"
# Keep in sync with esphome/components/ethernet/__init__.py
espressif/ch390:
version: "0.3.0"
espressif/esp_tinyusb:
version: "2.2.1"
rules:
+14
View File
@@ -102,3 +102,17 @@ def is_in_flash(value: str) -> bool:
if not in_flash:
request_rtc_storage()
return in_flash
def reserve_preferences(
count: int, value_bytes: int, in_flash: bool | None = None
) -> None:
"""Reserve saved values from to_code so a platform with a small store can check they all fit.
in_flash is the value's own storage (is_in_flash(config[CONF_STORAGE])); leave it None for a
component that calls the two-argument make_preference() and takes the platform default.
"""
if CORE.is_esp8266:
from esphome.components.esp8266.const import reserve_preferences as reserve
reserve(count, value_bytes, in_flash)
+21 -2
View File
@@ -15,7 +15,7 @@ from esphome.const import (
SOURCE_FILE_EXTENSIONS,
__version__,
)
from esphome.core import CORE, EsphomeError
from esphome.core import CORE, LINE_DIRECTIVE_RESET, EsphomeError, line_directive
from esphome.helpers import (
copy_file_if_changed,
cpp_string_escape,
@@ -514,6 +514,25 @@ const char ESPHOME_COMMENT_STR[] = "{escaped_comment}";
"""
def _reset_line_directives(text: str, path: Path) -> str:
"""Point the code after each lambda back at its own line of *path*.
A lambda's #line directive points the compiler at the YAML; without a reset
every later line of main.cpp would be reported against the YAML too.
"""
if LINE_DIRECTIVE_RESET not in text:
return text
document = str(path.absolute())
lines = text.split("\n")
for index, line in enumerate(lines):
if line.endswith(LINE_DIRECTIVE_RESET):
# The directive numbers the line after it: index + 2, counted from 1
lines[index] = line.replace(
LINE_DIRECTIVE_RESET, line_directive(document, index + 2)
)
return "\n".join(lines)
def write_cpp(code_s):
path = CORE.relative_src_path("main.cpp")
if path.is_file():
@@ -540,7 +559,7 @@ def write_cpp(code_s):
f"{code_format[1] + CPP_AUTO_GENERATE_BEGIN}\n{code_s}{CPP_AUTO_GENERATE_END}"
)
full_file += code_format[2]
write_file_if_changed(path, full_file)
write_file_if_changed(path, _reset_line_directives(full_file, path))
def clean_cmake_cache():
+12 -5
View File
@@ -29,6 +29,7 @@ from esphome.config_helpers import Extend, Remove
from esphome.const import CONF_DEFAULTS
from esphome.core import (
CORE,
DocumentLocation,
DocumentRange,
EsphomeError,
Lambda,
@@ -142,14 +143,20 @@ class ESPHomeDataBase:
def content_offset(self):
return getattr(self, "_content_offset", 0)
@property
def content_start(self) -> DocumentLocation:
"""Where the value's own text starts: the line after the marker for a block scalar."""
start = self._esp_range.start_mark
return DocumentLocation(
start.document, start.line + self.content_offset, start.column
)
def from_node(self, node):
# pylint: disable=attribute-defined-outside-init
self._esp_range = DocumentRange.from_marks(node.start_mark, node.end_mark)
if (
isinstance(node, yaml.ScalarNode)
and node.style is not None
and node.style in "|>"
):
# Only block scalars (| and >) start their content on the next line. A plain
# scalar's style is "", which `in "|>"` would also match.
if isinstance(node, yaml.ScalarNode) and node.style in ("|", ">"):
self._content_offset = 1
def from_database(self, database):
+2
View File
@@ -599,6 +599,8 @@ build_flags =
${flags:clangtidy.build_flags}
-DUSE_BK72XX
-DUSE_LIBRETINY_VARIANT_BK7231N
; The libretiny component adds this at code generation so FreeRTOSQueue can use xQueueCreateStatic
-DconfigSUPPORT_STATIC_ALLOCATION=1
build_unflags =
${common.build_unflags}
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.5.0
click==8.5.0
aioesphomeapi==46.10.0
aioesphomeapi==46.11.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
+68
View File
@@ -49,6 +49,7 @@ Options:
from __future__ import annotations
import argparse
import ast
from collections import Counter
from collections.abc import Callable
from enum import StrEnum
@@ -58,6 +59,7 @@ import math
import os
from pathlib import Path
import statistics
import subprocess
import sys
from typing import Any
@@ -69,6 +71,7 @@ from clang_tidy_hash import (
)
from helpers import (
CPP_FILE_EXTENSIONS,
ESPHOME_COMPONENTS_PATH,
ESPHOME_TESTS_COMPONENTS_PATH,
INTEGRATION_TESTS_PATH,
PYTHON_FILE_EXTENSIONS,
@@ -805,6 +808,9 @@ def determine_cpp_unit_tests(
# Paths within tests/benchmarks/ that contain component benchmark files
BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components"
# The Python benchmark that imports components; its COMPONENTS tuple says which ones
PYTHON_IMPORT_BENCHMARK_FILE = "tests/benchmarks/python/test_component_import_bench.py"
# Files that, when changed, should trigger benchmark runs
BENCHMARK_INFRASTRUCTURE_FILES = frozenset(
{
@@ -815,6 +821,53 @@ BENCHMARK_INFRASTRUCTURE_FILES = frozenset(
)
def _benchmark_components_tuple(path: Path) -> frozenset[str]:
"""Return the benchmark file's ``COMPONENTS`` tuple.
Raises when the file has none, so renaming it fails CI instead of quietly
turning the trigger off.
"""
for node in ast.parse(path.read_text(encoding="utf-8")).body:
if isinstance(node, ast.Assign) and any(
isinstance(t, ast.Name) and t.id == "COMPONENTS" for t in node.targets
):
return frozenset(ast.literal_eval(node.value))
raise RuntimeError(f"{PYTHON_IMPORT_BENCHMARK_FILE} has no COMPONENTS tuple")
# Imports the given components and prints every component whose module got loaded
_IMPORT_PROBE = (
"import importlib, sys\n"
"for c in sys.argv[1:]: importlib.import_module('esphome.components.' + c)\n"
"print(*{n.split('.')[2] for n in sys.modules if n.startswith('esphome.components.')})"
)
def python_import_benchmarked_components() -> frozenset[str] | None:
"""Return the components loaded by importing the benchmark's ``COMPONENTS``.
Imported in a fresh interpreter from the checkout, so the set is exactly what
the benchmark times. None when the import fails.
"""
path = Path(root_path) / PYTHON_IMPORT_BENCHMARK_FILE
if not path.is_file():
return frozenset()
result = subprocess.run(
[sys.executable, "-c", _IMPORT_PROBE, *_benchmark_components_tuple(path)],
cwd=root_path,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
print(
f"Benchmark import probe failed, running benchmarks:\n{result.stderr}",
file=sys.stderr,
)
return None
return frozenset(result.stdout.split())
def should_run_benchmarks(branch: str | None = None) -> bool:
"""Determine if benchmarks (C++ and Python) should run based on changed files.
@@ -831,6 +884,8 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
4. A directly changed component has benchmark files (no dependency expansion)
5. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py,
script/build_helpers.py, script/setup_codspeed_lib.py)
6. A Python file changed in a component that importing the Python import
benchmark's ``COMPONENTS`` loads, even if it has no C++ benchmark
Unlike unit tests, benchmarks do NOT expand to dependent components.
Changing ``sensor`` does not trigger ``api`` benchmarks just because
@@ -862,6 +917,19 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
):
return True
# The Python import benchmark times importing its components and what they pull
# in, so their Python sources move its numbers even without a C++ benchmark.
# A failed import runs the benchmarks rather than risk skipping them.
changed_python = {
get_component_from_path(f)
for f in files
if f.startswith(ESPHOME_COMPONENTS_PATH) and f.endswith(PYTHON_FILE_EXTENSIONS)
}
if changed_python:
loaded = python_import_benchmarked_components()
if loaded is None or changed_python & loaded:
return True
# Check if any directly changed component has benchmarks
benchmarks_dir = Path(root_path) / BENCHMARKS_COMPONENTS_PATH
if not benchmarks_dir.is_dir():
@@ -0,0 +1,31 @@
esphome:
name: test
esp8266:
board: d1_mini
uart:
tx_pin: GPIO0
rx_pin: GPIO2
baud_rate: 256000
ld2450:
id: radar
text:
- platform: ld2450
ld2450_id: radar
polygon_zones:
- id: couch
name: Couch
- id: desk
name: Desk
binary_sensor:
- platform: ld2450
ld2450_id: radar
polygon_zones:
- polygon_zone_id: couch
name: Couch Occupied
- polygon_zone_id: desk
name: Desk Occupied
@@ -0,0 +1,64 @@
"""Tests for the polygon zone text pattern and maximum length.
Home Assistant validates polygon input with the pattern before it reaches the device, so the pattern must accept
what Polygon::parse() accepts. Coordinate ranges are the one exception: only the device checks those.
"""
from __future__ import annotations
import re
import pytest
from esphome.components.ld2450.text import (
MAX_POLYGON_POINTS,
POLYGON_MAX_LENGTH,
POLYGON_PATTERN,
)
# The same inputs as the C++ parser tests (ld2450_polygon.cpp), without out-of-range values
ACCEPTED = [
"",
" ",
"-1000,500;1000,500;0,3000",
" -1000 , 500 ; 1000,500; 0,3000; ",
"-4860,0;4860,0;4860,7560",
]
REJECTED = [
"0,0",
"0,0;100,100",
"junk",
"1,2;3",
"1,2;3,4;5,",
"1;2;3",
"1,2 3,4 5,6",
"1,2;;3,4;5,6",
"--1,2;3,4;5,6",
"1,2;3,4;5,6x",
"1,2;\t3,4;5,6",
]
@pytest.mark.parametrize("value", ACCEPTED)
def test_pattern_accepts_valid_polygons(value: str) -> None:
assert re.fullmatch(POLYGON_PATTERN, value)
@pytest.mark.parametrize("value", REJECTED)
def test_pattern_rejects_invalid_polygons(value: str) -> None:
assert not re.fullmatch(POLYGON_PATTERN, value)
def test_point_count_limits() -> None:
assert re.fullmatch(POLYGON_PATTERN, ";".join(["0,0"] * MAX_POLYGON_POINTS))
assert not re.fullmatch(
POLYGON_PATTERN, ";".join(["0,0"] * (MAX_POLYGON_POINTS + 1))
)
def test_longest_polygon_fits_max_length() -> None:
longest = ";".join(["-4860,7560"] * MAX_POLYGON_POINTS)
assert re.fullmatch(POLYGON_PATTERN, longest)
assert len(longest) <= POLYGON_MAX_LENGTH
# MAX_POLYGON_POINTS is the most that always fits
assert len(longest + ";-4860,7560") > POLYGON_MAX_LENGTH
@@ -0,0 +1,45 @@
"""Tests for the ld2450 polygon zone presence binary sensor schema."""
from __future__ import annotations
import pytest
from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES
from esphome.components.ld2450.binary_sensor import (
CONFIG_SCHEMA as BINARY_SENSOR_CONFIG_SCHEMA,
)
import esphome.config_validation as cv
from esphome.const import CONF_FILTERS, CONF_NAME, PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _presence(**extra: object) -> ConfigType:
return {
CONF_POLYGON_ZONES: [
{CONF_POLYGON_ZONE_ID: "couch_zone", CONF_NAME: "Couch Occupied", **extra}
]
}
def test_presence_without_filters_passes(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)(_presence())
def test_presence_with_filters_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Clearing a polygon bypasses filters, so they would get out of step with the zone."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(
cv.Invalid,
match=r"^Polygon zone presence binary sensors can't have filters; .*presence_timeout",
):
cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)(
_presence(**{CONF_FILTERS: [{"delayed_off": "1s"}]})
)
@@ -0,0 +1,163 @@
"""Tests for the ld2450 polygon zone validation.
Polygon zones are declared in the text platform and their presence sensors in the
binary sensor platform, so the pairing between the two is checked at final validation.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.esp8266.const import (
KEY_ESP8266,
KEY_PREFERENCES,
preference_words,
)
from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES
from esphome.components.ld2450.text import (
CONFIG_SCHEMA as TEXT_CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA as TEXT_FINAL_VALIDATE_SCHEMA,
POLYGON_SAVED_BYTES,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME, CONF_PLATFORM, PlatformFramework
from esphome.core import CORE, ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "ld2450_hub"
OTHER_HUB_ID = "ld2450_other"
def _text_config(*zone_ids: str) -> ConfigType:
"""A text platform config declaring the given polygon zones."""
return {
"ld2450_id": ID(HUB_ID, is_declaration=False, type="ld2450"),
CONF_POLYGON_ZONES: [
{CONF_ID: ID(zone_id, is_declaration=True), CONF_NAME: zone_id}
for zone_id in zone_ids
],
}
def _binary_sensors(*zone_ids: str, hub_id: str = HUB_ID) -> list[ConfigType]:
"""A binary sensor platform list with one presence sensor per given zone reference."""
return [
{
CONF_PLATFORM: "ld2450",
"ld2450_id": ID(hub_id, is_declaration=False, type="ld2450"),
CONF_POLYGON_ZONES: [
{CONF_POLYGON_ZONE_ID: ID(zone_id, is_declaration=False)}
for zone_id in zone_ids
],
}
]
def test_zones_paired_with_one_presence_pass(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"binary_sensor": _binary_sensors("couch", "desk")},
)
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk"))
def test_zone_without_presence_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"binary_sensor": _binary_sensors("couch")},
)
with pytest.raises(
cv.Invalid,
match=r"^Polygon zone 'desk' has no presence binary sensor; .* @ data\['polygon_zones'\]\[1\]$",
):
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk"))
def test_zone_without_any_binary_sensor_platform_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config={})
with pytest.raises(cv.Invalid, match="'couch' has no presence binary sensor"):
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch"))
def test_zone_with_two_presences_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Two references to one zone are rejected, even across binary sensor platform entries."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={
"binary_sensor": _binary_sensors("couch") + _binary_sensors("couch")
},
)
with pytest.raises(
cv.Invalid,
match=r"^Polygon zone 'couch' is used by more than one presence binary sensor; ",
):
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch"))
def test_other_platforms_are_ignored(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Binary sensors of other platforms never count as presence for a zone."""
other = {CONF_PLATFORM: "template", CONF_POLYGON_ZONES: []}
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"binary_sensor": [other, *_binary_sensors("couch")]},
)
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch"))
def test_zone_without_id_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The presence sensor refers to the zone by id, so an automatic id is no use."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match=r"^'id' is required, so the zone's presence"):
TEXT_CONFIG_SCHEMA({CONF_POLYGON_ZONES: [{CONF_NAME: "Couch Zone Polygon"}]})
def test_presence_on_another_ld2450_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The zone's own LD2450 drives its presence, so the sensor must name the same one."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"binary_sensor": _binary_sensors("couch", hub_id=OTHER_HUB_ID)},
)
with pytest.raises(
cv.Invalid,
match=(
r"^Polygon zone 'couch' belongs to LD2450 'ld2450_hub', but its presence binary "
r"sensor is under LD2450 'ld2450_other'; .* @ data\['polygon_zones'\]\[0\]$"
),
):
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch"))
def test_esp8266_zones_reserve_their_saved_space(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Every zone reserves its polygon, so the ESP8266 can check that all saved values fit together."""
generate_main(component_config_path("esp8266_zones.yaml"))
words = preference_words(POLYGON_SAVED_BYTES)
assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(words, False)] * 2
@@ -0,0 +1,39 @@
"""The ``-DLV_CONF_PATH`` build flag carries a path. PlatformIO, the host
build and the native ESP8266 build shell-lex build flags, while the ESP-IDF
and Zephyr builds write each flag into a quoted CMake argument; both must
decode it to one ``-DLV_CONF_PATH="..."`` argument, including when the
project path contains a space."""
from __future__ import annotations
from pathlib import Path
import re
import shlex
import pytest
from esphome.components.lvgl import lv_conf_path_flag
def _cmake_quoted_argument(flag: str) -> str:
"""Decode ``flag`` as CMake decodes a quoted argument: a backslash escapes the next character."""
return re.sub(r"\\(.)", r"\1", flag)
@pytest.mark.parametrize(
"build_path",
[
"/home/user/.esphome/build/node",
"/Users/me/with space/.esphome/build/node",
"/srv/odd 'quote' $(and)/build",
"C:/Users/Kris Maddens/esphome/.esphome/build/node",
],
ids=["plain", "space", "shell-specials", "windows"],
)
def test_flag_is_one_argument_everywhere(build_path: str) -> None:
lv_conf_h_file = Path(build_path) / "src" / "lv_conf.h"
flag = lv_conf_path_flag(lv_conf_h_file)
expected = f'-DLV_CONF_PATH="{lv_conf_h_file.as_posix()}"'
assert shlex.split(flag) == [expected]
assert _cmake_quoted_argument(flag) == expected
+6
View File
@@ -0,0 +1,6 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The driver lives in the time platform, so pull in what it builds against.
manifest.dependencies = manifest.dependencies + ["i2c", "time"]
+77
View File
@@ -0,0 +1,77 @@
#include <gtest/gtest.h>
#include <array>
#include <ctime>
#include "esphome/components/bm8563/bm8563.h"
#include "esphome/core/time.h"
namespace esphome::bm8563::testing {
static constexpr uint8_t ADDRESS = 0x51;
static constexpr uint8_t WEEKDAYS_REG = 0x06;
static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0
// The chip's 16 registers behind an address pointer that moves on after every byte.
class RegisterBus final : public i2c::I2CBus {
public:
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
EXPECT_EQ(address, ADDRESS);
if (size != 0) {
this->pointer_ = data[0];
for (size_t i = 1; i < size; i++)
this->registers[this->next_()] = data[i];
}
for (size_t i = 0; i < out_size; i++)
out[i] = this->registers[this->next_()];
return i2c::ERROR_OK;
}
std::array<uint8_t, 16> registers{};
protected:
size_t next_() { return this->pointer_++ % this->registers.size(); }
uint8_t pointer_{0};
};
// write_time() takes the host's current time, so reading it back never changes the host clock.
class BM8563Test : public ::testing::Test {
protected:
void SetUp() override {
this->rtc_.set_i2c_bus(&this->bus_);
this->rtc_.set_i2c_address(ADDRESS);
this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; });
this->rtc_.setup();
}
RegisterBus bus_;
BM8563 rtc_;
unsigned syncs_{0};
};
TEST_F(BM8563Test, WriteTimeStoresTheChipsWeekday) {
// Read the clock on both sides of the write, in case UTC midnight falls in between.
const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1;
this->rtc_.write_time();
const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1;
const int stored = this->bus_.registers[WEEKDAYS_REG];
EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before;
}
// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight.
// Seven steps from the written value visit every weekday once, and each has to read back as a valid time.
// Only the weekday moves, so the time read back is still the host's time.
TEST_F(BM8563Test, WeekdayRoundTripsThroughSundayAndSaturday) {
this->rtc_.write_time();
uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG];
for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) {
weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK;
const unsigned syncs_before = this->syncs_;
this->rtc_.read_time();
EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday);
}
}
} // namespace esphome::bm8563::testing
+2
View File
@@ -12,6 +12,7 @@ esphome:
bq25186:
- id: bq25186_charger
i2c_id: i2c_bus
configure_on_boot: false
fast_charge_current: 150mA
battery_regulation_voltage: 4.2V
@@ -20,6 +21,7 @@ bq25186:
charge_enabled: true
# Second charger with the boot-time write enabled
- id: bq25186_boot
i2c_id: i2c_bus
address: 0x6B
configure_on_boot: true
fast_charge_current: 20mA
+92
View File
@@ -0,0 +1,92 @@
#include <gtest/gtest.h>
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
using IntCallback = Callback<void(int)>;
// Counts destructions of the copies a callable carries, to check a boxed capture is freed exactly once.
struct DestroyCounter {
int *destroyed;
bool live{true};
explicit DestroyCounter(int *count) : destroyed(count) {}
DestroyCounter(const DestroyCounter &other) = delete;
DestroyCounter(DestroyCounter &&other) noexcept : destroyed(other.destroyed) { other.live = false; }
DestroyCounter &operator=(const DestroyCounter &other) = delete;
DestroyCounter &operator=(DestroyCounter &&other) = delete;
~DestroyCounter() {
if (this->live)
(*this->destroyed)++;
}
};
template<typename F> bool fits_inline(const F & /*callable*/) { return IntCallback::fits_inline<F>(); }
TEST(CallbackFitsInline, PointerSizedConstCallableFits) {
int value = 0;
EXPECT_TRUE(fits_inline([&value](int x) { value = x; }));
}
TEST(CallbackFitsInline, MutableCallableDoesNotFit) {
int calls = 0;
EXPECT_FALSE(fits_inline([calls](int) mutable { calls++; }));
}
TEST(CallbackFitsInline, LargeCallableDoesNotFit) {
int a = 0;
int b = 0;
EXPECT_FALSE(fits_inline([&a, &b](int x) { a = b = x; }));
}
TEST(CallbackCreate, InlineCallableIsCalled) {
int value = 0;
auto cb = IntCallback::create([&value](int x) { value = x; });
cb.call(42);
EXPECT_EQ(value, 42);
}
int mutable_seen = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// A pointer-sized mutable lambda used to be stored inline and run on a copy, losing its state.
TEST(CallbackCreate, MutableStateIsKeptAcrossCalls) {
auto counter = [n = 0](int x) mutable { mutable_seen = (n += x); };
static_assert(sizeof(counter) <= sizeof(void *) && std::is_trivially_copyable_v<decltype(counter)>);
// create() never frees its heap path; held for the whole run like a callback registered in setup().
static auto cb = IntCallback::create(counter);
cb.call(1);
cb.call(2);
EXPECT_EQ(mutable_seen, 3);
}
TEST(CallbackCreateBoxed, MutableStateIsKeptAcrossCalls) {
int seen = 0;
auto cb = IntCallback::create_boxed([count = 0, &seen](int x) mutable {
count += x;
seen = count;
});
cb.call(1);
cb.call(2);
cb.call(3);
EXPECT_EQ(seen, 6);
cb.free_boxed();
}
TEST(CallbackCreateBoxed, FreeDestroysCaptureExactlyOnce) {
int destroyed = 0;
int calls = 0;
{
auto cb = IntCallback::create_boxed([counter = DestroyCounter(&destroyed), &calls](int) { calls++; });
EXPECT_EQ(destroyed, 0);
cb.call(0);
cb.call(0);
EXPECT_EQ(destroyed, 0);
cb.free_boxed();
EXPECT_EQ(destroyed, 1);
}
// The Callback itself owns nothing, so going out of scope frees nothing more
EXPECT_EQ(destroyed, 1);
EXPECT_EQ(calls, 2);
}
} // namespace esphome::core::testing
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <vector>
#include "esphome/components/uart/uart_component.h"
namespace esphome::cse7766::testing {
class MockUARTComponent : public uart::UARTComponent {
public:
std::vector<uint8_t> rx;
void push_rx(const std::vector<uint8_t> &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); }
// UARTComponent
void write_array(const uint8_t *data, size_t len) override {}
bool read_array(uint8_t *data, size_t len) override {
if (this->rx.size() < len) {
return false;
}
std::copy(this->rx.begin(), this->rx.begin() + len, data);
this->rx.erase(this->rx.begin(), this->rx.begin() + len);
return true;
}
size_t available() override { return this->rx.size(); }
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif // defined(USE_ESP8266) || defined(USE_ESP32)
};
} // namespace esphome::cse7766::testing
@@ -0,0 +1,117 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/cse7766/cse7766.h"
#include "esphome/components/sensor/sensor.h"
#include "../common.h"
namespace esphome::cse7766::testing {
namespace {
// Coefficients and cycles taken from a Sonoff S60 (CSE7759B). With this power coefficient a power
// cycle of 2507613 is about 2.16 W, and the cycle register advances by about 1050000 per second.
constexpr uint32_t VOLTAGE_COEFF = 0x030BB0;
constexpr uint32_t VOLTAGE_CYCLE = 0x000352;
constexpr uint32_t CURRENT_COEFF = 0x004137;
constexpr uint32_t CURRENT_CYCLE = 0x053339;
constexpr uint32_t POWER_COEFF = 5425000;
constexpr uint32_t LOW_LOAD_CYCLE = 2507613;
constexpr uint32_t SHORT_CYCLE = 900000; // about 6 W, just under 1 s
constexpr uint32_t HIGH_LOAD_CYCLE = 2000;
constexpr uint8_t HEADER_OK = 0x55;
constexpr uint8_t HEADER_POWER_CYCLE_EXCEEDS_RANGE = 0xF2;
constexpr uint8_t ADJ_NEW_POWER = 0x71;
constexpr uint8_t ADJ_NO_NEW_POWER = 0x61;
void put24(std::vector<uint8_t> &frame, uint32_t value) {
frame.push_back(value >> 16);
frame.push_back(value >> 8);
frame.push_back(value);
}
std::vector<uint8_t> make_frame(uint8_t header, uint32_t power_cycle, uint8_t adj) {
std::vector<uint8_t> frame{header, 0x5A};
put24(frame, VOLTAGE_COEFF);
put24(frame, VOLTAGE_CYCLE);
put24(frame, CURRENT_COEFF);
put24(frame, CURRENT_CYCLE);
put24(frame, POWER_COEFF);
put24(frame, power_cycle);
frame.push_back(adj);
frame.push_back(0x0B);
frame.push_back(0x81);
uint8_t checksum = 0;
for (size_t i = 2; i < frame.size(); i++) {
checksum += frame[i];
}
frame.push_back(checksum);
return frame;
}
class CSE7766Test : public ::testing::Test {
protected:
void SetUp() override {
this->sut_.set_uart_parent(&this->uart_);
this->sut_.set_power_sensor(&this->power_);
}
float feed_(uint8_t header, uint32_t power_cycle, uint8_t adj) {
this->uart_.push_rx(make_frame(header, power_cycle, adj));
this->sut_.loop();
return this->power_.state;
}
MockUARTComponent uart_;
CSE7766Component sut_;
sensor::Sensor power_;
};
} // namespace
TEST_F(CSE7766Test, NewMeasurementIsReported) {
EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER), 2.163f, 0.001f);
}
// At low load one power cycle spans many frames. Frames in between carry the last cycle or the
// time since the last pulse, and must not be reported as 0 W.
TEST_F(CSE7766Test, LowLoadKeepsLastMeasurementBetweenPulses) {
this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER);
EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NO_NEW_POWER), 2.163f, 0.001f);
EXPECT_NEAR(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 2.163f, 0.001f);
EXPECT_NEAR(this->feed_(HEADER_OK, 4900000, ADJ_NO_NEW_POWER), 2.163f, 0.001f);
}
// Below 1 s the chip does not send waiting times in normal operation, so such a frame
// means the pulse is overdue and 0 W is reported at once, as before
TEST_F(CSE7766Test, ShortCycleReportsZeroWithoutNewMeasurement) {
this->feed_(HEADER_OK, SHORT_CYCLE, ADJ_NEW_POWER);
EXPECT_EQ(this->feed_(HEADER_OK, 1050000, ADJ_NO_NEW_POWER), 0.0f);
}
TEST_F(CSE7766Test, OverduePulseReportsZero) {
this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER);
EXPECT_EQ(this->feed_(HEADER_OK, 2 * LOW_LOAD_CYCLE + 1, ADJ_NO_NEW_POWER), 0.0f);
// Stays 0 even when a later frame shows a short cycle again, until a new measurement arrives
EXPECT_EQ(this->feed_(HEADER_OK, 1000000, ADJ_NO_NEW_POWER), 0.0f);
}
// A large load switching off: the first frame without a new measurement already shows about one
// second since the last pulse, far beyond the 2 ms cycle, so 0 W is reported at once.
TEST_F(CSE7766Test, HighLoadSwitchingOffReportsZeroAtOnce) {
EXPECT_NEAR(this->feed_(HEADER_OK, HIGH_LOAD_CYCLE, ADJ_NEW_POWER), 2712.5f, 0.1f);
EXPECT_EQ(this->feed_(HEADER_OK, 1072601, ADJ_NO_NEW_POWER), 0.0f);
}
TEST_F(CSE7766Test, NoMeasurementYetReportsZero) { EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); }
TEST_F(CSE7766Test, PowerCycleExceedsRangeReportsZero) {
this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER);
EXPECT_EQ(this->feed_(HEADER_POWER_CYCLE_EXCEEDS_RANGE, 9000000, ADJ_NO_NEW_POWER), 0.0f);
EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f);
}
} // namespace esphome::cse7766::testing
+481
View File
@@ -0,0 +1,481 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/components/fusb302b/pd.h"
namespace esphome::fusb302b::testing {
static constexpr uint32_t fixed_pdo(uint16_t mv, uint16_t ma) { return ((mv / 50u) << 10) | (ma / 10u); }
// An augmented (PPS) object; only its type matters to the sink
static constexpr uint32_t AUGMENTED_PDO = static_cast<uint32_t>(PD_PDO_TYPE_AUGMENTED) << 30;
// Unknown to the sink, so it must be answered with Reject
static constexpr uint8_t PD_CNTRL_DR_SWAP = 0x09;
class FakePhy : public PowerDelivery {
public:
using PowerDelivery::ams_start_;
using PowerDelivery::check_ams_;
using PowerDelivery::check_transition_timeout_;
using PowerDelivery::check_request_retry_;
using PowerDelivery::contract_;
using PowerDelivery::handle_message_;
using PowerDelivery::handle_transmit_failed_;
using PowerDelivery::retry_request_pending_;
using PowerDelivery::set_state_and_contract_;
using PowerDelivery::wait_received_ms_;
std::vector<PdMsg> sent;
bool send_ok{true};
int send_attempts{0};
int state_changes{0};
int soft_resets{0};
int source_caps{0};
int transition_timeouts{0};
void receive(uint8_t type, uint8_t id, std::vector<uint32_t> objects = {}) {
PdMsg msg;
msg.type = type;
msg.id = id;
msg.num_of_obj = objects.size();
for (size_t i = 0; i < objects.size(); i++)
msg.data_objects[i] = objects[i];
this->handle_message_(msg);
}
void receive_source_caps(uint8_t id) {
this->receive(PD_DATA_SOURCE_CAP, id,
{fixed_pdo(5000, 3000), fixed_pdo(9000, 3000), fixed_pdo(15000, 3000), fixed_pdo(20000, 2250)});
}
PackedContract contract() const { return PackedContract{this->contract_}; }
// What the PHY does when a source is attached
void attach() { this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); }
// Moves the timestamps back instead of sleeping, since millis() is real time on the host
void advance_ms(uint32_t ms) {
this->ams_start_ -= ms;
this->wait_received_ms_ -= ms;
}
protected:
bool send_message(const PdMsg &msg) override {
this->send_attempts++;
if (!this->send_ok)
return false;
this->sent.push_back(msg);
return true;
}
void on_state_changed() override { this->state_changes++; }
void on_soft_reset_received() override { this->soft_resets++; }
void on_source_caps_received() override {
// The hook runs before the request is sent
EXPECT_EQ(this->sent.size(), static_cast<size_t>(this->source_caps));
this->source_caps++;
}
void on_transition_timeout() override { this->transition_timeouts++; }
};
TEST(FUSB302BPd, HeaderRoundTrip) {
PdMsg msg;
msg.type = PD_DATA_REQUEST;
msg.spec_rev = PD_SPEC_REV_2;
msg.id = 5;
msg.num_of_obj = 1;
// Type 2, spec revision 1 at bit 6, MessageID 5 at bit 9, one object at bit 12
EXPECT_EQ(msg.get_coded_header(), 0x1A42);
PdMsg decoded;
decoded.set_header(0x1A42 | (1u << 15));
EXPECT_EQ(decoded.type, PD_DATA_REQUEST);
EXPECT_EQ(decoded.spec_rev, PD_SPEC_REV_2);
EXPECT_EQ(decoded.id, 5);
EXPECT_EQ(decoded.num_of_obj, 1);
EXPECT_TRUE(decoded.extended);
}
TEST(FUSB302BPd, RequestsHighestVoltageNotAboveLimit) {
FakePhy pd;
pd.set_request_voltage(18);
pd.receive_source_caps(0);
EXPECT_EQ(pd.source_caps, 1);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].type, PD_DATA_REQUEST);
EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); // 15 V is object 3
EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 300u);
}
TEST(FUSB302BPd, LowestRequestPicksFirstObject) {
FakePhy pd;
pd.set_request_voltage(5);
pd.receive_source_caps(0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 1u);
}
TEST(FUSB302BPd, NonFixedObjectKeepsItsPosition) {
FakePhy pd;
pd.set_request_voltage(9);
pd.receive(PD_DATA_SOURCE_CAP, 0, {fixed_pdo(5000, 3000), AUGMENTED_PDO, fixed_pdo(9000, 2000)});
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u);
EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 200u);
}
TEST(FUSB302BPd, NoFixedObjectFallsBackToObjectOne) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive(PD_DATA_SOURCE_CAP, 0, {AUGMENTED_PDO, AUGMENTED_PDO});
ASSERT_EQ(pd.sent.size(), 1u);
const uint32_t rdo = pd.sent[0].data_objects[0];
EXPECT_EQ(rdo >> 28, 1u);
EXPECT_EQ(rdo & 0x3FF, 30u); // 300 mA maximum
EXPECT_EQ((rdo >> 10) & 0x3FF, 10u); // 100 mA operating
}
TEST(FUSB302BPd, AcceptThenPsRdyReachesExplicitContract) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION);
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f);
EXPECT_FLOAT_EQ(pd.contract().current(), 2.25f);
}
TEST(FUSB302BPd, AcceptPublishesStateAndContractOnce) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.state_changes = 0;
pd.receive(PD_CNTRL_ACCEPT, 1);
EXPECT_EQ(pd.state_changes, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f);
}
TEST(FUSB302BPd, AcceptForSameContractSkipsTransition) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.receive(PD_CNTRL_PS_RDY, 2);
pd.receive_source_caps(3);
pd.receive(PD_CNTRL_ACCEPT, 4);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT);
pd.receive(PD_CNTRL_PS_RDY, 5);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT);
EXPECT_FALSE(pd.check_ams_());
}
TEST(FUSB302BPd, PsRdyWithoutAcceptIsIgnored) {
FakePhy pd;
pd.receive(PD_CNTRL_PS_RDY, 0);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED);
pd.receive_source_caps(1);
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED);
}
TEST(FUSB302BPd, PsRdyAfterRejectIsIgnored) {
FakePhy pd;
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_REJECT, 1);
EXPECT_FALSE(pd.check_ams_());
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED);
}
TEST(FUSB302BPd, PsRdyAfterSequenceTimeoutIsIgnored) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.advance_ms(2001);
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION);
}
TEST(FUSB302BPd, TransitionTimeoutFallsBackToDefault) {
FakePhy pd;
pd.attach();
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.check_transition_timeout_();
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION);
EXPECT_EQ(pd.transition_timeouts, 0);
pd.advance_ms(2001);
pd.state_changes = 0;
pd.check_transition_timeout_();
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f);
EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f);
EXPECT_EQ(pd.state_changes, 1);
EXPECT_EQ(pd.transition_timeouts, 1);
// Reported once, and a late PS_RDY does not bring back the unconfirmed contract
pd.check_transition_timeout_();
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.transition_timeouts, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT);
}
TEST(FUSB302BPd, RequestVoltageIsClamped) {
FakePhy pd;
pd.set_request_voltage(255);
EXPECT_EQ(pd.get_request_voltage(), MAX_REQUEST_VOLTAGE);
pd.set_request_voltage(0);
EXPECT_EQ(pd.get_request_voltage(), MIN_REQUEST_VOLTAGE);
}
TEST(FUSB302BPd, WaitRepeatsRequestAfterSinkRequestTime) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_WAIT, 1);
EXPECT_FALSE(pd.check_ams_());
pd.check_request_retry_();
EXPECT_EQ(pd.sent.size(), 1u);
pd.advance_ms(101);
pd.check_request_retry_();
ASSERT_EQ(pd.sent.size(), 2u);
EXPECT_EQ(pd.sent[1].type, PD_DATA_REQUEST);
EXPECT_EQ(pd.sent[1].data_objects[0], pd.sent[0].data_objects[0]);
EXPECT_TRUE(pd.check_ams_());
}
TEST(FUSB302BPd, WaitRetriesStopAtLimit) {
FakePhy pd;
pd.attach();
pd.receive_source_caps(0);
for (uint8_t i = 1; i <= 10; i++) {
pd.receive(PD_CNTRL_WAIT, i % 8);
pd.advance_ms(101);
pd.check_request_retry_();
}
EXPECT_EQ(pd.sent.size(), 11u);
pd.receive(PD_CNTRL_WAIT, 3);
EXPECT_FALSE(pd.retry_request_pending_);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
pd.advance_ms(101);
pd.check_request_retry_();
EXPECT_EQ(pd.sent.size(), 11u);
}
TEST(FUSB302BPd, FailedRequestEndsNegotiation) {
FakePhy pd;
pd.attach();
pd.set_request_voltage(20);
pd.send_ok = false;
pd.receive_source_caps(0);
EXPECT_EQ(pd.send_attempts, 1);
EXPECT_FALSE(pd.check_ams_());
EXPECT_FALSE(pd.retry_request_pending_);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
pd.receive(PD_CNTRL_ACCEPT, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f);
}
TEST(FUSB302BPd, FailedRetryIsTriedAgain) {
FakePhy pd;
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_WAIT, 1);
pd.send_ok = false;
pd.advance_ms(101);
pd.check_request_retry_();
EXPECT_EQ(pd.send_attempts, 2);
EXPECT_TRUE(pd.retry_request_pending_);
EXPECT_FALSE(pd.check_ams_());
pd.send_ok = true;
pd.check_request_retry_();
EXPECT_EQ(pd.send_attempts, 2); // waits another tSinkRequest first
pd.advance_ms(101);
pd.check_request_retry_();
EXPECT_EQ(pd.sent.size(), 2u);
EXPECT_FALSE(pd.retry_request_pending_);
}
TEST(FUSB302BPd, FailedRetriesStopAtLimit) {
FakePhy pd;
pd.attach();
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_WAIT, 1);
pd.send_ok = false;
for (int i = 0; i < 20 && pd.retry_request_pending_; i++) {
pd.advance_ms(101);
pd.check_request_retry_();
}
EXPECT_FALSE(pd.retry_request_pending_);
// The first request, then one attempt for each of the 10 allowed repeats
EXPECT_EQ(pd.send_attempts, 11);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
}
TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) {
FakePhy pd;
pd.attach();
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_REJECT, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f);
}
TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) {
FakePhy pd;
pd.attach();
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.receive(PD_CNTRL_PS_RDY, 2);
pd.set_request_voltage(9);
pd.receive_source_caps(3);
pd.receive(PD_CNTRL_REJECT, 4);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f);
}
TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) {
FakePhy pd;
pd.attach();
pd.receive(PD_CNTRL_REJECT, 0);
pd.receive(PD_CNTRL_WAIT, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT);
EXPECT_FALSE(pd.retry_request_pending_);
}
TEST(FUSB302BPd, UnsupportedDataMessageIsIgnored) {
FakePhy pd;
pd.receive(PD_DATA_REQUEST, 0, {0});
EXPECT_TRUE(pd.sent.empty());
EXPECT_EQ(pd.source_caps, 0);
}
TEST(FUSB302BPd, AcceptOutsideSequenceIsIgnored) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_REJECT, 1);
pd.receive(PD_CNTRL_ACCEPT, 2);
pd.receive(PD_CNTRL_PS_RDY, 3);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED);
EXPECT_FALSE(pd.contract().is_set());
}
TEST(FUSB302BPd, GoodCrcMustMatchMessageId) {
FakePhy pd;
pd.receive(PD_CNTRL_DR_SWAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].id, 0);
pd.receive(PD_CNTRL_GOODCRC, 1);
pd.receive(PD_CNTRL_DR_SWAP, 1);
ASSERT_EQ(pd.sent.size(), 2u);
EXPECT_EQ(pd.sent[1].id, 0);
pd.receive(PD_CNTRL_GOODCRC, 0);
pd.receive(PD_CNTRL_GOODCRC, 0); // duplicate
pd.receive(PD_CNTRL_DR_SWAP, 2);
ASSERT_EQ(pd.sent.size(), 3u);
EXPECT_EQ(pd.sent[2].id, 1);
}
TEST(FUSB302BPd, FailedTransmitAdvancesMessageId) {
FakePhy pd;
pd.receive_source_caps(0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].id, 0);
pd.handle_transmit_failed_();
// A late GoodCRC for the failed message must not advance the counter a second time
pd.receive(PD_CNTRL_GOODCRC, 0);
pd.receive(PD_CNTRL_DR_SWAP, 1);
ASSERT_EQ(pd.sent.size(), 2u);
EXPECT_EQ(pd.sent[1].id, 1);
}
TEST(FUSB302BPd, RetransmissionIsIgnored) {
FakePhy pd;
pd.receive_source_caps(0);
pd.receive_source_caps(0);
EXPECT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.source_caps, 1);
}
TEST(FUSB302BPd, SoftResetIsAccepted) {
FakePhy pd;
pd.receive(PD_CNTRL_SOFT_RESET, 0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].type, PD_CNTRL_ACCEPT);
EXPECT_EQ(pd.soft_resets, 1);
}
TEST(FUSB302BPd, SoftResetHookRunsWhenAcceptFails) {
FakePhy pd;
pd.send_ok = false;
pd.receive(PD_CNTRL_SOFT_RESET, 0);
EXPECT_EQ(pd.send_attempts, 1);
EXPECT_EQ(pd.soft_resets, 1);
}
TEST(FUSB302BPd, SoftResetDuringTransitionFallsBackToDefault) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.state_changes = 0;
pd.receive(PD_CNTRL_SOFT_RESET, 0);
EXPECT_EQ(pd.state_changes, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f);
EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f);
}
TEST(FUSB302BPd, UnknownControlMessageIsRejected) {
FakePhy pd;
pd.receive(PD_CNTRL_DR_SWAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].type, PD_CNTRL_REJECT);
}
TEST(FUSB302BPd, SinkCapAt5VHasOneObject) {
FakePhy pd;
pd.receive(PD_CNTRL_GET_SINK_CAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].type, PD_DATA_SINK_CAP);
ASSERT_EQ(pd.sent[0].num_of_obj, 1);
const uint32_t pdo = pd.sent[0].data_objects[0];
EXPECT_EQ((pdo >> 10) & 0x3FF, 100u);
EXPECT_EQ(pdo & 0x3FF, 300u);
EXPECT_FALSE(pdo & (1u << 28));
EXPECT_TRUE(pdo & (1u << 26));
}
TEST(FUSB302BPd, SinkCapListsRequestedVoltage) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive(PD_CNTRL_GET_SINK_CAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
ASSERT_EQ(pd.sent[0].num_of_obj, 2);
EXPECT_TRUE(pd.sent[0].data_objects[0] & (1u << 28));
const uint32_t pdo = pd.sent[0].data_objects[1];
EXPECT_EQ(pdo >> 30, 0u);
EXPECT_EQ((pdo >> 10) & 0x3FF, 400u);
EXPECT_EQ(pdo & 0x3FF, 300u);
EXPECT_EQ(pdo >> 25, 0u); // capability flags are only valid in the first object
}
TEST(FUSB302BPd, SinkCapForOutOfRangeVoltageStaysValid) {
FakePhy pd;
pd.set_request_voltage(255);
pd.receive(PD_CNTRL_GET_SINK_CAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
ASSERT_EQ(pd.sent[0].num_of_obj, 2);
const uint32_t pdo = pd.sent[0].data_objects[1];
EXPECT_EQ((pdo >> 10) & 0x3FF, 400u);
EXPECT_EQ(pdo >> 20, 0u); // nothing spills into the peak current or flag bits
}
} // namespace esphome::fusb302b::testing
+9
View File
@@ -0,0 +1,9 @@
from esphome.loader import FileResource
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# ld2450 is MULTI_CONF, so no codegen runs here to keep polygon.cpp; the tests need it
manifest.resources = manifest.resources + [
FileResource("esphome.components.ld2450", "polygon.cpp")
]
+14
View File
@@ -96,6 +96,20 @@ binary_sensor:
name: Moving Target
has_still_target:
name: Still Target
polygon_zones:
- polygon_zone_id: ld2450_couch_zone
name: Couch Occupied
- polygon_zone_id: ld2450_desk_zone
name: Desk Occupied
text:
- platform: ld2450
ld2450_id: ld2450_radar
polygon_zones:
- id: ld2450_couch_zone
name: Couch Zone Polygon
- id: ld2450_desk_zone
name: Desk Zone Polygon
switch:
- platform: ld2450
+162
View File
@@ -0,0 +1,162 @@
#include <cstring>
#include <gtest/gtest.h>
#include "esphome/components/ld2450/polygon.h"
namespace esphome::ld2450::testing {
static bool parse(Polygon &polygon, const char *str) { return polygon.parse(str, strlen(str)); }
static std::string format(const Polygon &polygon) {
char buf[POLYGON_STR_SIZE];
polygon.format(buf);
return buf;
}
// --- Parsing ---
TEST(LD2450PolygonTest, ParseValid) {
Polygon polygon;
ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000"));
ASSERT_EQ(polygon.count, 3);
EXPECT_EQ(polygon.points[0].x, -1000);
EXPECT_EQ(polygon.points[0].y, 500);
EXPECT_EQ(polygon.points[2].x, 0);
EXPECT_EQ(polygon.points[2].y, 3000);
EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000");
}
TEST(LD2450PolygonTest, ParseAllowsSpacesAndTrailingSeparator) {
Polygon polygon;
ASSERT_TRUE(parse(polygon, " -1000 , 500 ; 1000,500; 0,3000; "));
EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000");
}
TEST(LD2450PolygonTest, ParseEmptyDisablesZone) {
Polygon polygon;
ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000"));
ASSERT_TRUE(parse(polygon, ""));
EXPECT_TRUE(polygon.empty());
ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000"));
ASSERT_TRUE(parse(polygon, " "));
EXPECT_TRUE(polygon.empty());
EXPECT_EQ(format(polygon), "");
}
TEST(LD2450PolygonTest, ParseLimits) {
Polygon polygon;
EXPECT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560"));
EXPECT_FALSE(parse(polygon, "-4861,0;4860,0;4860,7560"));
EXPECT_FALSE(parse(polygon, "-4860,0;4861,0;4860,7560"));
EXPECT_FALSE(parse(polygon, "-4860,-1;4860,0;4860,7560"));
EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,7561"));
EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,99999999999"));
}
TEST(LD2450PolygonTest, ParsePointCount) {
Polygon polygon;
EXPECT_FALSE(parse(polygon, "0,0"));
EXPECT_FALSE(parse(polygon, "0,0;100,100"));
std::string text;
for (int i = 0; i < MAX_POLYGON_POINTS; i++) {
text += "-4860,7560;";
}
ASSERT_TRUE(parse(polygon, text.c_str()));
EXPECT_EQ(polygon.count, MAX_POLYGON_POINTS);
// The longest possible polygon fits in the format buffer
EXPECT_EQ(format(polygon) + ";", text);
text += "0,0";
EXPECT_FALSE(parse(polygon, text.c_str()));
}
TEST(LD2450PolygonTest, ParseInvalidKeepsPreviousPolygon) {
Polygon polygon;
ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000"));
const char *invalid[] = {
"junk",
"1,2;3",
"1,2;3,4;5,",
"1;2;3",
"1,2 3,4 5,6",
"1,2;;3,4;5,6",
"--1,2;3,4;5,6",
"1,2;3,4;5,6x",
// Only spaces are allowed around numbers, as in the text entity's pattern
"1,2;\t3,4;5,6",
};
for (const char *str : invalid) {
EXPECT_FALSE(parse(polygon, str)) << str;
EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000") << str;
}
}
TEST(LD2450PolygonTest, IsValid) {
Polygon polygon;
EXPECT_TRUE(polygon.is_valid());
polygon.count = 2;
EXPECT_FALSE(polygon.is_valid());
polygon.count = MAX_POLYGON_POINTS + 1;
EXPECT_FALSE(polygon.is_valid());
polygon.count = 3;
EXPECT_TRUE(polygon.is_valid());
polygon.points[1].y = -5;
EXPECT_FALSE(polygon.is_valid());
}
// --- Point in polygon ---
TEST(LD2450PolygonTest, ContainsEmpty) {
Polygon polygon;
EXPECT_FALSE(polygon.contains(0, 0));
EXPECT_FALSE(polygon.contains(100, 1000));
}
TEST(LD2450PolygonTest, ContainsSquare) {
Polygon polygon;
ASSERT_TRUE(parse(polygon, "-1000,1000;1000,1000;1000,3000;-1000,3000"));
EXPECT_TRUE(polygon.contains(0, 2000));
EXPECT_TRUE(polygon.contains(-999, 1001));
EXPECT_TRUE(polygon.contains(999, 2999));
EXPECT_FALSE(polygon.contains(0, 999));
EXPECT_FALSE(polygon.contains(0, 3001));
EXPECT_FALSE(polygon.contains(-1001, 2000));
EXPECT_FALSE(polygon.contains(1001, 2000));
}
TEST(LD2450PolygonTest, ContainsWithReversedWinding) {
Polygon clockwise;
Polygon counter_clockwise;
ASSERT_TRUE(parse(clockwise, "0,1000;2000,3000;-2000,3000"));
ASSERT_TRUE(parse(counter_clockwise, "-2000,3000;2000,3000;0,1000"));
for (int16_t x = -2500; x <= 2500; x += 250) {
for (int16_t y = 500; y <= 3500; y += 250) {
EXPECT_EQ(clockwise.contains(x, y), counter_clockwise.contains(x, y)) << x << "," << y;
}
}
EXPECT_TRUE(clockwise.contains(0, 2000));
EXPECT_FALSE(clockwise.contains(1500, 2000));
}
TEST(LD2450PolygonTest, ContainsConcave) {
// U shape: the notch between the two arms is outside
Polygon polygon;
ASSERT_TRUE(parse(polygon, "-3000,1000;3000,1000;3000,4000;1000,4000;1000,2000;-1000,2000;-1000,4000;-3000,4000"));
EXPECT_TRUE(polygon.contains(-2000, 3000));
EXPECT_TRUE(polygon.contains(2000, 3000));
EXPECT_TRUE(polygon.contains(0, 1500));
EXPECT_FALSE(polygon.contains(0, 3000));
EXPECT_FALSE(polygon.contains(0, 4500));
}
TEST(LD2450PolygonTest, ContainsFarOutsideRadarArea) {
// The radar reports 15-bit coordinates; the cross products must not overflow
Polygon polygon;
ASSERT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560;-4860,7560"));
EXPECT_FALSE(polygon.contains(32767, 32767));
EXPECT_FALSE(polygon.contains(-32767, -32767));
EXPECT_FALSE(polygon.contains(-32767, 3000));
EXPECT_TRUE(polygon.contains(0, 3000));
}
} // namespace esphome::ld2450::testing
@@ -0,0 +1,4 @@
climate:
- platform: midea_portasplit
id: midea_portasplit_unit
name: PortaSplit
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
midea_portasplit: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
midea_portasplit: !include common.yaml
@@ -0,0 +1,25 @@
# on_message with a payload filter, a subscription callback larger than a pointer with its unsubscribe,
# a callback that unsubscribes itself, and a mutable callback, which keeps its state instead of being copied per call.
mqtt:
id: mqtt_client
on_message:
- topic: livingroom/ota_mode
payload: "ON"
qos: 1
then:
- logger.log: Got livingroom/ota_mode ON
esphome:
on_boot:
- lambda: |-
std::string tag = "boxed";
id(mqtt_client).subscribe("some/topic/boxed", [tag](const std::string &topic, const std::string &payload) {
ESP_LOGD("test", "%s %s", tag.c_str(), payload.c_str());
});
id(mqtt_client).unsubscribe("some/topic/boxed");
id(mqtt_client).subscribe("some/topic/once", [](const std::string &topic, const std::string &payload) {
id(mqtt_client).unsubscribe(topic);
});
id(mqtt_client).subscribe("some/topic/counted", [n = 0](const std::string &topic, const std::string &payload) mutable {
ESP_LOGD("test", "message %d: %s", ++n, payload.c_str());
});
@@ -0,0 +1,3 @@
packages:
common: !include common.yaml
triggers: !include common-triggers.yaml
+6
View File
@@ -0,0 +1,6 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The driver lives in the time platform, so pull in what it builds against.
manifest.dependencies = manifest.dependencies + ["i2c", "time"]
@@ -0,0 +1,77 @@
#include <gtest/gtest.h>
#include <array>
#include <ctime>
#include "esphome/components/pcf85063/pcf85063.h"
#include "esphome/core/time.h"
namespace esphome::pcf85063::testing {
static constexpr uint8_t ADDRESS = 0x51;
static constexpr uint8_t WEEKDAYS_REG = 0x08;
static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0
// The chip's 18 registers behind an address pointer that moves on after every byte.
class RegisterBus final : public i2c::I2CBus {
public:
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
EXPECT_EQ(address, ADDRESS);
if (size != 0) {
this->pointer_ = data[0];
for (size_t i = 1; i < size; i++)
this->registers[this->next_()] = data[i];
}
for (size_t i = 0; i < out_size; i++)
out[i] = this->registers[this->next_()];
return i2c::ERROR_OK;
}
std::array<uint8_t, 18> registers{};
protected:
size_t next_() { return this->pointer_++ % this->registers.size(); }
uint8_t pointer_{0};
};
// write_time() takes the host's current time, so reading it back never changes the host clock.
class PCF85063Test : public ::testing::Test {
protected:
void SetUp() override {
this->rtc_.set_i2c_bus(&this->bus_);
this->rtc_.set_i2c_address(ADDRESS);
this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; });
this->rtc_.setup();
}
RegisterBus bus_;
PCF85063Component rtc_;
unsigned syncs_{0};
};
TEST_F(PCF85063Test, WriteTimeStoresTheChipsWeekday) {
// Read the clock on both sides of the write, in case UTC midnight falls in between.
const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1;
this->rtc_.write_time();
const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1;
const int stored = this->bus_.registers[WEEKDAYS_REG];
EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before;
}
// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight.
// Seven steps from the written value visit every weekday once, and each has to read back as a valid time.
// Only the weekday moves, so the time read back is still the host's time.
TEST_F(PCF85063Test, WeekdayRoundTripsThroughSundayAndSaturday) {
this->rtc_.write_time();
uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG];
for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) {
weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK;
const unsigned syncs_before = this->syncs_;
this->rtc_.read_time();
EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday);
}
}
} // namespace esphome::pcf85063::testing
+15
View File
@@ -78,6 +78,21 @@ esphome:
seup: 1
sedn: 0
seimin: true
- tmc2209.chopconf:
id: tmc2209_step_dir
toff: 4
tbl: 2
hend: 0
hstrt: !lambda "return 5;"
- tmc2209.pwmconf:
id: tmc2209_serial
pwm_ofs: 36
pwm_grad: 14
pwm_freq: 1
pwm_autoscale: true
pwm_autograd: true
pwm_reg: 4
pwm_lim: 12
- tmc2209.enable: tmc2209_serial
- stepper.set_target:
id: tmc2209_serial
@@ -0,0 +1,248 @@
esphome:
name: uart-mock-ld2450-has-target
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
responses:
# Generic ACK for any command, to unblock setup commands
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# One frame every 100 ms, as the radar sends them. Coordinates are sign-magnitude, with the high bit set for
# positive values.
injections:
# Target 1 at (-500, 1000)
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# No targets for 2 s: has_target must hold for the default 5 s presence_timeout
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
ld2450:
id: ld2450_dev
uart_id: mock_uart
# Only has_target: no sensor platform and no presence_timeout number
binary_sensor:
- platform: ld2450
ld2450_id: ld2450_dev
has_target:
name: "Has Target"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -0,0 +1,55 @@
esphome:
name: uart-mock-ld2450-zone-restore
host:
api:
batch_delay: 0ms
logger:
level: DEBUG
preferences:
flash_write_interval: 0s
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 256000
responses:
# Generic ACK for any command, to unblock setup commands
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2450:
id: ld2450_dev
uart_id: mock_uart
text:
- platform: ld2450
ld2450_id: ld2450_dev
polygon_zones:
- id: restore_zone
name: "Restore Zone Polygon"
binary_sensor:
- platform: ld2450
ld2450_id: ld2450_dev
polygon_zones:
- polygon_zone_id: restore_zone
name: "Restore Zone Presence"
@@ -0,0 +1,300 @@
esphome:
name: uart-mock-ld2450-zone-timeout
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
responses:
# Generic ACK for any command, to unblock setup commands
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# One frame every 100 ms, as the radar sends them. Target 1 is either at (-500, 1000), inside the zone, or at
# (300, 400), outside it. Coordinates are sign-magnitude, with the high bit set for positive values.
injections:
# Inside the zone
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside for one frame: radar noise that presence_timeout must hide
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Inside again: the last inside frame
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside: presence clears once presence_timeout passes
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Outside
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
ld2450:
id: ld2450_dev
uart_id: mock_uart
text:
- platform: ld2450
ld2450_id: ld2450_dev
polygon_zones:
- id: hold_zone
name: "Hold Zone Polygon"
binary_sensor:
- platform: ld2450
ld2450_id: ld2450_dev
polygon_zones:
- polygon_zone_id: hold_zone
name: "Hold Zone Presence"
number:
- platform: ld2450
ld2450_id: ld2450_dev
presence_timeout:
name: "Presence Timeout"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -0,0 +1,246 @@
esphome:
name: uart-mock-ld2450-zones
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
responses:
# Generic ACK for any command, to unblock setup commands
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Frame 1: target 1 at (-500, 1000), target 2 at (200, 500)
# Coordinates are sign-magnitude, with the high bit set for positive values.
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01,
0xC8, 0x80, 0xF4, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Frame 2: target 1 at (300, 400), no other targets
- delay: 200ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Frame 3: no targets
- delay: 200ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# No targets, frame after frame, until presence_timeout (1 s) has cleared every zone
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
ld2450:
id: ld2450_dev
uart_id: mock_uart
text:
- platform: ld2450
ld2450_id: ld2450_dev
polygon_zones:
- id: left_zone
name: "Left Zone Polygon"
- id: near_zone
name: "Near Zone Polygon"
- id: unset_zone
name: "Unset Zone Polygon"
binary_sensor:
- platform: ld2450
ld2450_id: ld2450_dev
polygon_zones:
- polygon_zone_id: left_zone
name: "Left Zone Presence"
- polygon_zone_id: near_zone
name: "Near Zone Presence"
- polygon_zone_id: unset_zone
name: "Unset Zone Presence"
number:
- platform: ld2450
ld2450_id: ld2450_dev
presence_timeout:
name: "Presence Timeout"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -0,0 +1,64 @@
"""Integration test for LD2450 has_target without the sensor and number platforms.
Tests:
1. has_target turns on when only binary sensors are configured (target coordinates are still decoded)
2. has_target holds for the default presence_timeout of 5 s, without the presence_timeout number
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from aioesphomeapi import BinarySensorInfo, BinarySensorState, ButtonInfo, EntityState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
# The fixture sends one frame with a target, then 2 s of frames without one
SCENARIO_S = 2.1
@pytest.mark.asyncio
async def test_uart_mock_ld2450_has_target_only(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test has_target with no sensor platform and no presence_timeout number."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
has_target = require_entity(entities, "has_target", BinarySensorInfo)
history: list[bool] = []
turned_on = asyncio.get_running_loop().create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, BinarySensorState) and state.key == has_target.key:
history.append(state.state)
if state.state and not turned_on.done():
turned_on.set_result(None)
helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(on_state))
await helper.wait_for_initial_states()
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
await asyncio.wait_for(turned_on, timeout=5.0)
await asyncio.sleep(SCENARIO_S)
assert history == [True], (
"has_target must turn on and hold through 2 s without targets, "
f"got {history}"
)
@@ -0,0 +1,117 @@
"""Integration test for restoring LD2450 polygon zones from saved preferences.
Tests:
1. A polygon set from the API is restored after a restart
2. A saved polygon that is no longer valid is reported and leaves the zone disabled
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import socket
import struct
from aioesphomeapi import TextInfo, TextState
import pytest
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
from .host_prefs import clear_host_prefs, read_host_prefs, write_host_prefs
from .state_utils import InitialStateHelper, require_entity
from .types import CompileFunction, ConfigWriter
DEVICE_NAME = "uart-mock-ld2450-zone-restore"
ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500"
# The saved Polygon starts with its points as little-endian int16 x, y pairs
SAVED_POLYGON_PREFIX = struct.pack("<hh", -1000, 800)
# Offset of Polygon::count: after MAX_POLYGON_POINTS (23) points of 4 bytes
COUNT_OFFSET = 23 * 4
@pytest.mark.asyncio
async def test_uart_mock_ld2450_polygon_zone_restore(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Test that polygon zones are restored after a restart, and invalid saved polygons are rejected."""
port, port_socket = reserved_tcp_port
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
clear_host_prefs(DEVICE_NAME)
binary_path = await compile_esphome(await write_yaml_config(yaml_config))
# Release the reserved port so the binary can bind to it
port_socket.close()
async def initial_polygon(line_callback=None) -> str:
"""Start the binary and return the zone's polygon state as first published."""
async with (
run_binary_and_wait_for_port(
binary_path, "127.0.0.1", port, line_callback=line_callback
),
wait_and_connect_api_client(port=port) as client,
):
entities, _ = await client.list_entities_services()
polygon = require_entity(entities, "restore_zone_polygon", TextInfo)
helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(lambda state: None))
await helper.wait_for_initial_states()
state = helper.initial_states[polygon.key]
assert isinstance(state, TextState)
return state.state
# First run: set a polygon, which is saved
async with (
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
wait_and_connect_api_client(port=port) as client,
):
entities, _ = await client.list_entities_services()
polygon = require_entity(entities, "restore_zone_polygon", TextInfo)
loop = asyncio.get_running_loop()
published = loop.create_future()
def on_state(state: object) -> None:
if (
isinstance(state, TextState)
and state.key == polygon.key
and state.state == ZONE_POLYGON
and not published.done()
):
published.set_result(None)
helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(on_state))
await helper.wait_for_initial_states()
assert helper.initial_states[polygon.key].state == ""
client.text_command(polygon.key, ZONE_POLYGON)
await asyncio.wait_for(published, timeout=5.0)
# Second run: the polygon is restored
assert await initial_polygon() == ZONE_POLYGON
# Third run: a saved polygon with too few points is rejected
prefs = read_host_prefs(DEVICE_NAME)
keys = [key for key, data in prefs.items() if data.startswith(SAVED_POLYGON_PREFIX)]
assert len(keys) == 1, f"saved polygon not found in {prefs}"
data = bytearray(prefs[keys[0]])
data[COUNT_OFFSET] = 2
prefs[keys[0]] = bytes(data)
write_host_prefs(DEVICE_NAME, prefs)
warnings: list[str] = []
def capture_warning(line: str) -> None:
if "saved polygon is invalid" in line:
warnings.append(line)
assert await initial_polygon(capture_warning) == ""
assert warnings, "expected a warning about the invalid saved polygon"
clear_host_prefs(DEVICE_NAME)
@@ -0,0 +1,102 @@
"""Integration test for LD2450 polygon zone presence with presence_timeout.
Tests:
1. A target leaving the zone for a single radar frame does not clear presence
2. Presence clears once presence_timeout has passed since a target was last inside the zone
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from aioesphomeapi import (
BinarySensorInfo,
BinarySensorState,
ButtonInfo,
EntityState,
NumberInfo,
NumberState,
TextInfo,
TextState,
)
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "uart-mock-ld2450-zone-timeout"
ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500"
PRESENCE_TIMEOUT_S = 1
@pytest.mark.asyncio
async def test_uart_mock_ld2450_polygon_zone_timeout(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that polygon zone presence follows presence_timeout."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
# The polygon and presence_timeout are saved, so a previous run would otherwise restore them
clear_host_prefs(DEVICE_NAME)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
polygon = require_entity(entities, "hold_zone_polygon", TextInfo)
presence = require_entity(entities, "hold_zone_presence", BinarySensorInfo)
timeout = require_entity(entities, "presence_timeout", NumberInfo)
loop = asyncio.get_running_loop()
# (time received, state) for each presence change
presence_history: list[tuple[float, bool]] = []
cleared = loop.create_future()
polygon_set = loop.create_future()
timeout_set = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, BinarySensorState) and state.key == presence.key:
presence_history.append((loop.time(), state.state))
if state.state is False and not cleared.done():
cleared.set_result(None)
elif (
isinstance(state, TextState)
and state.key == polygon.key
and state.state == ZONE_POLYGON
and not polygon_set.done()
):
polygon_set.set_result(None)
elif (
isinstance(state, NumberState)
and state.key == timeout.key
and state.state == PRESENCE_TIMEOUT_S
and not timeout_set.done()
):
timeout_set.set_result(None)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
client.text_command(polygon.key, ZONE_POLYGON)
client.number_command(timeout.key, PRESENCE_TIMEOUT_S)
await asyncio.wait_for(asyncio.gather(polygon_set, timeout_set), timeout=5.0)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
await asyncio.wait_for(cleared, timeout=5.0)
# The one-frame blip outside never reaches HA
assert [state for _, state in presence_history] == [True, False]
# The last inside frame comes 200 ms after the first, then presence_timeout must pass
(on_time, _), (off_time, _) = presence_history
assert off_time - on_time >= PRESENCE_TIMEOUT_S
@@ -0,0 +1,160 @@
"""Integration test for LD2450 polygon zones with mock UART.
Tests:
1. Polygons start empty and their presence sensors start unknown
2. A polygon set from the API is published back in canonical form
3. Invalid polygon text is rejected and the previous polygon is published again
4. Presence turns on and off as targets move in and out of each polygon
5. A zone without a polygon keeps presence unknown, and clearing a polygon makes it unknown again
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import re
from aioesphomeapi import (
BinarySensorInfo,
BinarySensorState,
ButtonInfo,
EntityState,
NumberInfo,
NumberState,
TextInfo,
TextState,
)
import pytest
from esphome.components.ld2450.text import POLYGON_MAX_LENGTH, POLYGON_PATTERN
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, StateWaiter, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "uart-mock-ld2450-zones"
LEFT_POLYGON = "-1000,800;0,800;0,1500;-1000,1500"
NEAR_POLYGON = "0,0;1000,0;1000,600;0,600"
@pytest.mark.asyncio
async def test_uart_mock_ld2450_polygon_zones(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test LD2450 polygon zone configuration and presence detection."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
# Polygons are saved, so a previous run would otherwise restore them
clear_host_prefs(DEVICE_NAME)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
polygons = {
name: require_entity(entities, f"{name}_zone_polygon", TextInfo)
for name in ("left", "near", "unset")
}
presences = {
name: require_entity(entities, f"{name}_zone_presence", BinarySensorInfo)
for name in ("left", "near", "unset")
}
presence_names = {info.key: name for name, info in presences.items()}
# None records presence going back to unknown
presence_history: dict[str, list[bool | None]] = {
name: [] for name in presences
}
waiter = StateWaiter()
expect = waiter.expect
def on_state(state: EntityState) -> None:
if isinstance(state, BinarySensorState) and state.key in presence_names:
presence_history[presence_names[state.key]].append(
None if state.missing_state else state.state
)
waiter.on_state(state)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
for name, polygon in polygons.items():
# Home Assistant validates input and the zone card finds zones with these
assert polygon.pattern == POLYGON_PATTERN
assert polygon.max_length == POLYGON_MAX_LENGTH
assert initial_state_helper.initial_states[polygon.key].state == ""
assert initial_state_helper.initial_states[
presences[name].key
].missing_state
async def set_polygon(name: str, value: str, expected: str) -> None:
key = polygons[name].key
published = expect(lambda s: isinstance(s, TextState) and s.key == key)
client.text_command(key, value)
state = await published
assert state.state == expected
assert re.fullmatch(POLYGON_PATTERN, state.state)
# Spaces and a trailing separator are accepted and removed
await set_polygon(
"left", f" {LEFT_POLYGON.replace(';', ' ; ')} ;", LEFT_POLYGON
)
await set_polygon("near", NEAR_POLYGON, NEAR_POLYGON)
# Invalid text keeps the polygon in use
await set_polygon("near", "0,0;1000,0", NEAR_POLYGON)
await set_polygon("near", "junk", NEAR_POLYGON)
# Zone presence follows presence_timeout; keep the scenario short
timeout = require_entity(entities, "presence_timeout", NumberInfo)
timeout_set = expect(
lambda s: (
isinstance(s, NumberState) and s.key == timeout.key and s.state == 1
)
)
client.number_command(timeout.key, 1)
await timeout_set
left_cleared = expect(
lambda s: (
isinstance(s, BinarySensorState)
and s.key == presences["left"].key
and s.state is False
)
)
near_cleared = expect(
lambda s: (
isinstance(s, BinarySensorState)
and s.key == presences["near"].key
and s.state is False
)
)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
await asyncio.gather(left_cleared, near_cleared)
# Frame 1: (-500, 1000) is in the left zone and (200, 500) is in the near zone.
# Frame 2: (300, 400) is in the near zone only; the left zone clears 1 s after frame 1.
# Frame 3 onwards: no targets; the near zone clears 1 s after frame 2.
assert presence_history["left"] == [True, False]
assert presence_history["near"] == [True, False]
assert presence_history["unset"] == []
left_unknown = expect(
lambda s: (
isinstance(s, BinarySensorState)
and s.key == presences["left"].key
and s.missing_state
)
)
await set_polygon("left", "", "")
await left_unknown
assert presence_history["left"] == [True, False, None]
+82
View File
@@ -4,6 +4,7 @@ from collections.abc import Generator
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
from unittest.mock import Mock, patch
@@ -2784,6 +2785,87 @@ def test_should_run_benchmarks_core_benchmark_file_change() -> None:
assert determine_jobs.should_run_benchmarks() is True
def test_python_import_benchmarked_components_imports_real_components() -> None:
"""The set is what importing the real benchmark's components loads."""
loaded = determine_jobs.python_import_benchmarked_components()
# mqtt is not listed in COMPONENTS; sensor imports it at module scope
assert {"binary_sensor", "sensor", "logger", "mqtt"} <= loaded
def test_should_run_benchmarks_python_import_benchmarked_component() -> None:
"""A Python change in a component the import benchmark loads runs benchmarks.
logger has no C++ benchmark, so before this a change to its __init__.py
skipped the Python import benchmark that times it.
"""
for changed, expected in [
("esphome/components/logger/__init__.py", True),
("esphome/components/logger/select/__init__.py", True),
("esphome/components/mqtt/__init__.py", True),
("esphome/components/logger/logger.cpp", False),
("tests/components/logger/common.yaml", False),
("esphome/components/wifi/__init__.py", False),
]:
with (
patch.object(determine_jobs, "changed_files", return_value=[changed]),
patch.object(
determine_jobs,
"python_import_benchmarked_components",
return_value=frozenset({"logger", "mqtt"}),
),
):
assert determine_jobs.should_run_benchmarks() is expected, changed
def test_should_run_benchmarks_when_import_probe_fails() -> None:
"""A component import that fails runs the benchmarks instead of skipping them."""
with (
patch.object(
determine_jobs,
"changed_files",
return_value=["esphome/components/wifi/__init__.py"],
),
patch.object(
determine_jobs, "python_import_benchmarked_components", return_value=None
),
):
assert determine_jobs.should_run_benchmarks() is True
def test_python_import_benchmarked_components_logs_probe_failure(
capsys: pytest.CaptureFixture[str],
) -> None:
"""A failed probe returns None and prints why on stderr, never stdout."""
failed = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="ModuleNotFoundError: boom"
)
with patch.object(determine_jobs.subprocess, "run", return_value=failed):
assert determine_jobs.python_import_benchmarked_components() is None
captured = capsys.readouterr()
assert "ModuleNotFoundError: boom" in captured.err
assert captured.out == ""
def test_python_import_benchmarked_components_missing_file(tmp_path: Path) -> None:
"""Without the benchmark file no component is treated as benchmarked."""
with patch.object(determine_jobs, "root_path", str(tmp_path)):
assert determine_jobs.python_import_benchmarked_components() == frozenset()
def test_python_import_benchmarked_components_requires_components(
tmp_path: Path,
) -> None:
"""A benchmark file without COMPONENTS fails loudly instead of disabling the rule."""
bench = tmp_path / determine_jobs.PYTHON_IMPORT_BENCHMARK_FILE
bench.parent.mkdir(parents=True)
bench.write_text('NAMES = ("logger",)\n')
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
pytest.raises(RuntimeError, match="COMPONENTS"),
):
determine_jobs.python_import_benchmarked_components()
def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None:
"""Test benchmarks trigger when a benchmarked component changes."""
# Create a fake benchmarks directory with an 'api' component
@@ -0,0 +1,107 @@
"""The config-time fit check must agree with make_preference() in preferences.cpp."""
import asyncio
import pytest
from esphome import preferences
from esphome.components.esp8266.const import (
KEY_ESP8266,
KEY_PREFERENCES,
check_preferences_fit,
preference_words,
reserve_preferences,
)
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def esp8266_core() -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"}
CORE.data[KEY_ESP8266] = {}
CORE.config = {KEY_ESP8266: {"restore_from_flash": False}}
def _check() -> None:
asyncio.run(check_preferences_fit())
@pytest.mark.parametrize(
("value_bytes", "words"), [(0, 1), (1, 2), (4, 2), (5, 3), (94, 25)]
)
def test_preference_words_rounds_up_and_adds_the_checksum(
value_bytes: int, words: int
) -> None:
assert preference_words(value_bytes) == words
@pytest.mark.parametrize(
("restore_from_flash", "in_flash", "fitting", "match"),
[
# 25 words each: 3 in the 78-word region and 1 in the 32-word one
(False, False, 4, "RTC memory has 110"),
(False, None, 4, "RTC memory has 110"),
# an explicit RTC value stays in RTC whatever restore_from_flash says
(True, False, 4, "RTC memory has 110"),
# 125 of 128 flash words, by the two-argument default or explicitly
(True, None, 5, "flash store has 128"),
(True, True, 5, "flash store has 128"),
# explicit flash values without restore_from_flash use the 64-word store
(False, True, 2, "flash store has 64"),
],
)
def test_reservations_add_up_against_the_store_they_land_in(
restore_from_flash: bool, in_flash: bool | None, fitting: int, match: str
) -> None:
CORE.config[KEY_ESP8266]["restore_from_flash"] = restore_from_flash
reserve_preferences(fitting - 1, 94, in_flash) # one component
reserve_preferences(1, 94, in_flash) # another component
_check()
reserve_preferences(1, 94, in_flash)
with pytest.raises(EsphomeError, match=match):
_check()
def test_values_that_cannot_split_between_the_regions_fail() -> None:
# 5 x 20 words is 100 of 110, but the normal region takes 3 and the eboot region 1
reserve_preferences(5, 76)
with pytest.raises(EsphomeError, match="cannot span"):
_check()
def test_a_value_larger_than_either_region_fails() -> None:
reserve_preferences(1, 400)
with pytest.raises(EsphomeError, match="cannot span"):
_check()
def test_a_split_that_works_in_some_order_passes_with_a_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
# 78 + 32: one value fills each region exactly
reserve_preferences(1, 308)
reserve_preferences(1, 124)
_check()
assert "depends on setup order" in caplog.text
def test_nothing_reserved_passes_quietly(caplog: pytest.LogCaptureFixture) -> None:
_check()
assert caplog.text == ""
def test_the_check_runs_as_a_scheduled_job() -> None:
reserve_preferences(3, 400)
CORE.add_job(check_preferences_fit)
with pytest.raises(EsphomeError, match="cannot span"):
CORE.flush_tasks()
def test_components_reserve_through_esphome_preferences() -> None:
preferences.reserve_preferences(2, 94, in_flash=True)
assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(25, True), (25, True)]
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
CORE.data[KEY_ESP8266] = {}
preferences.reserve_preferences(2, 94)
assert KEY_PREFERENCES not in CORE.data[KEY_ESP8266]
+43 -2
View File
@@ -1,16 +1,19 @@
from collections.abc import Iterator
import io
import math
from pathlib import Path
import pytest
from esphome import cpp_generator as cg, cpp_types as ct
from esphome import config_validation as cv, cpp_generator as cg, cpp_types as ct
from esphome.const import (
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
)
from esphome.core import CORE, ID, EsphomeError
from esphome.core import CORE, ID, LINE_DIRECTIVE_RESET, DocumentLocation, EsphomeError
from esphome.yaml_util import parse_yaml
class TestExpressions:
@@ -244,6 +247,22 @@ class TestLambdaExpression:
"[captured_var](int32_t x) -> int32_t {\n return captured_var + x;\n}"
)
def test_str__with_source_marks_end_of_body(self) -> None:
"""A lambda from the YAML marks the end of its body for the line reset."""
target = cg.LambdaExpression(
("return 42;",), (), "", int, source=DocumentLocation("test.yaml", 4, 0)
)
actual = str(target)
assert actual == (
"[]() -> int32_t {\n"
' #line 5 "test.yaml"\n'
" return 42;\n"
f" {LINE_DIRECTIVE_RESET}\n"
"}"
)
class TestCallLambda:
"""Tests for the call_lambda() function."""
@@ -735,6 +754,28 @@ class TestProcessLambda:
with pytest.raises(AssertionError):
await cg.process_lambda(lambda_obj, [(int,)])
def test_str__with_line_mark_in_body_marks_end_of_body(self) -> None:
"""A #line written into the body, as lvgl does, gets the reset too."""
target = cg.LambdaExpression(('#line 3 "test.yaml"\nfoo();',), (), "", None)
actual = str(target)
assert actual == (
f'[]() {{\n #line 3 "test.yaml"\n foo();\n {LINE_DIRECTIVE_RESET}\n}}'
)
async def test_process_lambda__twice_keeps_line(self) -> None:
"""Processing one lambda twice, as a YAML alias does, keeps its line."""
config = parse_yaml(Path("test.yaml"), io.StringIO("x: |-\n return 42;\n"))
lambda_obj = cv.lambda_(config["x"])
first = await cg.process_lambda(lambda_obj, [])
second = await cg.process_lambda(lambda_obj, [])
# The body of the block scalar is the second line, 1 counted from 0
assert first.source.line == 1
assert second.source.line == 1
@pytest.mark.asyncio
async def test_templatable__string_with_std_string_returns_flash_literal() -> None:
+10 -3
View File
@@ -858,16 +858,23 @@ def test_probe_ota_key_resolution_failure(
) -> None:
"""A name that never resolves is retried until the deadline, then reported."""
pytest.importorskip("aioesphomeapi.noise")
clock = [0.0]
def sleep(seconds: float) -> None:
clock[0] += seconds
with (
patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve,
patch("time.sleep"),
patch("time.sleep", side_effect=sleep),
patch("time.monotonic", side_effect=lambda: clock[0]),
caplog.at_level(logging.WARNING),
):
assert (
espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2)
espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=10)
is not ProbeResult.ACCEPTED
)
assert resolve.call_count > 1
# Looked up at 0, 3, 6 and 9 s; the last pause stops at the deadline
assert resolve.call_count == 4
assert any("failed, unreachable" in r.message for r in caplog.records)
+29 -1
View File
@@ -22,7 +22,9 @@ from esphome.const import (
PLATFORM_RP2,
PLATFORM_RTL87XX,
)
from esphome.core import EsphomeError
from esphome.core import DocumentLocation, EsphomeError, line_directive
from esphome.cpp_generator import LambdaExpression
from esphome.helpers import indent
from esphome.storage_json import StorageJSON
from esphome.writer import (
CPP_AUTO_GENERATE_BEGIN,
@@ -965,6 +967,32 @@ def test_write_cpp_creates_new_file(
assert "App.loop();" in written_content
@pytest.mark.usefixtures("mock_copy_src_tree")
@patch("esphome.writer.write_file_if_changed")
@patch("esphome.writer.CORE")
def test_write_cpp_resets_line_after_lambda(
mock_core: MagicMock,
mock_write_file: MagicMock,
tmp_path: Path,
) -> None:
"""The code after a YAML lambda is reported against main.cpp again."""
main_cpp = tmp_path / "main.cpp"
mock_core.relative_src_path.return_value = main_cpp
mock_core.cpp_global_section = ""
lambda_ = LambdaExpression(
("foo();",), (), source=DocumentLocation("test.yaml", 4, 0)
)
write_cpp(indent(f"bar({lambda_});\nbaz();") + "\n")
lines = mock_write_file.call_args[0][1].split("\n")
closing = lines.index(" });")
assert lines[closing - 3].strip() == '#line 5 "test.yaml"'
# The directive names the line after it, which is closing + 1 (1-based)
assert lines[closing - 1].strip() == line_directive(str(main_cpp), closing + 1)
assert lines[closing + 1] == " baz();"
@pytest.mark.usefixtures("mock_copy_src_tree")
@patch("esphome.writer.CORE")
def test_write_cpp_with_missing_end_marker(
+18
View File
@@ -2317,3 +2317,21 @@ def test_find_secret_references_in_flow_collections() -> None:
"""Flow mapping and sequence punctuation is not part of the secret name."""
text = "a: {password: !secret wifi_pw}\nb: [!secret one, !secret 'two']\n"
assert yaml_util.find_secret_references(text) == {"wifi_pw", "one", "two"}
@pytest.mark.parametrize(
("yaml_text", "expected_offset"),
[
("value: return 42;\n", 0),
('value: "return 42;"\n', 0),
("value: |-\n return 42;\n", 1),
("value: >-\n return 42;\n", 1),
],
)
def test_content_offset_only_for_block_scalars(
yaml_text: str, expected_offset: int
) -> None:
"""A one-line lambda starts on its own line; only | and > move it to the next."""
doc = yaml_util.parse_yaml(Path("test.yaml"), io.StringIO(yaml_text))
assert doc["value"].content_offset == expected_offset
assert doc["value"].content_start.line == expected_offset