Compare commits

..
12 changed files with 106 additions and 188 deletions

No files matched your search

+76 -32
View File
@@ -9,7 +9,6 @@
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <cinttypes>
@@ -20,6 +19,23 @@ namespace esphome::ld2450 {
ESPHOME_LOG_TAG(TAG, "ld2450");
enum BaudRate : uint8_t {
BAUD_RATE_9600 = 1,
BAUD_RATE_19200 = 2,
BAUD_RATE_38400 = 3,
BAUD_RATE_57600 = 4,
BAUD_RATE_115200 = 5,
BAUD_RATE_230400 = 6,
BAUD_RATE_256000 = 7,
BAUD_RATE_460800 = 8
};
enum ZoneType : uint8_t {
ZONE_DISABLED = 0,
ZONE_DETECTION = 1,
ZONE_FILTER = 2,
};
enum PeriodicData : uint8_t {
TARGET_X = 4,
TARGET_Y = 6,
@@ -38,20 +54,61 @@ enum AckData : uint8_t {
COMMAND_STATUS = 7,
};
// Index + 1 is the BaudRate value; the select options use the same order
PROGMEM_STRING_TABLE(BaudRateStrings, "9600", "19200", "38400", "57600", "115200", "230400", "256000", "460800");
static_assert(BAUD_RATE_9600 == 1 && BAUD_RATE_460800 == BaudRateStrings::COUNT, "BaudRate is index + 1");
// Index is the Direction value
PROGMEM_STRING_TABLE(DirectionStrings, "Approaching", "Moving away", "Stationary", "NA");
static_assert(DIRECTION_APPROACHING == 0 && DIRECTION_MOVING_AWAY == 1 && DIRECTION_STATIONARY == 2 &&
DIRECTION_NA == 3,
"Direction is the table index");
// Index is the ZoneType value; the select options use the same order
PROGMEM_STRING_TABLE(ZoneTypeStrings, "Disabled", "Detection", "Filter");
static_assert(ZONE_DISABLED == 0 && ZONE_DETECTION == 1 && ZONE_FILTER == 2, "ZoneType is the table index");
// Memory-efficient lookup tables
struct StringToUint8 {
const char *str;
const uint8_t value;
};
// Baud rates in the same order as BaudRateStrings for index-based lookup
static constexpr uint32_t BAUD_RATES[] PROGMEM = {9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800};
struct Uint8ToString {
const uint8_t value;
const char *str;
};
constexpr StringToUint8 BAUD_RATES_BY_STR[] = {
{"9600", BAUD_RATE_9600}, {"19200", BAUD_RATE_19200}, {"38400", BAUD_RATE_38400},
{"57600", BAUD_RATE_57600}, {"115200", BAUD_RATE_115200}, {"230400", BAUD_RATE_230400},
{"256000", BAUD_RATE_256000}, {"460800", BAUD_RATE_460800},
};
constexpr Uint8ToString DIRECTION_BY_UINT[] = {
{DIRECTION_APPROACHING, "Approaching"},
{DIRECTION_MOVING_AWAY, "Moving away"},
{DIRECTION_STATIONARY, "Stationary"},
{DIRECTION_NA, "NA"},
};
constexpr Uint8ToString ZONE_TYPE_BY_UINT[] = {
{ZONE_DISABLED, "Disabled"},
{ZONE_DETECTION, "Detection"},
{ZONE_FILTER, "Filter"},
};
constexpr StringToUint8 ZONE_TYPE_BY_STR[] = {
{"Disabled", ZONE_DISABLED},
{"Detection", ZONE_DETECTION},
{"Filter", ZONE_FILTER},
};
// Baud rates in the same order as BAUD_RATES_BY_STR for index-based lookup
constexpr uint32_t BAUD_RATES[] = {9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800};
// Helper functions for lookups
template<size_t N> uint8_t find_uint8(const StringToUint8 (&arr)[N], const std::string &str) {
for (const auto &entry : arr) {
if (str == entry.str)
return entry.value;
}
return 0xFF; // Not found
}
template<size_t N> const char *find_str(const Uint8ToString (&arr)[N], uint8_t value) {
for (const auto &entry : arr) {
if (value == entry.value)
return entry.str;
}
return ""; // Not found
}
// LD2450 UART Serial Commands
static constexpr uint8_t CMD_ENABLE_CONF = 0xFF;
@@ -489,7 +546,7 @@ void LD2450Component::handle_periodic_data_() {
if (this->direction_dedup_[index].next(direction)) {
text_sensor::TextSensor *tsd = this->direction_text_sensors_[index];
if (tsd != nullptr) {
tsd->publish_state(DirectionStrings::get_progmem_str(direction, DirectionStrings::LAST_INDEX));
tsd->publish_state(find_str(ld2450::DIRECTION_BY_UINT, direction));
}
}
#endif
@@ -807,15 +864,8 @@ void LD2450Component::set_bluetooth(bool enable) {
// Set Baud rate
void LD2450Component::set_baud_rate(const char *state) {
const int16_t index = progmem_string_table_find<BaudRateStrings>(state, strlen(state));
if (index >= 0) {
this->set_baud_rate(static_cast<BaudRate>(index + 1));
}
}
void LD2450Component::set_baud_rate(BaudRate rate) {
this->set_config_mode_(true);
const uint8_t cmd_value[2] = {rate, 0x00};
const uint8_t cmd_value[2] = {find_uint8(BAUD_RATES_BY_STR, state), 0x00};
this->send_command_(CMD_SET_BAUD_RATE, cmd_value, sizeof(cmd_value));
this->set_timeout(200, [this]() { this->restart_(); });
}
@@ -823,14 +873,8 @@ void LD2450Component::set_baud_rate(BaudRate rate) {
// Set Zone Type - one of: Disabled, Detection, Filter
void LD2450Component::set_zone_type(const char *state) {
ESP_LOGV(TAG, "Set zone type: %s", state);
const int16_t index = progmem_string_table_find<ZoneTypeStrings>(state, strlen(state));
if (index >= 0) {
this->set_zone_type(static_cast<ZoneType>(index));
}
}
void LD2450Component::set_zone_type(ZoneType type) {
this->zone_type_ = type;
uint8_t zone_type = find_uint8(ZONE_TYPE_BY_STR, state);
this->zone_type_ = zone_type;
this->send_set_zone_command_();
}
@@ -838,7 +882,7 @@ void LD2450Component::set_zone_type(ZoneType type) {
void LD2450Component::publish_zone_type() {
#ifdef USE_SELECT
if (this->zone_type_select_ != nullptr) {
this->zone_type_select_->publish_state(static_cast<size_t>(this->zone_type_));
this->zone_type_select_->publish_state(find_str(ZONE_TYPE_BY_UINT, this->zone_type_));
}
#endif
}
-19
View File
@@ -55,23 +55,6 @@ enum Direction : uint8_t {
DIRECTION_UNDEFINED = 4,
};
enum BaudRate : uint8_t {
BAUD_RATE_9600 = 1,
BAUD_RATE_19200 = 2,
BAUD_RATE_38400 = 3,
BAUD_RATE_57600 = 4,
BAUD_RATE_115200 = 5,
BAUD_RATE_230400 = 6,
BAUD_RATE_256000 = 7,
BAUD_RATE_460800 = 8
};
enum ZoneType : uint8_t {
ZONE_DISABLED = 0,
ZONE_DETECTION = 1,
ZONE_FILTER = 2,
};
// Target coordinate struct
struct Target {
int16_t x;
@@ -138,9 +121,7 @@ class LD2450Component : public Component, public uart::UARTDevice {
void set_bluetooth(bool enable);
void set_multi_target(bool enable);
void set_baud_rate(const char *state);
void set_baud_rate(BaudRate rate);
void set_zone_type(const char *state);
void set_zone_type(ZoneType type);
void publish_zone_type();
void factory_reset();
#ifdef USE_TEXT_SENSOR
@@ -35,7 +35,6 @@ CONFIG_SCHEMA = {
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2450_ID])
selects = select.sub_selects(config, parent=hub)
# Option lists follow the order of the C++ enums; the hub maps by index
await selects(
CONF_BAUD_RATE,
hub.set_baud_rate_select,
@@ -4,7 +4,7 @@ namespace esphome::ld2450 {
void BaudRateSelect::control(size_t index) {
this->publish_state(index);
this->parent_->set_baud_rate(static_cast<BaudRate>(index + 1));
this->parent_->set_baud_rate(this->option_at(index));
}
} // namespace esphome::ld2450
@@ -4,7 +4,7 @@ namespace esphome::ld2450 {
void ZoneTypeSelect::control(size_t index) {
this->publish_state(index);
this->parent_->set_zone_type(static_cast<ZoneType>(index));
this->parent_->set_zone_type(this->option_at(index));
}
} // namespace esphome::ld2450
+3 -11
View File
@@ -1,7 +1,6 @@
#pragma once
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <memory>
@@ -25,17 +24,10 @@
namespace esphome::ld24xx {
// Index of value in a constant table that may live in flash, or none
template<typename T, size_t N> optional<size_t> find_index(const T (&arr)[N], T value) {
static_assert(sizeof(T) == 1 || sizeof(T) == 4, "flash tables are read as bytes or aligned 32 bit words");
// Helper to find index of value in constexpr array
template<size_t N> optional<size_t> find_index(const uint32_t (&arr)[N], uint32_t value) {
for (size_t i = 0; i < N; i++) {
T entry;
if constexpr (sizeof(T) == 1) {
entry = progmem_read_byte(reinterpret_cast<const uint8_t *>(&arr[i]));
} else {
entry = arr[i]; // an aligned 32 bit read from flash is safe on ESP8266
}
if (entry == value)
if (arr[i] == value)
return i;
}
return {};
-1
View File
@@ -30,7 +30,6 @@ class TestableLD2450 : public LD2450Component {
using LD2450Component::buffer_pos_;
using LD2450Component::readline_;
using LD2450Component::zone_config_;
using LD2450Component::zone_type_;
void feed(const std::vector<uint8_t> &data) {
for (uint8_t byte : data) {
@@ -1,85 +0,0 @@
#include <algorithm>
#include "common.h"
namespace esphome::ld2450::testing {
namespace {
class CapturingUARTComponent : public MockUARTComponent {
public:
void write_array(const uint8_t *data, size_t len) override {
this->written.insert(this->written.end(), data, data + len);
}
std::vector<uint8_t> written;
};
// Header, length 4, command 0xA1 (set baud rate): the rate byte and a zero follow.
constexpr uint8_t SET_BAUD_RATE_PREFIX[] = {0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xA1, 0x00};
// Header, length 28, command 0xC2 (set zone): the zone type byte and a zero follow.
constexpr uint8_t SET_ZONE_PREFIX[] = {0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0xC2, 0x00};
// The two payload bytes after the first frame with this prefix, or -1 when it was not written.
template<size_t N> std::pair<int, int> payload_after(const std::vector<uint8_t> &w, const uint8_t (&prefix)[N]) {
auto it = std::search(w.begin(), w.end(), std::begin(prefix), std::end(prefix));
if (it == w.end() || static_cast<size_t>(w.end() - it) < N + 2)
return {-1, -1};
return {*(it + N), *(it + N + 1)};
}
} // namespace
class LD2450SelectNamesTest : public ::testing::Test {
protected:
void SetUp() override { this->ld2450_.set_uart_parent(&this->uart_); }
CapturingUARTComponent uart_;
TestableLD2450 ld2450_;
};
TEST_F(LD2450SelectNamesTest, BaudRateNamesSendTheirDeviceValue) {
static constexpr const char *NAMES[] = {"9600", "19200", "38400", "57600", "115200", "230400", "256000", "460800"};
for (uint8_t i = 0; i < 8; i++) {
this->uart_.written.clear();
this->ld2450_.set_baud_rate(NAMES[i]);
const auto payload = payload_after(this->uart_.written, SET_BAUD_RATE_PREFIX);
EXPECT_EQ(payload.first, i + 1) << NAMES[i];
EXPECT_EQ(payload.second, 0) << NAMES[i];
}
}
TEST_F(LD2450SelectNamesTest, BaudRateEnumMatchesTheSelectOrder) {
this->ld2450_.set_baud_rate(BAUD_RATE_256000);
EXPECT_EQ(payload_after(this->uart_.written, SET_BAUD_RATE_PREFIX).first, 7);
}
TEST_F(LD2450SelectNamesTest, UnknownBaudRateNameWritesNothing) {
this->ld2450_.set_baud_rate("1200");
EXPECT_TRUE(this->uart_.written.empty());
}
TEST_F(LD2450SelectNamesTest, ZoneTypeNamesSetTheTypeAndSendIt) {
static constexpr const char *NAMES[] = {"Disabled", "Detection", "Filter"};
for (uint8_t i = 0; i < 3; i++) {
this->uart_.written.clear();
this->ld2450_.set_zone_type(NAMES[i]);
EXPECT_EQ(this->ld2450_.zone_type_, i) << NAMES[i];
const auto payload = payload_after(this->uart_.written, SET_ZONE_PREFIX);
EXPECT_EQ(payload.first, i) << NAMES[i];
EXPECT_EQ(payload.second, 0) << NAMES[i];
}
}
TEST_F(LD2450SelectNamesTest, ZoneTypeEnumMatchesTheSelectOrder) {
this->ld2450_.set_zone_type(ZONE_FILTER);
EXPECT_EQ(this->ld2450_.zone_type_, 2);
this->ld2450_.set_zone_type(ZONE_DETECTION);
EXPECT_EQ(this->ld2450_.zone_type_, 1);
}
TEST_F(LD2450SelectNamesTest, UnknownZoneTypeNameChangesNothing) {
this->ld2450_.set_zone_type(ZONE_DETECTION);
this->uart_.written.clear();
this->ld2450_.set_zone_type("Bogus");
EXPECT_EQ(this->ld2450_.zone_type_, 1);
EXPECT_TRUE(this->uart_.written.empty());
}
} // namespace esphome::ld2450::testing
+13 -7
View File
@@ -595,8 +595,8 @@ class SensorStateCollector:
class SensorTracker:
"""Data-driven sensor state tracker with expected-value futures.
Tracks sensor and binary sensor state updates and resolves futures when
they report specific expected values. Eliminates per-sensor future
Tracks sensor, binary sensor and text sensor state updates and resolves
futures when they report specific expected values. Eliminates per-sensor future
boilerplate.
Usage::
@@ -608,12 +608,20 @@ class SensorTracker:
"""
def __init__(self, sensor_names: list[str]) -> None:
self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names}
self.sensor_states: dict[str, list[float | bool | str]] = {
name: [] for name in sensor_names
}
self.key_to_sensor: dict[int, str] = {}
self._expectations: dict[str, list[tuple[object, asyncio.Future]]] = {}
_ANY = object() # Sentinel: match any value
def map_entities(self, entities: list[EntityInfo]) -> None:
"""Map the tracked names to the keys of ``entities``."""
self.key_to_sensor.update(
build_key_to_entity_mapping(entities, list(self.sensor_states))
)
def expect(self, name: str, value: object) -> asyncio.Future:
"""Register an expected value for *name* and return a future for it."""
future: asyncio.Future = asyncio.get_running_loop().create_future()
@@ -638,7 +646,7 @@ class SensorTracker:
connect-time states so they cannot satisfy a later phase.
"""
if (
not isinstance(state, (SensorState, BinarySensorState))
not isinstance(state, (SensorState, BinarySensorState, TextSensorState))
or state.missing_state
):
return
@@ -708,9 +716,7 @@ class SensorTracker:
satisfy a later phase.
"""
entities, _ = await client.list_entities_services()
self.key_to_sensor.update(
build_key_to_entity_mapping(entities, list(self.sensor_states.keys()))
)
self.map_entities(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(self.on_state))
await initial_state_helper.wait_for_initial_states()
@@ -6,7 +6,7 @@ from aioesphomeapi import connection as api_connection
from aioesphomeapi.api_pb2 import GetTimeResponse
import pytest
from .state_utils import SensorTracker, build_key_to_entity_mapping
from .state_utils import SensorTracker
from .types import APIClientConnectedFactory, RunCompiledFunction
# 2024-01-01 00:00:00 UTC
@@ -32,7 +32,7 @@ async def test_api_get_time_response_timezone(
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
tracker = SensorTracker(["tz_offset"])
tracker.key_to_sensor = build_key_to_entity_mapping(entities, ["tz_offset"])
tracker.map_entities(entities)
client.subscribe_states(tracker.on_state)
await tracker.await_change(tracker.expect_any("tz_offset"), "tz_offset")
+2 -8
View File
@@ -19,11 +19,7 @@ import asyncio
import pytest
from .state_utils import (
SensorTracker,
build_key_to_entity_mapping,
subscribe_after_initial_states,
)
from .state_utils import SensorTracker, subscribe_after_initial_states
from .types import APIClientConnectedFactory, RunCompiledFunction
# Time to let the host-mode main loop run so status_led_light.loop() can
@@ -67,9 +63,7 @@ async def test_status_flags(
"status_led_last_state",
]
)
tracker.key_to_sensor.update(
build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys()))
)
tracker.map_entities(entities)
# Swallow initial state broadcasts so the test only reacts to
# state changes triggered by our service calls.
+8 -20
View File
@@ -2,14 +2,10 @@
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, TextSensorState
import pytest
from .state_utils import build_key_to_entity_mapping, subscribe_after_initial_states
from .state_utils import SensorTracker, subscribe_after_initial_states
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# Timestamps are exposed as text sensors (rather than 32-bit float sensor states,
# which cannot represent a UNIX epoch exactly) so the exact value can be checked.
@@ -30,18 +26,10 @@ async def test_template_time(
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
key_to_name = build_key_to_entity_mapping(entities, list(EXPECTED_STATES))
events = {name: asyncio.Event() for name in EXPECTED_STATES}
def on_state(state: EntityState) -> None:
if isinstance(state, TextSensorState) and not state.missing_state:
name = key_to_name.get(state.key)
if name is not None and state.state == EXPECTED_STATES[name]:
events[name].set()
await subscribe_after_initial_states(client, entities, on_state=on_state)
for name, event in events.items():
await wait_or_fail(
event, 3.0, f"Timeout waiting for {name} to report its expected value"
)
tracker = SensorTracker(list(EXPECTED_STATES))
tracker.map_entities(entities)
futures = tracker.expect_all(EXPECTED_STATES)
await subscribe_after_initial_states(
client, entities, on_state=tracker.on_state
)
await tracker.await_all(futures, timeout=3.0)