A weekend hardware/software co-design that streams WiFi Channel-State-Information (CSI) from a Raspberry Pi into an AMD Versal VCK190, parses the UDP/CSI packets in the Programmable Logic, and runs the STFT + activity / breathing / fall feature extraction on the AI Engine array — turning ordinary WiFi into a device-free room sensor. Validated bit-accurate on real silicon.
My ECE Master's at Mississippi State University was in software-defined-radio (SDR) based sensing. One project asked a deceptively simple question: can an ordinary WiFi signal tell what a person is doing in a room?
The answer is yes — and it comes from Channel-State-Information (CSI). Every OFDM WiFi receiver estimates how the channel distorted each subcarrier. When a body moves, the multipath reflections change, and those changes carry a signature of the motion. I built a WiFi-CSI human-activity recognition (HAR) pipeline, benchmarked it against a radar sensing baseline for accuracy, and published the results across several papers (see Publications).
The visual heart of that work is the spectrogram: take the Short-Time Fourier Transform of the CSI time series and each activity paints a distinct time–frequency fingerprint. These are real CSI spectrograms from the dataset — one per activity class:







When I presented that thesis to a former manager, they gently suggested that the whole pipeline could, one day, run in an FPGA — the packet parsing in fabric, the heavy DSP and inference on an accelerator. It was said kindly, almost in passing. But it lodged in my head as a personal challenge I promised myself I'd take on someday.
The napkin sketch of that idea looked like this:
The idea sat on the shelf while life moved on. What finally unblocked it was hardware and time: a VCK190 on the bench and a run of weekends and evenings. Modern tooling — and, honestly, LLMs for grinding through the deep Versal tool-flow issues — made a solo build of this scope tractable.
The pipeline now runs on real hardware. A Raspberry Pi running the nexmon_csi firmware patch turns its WiFi chip into a CSI sensor and packetizes CSI as UDP over Ethernet. That stream enters the VCK190 over an SFP link; the PL parses the UDP/CSI packet and streams the I/Q samples straight into the AI Engine, which computes the motion, breathing and phase-variance features. Only the compact results cross into Linux on the Arm cores, where an on-board web dashboard renders what's happening in the room.
Why this device is the right fit:
The full path from WiFi chip to browser. Raw CSI (hundreds of KB/s) never touches the CPU in the target design — only the tiny feature/label vector is DMA'd to Linux. Click any diagram to open it fullscreen and zoom / pan.
No camera, no wearable, often through a wall. The trick is that your body is part of the radio channel. Here is the intuition, in four steps.
An OFDM receiver estimates, per subcarrier k, a complex response — amplitude and phase of how the signal arrived. nexmon_csi exposes this vector every packet.
The receiver sums many rays: line-of-sight plus reflections off walls, furniture — and you. Move, and the reflected path length changes, rotating its phase and reshaping the summed H[k] over time.
A body part moving at velocity v imparts a Doppler shift. Different activities have different velocity signatures, so the STFT separates walking from falling from sitting.
The chest wall moves millimeters at ~0.2–0.4 Hz. That appears as a narrow low-frequency tone in the CSI — the basis of the breathing branch.
Why STFT. Activities are non-stationary, so we take the Short-Time Fourier Transform of the CSI time series — window, DFT, magnitude — producing the time–frequency spectrograms you saw in Act 1. (This directly builds on my paper Effect of the Short-Time Fourier Transform on the Classification of Complex-Valued Mobile Signals.)
Radar is dedicated, high-bandwidth and precise, but it's extra hardware. WiFi CSI reuses ubiquitous infrastructure: it's device-free, works in the dark and through walls, and is privacy-preserving (no imagery). My thesis and our RadarConf 2024 paper comparing Wi-Fi-CSI and radar-based HAR quantified that accuracy trade-off — the motivation for pushing the WiFi pipeline all the way onto an edge accelerator here.
In the target architecture the PL owns the packet. RX frames enter as an AXI4-Stream; a
custom csi_udp_parser (Vitis HLS)
filters the CSI UDP port, strips the Ethernet/IP/UDP headers, extracts the I/Q payload, and streams samples
through a csi_mux straight into the
AI Engine. This is the real Vivado block design that was built and run on the board:
csi_udp_parser → csi_mux → ai_engine → s2mm DMA → NoC → DDR4. Exported with write_bd_layout from the built project.A simplified view of the same PL datapath, for reference:
The AI Engine runs a three-branch feature graph. Each branch maps directly to a piece of the physics above, and every kernel is a small streaming function connected window-to-window over PLIO:
| Branch | Kernel chain | Output | Physical meaning |
|---|---|---|---|
| Motion | FIR band-pass → stats | {mean, var, power} | band-limit to the human-motion Doppler band, then measure energy → presence / activity intensity |
| Breathing | windowed-DFT magnitude | NB bins | STFT bin near 0.2–0.4 Hz → respiration rate |
| Phase-var | stats | {mean, var, power} | phase variance → micro-motion / stability, feeds fall detection |
The graph wiring is pure ADF — kernels connected stream-to-stream on the array:
// motion branch: FIR band-pass -> stats (kernel-to-kernel)
connect<>(in.out[0], kfir.in[0]);
connect<>(kfir.out[0], kstats.in[0]);
connect<>(kstats.out[0], out.in[0]);
// breathing branch: windowed-DFT magnitude
connect<>(brt_in.out[0], kdft.in[0]);
connect<>(kdft.out[0], brt_out.in[0]);
// phase-variance branch: stats -> variance is the phase-var feature
connect<>(phase_in.out[0], kphase.in[0]);
connect<>(kphase.out[0], phase_out.in[0]);
static inline void stats_core(const float *x, float *out) {
float s = 0.0f;
for (int i = 0; i < L; i++) s += x[i];
float mean = s / (float)L;
float sd = 0.0f, s2 = 0.0f;
for (int i = 0; i < L; i++) {
float d = x[i] - mean;
sd += d * d;
s2 += x[i] * x[i];
}
out[0] = mean; // DC / bias
out[1] = sd / (float)L; // variance == motion-band / phase-var energy
out[2] = s2 / (float)L; // power
}
The compact feature vector then feeds a tiny pretrained MLP for the activity label. The design does hand-written AIE kernels here; the vector-VLIW array chews through the FIR taps and DFT butterflies far faster than the scalar A72 could — which is the whole point of the offload.
A plain Vivado block design that wires a PL stream into the AI Engine's S00_AXIS stalls on
silicon — TREADY never asserts. The reason: a hand-built BD does not emit the paired AIE↔PL
shim solution that the graph's PLIO placement requires. The binding must be co-generated by
v++ --link. The fix inserts the PL stream on v++'s own post-SysLink block design via an
overlay hook, leaving ai_engine_0 untouched so the correct shim solution is preserved.
Talk is cheap — here is the datapath caught in the act on a real VCK190 (board chanterelle10). Four ILA taps were inserted on the live pipeline via the Versal debug automation: ETH_RX (nexmon UDP bytes), CSI_PARSED (parser output), AIE_IN (mux → AI Engine) and AIE_OUT (AIE features → DMA).
0x3f0ec77a = 0.558).
The end-to-end numerical check, on hardware:
The VCK190 serves its own dashboard (pure-Python, standard-library only, no CDN — it runs on a bare
PetaLinux rootfs). It consumes the AI Engine feature groups and derives presence, activity, breathing rate
and fall alerts — and renders a live CSI spectrogram by stacking the streamed
brt[33] windowed-DFT magnitude vectors over time (the same STFT view from Act 1, now updating in
real time). Below is that dashboard's logic, replaying a representative feature stream right in your
browser — empty room → someone walks in → sits and breathes → a fall → stillness:
The widget above is a representative feature-stream replay running the same metrics and thresholds as the on-board dashboard — it is not a live capture. On the board, these tiles update from live AI-Engine features.
A full live demo — Raspberry Pi streaming CSI into the VCK190, the room being sensed in real time — is being recorded. Until then, the interactive dashboard above is the teaser.
The gap between "works in simulation" and "works on silicon" was where the real engineering happened. A few that cost days:
S00_AXIS stalls — TREADY never asserts — because it lacks the v++-generated shim solution (aieshim_solution.aiesol / aie_pl_intf.json) the PLIO placement requires. Fix: insert the PL stream on v++'s own post-SysLink BD through an overlay hook, leaving ai_engine_0 untouched. The resulting XSA carries the full shim solution and the DDR → mm2s → AIE → s2mm → DDR stream is bit-accurate on silicon.place_design in "Phase 5.1 Post Place Optimization". Root cause wasn't the ILAs alone: the co-generated Ethernet GT quad was left unplaced (the external SFP ports aren't top-level accessible under v++, and the OOC gt_quad can't be LOC'd from a top XDC). Fix: an implementation OPT_DESIGN.TCL.POST hook (gtloc_hook.tcl) LOCs the leaf cells after link — quad_inst → GTY_QUAD_X0Y5, IBUFDS_GTE5 → GTY_REFCLK_X0Y10. With both placed, place + route + write_device_image all pass.interrupts-extended (the CU IRQs) to the zocl / zyxclmm_drm device-tree node; (2) bake the aie_image graph CDO into BOOT.BIN; (3) drop graph.wait() in the host, because the packaged CDO free-runs the graph — you drain s2mm instead.Image / boot.scr / dtb / rootfs, then booti. With that, the eth image boots Linux 6.12.40 and its ILAs capture live data end-to-end.| Layer | Technology | Role |
|---|---|---|
| CSI source | Raspberry Pi + nexmon_csi | WiFi NIC → per-packet CSI → UDP |
| Link | 1000BASE-X SFP | Pi Ethernet → VCK190 GTY |
| Device | AMD Versal VCK190 | PS (A72) + PL + AI Engine ACAP |
| Packet parse | Vitis HLS csi_udp_parser | UDP/CSI depacketize in PL |
| DSP + inference | AI Engine (ADF graph) | FIR / DFT / stats feature extraction |
| Data movement | mm2s / s2mm + NoC + DDR4 | PLIO ↔ DDR, results to PS |
| Tools | Vivado / Vitis 2025.2 | synth, AIE compiler, v++ link |
| Embedded OS | PetaLinux 2025.2 + XRT | A72 Linux, board host app |
| Visualization | Python stdlib SSE dashboard | on-board live web UI |
The research this build stands on. Full profile on Google Scholar.
The whole project is open source. I've deliberately made it turnkey — a single
project_top.tcl rebuilds the Vivado design end-to-end, and the tracked
petalinux/ project directory has everything you need to build the Linux image for the board.
Reproducibility isn't an afterthought here; it's how I ship. Across all of my reference designs the
entire Vivado project rebuilds from a single tracked TCL script and the
PetaLinux image from a tracked project-spec — no GUI click-throughs, no
undocumented state, so anyone can clone the repo and rebuild it bit-for-bit. You'll find the exact same
discipline in my
Versal Ethernet and
ZCU102 Ethernet
reference designs. It's the same production-grade, source-controlled methodology I apply to the AMD
reference designs I author professionally.
hw/scripts/project_top.tcl conveniently builds the inline Vivado project (Ethernet → csi_udp_parser → AI Engine → DMA) into an XSA end-to-end — no manual block-design wiring.
The petalinux/ directory ships the full project-spec/ (recipes + configs), the SDT flow, and the boot / rootfs bring-up scripts — everything needed to build and boot this project on the VCK190.
The AI Engine feature graph (aie/), the Vitis HLS UDP/CSI parser (hls/), the v++ co-generation flow and the on-board dashboard (live/) are all in the repo, with a phase-by-phase build log in docs/.