diff --git a/CODEOWNERS b/CODEOWNERS index a2267621e7..bdcc86ef0c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -395,6 +395,7 @@ esphome/components/radon_eye_rd200/* @jeffeb3 esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet +esphome/components/rd03d/* @jasstrong esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz diff --git a/esphome/components/bedjet/bedjet_hub.cpp b/esphome/components/bedjet/bedjet_hub.cpp index c941c49fe6..0d75bcb58e 100644 --- a/esphome/components/bedjet/bedjet_hub.cpp +++ b/esphome/components/bedjet/bedjet_hub.cpp @@ -194,7 +194,7 @@ bool BedJetHub::discover_characteristics_() { result = false; } else if (descr->uuid.get_uuid().len != ESP_UUID_LEN_16 || descr->uuid.get_uuid().uuid.uuid16 != ESP_GATT_UUID_CHAR_CLIENT_CONFIG) { - char uuid_buf[esp32_ble::UUID_STR_LEN]; + char uuid_buf[espbt::UUID_STR_LEN]; ESP_LOGW(TAG, "Config descriptor 0x%x (uuid %s) is not a client config char uuid", this->char_handle_status_, descr->uuid.to_str(uuid_buf)); result = false; diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index c7179dfcb3..47987b4a95 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -127,8 +127,8 @@ static bool load_from_rtc(size_t offset, uint32_t *data, size_t len) { return true; } -// Stack buffer size - 16 words covers up to 15 words of data (60 bytes) -// which handles virtually all real-world preferences without heap allocation +// Stack buffer size - 16 words total: up to 15 words of preference data + 1 word CRC (60 bytes of preference data) +// This handles virtually all real-world preferences without heap allocation static constexpr size_t PREF_BUFFER_WORDS = 16; class ESP8266PreferenceBackend : public ESPPreferenceBackend { diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.cpp b/esphome/components/radon_eye_ble/radon_eye_listener.cpp index 52cefdf3e7..2c3ef77add 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.cpp +++ b/esphome/components/radon_eye_ble/radon_eye_listener.cpp @@ -1,7 +1,6 @@ #include "radon_eye_listener.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include -#include #ifdef USE_ESP32 @@ -11,18 +10,11 @@ namespace radon_eye_ble { static const char *const TAG = "radon_eye_ble"; bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { - if (not device.get_name().empty()) { - // Vector containing the prefixes to search for - std::vector prefixes = {"FR:R", "FR:I", "FR:H"}; - - // Check if the device name starts with any of the prefixes - if (std::any_of(prefixes.begin(), prefixes.end(), - [&](const std::string &prefix) { return device.get_name().starts_with(prefix); })) { - // Device found - char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGD(TAG, "Found Radon Eye device Name: %s (MAC: %s)", device.get_name().c_str(), - device.address_str_to(addr_buf)); - } + // Radon Eye devices have names starting with "FR:" + if (device.get_name().starts_with("FR:")) { + char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD(TAG, "Found Radon Eye device Name: %s (MAC: %s)", device.get_name().c_str(), + device.address_str_to(addr_buf)); } return false; } diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index b0a691153d..f2d32d51de 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -1,6 +1,8 @@ #include "radon_eye_rd200.h" #include "esphome/components/esp32_ble/ble_uuid.h" +#include + #ifdef USE_ESP32 namespace esphome { @@ -8,6 +10,22 @@ namespace radon_eye_rd200 { static const char *const TAG = "radon_eye_rd200"; +static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID_V1 = + esp32_ble_tracker::ESPBTUUID::from_raw("00001523-1212-efde-1523-785feabcd123"); +static const esp32_ble_tracker::ESPBTUUID WRITE_CHARACTERISTIC_UUID_V1 = + esp32_ble_tracker::ESPBTUUID::from_raw("00001524-1212-efde-1523-785feabcd123"); +static const esp32_ble_tracker::ESPBTUUID READ_CHARACTERISTIC_UUID_V1 = + esp32_ble_tracker::ESPBTUUID::from_raw("00001525-1212-efde-1523-785feabcd123"); +static const uint8_t WRITE_COMMAND_V1 = 0x50; + +static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID_V2 = + esp32_ble_tracker::ESPBTUUID::from_raw("00001523-0000-1000-8000-00805f9b34fb"); +static const esp32_ble_tracker::ESPBTUUID WRITE_CHARACTERISTIC_UUID_V2 = + esp32_ble_tracker::ESPBTUUID::from_raw("00001524-0000-1000-8000-00805f9b34fb"); +static const esp32_ble_tracker::ESPBTUUID READ_CHARACTERISTIC_UUID_V2 = + esp32_ble_tracker::ESPBTUUID::from_raw("00001525-0000-1000-8000-00805f9b34fb"); +static const uint8_t WRITE_COMMAND_V2 = 0x40; + void RadonEyeRD200::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { switch (event) { @@ -24,6 +42,22 @@ void RadonEyeRD200::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } case ESP_GATTC_SEARCH_CMPL_EVT: { + if (this->parent()->get_service(SERVICE_UUID_V1) != nullptr) { + service_uuid_ = SERVICE_UUID_V1; + sensors_write_characteristic_uuid_ = WRITE_CHARACTERISTIC_UUID_V1; + sensors_read_characteristic_uuid_ = READ_CHARACTERISTIC_UUID_V1; + write_command_ = WRITE_COMMAND_V1; + } else if (this->parent()->get_service(SERVICE_UUID_V2) != nullptr) { + service_uuid_ = SERVICE_UUID_V2; + sensors_write_characteristic_uuid_ = WRITE_CHARACTERISTIC_UUID_V2; + sensors_read_characteristic_uuid_ = READ_CHARACTERISTIC_UUID_V2; + write_command_ = WRITE_COMMAND_V2; + } else { + ESP_LOGW(TAG, "No supported device has been found, disconnecting"); + parent()->set_enabled(false); + break; + } + this->read_handle_ = 0; auto *chr = this->parent()->get_characteristic(service_uuid_, sensors_read_characteristic_uuid_); if (chr == nullptr) { @@ -35,7 +69,6 @@ void RadonEyeRD200::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } this->read_handle_ = chr->handle; - // Write a 0x50 to the write characteristic. auto *write_chr = this->parent()->get_characteristic(service_uuid_, sensors_write_characteristic_uuid_); if (write_chr == nullptr) { char service_buf[esp32_ble::UUID_STR_LEN]; @@ -46,81 +79,106 @@ void RadonEyeRD200::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } this->write_handle_ = write_chr->handle; - this->node_state = esp32_ble_tracker::ClientState::ESTABLISHED; - - write_query_message_(); - - request_read_values_(); + esp_err_t status = + esp_ble_gattc_register_for_notify(gattc_if, this->parent()->get_remote_bda(), this->read_handle_); + if (status) { + ESP_LOGW(TAG, "Error registering for sensor notify, status=%d", status); + } break; } - case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.conn_id != this->parent()->get_conn_id()) - break; - if (param->read.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "Error reading char at handle %d, status=%d", param->read.handle, param->read.status); + case ESP_GATTC_WRITE_DESCR_EVT: { + if (param->write.status != ESP_GATT_OK) { + ESP_LOGE(TAG, "write descr failed, error status = %x", param->write.status); break; } - if (param->read.handle == this->read_handle_) { - read_sensors_(param->read.value, param->read.value_len); + ESP_LOGV(TAG, "Write descr success, writing 0x%02X at write_handle=%d", this->write_command_, + this->write_handle_); + esp_err_t status = + esp_ble_gattc_write_char(gattc_if, this->parent()->get_conn_id(), this->write_handle_, sizeof(write_command_), + (uint8_t *) &write_command_, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status) { + ESP_LOGW(TAG, "Error writing 0x%02x command, status=%d", write_command_, status); } break; } + case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.is_notify) { + ESP_LOGV(TAG, "ESP_GATTC_NOTIFY_EVT, receive notify value, %d bytes", param->notify.value_len); + } else { + ESP_LOGV(TAG, "ESP_GATTC_NOTIFY_EVT, receive indicate value, %d bytes", param->notify.value_len); + } + read_sensors_(param->notify.value, param->notify.value_len); + break; + } + default: break; } } void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) { - if (value_len < 20) { - ESP_LOGD(TAG, "Invalid read"); + if (value_len < 1) { + ESP_LOGW(TAG, "Unexpected empty message"); return; } - // Example data - // [13:08:47][D][radon_eye_rd200:107]: result bytes: 5010 85EBB940 00000000 00000000 2200 2500 0000 - ESP_LOGV(TAG, "result bytes: %02X%02X %02X%02X%02X%02X %02X%02X%02X%02X %02X%02X%02X%02X %02X%02X %02X%02X %02X%02X", - value[0], value[1], value[2], value[3], value[4], value[5], value[6], value[7], value[8], value[9], - value[10], value[11], value[12], value[13], value[14], value[15], value[16], value[17], value[18], - value[19]); + uint8_t command = value[0]; - if (value[0] != 0x50) { - // This isn't a sensor reading. + if ((command == WRITE_COMMAND_V1 && value_len < 20) || (command == WRITE_COMMAND_V2 && value_len < 68)) { + ESP_LOGW(TAG, "Unexpected command 0x%02X message length %d", command, value_len); return; } + // Example data V1: + // 501085EBB9400000000000000000220025000000 + // Example data V2: + // 4042323230313033525532303338330652443230304e56322e302e3200014a00060a00080000000300010079300000e01108001c00020000003822005c8f423fa4709d3f + ESP_LOGV(TAG, "radon sensors raw bytes"); + ESP_LOG_BUFFER_HEX_LEVEL(TAG, value, value_len, ESP_LOG_VERBOSE); + // Convert from pCi/L to Bq/m³ constexpr float convert_to_bwpm3 = 37.0; - RadonValue radon_value; - radon_value.chars[0] = value[2]; - radon_value.chars[1] = value[3]; - radon_value.chars[2] = value[4]; - radon_value.chars[3] = value[5]; - float radon_now = radon_value.number * convert_to_bwpm3; - if (is_valid_radon_value_(radon_now)) { - radon_sensor_->publish_state(radon_now); + float radon_now; // in Bq/m³ + float radon_day; // in Bq/m³ + float radon_month; // in Bq/m³ + if (command == WRITE_COMMAND_V1) { + // Use memcpy to avoid unaligned memory access + float temp; + memcpy(&temp, value + 2, sizeof(float)); + radon_now = temp * convert_to_bwpm3; + memcpy(&temp, value + 6, sizeof(float)); + radon_day = temp * convert_to_bwpm3; + memcpy(&temp, value + 10, sizeof(float)); + radon_month = temp * convert_to_bwpm3; + } else if (command == WRITE_COMMAND_V2) { + // Use memcpy to avoid unaligned memory access + uint16_t temp; + memcpy(&temp, value + 33, sizeof(uint16_t)); + radon_now = temp; + memcpy(&temp, value + 35, sizeof(uint16_t)); + radon_day = temp; + memcpy(&temp, value + 37, sizeof(uint16_t)); + radon_month = temp; + } else { + ESP_LOGW(TAG, "Unexpected command value: 0x%02X", command); + return; } - radon_value.chars[0] = value[6]; - radon_value.chars[1] = value[7]; - radon_value.chars[2] = value[8]; - radon_value.chars[3] = value[9]; - float radon_day = radon_value.number * convert_to_bwpm3; + if (this->radon_sensor_ != nullptr) { + this->radon_sensor_->publish_state(radon_now); + } - radon_value.chars[0] = value[10]; - radon_value.chars[1] = value[11]; - radon_value.chars[2] = value[12]; - radon_value.chars[3] = value[13]; - float radon_month = radon_value.number * convert_to_bwpm3; - - if (is_valid_radon_value_(radon_month)) { - ESP_LOGV(TAG, "Radon Long Term based on month"); - radon_long_term_sensor_->publish_state(radon_month); - } else if (is_valid_radon_value_(radon_day)) { - ESP_LOGV(TAG, "Radon Long Term based on day"); - radon_long_term_sensor_->publish_state(radon_day); + if (this->radon_long_term_sensor_ != nullptr) { + if (radon_month > 0) { + ESP_LOGV(TAG, "Radon Long Term based on month"); + this->radon_long_term_sensor_->publish_state(radon_month); + } else { + ESP_LOGV(TAG, "Radon Long Term based on day"); + this->radon_long_term_sensor_->publish_state(radon_day); + } } ESP_LOGV(TAG, @@ -135,49 +193,23 @@ void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) { parent()->set_enabled(false); } -bool RadonEyeRD200::is_valid_radon_value_(float radon) { return radon > 0.0 and radon < 37000; } - void RadonEyeRD200::update() { if (this->node_state != esp32_ble_tracker::ClientState::ESTABLISHED) { if (!parent()->enabled) { ESP_LOGW(TAG, "Reconnecting to device"); parent()->set_enabled(true); - parent()->connect(); } else { ESP_LOGW(TAG, "Connection in progress"); } } } -void RadonEyeRD200::write_query_message_() { - ESP_LOGV(TAG, "writing 0x50 to write service"); - int request = 0x50; - auto status = esp_ble_gattc_write_char_descr(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), - this->write_handle_, sizeof(request), (uint8_t *) &request, - ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "Error sending write request for sensor, status=%d", status); - } -} - -void RadonEyeRD200::request_read_values_() { - auto status = esp_ble_gattc_read_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), - this->read_handle_, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "Error sending read request for sensor, status=%d", status); - } -} - void RadonEyeRD200::dump_config() { LOG_SENSOR(" ", "Radon", this->radon_sensor_); LOG_SENSOR(" ", "Radon Long Term", this->radon_long_term_sensor_); } -RadonEyeRD200::RadonEyeRD200() - : PollingComponent(10000), - service_uuid_(esp32_ble_tracker::ESPBTUUID::from_raw(SERVICE_UUID)), - sensors_write_characteristic_uuid_(esp32_ble_tracker::ESPBTUUID::from_raw(WRITE_CHARACTERISTIC_UUID)), - sensors_read_characteristic_uuid_(esp32_ble_tracker::ESPBTUUID::from_raw(READ_CHARACTERISTIC_UUID)) {} +RadonEyeRD200::RadonEyeRD200() : PollingComponent(10000) {} } // namespace radon_eye_rd200 } // namespace esphome diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index 7b29be7bd8..f874c815f8 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -14,10 +14,6 @@ namespace esphome { namespace radon_eye_rd200 { -static const char *const SERVICE_UUID = "00001523-1212-efde-1523-785feabcd123"; -static const char *const WRITE_CHARACTERISTIC_UUID = "00001524-1212-efde-1523-785feabcd123"; -static const char *const READ_CHARACTERISTIC_UUID = "00001525-1212-efde-1523-785feabcd123"; - class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode { public: RadonEyeRD200(); @@ -32,25 +28,17 @@ class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode void set_radon_long_term(sensor::Sensor *radon_long_term) { radon_long_term_sensor_ = radon_long_term; } protected: - bool is_valid_radon_value_(float radon); - void read_sensors_(uint8_t *value, uint16_t value_len); - void write_query_message_(); - void request_read_values_(); sensor::Sensor *radon_sensor_{nullptr}; sensor::Sensor *radon_long_term_sensor_{nullptr}; + uint8_t write_command_; uint16_t read_handle_; uint16_t write_handle_; esp32_ble_tracker::ESPBTUUID service_uuid_; esp32_ble_tracker::ESPBTUUID sensors_write_characteristic_uuid_; esp32_ble_tracker::ESPBTUUID sensors_read_characteristic_uuid_; - - union RadonValue { - char chars[4]; - float number; - }; }; } // namespace radon_eye_rd200 diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py new file mode 100644 index 0000000000..52e9a2c09a --- /dev/null +++ b/esphome/components/rd03d/__init__.py @@ -0,0 +1,50 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THROTTLE + +CODEOWNERS = ["@jasstrong"] +DEPENDENCIES = ["uart"] +MULTI_CONF = True + +CONF_RD03D_ID = "rd03d_id" +CONF_TRACKING_MODE = "tracking_mode" + +rd03d_ns = cg.esphome_ns.namespace("rd03d") +RD03DComponent = rd03d_ns.class_("RD03DComponent", cg.Component, uart.UARTDevice) +TrackingMode = rd03d_ns.enum("TrackingMode", is_class=True) + +TRACKING_MODES = { + "single": TrackingMode.SINGLE_TARGET, + "multi": TrackingMode.MULTI_TARGET, +} + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RD03DComponent), + cv.Optional(CONF_TRACKING_MODE): cv.enum(TRACKING_MODES, lower=True), + cv.Optional(CONF_THROTTLE): cv.positive_time_period_milliseconds, + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rd03d", + require_tx=False, + require_rx=True, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if CONF_TRACKING_MODE in config: + cg.add(var.set_tracking_mode(config[CONF_TRACKING_MODE])) + + if CONF_THROTTLE in config: + cg.add(var.set_throttle(config[CONF_THROTTLE])) diff --git a/esphome/components/rd03d/binary_sensor.py b/esphome/components/rd03d/binary_sensor.py new file mode 100644 index 0000000000..afb7527aa1 --- /dev/null +++ b/esphome/components/rd03d/binary_sensor.py @@ -0,0 +1,39 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY + +from . import CONF_RD03D_ID, RD03DComponent + +DEPENDENCIES = ["rd03d"] + +MAX_TARGETS = 3 + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_RD03D_ID): cv.use_id(RD03DComponent), + cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ), + } +).extend( + { + cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(MAX_TARGETS) + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_RD03D_ID]) + + if target_config := config.get(CONF_TARGET): + sens = await binary_sensor.new_binary_sensor(target_config) + cg.add(hub.set_target_binary_sensor(sens)) + + for i in range(MAX_TARGETS): + if target_config := config.get(f"target_{i + 1}"): + sens = await binary_sensor.new_binary_sensor(target_config) + cg.add(hub.set_target_binary_sensor(i, sens)) diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp new file mode 100644 index 0000000000..44e479c153 --- /dev/null +++ b/esphome/components/rd03d/rd03d.cpp @@ -0,0 +1,243 @@ +#include "rd03d.h" +#include "esphome/core/log.h" +#include + +namespace esphome::rd03d { + +static const char *const TAG = "rd03d"; + +// Delay before sending configuration commands to allow radar to initialize +static constexpr uint32_t SETUP_TIMEOUT_MS = 100; + +// Data frame format (radar -> host) +static constexpr uint8_t FRAME_HEADER[] = {0xAA, 0xFF, 0x03, 0x00}; +static constexpr uint8_t FRAME_FOOTER[] = {0x55, 0xCC}; + +// Command frame format (host -> radar) +static constexpr uint8_t CMD_FRAME_HEADER[] = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[] = {0x04, 0x03, 0x02, 0x01}; + +// RD-03D tracking mode commands +static constexpr uint16_t CMD_SINGLE_TARGET = 0x0080; +static constexpr uint16_t CMD_MULTI_TARGET = 0x0090; + +// Decode coordinate/speed value from RD-03D format +// Per datasheet: MSB=1 means positive, MSB=0 means negative +static constexpr int16_t decode_value(uint8_t low_byte, uint8_t high_byte) { + int16_t value = ((high_byte & 0x7F) << 8) | low_byte; + if ((high_byte & 0x80) == 0) { + value = -value; + } + return value; +} + +void RD03DComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up RD-03D..."); + this->set_timeout(SETUP_TIMEOUT_MS, [this]() { this->apply_config_(); }); +} + +void RD03DComponent::dump_config() { + ESP_LOGCONFIG(TAG, "RD-03D:"); + if (this->tracking_mode_.has_value()) { + ESP_LOGCONFIG(TAG, " Tracking Mode: %s", + *this->tracking_mode_ == TrackingMode::SINGLE_TARGET ? "single" : "multi"); + } + if (this->throttle_ > 0) { + ESP_LOGCONFIG(TAG, " Throttle: %ums", this->throttle_); + } +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Target", this->target_binary_sensor_); +#endif + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + ESP_LOGCONFIG(TAG, " Target %d:", i + 1); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "X", this->targets_[i].x); + LOG_SENSOR(" ", "Y", this->targets_[i].y); + LOG_SENSOR(" ", "Speed", this->targets_[i].speed); + LOG_SENSOR(" ", "Distance", this->targets_[i].distance); + LOG_SENSOR(" ", "Resolution", this->targets_[i].resolution); + LOG_SENSOR(" ", "Angle", this->targets_[i].angle); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Presence", this->target_presence_[i]); +#endif + } +} + +void RD03DComponent::loop() { + while (this->available()) { + uint8_t byte = this->read(); + ESP_LOGVV(TAG, "Received byte: 0x%02X, buffer_pos: %d", byte, this->buffer_pos_); + + // Check if we're looking for frame header + if (this->buffer_pos_ < FRAME_HEADER_SIZE) { + if (byte == FRAME_HEADER[this->buffer_pos_]) { + this->buffer_[this->buffer_pos_++] = byte; + } else if (byte == FRAME_HEADER[0]) { + // Start over if we see a potential new header + this->buffer_[0] = byte; + this->buffer_pos_ = 1; + } else { + this->buffer_pos_ = 0; + } + continue; + } + + // Accumulate data bytes + this->buffer_[this->buffer_pos_++] = byte; + + // Check if we have a complete frame + if (this->buffer_pos_ == FRAME_SIZE) { + // Validate footer + if (this->buffer_[FRAME_SIZE - 2] == FRAME_FOOTER[0] && this->buffer_[FRAME_SIZE - 1] == FRAME_FOOTER[1]) { + this->process_frame_(); + } else { + ESP_LOGW(TAG, "Invalid frame footer: 0x%02X 0x%02X (expected 0x55 0xCC)", this->buffer_[FRAME_SIZE - 2], + this->buffer_[FRAME_SIZE - 1]); + } + this->buffer_pos_ = 0; + } + } +} + +void RD03DComponent::process_frame_() { + // Apply throttle if configured + if (this->throttle_ > 0) { + uint32_t now = millis(); + if (now - this->last_publish_time_ < this->throttle_) { + return; + } + this->last_publish_time_ = now; + } + + uint8_t target_count = 0; + + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + // Calculate offset for this target's data + // Header is 4 bytes, each target is 8 bytes + uint8_t offset = FRAME_HEADER_SIZE + (i * TARGET_DATA_SIZE); + + // Extract raw bytes for this target + uint8_t x_low = this->buffer_[offset + 0]; + uint8_t x_high = this->buffer_[offset + 1]; + uint8_t y_low = this->buffer_[offset + 2]; + uint8_t y_high = this->buffer_[offset + 3]; + uint8_t speed_low = this->buffer_[offset + 4]; + uint8_t speed_high = this->buffer_[offset + 5]; + uint8_t res_low = this->buffer_[offset + 6]; + uint8_t res_high = this->buffer_[offset + 7]; + + // Decode values per RD-03D format + int16_t x = decode_value(x_low, x_high); + int16_t y = decode_value(y_low, y_high); + int16_t speed = decode_value(speed_low, speed_high); + uint16_t resolution = (res_high << 8) | res_low; + + // Check if target is present (non-zero coordinates) + bool target_present = (x != 0 || y != 0); + if (target_present) { + target_count++; + } + +#ifdef USE_SENSOR + this->publish_target_(i, x, y, speed, resolution); +#endif + +#ifdef USE_BINARY_SENSOR + if (this->target_presence_[i] != nullptr) { + this->target_presence_[i]->publish_state(target_present); + } +#endif + } + +#ifdef USE_SENSOR + if (this->target_count_sensor_ != nullptr) { + this->target_count_sensor_->publish_state(target_count); + } +#endif + +#ifdef USE_BINARY_SENSOR + if (this->target_binary_sensor_ != nullptr) { + this->target_binary_sensor_->publish_state(target_count > 0); + } +#endif +} + +#ifdef USE_SENSOR +void RD03DComponent::publish_target_(uint8_t target_num, int16_t x, int16_t y, int16_t speed, uint16_t resolution) { + TargetSensor &target = this->targets_[target_num]; + + // Publish X coordinate (mm) + if (target.x != nullptr) { + target.x->publish_state(x); + } + + // Publish Y coordinate (mm) + if (target.y != nullptr) { + target.y->publish_state(y); + } + + // Publish speed (convert from cm/s to mm/s) + if (target.speed != nullptr) { + target.speed->publish_state(static_cast(speed) * 10.0f); + } + + // Publish resolution (mm) + if (target.resolution != nullptr) { + target.resolution->publish_state(resolution); + } + + // Calculate and publish distance (mm) + if (target.distance != nullptr) { + float distance = std::hypot(static_cast(x), static_cast(y)); + target.distance->publish_state(distance); + } + + // Calculate and publish angle (degrees) + // Angle is measured from the Y axis (radar forward direction) + if (target.angle != nullptr) { + if (x == 0 && y == 0) { + target.angle->publish_state(0); + } else { + float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / M_PI; + target.angle->publish_state(angle); + } + } +} +#endif + +void RD03DComponent::send_command_(uint16_t command, const uint8_t *data, uint8_t data_len) { + // Send header + this->write_array(CMD_FRAME_HEADER, sizeof(CMD_FRAME_HEADER)); + + // Send length (command word + data) + uint16_t len = 2 + data_len; + this->write_byte(len & 0xFF); + this->write_byte((len >> 8) & 0xFF); + + // Send command word (little-endian) + this->write_byte(command & 0xFF); + this->write_byte((command >> 8) & 0xFF); + + // Send data if any + if (data != nullptr && data_len > 0) { + this->write_array(data, data_len); + } + + // Send footer + this->write_array(CMD_FRAME_FOOTER, sizeof(CMD_FRAME_FOOTER)); + + ESP_LOGD(TAG, "Sent command 0x%04X with %d bytes of data", command, data_len); +} + +void RD03DComponent::apply_config_() { + if (this->tracking_mode_.has_value()) { + uint16_t mode_cmd = (*this->tracking_mode_ == TrackingMode::SINGLE_TARGET) ? CMD_SINGLE_TARGET : CMD_MULTI_TARGET; + this->send_command_(mode_cmd); + } +} + +} // namespace esphome::rd03d diff --git a/esphome/components/rd03d/rd03d.h b/esphome/components/rd03d/rd03d.h new file mode 100644 index 0000000000..7413fe38f2 --- /dev/null +++ b/esphome/components/rd03d/rd03d.h @@ -0,0 +1,93 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +#include + +namespace esphome::rd03d { + +static constexpr uint8_t MAX_TARGETS = 3; +static constexpr uint8_t FRAME_HEADER_SIZE = 4; +static constexpr uint8_t FRAME_FOOTER_SIZE = 2; +static constexpr uint8_t TARGET_DATA_SIZE = 8; +static constexpr uint8_t FRAME_SIZE = + FRAME_HEADER_SIZE + (MAX_TARGETS * TARGET_DATA_SIZE) + FRAME_FOOTER_SIZE; // 30 bytes + +enum class TrackingMode : uint8_t { + SINGLE_TARGET = 0, + MULTI_TARGET = 1, +}; + +#ifdef USE_SENSOR +struct TargetSensor { + sensor::Sensor *x{nullptr}; + sensor::Sensor *y{nullptr}; + sensor::Sensor *speed{nullptr}; + sensor::Sensor *distance{nullptr}; + sensor::Sensor *resolution{nullptr}; + sensor::Sensor *angle{nullptr}; +}; +#endif + +class RD03DComponent : public Component, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + +#ifdef USE_SENSOR + void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; } + void set_x_sensor(uint8_t target, sensor::Sensor *sensor) { this->targets_[target].x = sensor; } + void set_y_sensor(uint8_t target, sensor::Sensor *sensor) { this->targets_[target].y = sensor; } + void set_speed_sensor(uint8_t target, sensor::Sensor *sensor) { this->targets_[target].speed = sensor; } + void set_distance_sensor(uint8_t target, sensor::Sensor *sensor) { this->targets_[target].distance = sensor; } + void set_resolution_sensor(uint8_t target, sensor::Sensor *sensor) { this->targets_[target].resolution = sensor; } + void set_angle_sensor(uint8_t target, sensor::Sensor *sensor) { this->targets_[target].angle = sensor; } +#endif +#ifdef USE_BINARY_SENSOR + void set_target_binary_sensor(binary_sensor::BinarySensor *sensor) { this->target_binary_sensor_ = sensor; } + void set_target_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) { + this->target_presence_[target] = sensor; + } +#endif + + // Configuration setters (called from code generation) + void set_tracking_mode(TrackingMode mode) { this->tracking_mode_ = mode; } + void set_throttle(uint32_t throttle) { this->throttle_ = throttle; } + + protected: + void apply_config_(); + void send_command_(uint16_t command, const uint8_t *data = nullptr, uint8_t data_len = 0); + void process_frame_(); +#ifdef USE_SENSOR + void publish_target_(uint8_t target_num, int16_t x, int16_t y, int16_t speed, uint16_t resolution); +#endif + +#ifdef USE_SENSOR + std::array targets_{}; + sensor::Sensor *target_count_sensor_{nullptr}; +#endif +#ifdef USE_BINARY_SENSOR + std::array target_presence_{}; + binary_sensor::BinarySensor *target_binary_sensor_{nullptr}; +#endif + + // Configuration (only sent if explicitly set) + optional tracking_mode_{}; + uint32_t throttle_{0}; + uint32_t last_publish_time_{0}; + + std::array buffer_{}; + uint8_t buffer_pos_{0}; +}; + +} // namespace esphome::rd03d diff --git a/esphome/components/rd03d/sensor.py b/esphome/components/rd03d/sensor.py new file mode 100644 index 0000000000..4b4fcfd4e4 --- /dev/null +++ b/esphome/components/rd03d/sensor.py @@ -0,0 +1,105 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ANGLE, + CONF_DISTANCE, + CONF_RESOLUTION, + CONF_SPEED, + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_SPEED, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREES, + UNIT_MILLIMETER, +) + +from . import CONF_RD03D_ID, RD03DComponent + +DEPENDENCIES = ["rd03d"] + +CONF_TARGET_COUNT = "target_count" + +MAX_TARGETS = 3 + +UNIT_MILLIMETER_PER_SECOND = "mm/s" + +TARGET_SCHEMA = cv.Schema( + { + cv.Optional(CONF_X): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Y): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_SPEED): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER_PER_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SPEED, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RESOLUTION): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ANGLE): sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_RD03D_ID): cv.use_id(RD03DComponent), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_RD03D_ID]) + + if target_count_config := config.get(CONF_TARGET_COUNT): + sens = await sensor.new_sensor(target_count_config) + cg.add(hub.set_target_count_sensor(sens)) + + for i in range(MAX_TARGETS): + if target_config := config.get(f"target_{i + 1}"): + if x_config := target_config.get(CONF_X): + sens = await sensor.new_sensor(x_config) + cg.add(hub.set_x_sensor(i, sens)) + if y_config := target_config.get(CONF_Y): + sens = await sensor.new_sensor(y_config) + cg.add(hub.set_y_sensor(i, sens)) + if speed_config := target_config.get(CONF_SPEED): + sens = await sensor.new_sensor(speed_config) + cg.add(hub.set_speed_sensor(i, sens)) + if distance_config := target_config.get(CONF_DISTANCE): + sens = await sensor.new_sensor(distance_config) + cg.add(hub.set_distance_sensor(i, sens)) + if resolution_config := target_config.get(CONF_RESOLUTION): + sens = await sensor.new_sensor(resolution_config) + cg.add(hub.set_resolution_sensor(i, sens)) + if angle_config := target_config.get(CONF_ANGLE): + sens = await sensor.new_sensor(angle_config) + cg.add(hub.set_angle_sensor(i, sens)) diff --git a/tests/components/rd03d/common.yaml b/tests/components/rd03d/common.yaml new file mode 100644 index 0000000000..b13d899ab3 --- /dev/null +++ b/tests/components/rd03d/common.yaml @@ -0,0 +1,59 @@ +rd03d: + id: rd03d_radar + +sensor: + - platform: rd03d + rd03d_id: rd03d_radar + target_count: + name: Target Count + target_1: + x: + name: Target-1 X + y: + name: Target-1 Y + speed: + name: Target-1 Speed + angle: + name: Target-1 Angle + distance: + name: Target-1 Distance + resolution: + name: Target-1 Resolution + target_2: + x: + name: Target-2 X + y: + name: Target-2 Y + speed: + name: Target-2 Speed + angle: + name: Target-2 Angle + distance: + name: Target-2 Distance + resolution: + name: Target-2 Resolution + target_3: + x: + name: Target-3 X + y: + name: Target-3 Y + speed: + name: Target-3 Speed + angle: + name: Target-3 Angle + distance: + name: Target-3 Distance + resolution: + name: Target-3 Resolution + +binary_sensor: + - platform: rd03d + rd03d_id: rd03d_radar + target: + name: Presence + target_1: + name: Target-1 Presence + target_2: + name: Target-2 Presence + target_3: + name: Target-3 Presence diff --git a/tests/components/rd03d/test.esp32-idf.yaml b/tests/components/rd03d/test.esp32-idf.yaml new file mode 100644 index 0000000000..2d29656c94 --- /dev/null +++ b/tests/components/rd03d/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/rd03d/test.esp8266-ard.yaml b/tests/components/rd03d/test.esp8266-ard.yaml new file mode 100644 index 0000000000..5a05efa259 --- /dev/null +++ b/tests/components/rd03d/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/rd03d/test.rp2040-ard.yaml b/tests/components/rd03d/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f1df2daf83 --- /dev/null +++ b/tests/components/rd03d/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml