mirror of
https://github.com/esphome/esphome.git
synced 2026-10-16 11:12:52 +00:00
Merge remote-tracking branch 'origin/dev' into binary-sensor-lazy-integration-imports
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
CODEOWNERS = ["@remcom"]
|
||||
DOMAIN = "fusb302b"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user