feat(privshield): E2E hardware program — validated C core + multi-provider firmware scaffolds

Take VEIL from the synthetic Rust reference model toward real WiFi silicon
across multiple hardware providers, around one shared, host-validated core.
Answers the questions "can OpenWRT / open WiFi software implement this?" and
"can ESP32 help scramble signals?" with an honest per-platform feasibility map.

Portable C shield core (firmware/privshield/core/) — VALIDATED (host test):
- veil_shield.{h,c}: keyed Givens-rotation obfuscation of the identity-bearing
  "fine" subspace, C99, no malloc / no libc I/O, only <math.h>. SplitMix64 key
  schedule byte-identical to the Rust crate, so on-air behavior is consistent
  everywhere and every adapter links the same math.
- make test passes: energy conservation (orthogonal => "not jamming"),
  reversibility (recover inverts apply), wrong-key-fails, and PRNG stream parity
  with the Rust crate. This is build/host evidence, NOT silicon.

Per-provider adapters (all SYNTHETIC / L0, build-only, TODO(hw) markers):
- openwifi/  grade B (ceiling A, effort D): only open PHY/MAC (FPGA) that can
  host the full keyed rotation + inverse; needs new HDL + 2nd TX chain. Carries
  the P5 measurement protocol (MEASUREMENT.md) for the first MEASURED result.
- openwrt/   grade C: per-packet keyed unitary is blob-blocked on commodity APs;
  coarse compliant knobs (TX antenna map, sounding-cadence jitter) reachable
  from userspace/hostapd; ath9k is the one credible driver-patch route.
- nexmon/    grade C: reading the compressed-BF angles is solved (nexmon_csi /
  Wi-BFI); shaping the transmitted report is research-grade (D11 ucode-adjacent).
- esp32/     grade F (self) / B (supporting): cannot shape its own BF feedback
  (closed esp-phy-lib blob); legitimate as a sensing detector and external-RIS
  controller — the honest way ESP32 "helps scramble", via an external surface.

Docs:
- firmware/privshield/README.md: architecture, layout, and the feasibility matrix.
- ADR-290: the E2E hardware program, PROOF discipline, and per-provider decision;
  added to docs/adr/README.md index.

Compliant waveform controls only, never jamming. No adapter has run on silicon;
no MEASURED claim is made (that is roadmap P5, gated on a captured log).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WEXNqzs7UsfNFBcP5yW21p
This commit is contained in:
Claude
2026-08-09 16:34:11 +00:00
parent 192ed2a236
commit b827dc40b1
27 changed files with 2910 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
# VEIL on ESP32 — feasibility and honest scope
**Status: `SYNTHETIC / L0` (build-only).** Everything in this directory is an
ESP-IDF component *skeleton*. Nothing here has been flashed, run, or captured on
silicon. Hardware-touching paths are marked `TODO(hw)`. Per `CLAUDE.md`, no
runtime or on-air claim is valid without a captured hardware log — none exists.
This is a **defensive-security, compliance-only** effort. Nothing here jams,
transmits into a band to deny it, or amplifies energy. The ESP32 either
*observes* the channel or *toggles the control pins of a passive external
surface*.
---
## The direct question: "can we use the ESP32 to scramble signals?"
**Short answer: not the way you probably mean, and yes in three narrow
supporting roles.**
The ESP32 **cannot shape its own transmitted 802.11 beamforming feedback.** The
VEIL shield works by perturbing the *compressed beamforming feedback report* (the
Givens/phi-psi angles a station sends back to an AP) with a keyed orthogonal
rotation. On the ESP32 that report is generated **inside the closed Espressif
Wi-Fi PHY/MAC binary blob** (`esp-phy-lib`, shipped in object form; the Wi-Fi
stack is a proprietary blob bound by a hardware NDA and third-party IP
licensing). There is **no ESP-IDF API to intercept, replace, or rotate the
compressed-BF-report the PHY emits.** `esp_wifi_80211_tx()` lets you inject raw
frames, but it is explicitly limited to *beacon, probe req/resp, (non-QoS) data,
and action* frames with the PHY choosing the actual precoding — it will not let
you hand-craft the VHT/HE sounding-feedback subtype with a chosen precoder. So
the ESP32 is **not** a beamforming-feedback protector.
**Feasibility grade for "ESP32 as a self-protecting VEIL node": F (infeasible).**
The one waveform we need to touch is behind a blob with no hook.
**Feasibility grade for "ESP32 as a VEIL supporting device": B (feasible,
build-only).** Three legitimate roles below, best-first.
---
## What the ESP32 can and cannot do
| Capability | ESP-IDF surface | VEIL-relevant? | Verdict |
|---|---|---|---|
| Read CSI (channel state) | `esp_wifi_set_csi_config` / `esp_wifi_set_csi_rx_cb` / `esp_wifi_set_csi` | Yes — detect *being sensed* | **CAN** (observe only) |
| Promiscuous / sniffer RX | `esp_wifi_set_promiscuous` | Yes — more CSI, frame cadence | **CAN** (observe only) |
| Inject raw mgmt/data frames | `esp_wifi_80211_tx` (beacon, probe, action, non-QoS data only) | Marginal; not for BF feedback | **CAN (limited)** |
| Drive external GPIO/SPI hardware | `gpio_*`, `spi_master_*` | Yes — control an external RIS | **CAN** |
| Shape its own **beamforming feedback** (compressed BF report angles) | *none* — generated in closed PHY blob | This is the actual VEIL waveform | **CANNOT** |
| Choose/replace its own **precoding matrix** | *none* — PHY-internal | Yes, but inaccessible | **CANNOT** |
| Modify the Wi-Fi PHY / `esp-phy-lib` | *none* — object-only, NDA | — | **CANNOT** |
Bottom line: the ESP32 **cannot scramble its own WiFi beamforming feedback**, but
it **can** (a) tell an AP-side shield *when* to act, and (b) drive an **external
passive surface** that scrambles the channel in the *sensing* direction. The
latter is the only honest sense in which an ESP32 "helps scramble" a signal, and
it does so without the ESP32 emitting any RF of its own.
---
## The three legitimate roles
### 1. `veil_sensing_detector/` — sensing-solicitation detector (strongest, clearly compliant)
Uses the CSI callback (+ promiscuous RX) to estimate how often the node is being
sounded/solicited, and raises an engage **trigger** (GPIO / MQTT / ESP-NOW) that
tells the *AP-side* VEIL shield (running the portable `../core/veil_shield.c`) to
turn on. Pure observe-plus-control-signal; the ESP32 shapes nothing on air. This
is the role we would actually build first.
### 2. `veil_ris_controller/` — external RIS driver (the honest "help scramble")
Drives a **reconfigurable intelligent surface** over GPIO/SPI. Following the
PrivISAC pattern, each surface element has two phase states designed offline so
the array response is ~identical in the *communication* direction (throughput
preserved) but differs sharply in the *sensing* direction (an eavesdropper's
channel is perturbed). The ESP32 is just a keyed pin-driver; the surface is
**passive** (re-reflects ambient energy, adds none), which is what keeps this on
the compliant side of the jamming line. The switching **schedule is keyed** via
the portable core's `veil_rng` (SplitMix64), so an authorized sensor holding the
key can reconstruct and tolerate the schedule while an eavesdropper cannot.
### 3. `esp_wifi_80211_tx` action-frame signaling (minor)
Not a separate component. The trigger in role 1 could ride an action frame via
`esp_wifi_80211_tx` instead of GPIO/MQTT/ESP-NOW. Useful only as a transport for
the control signal — it does **not** touch beamforming feedback.
---
## Not recommended: decoy / cover-traffic
One could have the ESP32 emit extra frames (via `esp_wifi_80211_tx`) to inject
motion-like or clutter-like variation into an observer's CSI ("cover traffic").
**We do not implement this and do not recommend it.** It is (a) **legally
sensitive** — deliberately adding channel-occupying transmissions to degrade
another party's reception sits close to the *jamming* line and can violate
radio regulations depending on rate, power, and intent; and (b) **low-value**
it costs airtime, harms your own network, and a determined observer can often
filter periodic decoys. It is documented here only so the option is explicitly
weighed and rejected in favor of the passive-RIS approach (role 2), which
perturbs the *sensing* direction without occupying spectrum.
---
## Build notes
Both components are standard ESP-IDF components (`idf_component_register`) and
are intended to be dropped into an ESP-IDF project's `components/` (or referenced
via `EXTRA_COMPONENT_DIRS`). `veil_ris_controller` compiles the portable core
(`../core/veil_shield.c`) directly to reuse `veil_rng`. They **build** as
skeletons; they do not run — every RF/GPIO/SPI/network path is a `TODO(hw)` stub.
---
## Sources
- ESP-IDF Wi-Fi API (`esp_wifi_80211_tx` supported frame types; CSI APIs):
<https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/network/esp_wifi.html>
- ESP-IDF Wi-Fi CSI (Vendor Features — `esp_wifi_set_csi*`, promiscuous CSI):
<https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/wifi-driver/wifi-vendor-features.html>
- ESP32-C6 beamforming-feedback limitations (IDFGH-15163):
<https://github.com/espressif/esp-idf/issues/15839>
- Closed Wi-Fi PHY blob (`esp-phy-lib`, object-only, NDA):
<https://github.com/espressif/esp-phy-lib>
- ESP32 Wi-Fi binary-blob reverse-engineering context (why the PHY is not modifiable):
<https://esp32-open-mac.be/posts/0005-the-road-ahead/>
- Raw 802.11 TX capability/limits reference (`esp32-80211-tx`):
<https://github.com/Jeija/esp32-80211-tx>
- PrivISAC — RIS-based privacy-preserving ISAC (sensing vs. comm direction):
<https://arxiv.org/abs/2601.04488>
- Wi-BFI — beamforming-feedback extraction (why unprotected BF reports leak):
<https://arxiv.org/pdf/2309.04408>

View File

@@ -0,0 +1,23 @@
# veil_ris_controller — ESP-IDF component (SYNTHETIC / L0, build-only)
#
# Drives an EXTERNAL reconfigurable intelligent surface (RIS) over GPIO/SPI to
# scramble the *sensing-direction* channel while preserving the *comm-direction*
# channel (the PrivISAC pattern, arXiv:2601.04488). This is the honest way an
# ESP32 "helps scramble": through an external passive surface, NOT its own
# closed Wi-Fi PHY. See the subdir README.md.
#
# The keyed configuration schedule reuses the portable VEIL core's SplitMix64
# `veil_rng` (../../core/veil_shield.{h,c}) so the schedule is deterministic and
# byte-consistent with the Rust reference — the same key can be shared with an
# associated receiver.
#
# NOTE: build-only skeleton, never run on silicon. Hardware paths -> TODO(hw).
set(VEIL_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../core")
idf_component_register(
SRCS "veil_ris_controller.c"
"${VEIL_CORE_DIR}/veil_shield.c" # reuse veil_rng from the portable core
INCLUDE_DIRS "include" "${VEIL_CORE_DIR}"
REQUIRES esp_timer esp_driver_gpio esp_driver_spi
)

View File

@@ -0,0 +1,91 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_ris_controller — drive an EXTERNAL reconfigurable intelligent surface
* (RIS) to obfuscate the sensing-direction channel.
*
* STATUS: SYNTHETIC / L0. Build-only ESP-IDF component skeleton. Never flashed,
* never captured on silicon. No RIS hardware exists in this repo. Do NOT claim
* runtime or on-air behavior without a captured hardware log.
*
* WHY THIS EXISTS (honest framing): the ESP32 cannot shape its own transmitted
* beamforming feedback — the precoding / compressed-BF-report path lives in the
* closed Espressif Wi-Fi PHY blob (esp-phy-lib) and is not modifiable (see
* README.md). The legitimate, compliant way an ESP32 can "help scramble" a
* sensing signal is to act as the *controller for a separate passive surface*:
* a RIS whose per-element phase states are switched over time. Following the
* PrivISAC pattern (arXiv:2601.04488), each element is toggled between two
* states chosen so the surface's response is ~identical in the *communication*
* direction (throughput preserved) but differs sharply in the *sensing*
* direction (an eavesdropper's channel is perturbed). The ESP32 is a GPIO/SPI
* pin-driver here; it emits no RF of its own.
*
* The state schedule is *keyed* and deterministic: it is drawn from the
* portable core's `veil_rng` (SplitMix64), so an associated / authorized
* sensor holding the same key can reconstruct — and thus tolerate — the
* schedule, while an unauthorized observer cannot.
*/
#ifndef VEIL_RIS_CONTROLLER_H
#define VEIL_RIS_CONTROLLER_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* How the surface's element bits are clocked out. */
typedef enum {
VEIL_RIS_IFACE_GPIO = 0, /* small surfaces: one GPIO per element / bank */
VEIL_RIS_IFACE_SPI, /* larger surfaces: shift-register / driver IC */
} veil_ris_iface_t;
typedef struct {
veil_ris_iface_t iface;
/* Number of independently switchable RIS elements (or 1-bit banks). */
size_t n_elements;
/* Keyed, deterministic schedule (shared with the associated receiver). */
uint64_t key;
/* Dwell time per configuration, microseconds. Must be short vs. the
* channel coherence time to spread perturbation across the sensing burst,
* yet long enough for the surface's switching diodes to settle. */
uint32_t dwell_us;
/* GPIO backend: one pin per element (n_elements <= number of pins). */
const int *gpio_pins; /* borrowed; length == n_elements */
/* SPI backend: bits are packed MSB-first into ceil(n_elements/8) bytes and
* shifted out per configuration. */
int spi_host; /* e.g. SPI2_HOST */
int spi_cs_gpio; /* latch / chip-select */
int spi_clock_hz; /* driver-IC clock */
} veil_ris_controller_cfg_t;
/* Initialize the chosen interface. Registration only — says nothing about a
* physical surface actually switching. */
esp_err_t veil_ris_controller_init(const veil_ris_controller_cfg_t *cfg);
/* Compute the next keyed configuration bitmap and clock it to the surface.
* `out_bits` (optional, may be NULL) receives the packed bitmap for tests.
* `out_len` is the byte length of `out_bits` on input. The bit pattern is
* derived purely from `veil_rng` + the PrivISAC two-state assignment, so it is
* reproducible from (key, step_index). */
esp_err_t veil_ris_controller_step(uint8_t *out_bits, size_t out_len);
/* Start/stop a periodic timer that calls _step() every dwell_us. */
esp_err_t veil_ris_controller_start(void);
esp_err_t veil_ris_controller_stop(void);
/* Monotonic count of configurations applied since init (telemetry/tests). */
uint64_t veil_ris_controller_step_count(void);
#ifdef __cplusplus
}
#endif
#endif /* VEIL_RIS_CONTROLLER_H */

View File

@@ -0,0 +1,196 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_ris_controller — see veil_ris_controller.h.
*
* STATUS: SYNTHETIC / L0. Build-only skeleton. Never run on silicon; no RIS
* hardware exists here. Hardware-touching paths are marked TODO(hw). The keyed
* bitmap generator (pure math over veil_rng) is fully implemented and testable
* off target; the GPIO/SPI clock-out is stubbed.
*
* Compliance: the ESP32 only toggles control pins of a *passive* external
* surface. It emits no RF and does not transmit into any band. The surface
* re-reflects ambient energy; it does not add energy or occupy spectrum, which
* is what keeps this on the compliant side of the jamming line. (A powered,
* amplifying, or spectrum-occupying surface would NOT be compliant and is out
* of scope.)
*/
#include "veil_ris_controller.h"
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "veil_shield.h" /* portable core: veil_rng, veil_rng_next_u64/_f32 */
static const char *TAG = "veil_ris";
static veil_ris_controller_cfg_t s_cfg;
static bool s_inited;
static uint64_t s_step; /* configurations applied so far */
static esp_timer_handle_t s_timer;
/* ---- keyed configuration generator (pure, testable off-target) ----------- */
/* PrivISAC two-state assignment: every element has two candidate phase states
* (A/B) designed offline so the *comm-direction* array response is ~invariant
* under A<->B while the *sensing-direction* response changes. At runtime we
* only pick, per element, which of the two states is active this step. That
* choice is the single bit we clock out. Drawing the bits from the keyed
* veil_rng makes the whole schedule reproducible from (key, step_index) and
* shareable with an authorized receiver.
*
* `step_index` seeds a per-step substream so any step can be regenerated
* without replaying history (matches the core's deterministic style).
* Fills `bits` (packed MSB-first) with n_elements selection bits. */
void veil_ris_gen_bits(uint64_t key, uint64_t step_index,
size_t n_elements, uint8_t *bits, size_t bits_len)
{
if (!bits || bits_len == 0) {
return;
}
memset(bits, 0, bits_len);
veil_rng r;
/* Mix the step index into the key so each dwell gets an independent draw
* while staying a pure function of (key, step_index). */
veil_rng_seed(&r, key ^ (step_index * 0x9E3779B97F4A7C15ULL));
for (size_t e = 0; e < n_elements; e++) {
size_t byte = e >> 3;
if (byte >= bits_len) {
break;
}
/* Top bit of the draw selects state B (1) vs state A (0). */
uint64_t w = veil_rng_next_u64(&r);
if (w >> 63) {
bits[byte] |= (uint8_t)(0x80u >> (e & 7));
}
}
}
/* ---- interface clock-out (stubs) ----------------------------------------- */
static esp_err_t veil_ris_write(const uint8_t *bits, size_t bits_len)
{
switch (s_cfg.iface) {
case VEIL_RIS_IFACE_GPIO:
/* TODO(hw): for each element e, set its pin to the selected state.
* for (size_t e = 0; e < s_cfg.n_elements; e++) {
* int level = (bits[e >> 3] >> (7 - (e & 7))) & 1;
* gpio_set_level(s_cfg.gpio_pins[e], level);
* }
* Requires each pin configured as output in _init(). Unverified. */
ESP_LOGD(TAG, "TODO(hw) GPIO write %u bits (stub)",
(unsigned)s_cfg.n_elements);
return ESP_ERR_NOT_SUPPORTED;
case VEIL_RIS_IFACE_SPI:
/* TODO(hw): shift the packed bitmap to the surface driver IC.
* spi_transaction_t t = {
* .length = bits_len * 8,
* .tx_buffer = bits,
* };
* spi_device_transmit(s_spi_dev, &t); // then latch via CS
* s_spi_dev created in _init() via spi_bus_add_device(). Unverified. */
ESP_LOGD(TAG, "TODO(hw) SPI write %u bytes (stub)", (unsigned)bits_len);
return ESP_ERR_NOT_SUPPORTED;
default:
return ESP_ERR_INVALID_ARG;
}
}
/* ---- public API ---------------------------------------------------------- */
esp_err_t veil_ris_controller_init(const veil_ris_controller_cfg_t *cfg)
{
if (!cfg || cfg->n_elements == 0) {
return ESP_ERR_INVALID_ARG;
}
if (s_inited) {
return ESP_ERR_INVALID_STATE;
}
s_cfg = *cfg;
s_step = 0;
if (s_cfg.iface == VEIL_RIS_IFACE_GPIO) {
/* TODO(hw): configure each s_cfg.gpio_pins[e] as GPIO_MODE_OUTPUT via
* gpio_config() (build a pin_bit_mask over all elements). */
ESP_LOGW(TAG, "TODO(hw) configure %u GPIO element pins (stub)",
(unsigned)s_cfg.n_elements);
} else {
/* TODO(hw): spi_bus_initialize(s_cfg.spi_host, &buscfg, ...) +
* spi_bus_add_device(s_cfg.spi_host, &devcfg, &s_spi_dev). */
ESP_LOGW(TAG, "TODO(hw) init SPI host %d @ %d Hz (stub)",
s_cfg.spi_host, s_cfg.spi_clock_hz);
}
s_inited = true;
ESP_LOGI(TAG, "init (SYNTHETIC/L0): %u elements, dwell=%uus, keyed schedule",
(unsigned)s_cfg.n_elements, s_cfg.dwell_us);
return ESP_OK;
}
esp_err_t veil_ris_controller_step(uint8_t *out_bits, size_t out_len)
{
if (!s_inited) {
return ESP_ERR_INVALID_STATE;
}
/* Bounded, malloc-free scratch: cap at 256 elements (32 bytes) for the
* skeleton. Larger surfaces would stream in chunks. */
enum { VEIL_RIS_MAX_BYTES = 32 };
uint8_t bits[VEIL_RIS_MAX_BYTES];
size_t need = (s_cfg.n_elements + 7) / 8;
if (need > sizeof bits) {
need = sizeof bits;
}
veil_ris_gen_bits(s_cfg.key, s_step, s_cfg.n_elements, bits, need);
esp_err_t err = veil_ris_write(bits, need); /* stub on host/no-hw */
s_step++;
if (out_bits && out_len) {
size_t n = out_len < need ? out_len : need;
memcpy(out_bits, bits, n);
}
/* NOT_SUPPORTED from the stubbed writer is expected off-silicon; surface
* the generator result as OK so tests can validate the keyed bitmap. */
return (err == ESP_ERR_NOT_SUPPORTED) ? ESP_OK : err;
}
static void veil_ris_timer_cb(void *arg)
{
(void)arg;
(void)veil_ris_controller_step(NULL, 0);
}
esp_err_t veil_ris_controller_start(void)
{
if (!s_inited) {
return ESP_ERR_INVALID_STATE;
}
/* TODO(hw): a real deployment would gate this on the sensing detector's
* engage trigger so the surface only churns during a sensing burst. */
const esp_timer_create_args_t args = {
.callback = veil_ris_timer_cb,
.name = "veil_ris",
};
esp_err_t err = esp_timer_create(&args, &s_timer);
if (err != ESP_OK) {
return err;
}
return esp_timer_start_periodic(s_timer, s_cfg.dwell_us);
}
esp_err_t veil_ris_controller_stop(void)
{
if (s_timer) {
esp_timer_stop(s_timer);
esp_timer_delete(s_timer);
s_timer = NULL;
}
return ESP_OK;
}
uint64_t veil_ris_controller_step_count(void) { return s_step; }

View File

@@ -0,0 +1,19 @@
# veil_sensing_detector — ESP-IDF component (SYNTHETIC / L0, build-only)
#
# Estimates the 802.11 sensing-solicitation rate from the ESP32 CSI callback
# and raises a trigger (GPIO / MQTT / ESP-NOW) that engages the AP-side VEIL
# shield. This component only READS the channel; it never shapes RF. See the
# subdir README.md for the honest capability boundary.
#
# NOTE: This is a build-only skeleton. It has never run on silicon. All
# hardware-touching paths are marked TODO(hw).
idf_component_register(
SRCS "veil_sensing_detector.c"
INCLUDE_DIRS "include"
# esp_wifi: esp_wifi_set_csi_rx_cb / esp_wifi_set_csi / promiscuous.
# The MQTT and ESP-NOW trigger backends are optional; they are only
# referenced under CONFIG_ guards so the core build stays minimal.
REQUIRES esp_wifi esp_event esp_timer esp_driver_gpio
PRIV_REQUIRES esp_mqtt
)

View File

@@ -0,0 +1,88 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_sensing_detector — detect 802.11 sensing solicitation and raise a
* trigger that engages the AP-side VEIL shield.
*
* STATUS: SYNTHETIC / L0. Build-only ESP-IDF component skeleton. Never flashed,
* never captured on silicon. Do NOT claim runtime behavior without a captured
* hardware log (CLAUDE.md hardware-evidence rule).
*
* ROLE (honest): the ESP32 is a passive CSI *observer* here. It watches how
* often it is being sounded / probed (NDP announcements, action frames, and the
* cadence of incoming CSI-bearing frames) and, when that rate crosses a
* threshold, tells a *separate* protector (the AP running the veil_shield core)
* that a sensing burst is in progress. The ESP32 does NOT modify any waveform
* and does NOT protect its own beamforming feedback (see README.md). This is
* the strongest, clearly-compliant supporting role for the ESP32.
*/
#ifndef VEIL_SENSING_DETECTOR_H
#define VEIL_SENSING_DETECTOR_H
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* How the detector announces "sensing burst detected" to the protector. */
typedef enum {
VEIL_TRIGGER_GPIO = 0, /* drive a GPIO line to a co-located AP / relay */
VEIL_TRIGGER_MQTT, /* publish to a broker the AP subscribes to */
VEIL_TRIGGER_ESPNOW, /* connectionless ESP-NOW unicast to the AP node */
} veil_trigger_backend_t;
typedef struct {
/* Sliding-window length for the solicitation-rate estimate, milliseconds. */
uint32_t window_ms;
/* Solicitations/second above which the shield should be engaged. */
float trigger_rate_hz;
/* Hysteresis: rate must fall below this to clear the trigger. */
float release_rate_hz;
veil_trigger_backend_t backend;
/* GPIO backend. */
int gpio_num; /* output line; active-high engage */
/* MQTT backend. broker_uri/topic are borrowed, must outlive the detector. */
const char *mqtt_broker_uri; /* e.g. "mqtts://ap.local:8883" */
const char *mqtt_topic; /* e.g. "veil/engage" */
/* ESP-NOW backend. */
uint8_t espnow_peer[6]; /* AP node MAC */
} veil_sensing_detector_cfg_t;
/* Sensible SYNTHETIC defaults (not silicon-validated). */
#define VEIL_SENSING_DETECTOR_DEFAULT_CFG() \
(veil_sensing_detector_cfg_t){ \
.window_ms = 1000, \
.trigger_rate_hz = 20.0f, \
.release_rate_hz = 5.0f, \
.backend = VEIL_TRIGGER_GPIO, \
.gpio_num = -1, \
.mqtt_broker_uri = NULL, \
.mqtt_topic = "veil/engage", \
.espnow_peer = {0}, \
}
/* Install the CSI callback + configured trigger backend. Enables promiscuous
* CSI capture. Returns ESP_OK on successful *registration* only — this says
* nothing about on-air behavior. */
esp_err_t veil_sensing_detector_start(const veil_sensing_detector_cfg_t *cfg);
/* Tear down callback + backend. */
esp_err_t veil_sensing_detector_stop(void);
/* Last estimated solicitation rate (Hz), for telemetry/tests. */
float veil_sensing_detector_rate_hz(void);
/* True while the engage trigger is asserted. */
bool veil_sensing_detector_engaged(void);
#ifdef __cplusplus
}
#endif
#endif /* VEIL_SENSING_DETECTOR_H */

View File

@@ -0,0 +1,188 @@
/* SPDX-License-Identifier: MIT OR Apache-2.0
*
* veil_sensing_detector — see veil_sensing_detector.h.
*
* STATUS: SYNTHETIC / L0. Build-only skeleton. Never run on silicon. Every
* hardware-touching path is marked TODO(hw). The rate estimator (pure math over
* timestamps) is the only fully-implemented piece and is unit-testable off
* target; the RF/observe path and the trigger backends are stubs.
*
* Compliance: this component only READS the channel (CSI + frame cadence). It
* emits no RF and shapes no waveform. It cannot and does not touch the closed
* ESP32 Wi-Fi PHY blob. The "action" it takes is a low-rate control signal to a
* separate protector.
*/
#include "veil_sensing_detector.h"
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_wifi.h" /* esp_wifi_set_csi_rx_cb, esp_wifi_set_csi, ... */
#include "esp_wifi_types.h" /* wifi_csi_info_t, wifi_csi_config_t */
#include "driver/gpio.h" /* gpio_config, gpio_set_level */
static const char *TAG = "veil_sense";
/* ---- module state -------------------------------------------------------- */
static veil_sensing_detector_cfg_t s_cfg;
static bool s_running;
static bool s_engaged;
static float s_rate_hz;
/* Bounded ring of recent solicitation timestamps (µs), malloc-free. */
enum { VEIL_TS_RING = 256 };
static int64_t s_ts[VEIL_TS_RING];
static size_t s_ts_head; /* next write slot */
static size_t s_ts_count; /* live entries, capped at VEIL_TS_RING */
/* ---- rate estimator (pure, testable off-target) -------------------------- */
/* Record one solicitation at time `now_us` and recompute the sliding-window
* rate. Returns the current rate in Hz. This function is deliberately free of
* any ESP-IDF dependency so it can be exercised in host unit tests. */
float veil_sd_note_solicitation(int64_t now_us)
{
s_ts[s_ts_head] = now_us;
s_ts_head = (s_ts_head + 1) % VEIL_TS_RING;
if (s_ts_count < VEIL_TS_RING) {
s_ts_count++;
}
const int64_t window_us = (int64_t)s_cfg.window_ms * 1000;
const int64_t cutoff = now_us - window_us;
size_t in_window = 0;
for (size_t k = 0; k < s_ts_count; k++) {
if (s_ts[k] >= cutoff) {
in_window++;
}
}
/* rate = events within the trailing window / window length. */
s_rate_hz = (float)in_window * 1000.0f / (float)s_cfg.window_ms;
/* Hysteresis around engage/release. */
if (!s_engaged && s_rate_hz >= s_cfg.trigger_rate_hz) {
s_engaged = true;
ESP_LOGI(TAG, "sensing burst: %.1f Hz >= %.1f -> ENGAGE",
s_rate_hz, s_cfg.trigger_rate_hz);
/* fire-and-forget; backend errors are logged, not fatal */
(void)0; /* veil_sd_emit_trigger(true) — see below */
} else if (s_engaged && s_rate_hz <= s_cfg.release_rate_hz) {
s_engaged = false;
ESP_LOGI(TAG, "sensing quiet: %.1f Hz <= %.1f -> RELEASE",
s_rate_hz, s_cfg.release_rate_hz);
}
return s_rate_hz;
}
/* ---- trigger backends (all stubs) ---------------------------------------- */
static esp_err_t veil_sd_emit_trigger(bool engage)
{
switch (s_cfg.backend) {
case VEIL_TRIGGER_GPIO:
/* TODO(hw): drive the engage line to the co-located AP/relay.
* gpio_set_level(s_cfg.gpio_num, engage ? 1 : 0);
* Requires a wired GPIO to the protector; unverified on silicon. */
ESP_LOGW(TAG, "TODO(hw) GPIO trigger -> %d (stub)", engage);
return ESP_ERR_NOT_SUPPORTED;
case VEIL_TRIGGER_MQTT:
/* TODO(hw): esp_mqtt_client_publish(client, s_cfg.mqtt_topic,
* engage ? "1" : "0", 0, 1 /qos/, 0 /retain/);
* Client lifecycle (esp_mqtt_client_init/_start) omitted from skeleton. */
ESP_LOGW(TAG, "TODO(hw) MQTT trigger -> %d (stub)", engage);
return ESP_ERR_NOT_SUPPORTED;
case VEIL_TRIGGER_ESPNOW:
/* TODO(hw): esp_now_send(s_cfg.espnow_peer, &payload, sizeof payload);
* Requires esp_now_init() + esp_now_add_peer() during start(). */
ESP_LOGW(TAG, "TODO(hw) ESP-NOW trigger -> %d (stub)", engage);
return ESP_ERR_NOT_SUPPORTED;
default:
return ESP_ERR_INVALID_ARG;
}
}
/* ---- CSI callback (observe path) ----------------------------------------- */
/* Runs in the Wi-Fi task. Keep it short: post to a queue in real firmware.
* Here we only classify whether this frame indicates a sounding/solicitation
* and, if so, feed the estimator. */
static void veil_sd_csi_cb(void *ctx, wifi_csi_info_t *info)
{
(void)ctx;
if (!info) {
return;
}
/* TODO(hw): a real classifier would inspect info->rx_ctrl (rate, sig_mode,
* channel, secondary channel) and, alongside a promiscuous frame-type
* filter, distinguish NDP / NDP-announcement / CSI-solicit action frames
* from ordinary data. On silicon the ESP32 does NOT surface the raw
* VHT/HE sounding subtype through the CSI struct, so this classifier is
* necessarily heuristic (cadence + rate + frame length). Treated here as
* "every CSI-bearing frame is a candidate solicitation" for the skeleton. */
(void)veil_sd_note_solicitation(esp_timer_get_time());
}
/* ---- lifecycle ----------------------------------------------------------- */
esp_err_t veil_sensing_detector_start(const veil_sensing_detector_cfg_t *cfg)
{
if (!cfg) {
return ESP_ERR_INVALID_ARG;
}
if (s_running) {
return ESP_ERR_INVALID_STATE;
}
s_cfg = *cfg;
s_engaged = false;
s_rate_hz = 0.0f;
s_ts_head = 0;
s_ts_count = 0;
if (s_cfg.backend == VEIL_TRIGGER_GPIO && s_cfg.gpio_num >= 0) {
/* TODO(hw): configure the engage line.
* gpio_config_t io = {
* .pin_bit_mask = 1ULL << s_cfg.gpio_num,
* .mode = GPIO_MODE_OUTPUT,
* };
* gpio_config(&io);
* gpio_set_level(s_cfg.gpio_num, 0);
*/
ESP_LOGW(TAG, "TODO(hw) configure GPIO %d (stub)", s_cfg.gpio_num);
}
/* Observe path. On real hardware:
* wifi_csi_config_t csi = { ... };
* ESP_ERROR_CHECK(esp_wifi_set_csi_config(&csi));
* ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(veil_sd_csi_cb, NULL));
* ESP_ERROR_CHECK(esp_wifi_set_csi(true));
* ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true)); // more CSI when idle
* The Wi-Fi driver must already be started by the app. */
ESP_LOGW(TAG, "TODO(hw) esp_wifi_set_csi_rx_cb/_set_csi/_set_promiscuous "
"(stub; not wired on silicon)");
(void)veil_sd_csi_cb; /* referenced once wired */
s_running = true;
ESP_LOGI(TAG, "started (SYNTHETIC/L0): window=%ums engage>=%.1fHz",
s_cfg.window_ms, s_cfg.trigger_rate_hz);
return ESP_OK;
}
esp_err_t veil_sensing_detector_stop(void)
{
if (!s_running) {
return ESP_ERR_INVALID_STATE;
}
/* TODO(hw): esp_wifi_set_csi(false); esp_wifi_set_csi_rx_cb(NULL, NULL);
* esp_wifi_set_promiscuous(false); release GPIO/MQTT/ESP-NOW. */
if (s_engaged) {
(void)veil_sd_emit_trigger(false);
}
s_running = false;
return ESP_OK;
}
float veil_sensing_detector_rate_hz(void) { return s_rate_hz; }
bool veil_sensing_detector_engaged(void) { return s_engaged; }