Dog Soldier v1 is a wearable radar array system built around an ESP32 platform that fuses data from six HLK-LD2450 24 GHz trajectory sensors to create a 360-degree motion detection and tracking capability. The system integrates stereo vision, thermal imaging, and environmental sensing through RayNeo AR glasses, delivering real-time spatial awareness with adaptive night mode and multi-modal sensor fusion.
This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for integrating the dual UART bridges, power distribution with fused protection, camera modules, and the full sensor stack. Builders will learn how to configure the ESP32 firmware for radar data aggregation, set up the wireless API for sensor telemetry, and synchronize the AR display output with the underlying perception pipeline.
Wiring diagram
Gather all the parts
Assemble it in 16 steps
1. Lay out the six radar modules
Place radar_1 through radar_6 on a rigid panel with their antenna faces aimed into the scan area. Keep metal, carbon fibre, and power banks away from their antenna faces.
- Do not mount a radar behind metal or carbon fibre because it can block or distort its signal.
2. Build the fused radar power lead
Connect tilnce_powerbank to usb_c_5v_breakout. Connect 5V OUT to radar_fuse IN (power), radar_fuse OUT to the radar 5 V rail (fused power), and GND OUT to the shared ground rail (ground).
- Use only 5 V here; higher USB-C trigger voltages can destroy the radar modules.
3. Connect radar power and ground
Wire every radar VCC to the fused 5 V rail (power). Wire every radar GND to usb_c_5v_breakout GND OUT (ground), then continue that ground rail to all UART bridges and the main XIAO GND.
- Do not power an LD2450 from the XIAO 3.3 V pin.
4. Wire the serial bridge boards
Connect each bridge VCC to the main XIAO 3V3 pin (power) and GND to the shared ground rail (ground). Connect SCK to GPIO12 (clock), MOSI to GPIO11 (data), MISO to GPIO13 (data), and the three CS pins to GPIO10, GPIO1, and GPIO2 (select).
- Bridge VCC must be 3.3 V; 5 V logic can damage the XIAO.
5. Connect each radar data pair
Cross every data pair: radar TX goes to its bridge RX (data), and bridge TX goes to radar RX (commands). Use bridge 1 for radars 1–2, bridge 2 for 3–4, and bridge 3 for 5–6.
- Do not join TX to TX or RX to RX.
6. Wire the daylight sensor
Mount ambient_lux where it sees forward daylight. Connect VCC to XIAO 3V3 (power), GND to XIAO GND (ground), SDA to GPIO5/D4 (data), and SCL to GPIO6/D5 (clock).
- Do not point the sensor directly at the night camera’s IR LEDs.
7. Power the Orange Pi and USB hub
Connect ugreen_host_powerbank USB-C1 to orangepi5ultra_host USB-C input (computer power). Connect USB-C2 to powered_usb_hub 5V POWER (hub power). Keep this battery separate from the radar battery.
- Do not power the Orange Pi from the fused radar breakout or connect compute banks in parallel.
8. Connect the glasses and cameras
Connect Orange Pi HDMI OUT to rayneo_hdmi_adapter HDMI IN (video), power the adapter from the compute bank (power), then connect its USB-C display output to the glasses. Connect the hub to the Orange Pi and plug in the cameras.
- A passive HDMI-to-USB-C cable will not drive the glasses. Keep night-camera IR light facing away from your eyes.
9. Connect microphones and control devices
Connect usb2_audio_control_hub to the powered USB hub (data). Plug internal_voice_mic into PORT1, esp32_s3_node_2 into PORT2, external_audio_amp into PORT3, and plug nano_voice into the powered hub.
- Keep the internal microphone away from the external sound panel.
10. Install the external sound panel
Bond external_exciter to a thin rigid non-metal panel. Connect external_audio_amp SPK+ to exciter SPK+ (audio positive) and SPK- to SPK- (audio negative). Start at minimum volume.
- Never connect either exciter wire to ground.
11. Power and capture the Walksnail receiver
Set vrx_pd_trigger to 12 V. Connect the active Walksnail battery to it, then V+ to walksnail_vrx DC IN + (power) and GND to DC IN - (ground). Connect VRX HDMI OUT to capture HDMI IN, and capture USB 3 to Orange Pi USB 3 HOST 2.
- Do not connect the Walksnail receiver to the 5 V camera hub or parallel its batteries.
12. Mount the forward cameras safely
Mount thermal_camera and night_camera facing forward with unobstructed lenses. Keep both clear of the inward-facing eye cameras.
- Thermal detail is limited and is not a replacement for safe navigation.
13. Set radar serial speed
Using the LD2450 configuration utility and one radar at a time, set each saved speed to 115200, restart it, and label sensors 1 through 6.
- A radar left at a different saved serial speed will not appear correctly.
14. Mount the mobile mesh radios
Give every wearable or drone one mesh_client node. Fit its antenna in open air, upright when possible, clear of carbon fibre, batteries, people, and propellers. There is no fixed base access point: each powered node can relay for neighbours.
- Keep at least 30 cm between a mesh antenna and any other radio antenna on the same unit to reduce interference.
15. Connect each mesh node locally
On each unit, power its mesh node from that unit’s regulated 5 V USB-C output (power). Connect its Ethernet jack to the local AI computer or a USB-to-Ethernet adapter (data). Do this separately for every unit; one node cannot power another node.
- Do not take power from a drone flight-controller USB port unless its current rating is confirmed; a brownout can interrupt the mesh.
16. Test the mobile mesh in stages
Start two nearby mesh nodes first, then add nodes one at a time while walking or flying them apart. Confirm voice and telemetry before enabling low-resolution video. Use only one selected higher-quality video feed at a time; every relay hop reduces available video capacity.
- This mesh is not a flight-control link. Keep each drone’s approved flight-control and failsafe radio independent.
Review all connections
1. Connections between "uart_bridge_1" and "ESP32"
2. Connections between "uart_bridge_2" and "ESP32"
3. Connections between "uart_bridge_3" and "ESP32"
4. Connections between "radar_1" and "ESP32"
5. Connections between "radar_2" and "ESP32"
6. Connections between "radar_3" and "ESP32"
7. Connections between "radar_4" and "ESP32"
8. Connections between "radar_5" and "ESP32"
9. Connections between "radar_6" and "ESP32"
10. Connections between "tilnce_powerbank" and "ESP32"
11. Connections between "usb_c_5v_breakout" and "ESP32"
12. Connections between "radar_fuse" and "ESP32"
13. Connections between "ugreen_host_powerbank" and "ESP32"
14. Connections between "powered_usb_hub" and "ESP32"
15. Connections between "orangepi5ultra_host" and "ESP32"
16. Connections between "rayneo_hdmi_adapter" and "ESP32"
17. Connections between "ambient_lux" and "ESP32"
18. Connections between "thermal_camera" and "ESP32"
19. Connections between "night_camera" and "ESP32"
20. Connections between "external_audio_amp" and "ESP32"
21. Connections between "usb2_audio_control_hub" and "ESP32"
22. Connections between "nano_voice" and "ESP32"
23. Connections between "walksnail_capture" and "ESP32"
24. Connections between "walksnail_vrx" and "ESP32"
25. Connections between "vrx_pd_powerbank" and "ESP32"
26. Connections between "vrx_pd_trigger" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_VEML7700.h>
// Radar modules receive fused 5 V from the sealed USB-C power bank; their ground
// is shared with all three UART bridges and the XIAO controller.
// Walksnail video is captured directly by the Orange Pi and selected in its compositor;
// it does not use a XIAO GPIO or change the radar HTTP API.
// Seeed Studio XIAO ESP32S3: GPIO12/13/11 are the external SPI lines.
static constexpr uint8_t SPI_SCK_PIN = 12;
static constexpr uint8_t SPI_MISO_PIN = 13;
static constexpr uint8_t SPI_MOSI_PIN = 11;
static constexpr uint8_t BRIDGE_CS[3] = {10, 1, 2};
static constexpr uint8_t LIGHT_SDA_PIN = 5;
static constexpr uint8_t LIGHT_SCL_PIN = 6;
static constexpr float NIGHT_LOCKOUT_LUX = 300.0F;
static constexpr float NIGHT_REENABLE_LUX = 200.0F;
const char *AP_NAME = "RadarArray";
const char *AP_PASSWORD = "radar-array-6";
WebServer server(80);
struct Track { bool valid = false; int16_t xMm = 0, yMm = 0, speedCms = 0; uint16_t resolutionMm = 0; };
struct RadarState { uint8_t bytes[30]; uint8_t count = 0; Track tracks[3]; uint32_t lastFrameMs = 0; };
RadarState radars[6];
uint8_t focusedRadar = 0;
// The ROCK 5B display application reads this state: false is the small corner overlay;
// true requests a 1.5x radar panel while the gaze tracker says the wearer is looking at it.
bool radarOverlayExpanded = false;
enum class VisionMode : uint8_t { Normal, Thermal, Night };
VisionMode requestedVisionMode = VisionMode::Normal;
bool nightSafetyLockout = false;
bool ambientLightAvailable = false;
float ambientLux = 0.0F;
uint32_t lastLuxReadMs = 0;
Adafruit_VEML7700 veml7700;
const char *visionModeName(VisionMode mode) {
if (mode == VisionMode::Thermal) return "thermal";
if (mode == VisionMode::Night) return "night";
return "normal";
}
uint8_t spiCommand(uint8_t reg, uint8_t channel, bool read) { return (uint8_t)((reg << 3) | (channel << 1) | (read ? 0x80 : 0)); }
void uartWrite(uint8_t port, uint8_t reg, uint8_t value) {
SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); digitalWrite(BRIDGE_CS[port / 2], LOW);
SPI.transfer(spiCommand(reg, port % 2, false)); SPI.transfer(value); digitalWrite(BRIDGE_CS[port / 2], HIGH); SPI.endTransaction();
}
uint8_t uartRead(uint8_t port, uint8_t reg) {
SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); digitalWrite(BRIDGE_CS[port / 2], LOW);
SPI.transfer(spiCommand(reg, port % 2, true)); uint8_t value = SPI.transfer(0xFF); digitalWrite(BRIDGE_CS[port / 2], HIGH); SPI.endTransaction(); return value;
}
void configureUart(uint8_t port) {
// Common SC16IS752 boards use 1.8432 MHz: divisor 1 produces 115200 baud.
uartWrite(port, 3, 0x80); uartWrite(port, 0, 1); uartWrite(port, 1, 0);
uartWrite(port, 3, 0x03); uartWrite(port, 2, 0x07);
}
int16_t decodeSigned(uint16_t raw) { return (raw & 0x8000) ? (int16_t)(raw - 0x8000) : -(int16_t)raw; }
void decodeFrame(uint8_t radar) {
RadarState &s = radars[radar];
if (s.bytes[0] != 0xAA || s.bytes[1] != 0xFF || s.bytes[2] != 0x03 || s.bytes[3] != 0x00 || s.bytes[28] != 0x55 || s.bytes[29] != 0xCC) return;
for (uint8_t t = 0; t < 3; ++t) {
uint8_t o = 4 + t * 8; uint16_t x = s.bytes[o] | ((uint16_t)s.bytes[o + 1] << 8); uint16_t y = s.bytes[o + 2] | ((uint16_t)s.bytes[o + 3] << 8);
uint16_t v = s.bytes[o + 4] | ((uint16_t)s.bytes[o + 5] << 8); uint16_t q = s.bytes[o + 6] | ((uint16_t)s.bytes[o + 7] << 8);
s.tracks[t].valid = x || y || v || q; s.tracks[t].xMm = decodeSigned(x); s.tracks[t].yMm = decodeSigned(y); s.tracks[t].speedCms = decodeSigned(v); s.tracks[t].resolutionMm = q;
}
s.lastFrameMs = millis();
}
void acceptByte(uint8_t radar, uint8_t value) {
RadarState &s = radars[radar]; if (s.count == 0 && value != 0xAA) return;
if (s.count == 1 && value != 0xFF) { s.count = value == 0xAA ? 1 : 0; return; }
s.bytes[s.count++] = value; if (s.count == 30) { decodeFrame(radar); s.count = 0; }
}
void pollRadars() { for (uint8_t p = 0; p < 6; ++p) { uint8_t safety = 0; while ((uartRead(p, 5) & 1) && safety++ < 64) acceptByte(p, uartRead(p, 0)); } }
bool readAmbientLux(float &lux) {
if (!ambientLightAvailable) return false;
lux = veml7700.readLux();
return isfinite(lux) && lux >= 0.0F;
}
void updateNightSafety() {
if (millis() - lastLuxReadMs < 100) return;
lastLuxReadMs = millis();
float nextLux = 0.0F;
ambientLightAvailable = readAmbientLux(nextLux);
if (!ambientLightAvailable) return;
ambientLux = nextLux;
if (nightSafetyLockout) {
if (ambientLux < NIGHT_REENABLE_LUX) nightSafetyLockout = false;
} else if (ambientLux > NIGHT_LOCKOUT_LUX) {
nightSafetyLockout = true;
}
}
String radarJson() {
VisionMode effectiveVisionMode = (requestedVisionMode == VisionMode::Night && nightSafetyLockout) ? VisionMode::Normal : requestedVisionMode;
String json = "{\"focusedRadar\":" + String(focusedRadar + 1) + ",\"overlayExpanded\":" + String(radarOverlayExpanded ? "true" : "false") + ",\"requestedVisionMode\":\"" + visionModeName(requestedVisionMode) + "\",\"effectiveVisionMode\":\"" + visionModeName(effectiveVisionMode) + "\",\"ambientLux\":" + String(ambientLux, 1) + ",\"ambientLightAvailable\":" + String(ambientLightAvailable ? "true" : "false") + ",\"nightSafetyLockout\":" + String(nightSafetyLockout ? "true" : "false") + ",\"radars\":[";
for (uint8_t r = 0; r < 6; ++r) { if (r) json += ','; json += "{\"id\":" + String(r + 1) + ",\"ageMs\":" + String(millis() - radars[r].lastFrameMs) + ",\"tracks\":[";
for (uint8_t t = 0; t < 3; ++t) { if (t) json += ','; const Track &v = radars[r].tracks[t]; json += "{\"valid\":" + String(v.valid ? "true" : "false") + ",\"xMm\":" + String(v.xMm) + ",\"yMm\":" + String(v.yMm) + ",\"speedCms\":" + String(v.speedCms) + ",\"resolutionMm\":" + String(v.resolutionMm) + "}"; }
json += "]}";
}
return json + "]}";
}
void handleRoot() {
const char page[] = R"HTML(<!doctype html><html><head><meta name="viewport" content="width=device-width"><style>body{margin:0;background:#06100c;color:#8cffb1;font-family:monospace}canvas{width:100vw;height:100vh}</style></head><body><canvas id="r"></canvas><script>const c=document.querySelector('#r'),x=c.getContext('2d');function draw(d){c.width=innerWidth;c.height=innerHeight;let w=c.width,h=c.height,cx=w/2,cy=h*.88,s=Math.min(w,h)/13000;x.fillStyle='#06100c';x.fillRect(0,0,w,h);x.strokeStyle='#176936';for(let i=1;i<5;i++){x.beginPath();x.arc(cx,cy,i*2500*s,Math.PI,2*Math.PI);x.stroke()}x.beginPath();x.moveTo(cx-6500*s,cy);x.lineTo(cx+6500*s,cy);x.stroke();d.radars.forEach((r,ri)=>r.tracks.forEach((t,ti)=>{if(!t.valid)return;x.fillStyle=ri===d.focusedRadar-1?'#ffcf4d':'#8cffb1';x.beginPath();x.arc(cx+t.xMm*s,cy-t.yMm*s,7,0,7);x.fill();x.fillText('R'+r.id+'.'+(ti+1),cx+t.xMm*s+9,cy-t.yMm*s)}));x.fillStyle='#8cffb1';x.fillText('RADAR ARRAY | FOCUS R'+d.focusedRadar,18,28)}async function tick(){try{draw(await (await fetch('/data')).json())}catch(e){}setTimeout(tick,100)}tick();</script></body></html>)HTML";
server.send(200, "text/html", page);
}
void setup() {
Serial.begin(115200); SPI.begin(SPI_SCK_PIN, SPI_MISO_PIN, SPI_MOSI_PIN);
Wire.begin(LIGHT_SDA_PIN, LIGHT_SCL_PIN);
ambientLightAvailable = veml7700.begin();
if (ambientLightAvailable) {
veml7700.setGain(VEML7700_GAIN_1_8);
veml7700.setIntegrationTime(VEML7700_IT_100MS);
}
for (uint8_t pin : BRIDGE_CS) { pinMode(pin, OUTPUT); digitalWrite(pin, HIGH); }
for (uint8_t p = 0; p < 6; ++p) configureUart(p);
WiFi.mode(WIFI_AP); WiFi.softAP(AP_NAME, AP_PASSWORD);
server.on("/", HTTP_GET, handleRoot); server.on("/data", HTTP_GET, [](){ server.send(200, "application/json", radarJson()); });
server.on("/focus", HTTP_POST, [](){ if (!server.hasArg("sensor")) { server.send(400, "text/plain", "sensor is required"); return; } int sensor = server.arg("sensor").toInt(); if (sensor < 1 || sensor > 6) { server.send(400, "text/plain", "sensor must be 1 through 6"); return; } focusedRadar = sensor - 1; server.send(200, "application/json", radarJson()); });
// The Orange Pi gaze process sends expanded=1 while the calibrated gaze point stays in the radar overlay.
server.on("/overlay", HTTP_POST, [](){ if (!server.hasArg("expanded")) { server.send(400, "text/plain", "expanded must be 0 or 1"); return; } String value = server.arg("expanded"); if (value != "0" && value != "1") { server.send(400, "text/plain", "expanded must be 0 or 1"); return; } radarOverlayExpanded = value == "1"; server.send(200, "application/json", radarJson()); });
// The Orange Pi voice service calls /vision?mode=normal, thermal, or night.
// A bright-light lockout changes an active night request to normal until lux drops below the lower threshold.
server.on("/vision", HTTP_POST, [](){ if (!server.hasArg("mode")) { server.send(400, "text/plain", "mode is required"); return; } String mode = server.arg("mode"); if (mode == "normal") requestedVisionMode = VisionMode::Normal; else if (mode == "thermal") requestedVisionMode = VisionMode::Thermal; else if (mode == "night") requestedVisionMode = VisionMode::Night; else { server.send(400, "text/plain", "mode must be normal, thermal, or night"); return; } server.send(200, "application/json", radarJson()); });
server.begin();
}
void loop() { server.handleClient(); pollRadars(); updateNightSafety(); delay(1); }Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




