Community project

Dog soldier v1

Photo of Dog soldier v1

J Scott

Published November 2, 2026

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

Loading diagram…

Gather all the parts

QtyComponent
1

HLK-LD2450 24 GHz Trajectory Sensor

A 24 GHz radar module that reports the position and speed of up to three moving targets.

1

HLK-LD2450 24 GHz Trajectory Sensor

A 24 GHz radar module that reports the position and speed of up to three moving targets.

1

HLK-LD2450 24 GHz Trajectory Sensor

A 24 GHz radar module that reports the position and speed of up to three moving targets.

1

HLK-LD2450 24 GHz Trajectory Sensor

A 24 GHz radar module that reports the position and speed of up to three moving targets.

1

HLK-LD2450 24 GHz Trajectory Sensor

A 24 GHz radar module that reports the position and speed of up to three moving targets.

1

HLK-LD2450 24 GHz Trajectory Sensor

A 24 GHz radar module that reports the position and speed of up to three moving targets.

1

SC16IS752 Dual SPI-to-UART Bridge

A dual serial-port bridge that lets the ESP32-S3 communicate with two radar modules over a shared SPI bus.

1

SC16IS752 Dual SPI-to-UART Bridge

A dual serial-port bridge that lets the ESP32-S3 communicate with two radar modules over a shared SPI bus.

1

SC16IS752 Dual SPI-to-UART Bridge

A dual serial-port bridge that lets the ESP32-S3 communicate with two radar modules over a shared SPI bus.

1

Arduino Nano 33 BLE Sense Rev2

A small microcontroller board with a built-in microphone used as the voice-command companion.

1

Seeed Studio XIAO ESP32S3 (second controller)

The second compact ESP32-S3 board, connected by USB to the ROCK computer for the radar network.

1

GC0308 IR Eye Camera Module

A small infrared-lit camera that views one eye for gaze selection on the display host.

1

GC0308 IR Eye Camera Module

A small infrared-lit camera that views one eye for gaze selection on the display host.

1

RayNeo Air 4 Pro Display Glasses

Private large-screen glasses that display the radar dashboard from a USB-C video host.

1

TILNCE 40,000 mAh USB-C PD Power Bank

40,000 mAh, USB-C PD, 30 W claimed

A sealed rechargeable power bank that supplies the radar array from its protected USB-C output.

1

USB-C 5 V Power Breakout

A preassembled USB-C socket breakout that brings the power bank's normal 5 V output to screw terminals.

1

3 A Inline Blade Fuse Holder

3 A automotive blade fuse

A replaceable fuse holder that disconnects the radar supply if a wiring fault draws too much current.

1

IFWATER 120 fps Global-Shutter Stereo USB Camera

3200 × 1200 at 60 fps; 1600 × 600 at 120 fps, MJPEG

A synchronized dual-lens camera that gives the standalone computer a wide, low-distortion 3D video view.

1

Powered USB 3.0 Hub, 7-Port

Externally powered, 5 V

A battery-powered seven-port USB hub that connects all cameras and controllers to the Orange Pi.

1

Anker Prime Power Bank (27,650 mAh, 250 W) — active compute supply

27,650 mAh, 99.54 Wh, 250 W maximum, 665 g

The active sealed USB-C battery that powers the Orange Pi, USB hub, and display adapter.

1

Orange Pi 5 Ultra 16 GB

The small Linux computer that runs the video, gaze tracking, radar overlay, and local object-identification program.

1

Powered HDMI-to-USB-C Display Adapter for RayNeo Air 4 Pro

A powered adapter that converts the Orange Pi HDMI video output into the USB-C display connection used by the glasses.

1

Thermal Master P2 USB Thermal Camera

A compact USB thermal camera that shows heat differences for the Orange Pi display application.

1

Arducam B0506 1080p Day/Night USB Camera

A USB camera with automatic IR-cut switching and built-in 850 nm illumination for low-light video.

1

VEML7700 Ambient Light Sensor

Vishay VEML7700 high-accuracy ambient light sensor with 16-bit I2C lux output.

1

USB Noise-Cancelling Microphone Module

A plug-in USB microphone board that captures voice commands with its built-in noise reduction.

1

Dayton Audio DAEX25Q-4 Quad Feet 25 mm Exciter

4 Ω, 20 W RMS maximum

A 4 ohm surface transducer that turns its firmly bonded mounting panel into an external speaker.

1

5 V USB Audio Class-D Mono Amplifier Module

A preassembled USB audio amplifier that receives audio from the Orange Pi and safely drives the external surface speaker.

1

Compact 4-Port USB 2.0 Hub

A small USB hub for low-speed voice and audio devices so the direct USB 3 port remains available for video capture.

1

Walksnail Avatar HD VRX Digital FPV Receiver

A 5.8 GHz digital FPV receiver that supplies the selected Walksnail air-unit picture over HDMI.

1

UVC USB 3 HDMI Capture Device, 1080p60 capable

A USB video-capture device that lets the Orange Pi use the Walksnail receiver HDMI picture as a camera source.

1

Anker Prime Power Bank (27,650 mAh, 250 W) — active Walksnail supply

27,650 mAh, 99.54 Wh, 250 W maximum, 665 g

The active sealed USB-C PD battery that provides 12 V through the PD trigger to the Walksnail receiver.

1

Adafruit USB Type C Power Delivery Dummy - I2C or Switchable - HUSB238

Set hardware voltage switch to 12 V

HUSB238-based USB-C PD trigger breakout with I2C control or on-board DIP-switch voltage selection, STEMMA QT/Qwiic connector, USB-C input, and screw-terminal power output. Negotiates a fixed PD voltage from a USB-C PD supply.

1

Anker Prime Power Bank (27,650 mAh, 250 W) — compute spare 1

27,650 mAh, 99.54 Wh, 250 W maximum, 665 g

A sealed charged replacement battery carried for the Orange Pi and camera system.

1

Anker Prime Power Bank (27,650 mAh, 250 W) — compute spare 2

27,650 mAh, 99.54 Wh, 250 W maximum, 665 g

A second sealed charged replacement battery carried for the Orange Pi and camera system.

1

Anker Prime Power Bank (27,650 mAh, 250 W) — Walksnail spare

27,650 mAh, 99.54 Wh, 250 W maximum, 665 g

A sealed charged replacement battery carried for the Walksnail receiver supply.

1

Morse Micro MM6108-EKH01-05US Wi-Fi HaLow Mobile Mesh Node — unit 1

A preassembled Linux Wi-Fi HaLow radio node that can relay encrypted voice, telemetry, and selected compressed video between moving units.

1

Morse Micro MM6108-EKH01-05US Wi-Fi HaLow Mobile Mesh Node — unit 2

A preassembled Linux Wi-Fi HaLow radio node that can relay encrypted voice, telemetry, and selected compressed video between moving units.

1

Morse Micro MM6108-EKH01-05US Wi-Fi HaLow Mobile Mesh Node — unit 3

A preassembled Linux Wi-Fi HaLow radio node that can relay encrypted voice, telemetry, and selected compressed video between moving units.

1

Morse Micro MM6108-EKH01-05US Wi-Fi HaLow Mobile Mesh Node — unit 4

A preassembled Linux Wi-Fi HaLow radio node that can relay encrypted voice, telemetry, and selected compressed video between moving units.

1

Morse Micro MM6108-EKH01-05US Wi-Fi HaLow Mobile Mesh Node — unit 5

A preassembled Linux Wi-Fi HaLow radio node that can relay encrypted voice, telemetry, and selected compressed video between moving units.

1

Morse Micro MM6108-EKH01-05US Wi-Fi HaLow Mobile Mesh Node — unit 6

A preassembled Linux Wi-Fi HaLow radio node that can relay encrypted voice, telemetry, and selected compressed video between moving units.

1

Morse Micro MM6108-EKH01-05US Wi-Fi HaLow Mobile Mesh Node — unit 7

A preassembled Linux Wi-Fi HaLow radio node that can relay encrypted voice, telemetry, and selected compressed video between moving units.

1

Morse Micro MM6108-EKH01-05US Wi-Fi HaLow Mobile Mesh Node — unit 8

A preassembled Linux Wi-Fi HaLow radio node that can relay encrypted voice, telemetry, and selected compressed video between moving units.

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"

Functionuart_bridge_1ESP32
powerVCC → 3.3 V bridge logic3V3
uartTXA → HLK-LD2450 24 GHz Trajectory Sensor RXEXT
uartTXB → HLK-LD2450 24 GHz Trajectory Sensor RXEXT
groundGND → SC16IS752 Dual SPI-to-UART Bridge GNDEXT
spiSCK → shared SPI clockGPIO 12
spiMOSI → shared SPI data to bridgeGPIO 11
spiMISO → shared SPI data from bridgeGPIO 13
digitalCS → bridge 1 selectGPIO 10

2. Connections between "uart_bridge_2" and "ESP32"

Functionuart_bridge_2ESP32
powerVCC → 3.3 V bridge logic3V3
uartTXA → HLK-LD2450 24 GHz Trajectory Sensor RXEXT
uartTXB → HLK-LD2450 24 GHz Trajectory Sensor RXEXT
groundGND → SC16IS752 Dual SPI-to-UART Bridge GNDEXT
spiSCK → shared SPI clockGPIO 12
spiMOSI → shared SPI data to bridgeGPIO 11
spiMISO → shared SPI data from bridgeGPIO 13
digitalCS → bridge 2 selectGPIO 1

3. Connections between "uart_bridge_3" and "ESP32"

Functionuart_bridge_3ESP32
powerVCC → 3.3 V bridge logic3V3
uartTXA → HLK-LD2450 24 GHz Trajectory Sensor RXEXT
uartTXB → HLK-LD2450 24 GHz Trajectory Sensor RXEXT
spiSCK → shared SPI clockGPIO 12
spiMOSI → shared SPI data to bridgeGPIO 11
spiMISO → shared SPI data from bridgeGPIO 13
digitalCS → bridge 3 selectGPIO 2
groundGND → shared XIAO, bridge, and radar power returnGND

4. Connections between "radar_1" and "ESP32"

Functionradar_1ESP32
powerVCC → 5 V radar power5V
uartTX → SC16IS752 Dual SPI-to-UART Bridge RXAEXT
groundGND → USB-C 5 V Power Breakout GND OUTEXT

5. Connections between "radar_2" and "ESP32"

Functionradar_2ESP32
powerVCC → 5 V radar power5V
uartTX → SC16IS752 Dual SPI-to-UART Bridge RXBEXT
groundGND → USB-C 5 V Power Breakout GND OUTEXT

6. Connections between "radar_3" and "ESP32"

Functionradar_3ESP32
powerVCC → 5 V radar power5V
uartTX → SC16IS752 Dual SPI-to-UART Bridge RXAEXT
groundGND → USB-C 5 V Power Breakout GND OUTEXT

7. Connections between "radar_4" and "ESP32"

Functionradar_4ESP32
powerVCC → 5 V radar power5V
uartTX → SC16IS752 Dual SPI-to-UART Bridge RXBEXT
groundGND → USB-C 5 V Power Breakout GND OUTEXT

8. Connections between "radar_5" and "ESP32"

Functionradar_5ESP32
powerVCC → 5 V radar power5V
uartTX → SC16IS752 Dual SPI-to-UART Bridge RXAEXT
groundGND → USB-C 5 V Power Breakout GND OUTEXT

9. Connections between "radar_6" and "ESP32"

Functionradar_6ESP32
powerVCC → 5 V radar power5V
uartTX → SC16IS752 Dual SPI-to-UART Bridge RXBEXT
groundGND → USB-C 5 V Power Breakout GND OUTEXT

10. Connections between "tilnce_powerbank" and "ESP32"

Functiontilnce_powerbankESP32
powerUSB-C OUT → USB-C 5 V Power Breakout USB-C INEXT
groundUSB-C GND → USB-C 5 V Power Breakout GND INEXT

11. Connections between "usb_c_5v_breakout" and "ESP32"

Functionusb_c_5v_breakoutESP32
power5V OUT → 3 A Inline Blade Fuse Holder INEXT
groundGND OUT → SC16IS752 Dual SPI-to-UART Bridge GNDEXT

12. Connections between "radar_fuse" and "ESP32"

Functionradar_fuseESP32
powerOUT → fused 5 V radar supply rail5V

13. Connections between "ugreen_host_powerbank" and "ESP32"

Functionugreen_host_powerbankESP32
powerUSB-C2 OUT → Powered USB 3.0 Hub, 7-Port 5V POWEREXT
groundGND → Powered USB 3.0 Hub, 7-Port 5V POWEREXT
powerUSB-C1 PD OUT → Orange Pi 5 Ultra 16 GB USB-C 5 V / 5 A INEXT
powerUSB-A 5 V OUT → Powered HDMI-to-USB-C Display Adapter for RayNeo Air 4 Pro USB-C 5 V INEXT

14. Connections between "powered_usb_hub" and "ESP32"

Functionpowered_usb_hubESP32
dataSTEREO_CAMERA → IFWATER 120 fps Global-Shutter Stereo USB Camera USB-CEXT
dataEYE_LEFT → GC0308 IR Eye Camera Module USBEXT
dataEYE_RIGHT → GC0308 IR Eye Camera Module USBEXT
dataESP32_NODE → 5 V USB Audio Class-D Mono Amplifier Module USBEXT
dataNANO → Compact 4-Port USB 2.0 Hub UPSTREAMEXT

15. Connections between "orangepi5ultra_host" and "ESP32"

Functionorangepi5ultra_hostESP32
spiUSB 3.0 HOST 1 → Powered USB 3.0 Hub, 7-Port UPSTREAMEXT
dataHDMI 2.1 OUT → Powered HDMI-to-USB-C Display Adapter for RayNeo Air 4 Pro HDMI INEXT

16. Connections between "rayneo_hdmi_adapter" and "ESP32"

Functionrayneo_hdmi_adapterESP32
dataUSB-C DISPLAY OUT → RayNeo Air 4 Pro Display Glasses USB-C videoEXT

17. Connections between "ambient_lux" and "ESP32"

Functionambient_luxESP32
powerVCC → 3.3 V sensor power3V3
groundGND → shared controller groundGND
i2cSDA → I2C dataGPIO 5
i2cSCL → I2C clockGPIO 6

18. Connections between "thermal_camera" and "ESP32"

Functionthermal_cameraESP32
powerUSB-C → Powered USB 3.0 Hub, 7-Port THERMAL_CAMERAEXT

19. Connections between "night_camera" and "ESP32"

Functionnight_cameraESP32
powerUSB → Powered USB 3.0 Hub, 7-Port NIGHT_CAMERAEXT

20. Connections between "external_audio_amp" and "ESP32"

Functionexternal_audio_ampESP32
dataSPK+ → Dayton Audio DAEX25Q-4 Quad Feet 25 mm Exciter SPK+EXT
dataSPK- → Dayton Audio DAEX25Q-4 Quad Feet 25 mm Exciter SPK-EXT
dataUSB → Compact 4-Port USB 2.0 Hub PORT3EXT

21. Connections between "usb2_audio_control_hub" and "ESP32"

Functionusb2_audio_control_hubESP32
dataPORT1 → USB Noise-Cancelling Microphone Module USBEXT
dataPORT2 → Seeed Studio XIAO ESP32S3 (second controller) USB-CEXT

22. Connections between "nano_voice" and "ESP32"

Functionnano_voiceESP32
powerUSB → Powered USB 3.0 Hub, 7-Port ESP32_NODEEXT

23. Connections between "walksnail_capture" and "ESP32"

Functionwalksnail_captureESP32
dataUSB 3 → Orange Pi 5 Ultra 16 GB USB 3.0 HOST 2EXT

24. Connections between "walksnail_vrx" and "ESP32"

Functionwalksnail_vrxESP32
dataHDMI OUT → UVC USB 3 HDMI Capture Device, 1080p60 capable HDMI INEXT

25. Connections between "vrx_pd_powerbank" and "ESP32"

Functionvrx_pd_powerbankESP32
powerUSB-C PD OUT → Adafruit USB Type C Power Delivery Dummy - I2C or Switchable - HUSB238 V+EXT
groundGND → Adafruit USB Type C Power Delivery Dummy - I2C or Switchable - HUSB238 GNDEXT

26. Connections between "vrx_pd_trigger" and "ESP32"

Functionvrx_pd_triggerESP32
powerV+ → Walksnail Avatar HD VRX Digital FPV Receiver DC IN +EXT
groundGND → Walksnail Avatar HD VRX Digital FPV Receiver DC IN -EXT

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.

Open in Schematik