[rd03d] Add Ai-Thinker RD-03D mmWave radar component (#12764)

Co-authored-by: jas <jas@asspa.in>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
Jas Strong
2026-01-05 19:35:32 -08:00
committed by GitHub
parent 84dd17187d
commit 28cf3b7a9b
10 changed files with 602 additions and 0 deletions

View File

@@ -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

View File

@@ -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]))

View File

@@ -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))

View File

@@ -0,0 +1,243 @@
#include "rd03d.h"
#include "esphome/core/log.h"
#include <cmath>
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<float>(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<float>(x), static_cast<float>(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<float>(x), static_cast<float>(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

View File

@@ -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 <array>
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<TargetSensor, MAX_TARGETS> targets_{};
sensor::Sensor *target_count_sensor_{nullptr};
#endif
#ifdef USE_BINARY_SENSOR
std::array<binary_sensor::BinarySensor *, MAX_TARGETS> target_presence_{};
binary_sensor::BinarySensor *target_binary_sensor_{nullptr};
#endif
// Configuration (only sent if explicitly set)
optional<TrackingMode> tracking_mode_{};
uint32_t throttle_{0};
uint32_t last_publish_time_{0};
std::array<uint8_t, FRAME_SIZE> buffer_{};
uint8_t buffer_pos_{0};
};
} // namespace esphome::rd03d

View File

@@ -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))

View File

@@ -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

View File

@@ -0,0 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
<<: !include common.yaml